diff --git a/Anti-Spam/src/commands.js b/Anti-Spam/src/commands.js index e3c49bd..6e322a0 100644 --- a/Anti-Spam/src/commands.js +++ b/Anti-Spam/src/commands.js @@ -140,10 +140,7 @@ export async function handleAntispamCommand(interaction) { { name: "Trusted roles", value: trustedRoles }, { name: "Detection rules", - value: [ - "Text spam: same message in multiple channels + role ping + link (all three)", - "Image spam: image-only message posted across multiple channels", - ].join("\n"), + value: "Same content posted across multiple channels within 2.5 minutes", }, { name: "Global actions", diff --git a/Anti-Spam/src/index.js b/Anti-Spam/src/index.js index 5d76ce3..5f3e415 100644 --- a/Anti-Spam/src/index.js +++ b/Anti-Spam/src/index.js @@ -7,12 +7,11 @@ import { } from "discord.js"; import { config } from "./config.js"; import { - buildImageFingerprint, - collectScannableText, + buildMessageTrackingKeys, + collectTrackableText, detectSpam, - isImageOnlyMessage, } from "./spamDetector.js"; -import { trackCrossChannelMessage, trackImageOnlySpam, dedupePriorMessages } from "./rapidSpamTracker.js"; +import { trackCrossChannelKeys } from "./rapidSpamTracker.js"; import { getGuildSettings } from "./guildSettings.js"; import { handleAntispamCommand } from "./commands.js"; import { registerCommands, registerCommandsForGuild } from "./registerCommands.js"; @@ -143,15 +142,23 @@ async function handleSpam(message, detection, guildSettings, priorMessages = []) name: "Message preview", value: preview || "(empty)", }); - } else if (message.attachments.size > 0) { - const attachmentPreview = [...message.attachments.values()] - .slice(0, 3) - .map((attachment) => attachment.name || attachment.url) - .join("\n"); - embed.addFields({ - name: "Attachments", - value: attachmentPreview || "(none)", - }); + } else { + const trackable = collectTrackableText(message); + if (trackable) { + embed.addFields({ + name: "Message preview", + value: trackable.slice(0, 1000), + }); + } else if (message.attachments.size > 0) { + const attachmentPreview = [...message.attachments.values()] + .slice(0, 3) + .map((attachment) => attachment.name || attachment.url) + .join("\n"); + embed.addFields({ + name: "Attachments", + value: attachmentPreview || "(none)", + }); + } } await sendModLog(guild, guildSettings, embed); @@ -232,39 +239,21 @@ client.on("messageCreate", async (message) => { if (isTrusted(message.member, guildSettings)) return; - const scannableText = collectScannableText(message); - const crossChannel = trackCrossChannelMessage( + const crossChannel = trackCrossChannelKeys( message.guild.id, message.author.id, message.channel.id, message.id, - scannableText + buildMessageTrackingKeys(message) ); - let imageCrossChannel = { duplicateAcrossChannels: false, priorMessages: [] }; - if (isImageOnlyMessage(message)) { - imageCrossChannel = trackImageOnlySpam( - message.guild.id, - message.author.id, - message.channel.id, - message.id, - buildImageFingerprint(message) - ); - } - const detection = detectSpam(message, { duplicateAcrossChannels: crossChannel.duplicateAcrossChannels, - duplicateImageAcrossChannels: imageCrossChannel.duplicateAcrossChannels, }); if (!detection.isSpam) return; - const priorMessages = dedupePriorMessages([ - ...crossChannel.priorMessages, - ...imageCrossChannel.priorMessages, - ]); - - await handleSpam(message, detection, guildSettings, priorMessages); + 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 index a9d31df..53fad11 100644 --- a/Anti-Spam/src/rapidSpamTracker.js +++ b/Anti-Spam/src/rapidSpamTracker.js @@ -63,15 +63,16 @@ export function trackCrossChannelMessage(guildId, userId, channelId, messageId, return trackCrossChannelPost(guildId, userId, channelId, messageId, normalizeKey(text)); } -export function trackImageOnlySpam(guildId, userId, channelId, messageId, fingerprint) { - const trackers = [trackCrossChannelPost(guildId, userId, channelId, messageId, "imgonly")]; - - if (fingerprint) { - trackers.push( - trackCrossChannelPost(guildId, userId, channelId, messageId, `imgfp:${fingerprint}`) - ); +export function trackCrossChannelKeys(guildId, userId, channelId, messageId, keys) { + const uniqueKeys = [...new Set(keys.map((key) => normalizeKey(key)).filter(Boolean))]; + if (uniqueKeys.length === 0) { + return { duplicateAcrossChannels: false, priorMessages: [] }; } + const trackers = uniqueKeys.map((key) => + trackCrossChannelPost(guildId, userId, channelId, messageId, key) + ); + const duplicateAcrossChannels = trackers.some((tracker) => tracker.duplicateAcrossChannels); const priorMessages = duplicateAcrossChannels ? dedupePriorMessages(trackers.flatMap((tracker) => tracker.priorMessages)) diff --git a/Anti-Spam/src/spamDetector.js b/Anti-Spam/src/spamDetector.js index 15bedf3..0c6e0db 100644 --- a/Anti-Spam/src/spamDetector.js +++ b/Anti-Spam/src/spamDetector.js @@ -1,10 +1,15 @@ const URL_PATTERN = /https?:\/\/[^\s<>"{}|\\^`[\]]+|(?:www\.)?[a-z0-9][-a-z0-9]*(?:\.[a-z0-9-]+)+(?:\/[^\s]*)?/gi; +const DISCORD_INVITE_PATTERN = + /(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite|discord\.me)\/([a-z0-9-]+)/gi; + +const DISCORD_INVITE_LOOSE_PATTERN = /discord\s*[.\s]*gg\s*\/\s*([a-z0-9-]+)/gi; + const ROLE_MENTION_PATTERN = /<@&(\d+)>/g; const ROLE_MENTION_TEST = /<@&\d+>/; -export function collectScannableText(message) { +export function collectTrackableText(message) { const parts = [message.content ?? ""]; for (const embed of message.embeds ?? []) { @@ -16,6 +21,12 @@ export function collectScannableText(message) { } } + return parts.filter(Boolean).join("\n"); +} + +export function collectScannableText(message) { + const parts = [collectTrackableText(message)]; + for (const attachment of message.attachments?.values() ?? []) { if (attachment.url) parts.push(attachment.url); if (attachment.proxyURL) parts.push(attachment.proxyURL); @@ -28,6 +39,64 @@ function extractUrls(text) { return [...text.matchAll(URL_PATTERN)].map((match) => match[0]); } +function extractInviteCodes(text) { + const codes = new Set(); + + for (const pattern of [DISCORD_INVITE_PATTERN, DISCORD_INVITE_LOOSE_PATTERN]) { + pattern.lastIndex = 0; + for (const match of text.matchAll(pattern)) { + if (match[1]) codes.add(match[1].toLowerCase()); + } + } + + return [...codes]; +} + +export function canonicalizeForTracking(text) { + let result = text.toLowerCase(); + + for (const pattern of [DISCORD_INVITE_PATTERN, DISCORD_INVITE_LOOSE_PATTERN]) { + pattern.lastIndex = 0; + result = result.replace(pattern, "discord.gg/$1"); + } + + result = result.replace(/https?:\/\//g, "").replace(/www\./g, ""); + return result.replace(/\s+/g, " ").trim().slice(0, 200); +} + +export function buildMessageTrackingKeys(message) { + const keys = new Set(); + const trackableText = collectTrackableText(message); + const canonical = canonicalizeForTracking(trackableText); + + if (canonical) keys.add(canonical); + + for (const code of extractInviteCodes(trackableText)) { + keys.add(`invite:${code}`); + } + + const imageFingerprint = buildImageFingerprint(message); + if (imageFingerprint) keys.add(`imgfp:${imageFingerprint}`); + + if (isImageOnlyMessage(message)) { + keys.add("imgonly"); + } + + return [...keys]; +} + +function hasTrackableContent(message) { + const trackableText = collectTrackableText(message); + if (canonicalizeForTracking(trackableText)) return true; + if (buildImageFingerprint(message)) return true; + return false; +} + +function messageHasLink(text) { + if (extractUrls(text).length > 0) return true; + return extractInviteCodes(text).length > 0; +} + function collectRolePingReasons(message) { const reasons = []; @@ -106,27 +175,27 @@ export function isImageOnlyMessage(message) { return true; } -export function detectSpam( - message, - { duplicateAcrossChannels = false, duplicateImageAcrossChannels = false } = {} -) { - const scannableText = collectScannableText(message); - const urls = extractUrls(scannableText); - const hasLink = urls.length > 0; +export function detectSpam(message, { duplicateAcrossChannels = false } = {}) { + const trackableText = collectTrackableText(message); + const urls = extractUrls(trackableText); + const inviteCodes = extractInviteCodes(trackableText); + const hasLink = messageHasLink(trackableText); const rolePingReasons = collectRolePingReasons(message); - const rolePing = hasRolePing(message); const imageOnly = isImageOnlyMessage(message); const reasons = [...rolePingReasons]; if (hasLink) { - const preview = urls.slice(0, 3).join(", "); + const linkPreview = [...new Set([...urls, ...inviteCodes.map((code) => `discord.gg/${code}`)])] + .slice(0, 3) + .join(", "); + const linkCount = new Set([...urls, ...inviteCodes.map((code) => `discord.gg/${code}`)]).size; reasons.push( - urls.length > 3 ? `link in message: ${preview} (+${urls.length - 3} more)` : `link in message: ${preview}` + linkCount > 3 ? `link in message: ${linkPreview} (+${linkCount - 3} more)` : `link in message: ${linkPreview}` ); } if (duplicateAcrossChannels) { - reasons.push("same message posted across multiple channels"); + reasons.push("same content posted across multiple channels"); } if (imageOnly) { @@ -142,13 +211,7 @@ export function detectSpam( ); } - if (duplicateImageAcrossChannels) { - reasons.push("image-only spam posted across multiple channels"); - } - - const isClassicSpam = rolePing && hasLink && duplicateAcrossChannels; - const isImageSpam = imageOnly && duplicateImageAcrossChannels; - const isSpam = isClassicSpam || isImageSpam; + const isSpam = duplicateAcrossChannels && hasTrackableContent(message); return { isSpam,