diff --git a/Anti-Spam/.env.example b/Anti-Spam/.env.example index 835c6a0..804f445 100644 --- a/Anti-Spam/.env.example +++ b/Anti-Spam/.env.example @@ -16,8 +16,8 @@ DELETE_MESSAGE=true # Flag role mentions when the role has at least this many members MAX_ROLE_MENTION_SIZE=50 -# Extra strict on accounts younger than this many days (0 = disabled) -MIN_ACCOUNT_AGE_DAYS=0 +# Extra strict on accounts younger than this many days (0 = disabled; 7 recommended for raid protection) +MIN_ACCOUNT_AGE_DAYS=7 # Comma-separated user IDs that bypass checks on all servers (e.g. bot owner) TRUSTED_USER_IDS= diff --git a/Anti-Spam/src/commands.js b/Anti-Spam/src/commands.js index e13b874..4942e9a 100644 --- a/Anti-Spam/src/commands.js +++ b/Anti-Spam/src/commands.js @@ -139,12 +139,19 @@ export async function handleAntispamCommand(interaction) { }, { name: "Trusted roles", value: trustedRoles }, { - name: "Global defaults", + name: "Detection rules", + value: [ + "Same message in multiple channels", + "Any role ping (or @everyone/@here)", + "Contains a link", + "(all three required)", + ].join("\n"), + }, + { + name: "Global actions", value: [ `Ban on spam: ${config.banOnSpam}`, `Delete messages: ${config.deleteMessage}`, - `Large role mention threshold: ${config.maxRoleMentionSize}`, - `Min account age (days): ${config.minAccountAgeDays || "disabled"}`, ].join("\n"), } ); diff --git a/Anti-Spam/src/index.js b/Anti-Spam/src/index.js index 9ac72a3..ad7c75b 100644 --- a/Anti-Spam/src/index.js +++ b/Anti-Spam/src/index.js @@ -6,7 +6,8 @@ import { EmbedBuilder, } from "discord.js"; import { config } from "./config.js"; -import { detectSpam } from "./spamDetector.js"; +import { collectScannableText, detectSpam } from "./spamDetector.js"; +import { trackCrossChannelMessage } from "./rapidSpamTracker.js"; import { getGuildSettings } from "./guildSettings.js"; import { handleAntispamCommand } from "./commands.js"; import { registerCommands, registerCommandsForGuild } from "./registerCommands.js"; @@ -49,46 +50,79 @@ async function sendModLog(guild, guildSettings, embed) { }); } -async function handleSpam(message, detection, guildSettings) { +async function deleteTrackedMessages(guild, priorMessages) { + for (const { channelId, messageId } of priorMessages) { + const channel = await guild.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased()) continue; + + const priorMessage = await channel.messages.fetch(messageId).catch(() => null); + if (!priorMessage) continue; + + await priorMessage.delete().catch((error) => { + console.error(`Failed to delete prior spam message ${messageId}:`, error.message); + }); + } +} + +async function handleSpam(message, detection, guildSettings, priorMessages = []) { const { member, guild, author, channel } = message; const reasonSummary = detection.reasons.join("; "); if (config.deleteMessage) { + await deleteTrackedMessages(guild, priorMessages); + await message.delete().catch((error) => { console.error(`Failed to delete message ${message.id}:`, error.message); }); } - let banned = false; - let banError = null; + let moderationAction = null; + let moderationError = null; + const moderationReason = `Anti-Spam: ${reasonSummary}`.slice(0, 512); - if (config.banOnSpam && member && member.bannable) { + if (config.banOnSpam && member?.bannable) { try { await member.ban({ - reason: `Anti-Spam: ${reasonSummary}`.slice(0, 512), + reason: moderationReason, deleteMessageSeconds: 60 * 60 * 24, }); - banned = true; + moderationAction = "banned"; } catch (error) { - banError = error.message; + moderationError = error.message; console.error(`Failed to ban ${author.tag}:`, error.message); } } + if (!moderationAction && config.banOnSpam && member?.kickable) { + try { + await member.kick(moderationReason); + moderationAction = "kicked"; + } catch (error) { + moderationError = error.message; + console.error(`Failed to kick ${author.tag}:`, error.message); + } + } + + const actionLabel = + moderationAction ?? + (config.banOnSpam ? "moderation failed" : "moderation disabled"); + const embed = new EmbedBuilder() - .setColor(banned ? 0xed4245 : 0xfaa61a) - .setTitle(banned ? "Spam blocked — user banned" : "Spam blocked") + .setColor(moderationAction ? 0xed4245 : 0xfaa61a) + .setTitle(moderationAction ? `Spam blocked — user ${moderationAction}` : "Spam blocked") .setDescription( [ `**User:** ${author.tag} (\`${author.id}\`)`, `**Channel:** <#${channel.id}>`, `**Action:** ${[ - config.deleteMessage ? "message deleted" : null, - banned ? "user banned" : config.banOnSpam ? "ban failed" : "ban disabled", + config.deleteMessage + ? `message deleted${priorMessages.length > 0 ? ` (+${priorMessages.length} in other channels)` : ""}` + : null, + actionLabel, ] .filter(Boolean) .join(", ")}`, - banError ? `**Ban error:** ${banError}` : null, + moderationError ? `**Moderation error:** ${moderationError}` : null, "", "**Triggers:**", detection.reasons.map((r) => `• ${r}`).join("\n"), @@ -109,7 +143,7 @@ async function handleSpam(message, detection, guildSettings) { await sendModLog(guild, guildSettings, embed); console.log( `[${guild.name}] Spam from ${author.tag}: ${reasonSummary}${ - banned ? " (banned)" : "" + moderationAction ? ` (${moderationAction})` : "" }` ); } @@ -170,23 +204,36 @@ client.on("messageCreate", async (message) => { PermissionFlagsBits.ManageMessages, ]; if (config.banOnSpam) { - requiredPerms.push(PermissionFlagsBits.BanMembers); + requiredPerms.push(PermissionFlagsBits.BanMembers, PermissionFlagsBits.KickMembers); } - if (!message.channel.permissionsFor(me)?.has(requiredPerms, true)) { + const channelPerms = message.channel.permissionsFor(me); + if (!channelPerms?.has(requiredPerms, true)) { + const missing = requiredPerms.filter((perm) => !channelPerms?.has(perm, true)); + console.warn( + `[${message.guild.name}] Missing permissions in #${message.channel.name}: ${missing.join(", ")}` + ); return; } if (isTrusted(message.member, guildSettings)) return; + const scannableText = collectScannableText(message); + const crossChannel = trackCrossChannelMessage( + message.guild.id, + message.author.id, + message.channel.id, + message.id, + scannableText + ); + const detection = detectSpam(message, { - maxRoleMentionSize: config.maxRoleMentionSize, - minAccountAgeDays: config.minAccountAgeDays, + duplicateAcrossChannels: crossChannel.duplicateAcrossChannels, }); if (!detection.isSpam) return; - await handleSpam(message, detection, guildSettings); + await handleSpam(message, detection, guildSettings, crossChannel.priorMessages); }); process.on("unhandledRejection", (error) => { diff --git a/Anti-Spam/src/rapidSpamTracker.js b/Anti-Spam/src/rapidSpamTracker.js new file mode 100644 index 0000000..6995a82 --- /dev/null +++ b/Anti-Spam/src/rapidSpamTracker.js @@ -0,0 +1,46 @@ +const WINDOW_MS = 150_000; // 2.5 minutes +const recentPosts = new Map(); + +function normalizeKey(text) { + return text.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 200); +} + +export function trackCrossChannelMessage(guildId, userId, channelId, messageId, text) { + const normalized = normalizeKey(text); + if (!normalized) { + return { duplicateAcrossChannels: false, priorMessages: [] }; + } + + const key = `${guildId}:${userId}:${normalized}`; + const now = Date.now(); + const existing = recentPosts.get(key); + + if (existing && now - existing.firstSeen < WINDOW_MS) { + const priorMessages = [...existing.messages.entries()].map(([chId, msgId]) => ({ + channelId: chId, + messageId: msgId, + })); + + existing.channels.add(channelId); + existing.messages.set(channelId, messageId); + + const duplicateAcrossChannels = existing.channels.size >= 2; + return { duplicateAcrossChannels, priorMessages: duplicateAcrossChannels ? priorMessages : [] }; + } + + recentPosts.set(key, { + channels: new Set([channelId]), + messages: new Map([[channelId, messageId]]), + firstSeen: now, + }); + + if (recentPosts.size > 5000) { + for (const [entryKey, entry] of recentPosts) { + if (now - entry.firstSeen > WINDOW_MS) { + recentPosts.delete(entryKey); + } + } + } + + return { duplicateAcrossChannels: false, priorMessages: [] }; +} diff --git a/Anti-Spam/src/spamDetector.js b/Anti-Spam/src/spamDetector.js index 2e6a43e..3c1b71b 100644 --- a/Anti-Spam/src/spamDetector.js +++ b/Anti-Spam/src/spamDetector.js @@ -1,209 +1,90 @@ const URL_PATTERN = - /https?:\/\/[^\s<>"{}|\\^`[\]]+|(?:www\.)[a-z0-9-]+(?:\.[a-z0-9-]+)+[^\s]*/gi; + /https?:\/\/[^\s<>"{}|\\^`[\]]+|(?:www\.)?[a-z0-9][-a-z0-9]*(?:\.[a-z0-9-]+)+(?:\/[^\s]*)?/gi; -const IMAGE_URL_PATTERN = - /https?:\/\/[^\s]+\.(?:png|jpe?g|gif|webp|bmp)(?:\?[^\s]*)?/gi; +const ROLE_MENTION_PATTERN = /<@&(\d+)>/g; +const ROLE_MENTION_TEST = /<@&\d+>/; -const DISCORD_INVITE_PATTERN = - /(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\/[a-z0-9-]+/gi; +export function collectScannableText(message) { + const parts = [message.content ?? ""]; -const PHISHING_DOMAIN_FRAGMENTS = [ - "discord-gift", - "discordgift", - "discord-nitro", - "discordnitro", - "discord-app", - "discordapp", - "discorcl", - "discrod", - "dlscord", - "discocrd", - "steamcommunjty", - "steamcommunlty", - "steamcornmunity", - "stearncommunity", - "free-nitro", - "freenitro", - "nitro-free", - "nitrofree", - "airdrop", - "claim-nitro", - "claimnitro", - "verify-account", - "account-verify", - "login-discord", - "discord-login", -]; + for (const embed of message.embeds ?? []) { + if (embed.url) parts.push(embed.url); + if (embed.title) parts.push(embed.title); + if (embed.description) parts.push(embed.description); + for (const field of embed.fields ?? []) { + parts.push(field.name, field.value); + } + } -const PHISHING_KEYWORDS = [ - "free nitro", - "claim your nitro", - "claim nitro", - "nitro giveaway", - "steam gift", - "verify your account", - "account suspended", - "unusual activity", - "click to verify", - "limited time offer", -]; + for (const attachment of message.attachments?.values() ?? []) { + if (attachment.url) parts.push(attachment.url); + if (attachment.proxyURL) parts.push(attachment.proxyURL); + } -const SUSPICIOUS_TLDS = [ - ".xyz", - ".top", - ".click", - ".icu", - ".buzz", - ".monster", - ".rest", - ".cfd", - ".sbs", - ".lat", -]; - -function normalizeText(text) { - return text.toLowerCase().replace(/\s+/g, " ").trim(); + return parts.filter(Boolean).join("\n"); } function extractUrls(text) { return [...text.matchAll(URL_PATTERN)].map((match) => match[0]); } -function hasPhishingDomain(url) { - const lower = url.toLowerCase(); - return PHISHING_DOMAIN_FRAGMENTS.some((fragment) => lower.includes(fragment)); -} - -function hasSuspiciousTld(url) { - const lower = url.toLowerCase(); - return SUSPICIOUS_TLDS.some((tld) => { - const index = lower.indexOf(tld); - if (index === -1) return false; - const after = lower[index + tld.length]; - return !after || /[/?#]/.test(after); - }); -} - -function looksLikeDiscordPhish(url) { - const lower = url.toLowerCase(); - if (!lower.includes("discord") && !lower.includes("nitro")) { - return false; - } - return !/(?:^|\/\/)(?:www\.)?discord\.(?:com|gg)(?:\/|$)/i.test(lower); -} - -function hasPhishingKeywords(text) { - const normalized = normalizeText(text); - return PHISHING_KEYWORDS.some((keyword) => normalized.includes(keyword)); -} - -function analyzeUrls(text, context = {}) { - const urls = extractUrls(text); +function collectRolePingReasons(message) { const reasons = []; - const { massMention = false, phishingKeywords = false } = context; - - for (const url of urls) { - if (hasPhishingDomain(url)) { - reasons.push(`phishing domain in URL: ${url}`); - continue; - } - if (looksLikeDiscordPhish(url)) { - reasons.push(`fake Discord URL: ${url}`); - continue; - } - if (hasSuspiciousTld(url) && (phishingKeywords || massMention)) { - reasons.push(`suspicious TLD in URL: ${url}`); - } - } - - const imageUrls = [...text.matchAll(IMAGE_URL_PATTERN)].map((m) => m[0]); - const invites = [...text.matchAll(DISCORD_INVITE_PATTERN)].map((m) => m[0]); - const nonInviteUrls = urls.filter( - (url) => !invites.some((invite) => url.includes(invite)) - ); - - if (imageUrls.length > 0 && nonInviteUrls.length > 0) { - reasons.push("message contains image links with other URLs"); - } - - return { urls, reasons }; -} - -function roleMentionSize(role) { - return role.members?.size ?? 0; -} - -function hasMassMentionSignals(message, maxRoleMentionSize) { - if (message.mentions.everyone) return true; - return message.mentions.roles.some((role) => { - const size = roleMentionSize(role); - return size === 0 || size >= maxRoleMentionSize; - }); -} - -export function detectSpam(message, options) { - const { maxRoleMentionSize, minAccountAgeDays } = options; - const content = message.content ?? ""; - const reasons = []; - const massMention = hasMassMentionSignals(message, maxRoleMentionSize); if (message.mentions.everyone) { - reasons.push("@everyone mention"); + reasons.push("@everyone/@here mention"); } - if (message.mentions.roles.length > 0) { - for (const role of message.mentions.roles.values()) { - const size = roleMentionSize(role); - if (size >= maxRoleMentionSize) { - reasons.push(`large role mention: @${role.name} (${size} members)`); - } else { - reasons.push(`role mention: @${role.name}`); - } - } + const seenRoleIds = new Set(); + + for (const role of message.mentions.roles.values()) { + seenRoleIds.add(role.id); + reasons.push(`role mention: @${role.name}`); } - const phishingKeywords = hasPhishingKeywords(content); - const urlAnalysis = analyzeUrls(content, { massMention, phishingKeywords }); - reasons.push(...urlAnalysis.reasons); + const content = message.content ?? ""; + for (const match of content.matchAll(ROLE_MENTION_PATTERN)) { + const roleId = match[1]; + if (seenRoleIds.has(roleId)) continue; - if (phishingKeywords) { - reasons.push("phishing keywords in message"); + seenRoleIds.add(roleId); + const role = message.guild?.roles.cache.get(roleId); + reasons.push(role ? `role mention: @${role.name}` : `role mention: <@&${roleId}>`); } - const hasRiskyUrl = urlAnalysis.reasons.length > 0; - const hasMassMention = massMention; + return reasons; +} - if (hasMassMention && urlAnalysis.urls.length > 0) { - reasons.push("mass mention combined with URLs"); +function hasRolePing(message) { + if (message.mentions.everyone) return true; + if (message.mentions.roles.length > 0) return true; + return ROLE_MENTION_TEST.test(message.content ?? ""); +} + +export function detectSpam(message, { duplicateAcrossChannels = false } = {}) { + const scannableText = collectScannableText(message); + const urls = extractUrls(scannableText); + const hasLink = urls.length > 0; + const rolePingReasons = collectRolePingReasons(message); + const rolePing = hasRolePing(message); + const reasons = [...rolePingReasons]; + + if (hasLink) { + const preview = urls.slice(0, 3).join(", "); + reasons.push( + urls.length > 3 ? `link in message: ${preview} (+${urls.length - 3} more)` : `link in message: ${preview}` + ); } - if (message.mentions.roles.length > 0 && urlAnalysis.urls.length > 0 && hasRiskyUrl) { - reasons.push("role mention combined with suspicious URLs"); + if (duplicateAcrossChannels) { + reasons.push("same message posted across multiple channels"); } - if (minAccountAgeDays > 0 && message.author.createdTimestamp) { - const ageMs = Date.now() - message.author.createdTimestamp; - const ageDays = ageMs / (1000 * 60 * 60 * 24); - if (ageDays < minAccountAgeDays && (hasRiskyUrl || hasMassMention)) { - reasons.push( - `young account (${Math.floor(ageDays)} days old) with spam signals` - ); - } - } - - const uniqueReasons = [...new Set(reasons)]; - const hasPhishingText = uniqueReasons.some((r) => r.includes("phishing keywords")); - const isSpam = - uniqueReasons.length > 0 && - (hasRiskyUrl || - (hasMassMention && urlAnalysis.urls.length > 0) || - (hasPhishingText && urlAnalysis.urls.length > 0) || - (minAccountAgeDays > 0 && - uniqueReasons.some((r) => r.includes("young account")))); + const isSpam = rolePing && hasLink && duplicateAcrossChannels; return { isSpam, - reasons: uniqueReasons, - urls: urlAnalysis.urls, + reasons, + urls, }; }