diff --git a/Anti-Spam/src/commands.js b/Anti-Spam/src/commands.js index 4942e9a..e3c49bd 100644 --- a/Anti-Spam/src/commands.js +++ b/Anti-Spam/src/commands.js @@ -141,10 +141,8 @@ export async function handleAntispamCommand(interaction) { { name: "Detection rules", value: [ - "Same message in multiple channels", - "Any role ping (or @everyone/@here)", - "Contains a link", - "(all three required)", + "Text spam: same message in multiple channels + role ping + link (all three)", + "Image spam: image-only message posted across multiple channels", ].join("\n"), }, { diff --git a/Anti-Spam/src/index.js b/Anti-Spam/src/index.js index ad7c75b..5d76ce3 100644 --- a/Anti-Spam/src/index.js +++ b/Anti-Spam/src/index.js @@ -6,8 +6,13 @@ import { EmbedBuilder, } from "discord.js"; import { config } from "./config.js"; -import { collectScannableText, detectSpam } from "./spamDetector.js"; -import { trackCrossChannelMessage } from "./rapidSpamTracker.js"; +import { + buildImageFingerprint, + collectScannableText, + detectSpam, + isImageOnlyMessage, +} from "./spamDetector.js"; +import { trackCrossChannelMessage, trackImageOnlySpam, dedupePriorMessages } from "./rapidSpamTracker.js"; import { getGuildSettings } from "./guildSettings.js"; import { handleAntispamCommand } from "./commands.js"; import { registerCommands, registerCommandsForGuild } from "./registerCommands.js"; @@ -138,6 +143,15 @@ 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)", + }); } await sendModLog(guild, guildSettings, embed); @@ -227,13 +241,30 @@ client.on("messageCreate", async (message) => { scannableText ); + 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; - await handleSpam(message, detection, guildSettings, crossChannel.priorMessages); + const priorMessages = dedupePriorMessages([ + ...crossChannel.priorMessages, + ...imageCrossChannel.priorMessages, + ]); + + await handleSpam(message, detection, guildSettings, priorMessages); }); process.on("unhandledRejection", (error) => { diff --git a/Anti-Spam/src/rapidSpamTracker.js b/Anti-Spam/src/rapidSpamTracker.js index 6995a82..a9d31df 100644 --- a/Anti-Spam/src/rapidSpamTracker.js +++ b/Anti-Spam/src/rapidSpamTracker.js @@ -5,8 +5,18 @@ 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); +function dedupePriorMessages(priorMessages) { + const seen = new Set(); + return priorMessages.filter(({ channelId, messageId }) => { + const entry = `${channelId}:${messageId}`; + if (seen.has(entry)) return false; + seen.add(entry); + return true; + }); +} + +function trackCrossChannelPost(guildId, userId, channelId, messageId, keySuffix) { + const normalized = normalizeKey(keySuffix); if (!normalized) { return { duplicateAcrossChannels: false, priorMessages: [] }; } @@ -34,13 +44,40 @@ export function trackCrossChannelMessage(guildId, userId, channelId, messageId, firstSeen: now, }); - if (recentPosts.size > 5000) { - for (const [entryKey, entry] of recentPosts) { - if (now - entry.firstSeen > WINDOW_MS) { - recentPosts.delete(entryKey); - } - } - } + pruneExpiredPosts(now); return { duplicateAcrossChannels: false, priorMessages: [] }; } + +function pruneExpiredPosts(now) { + if (recentPosts.size <= 5000) return; + + for (const [entryKey, entry] of recentPosts) { + if (now - entry.firstSeen > WINDOW_MS) { + recentPosts.delete(entryKey); + } + } +} + +export function trackCrossChannelMessage(guildId, userId, channelId, messageId, text) { + 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}`) + ); + } + + const duplicateAcrossChannels = trackers.some((tracker) => tracker.duplicateAcrossChannels); + const priorMessages = duplicateAcrossChannels + ? dedupePriorMessages(trackers.flatMap((tracker) => tracker.priorMessages)) + : []; + + return { duplicateAcrossChannels, priorMessages }; +} + +export { dedupePriorMessages }; diff --git a/Anti-Spam/src/spamDetector.js b/Anti-Spam/src/spamDetector.js index 3c1b71b..15bedf3 100644 --- a/Anti-Spam/src/spamDetector.js +++ b/Anti-Spam/src/spamDetector.js @@ -61,12 +61,61 @@ function hasRolePing(message) { return ROLE_MENTION_TEST.test(message.content ?? ""); } -export function detectSpam(message, { duplicateAcrossChannels = false } = {}) { +function isImageAttachment(attachment) { + if (attachment.contentType?.startsWith("image/")) return true; + return Boolean(attachment.width && attachment.height); +} + +export function getImageAttachments(message) { + return [...(message.attachments?.values() ?? [])].filter(isImageAttachment); +} + +export function buildImageFingerprint(message) { + const images = getImageAttachments(message); + const stickers = [...(message.stickers?.values() ?? [])].map((sticker) => sticker.id); + + const imageParts = images + .map( + (attachment) => + `${(attachment.name ?? "").toLowerCase()}:${attachment.size}:${attachment.width ?? 0}x${attachment.height ?? 0}` + ) + .sort(); + + const stickerPart = stickers.length > 0 ? `stickers:${stickers.sort().join(",")}` : ""; + + const fingerprint = [imageParts.join("|"), stickerPart].filter(Boolean).join("|"); + return fingerprint || null; +} + +export function isImageOnlyMessage(message) { + const content = (message.content ?? "").trim(); + if (content) return false; + if (hasRolePing(message)) return false; + + const images = getImageAttachments(message); + const stickers = message.stickers?.size ?? 0; + if (images.length === 0 && stickers === 0) return false; + + const attachments = [...(message.attachments?.values() ?? [])]; + if (attachments.length > images.length) return false; + + for (const embed of message.embeds ?? []) { + if (embed.title || embed.description || embed.url) return false; + } + + return true; +} + +export function detectSpam( + message, + { duplicateAcrossChannels = false, duplicateImageAcrossChannels = false } = {} +) { const scannableText = collectScannableText(message); const urls = extractUrls(scannableText); const hasLink = urls.length > 0; const rolePingReasons = collectRolePingReasons(message); const rolePing = hasRolePing(message); + const imageOnly = isImageOnlyMessage(message); const reasons = [...rolePingReasons]; if (hasLink) { @@ -80,7 +129,26 @@ export function detectSpam(message, { duplicateAcrossChannels = false } = {}) { reasons.push("same message posted across multiple channels"); } - const isSpam = rolePing && hasLink && duplicateAcrossChannels; + if (imageOnly) { + const images = getImageAttachments(message); + const preview = images + .slice(0, 3) + .map((attachment) => attachment.name || "image") + .join(", "); + reasons.push( + images.length > 0 + ? `image-only message (${preview}${images.length > 3 ? ` +${images.length - 3} more` : ""})` + : "image-only message (stickers)" + ); + } + + if (duplicateImageAcrossChannels) { + reasons.push("image-only spam posted across multiple channels"); + } + + const isClassicSpam = rolePing && hasLink && duplicateAcrossChannels; + const isImageSpam = imageOnly && duplicateImageAcrossChannels; + const isSpam = isClassicSpam || isImageSpam; return { isSpam,