Add image-only spam detection and tracking

Add support for detecting and tracking image-only spam across channels. spamDetector.js: introduce image helpers (isImageAttachment, getImageAttachments), buildImageFingerprint, isImageOnlyMessage, and extend detectSpam to report image-only reasons and a duplicateImageAcrossChannels flag. rapidSpamTracker.js: refactor cross-channel tracking with trackCrossChannelPost, pruneExpiredPosts, dedupePriorMessages, and add trackImageOnlySpam which uses optional image fingerprints; export dedupePriorMessages. index.js: wire image-only tracking into message handling (build fingerprint, trackImageOnlySpam, pass duplicateImageAcrossChannels to detectSpam), merge and dedupe prior message lists, and include attachments in mod log previews. commands.js: update detection rules text to mention image spam. These changes enable identifying image-only spam (including fingerprinting) and avoid duplicate prior-message entries when creating moderation logs.
This commit is contained in:
2026-06-17 17:35:07 +12:00
parent 9007f210ef
commit ef4d2aeb40
4 changed files with 152 additions and 18 deletions
+2 -4
View File
@@ -141,10 +141,8 @@ export async function handleAntispamCommand(interaction) {
{ {
name: "Detection rules", name: "Detection rules",
value: [ value: [
"Same message in multiple channels", "Text spam: same message in multiple channels + role ping + link (all three)",
"Any role ping (or @everyone/@here)", "Image spam: image-only message posted across multiple channels",
"Contains a link",
"(all three required)",
].join("\n"), ].join("\n"),
}, },
{ {
+34 -3
View File
@@ -6,8 +6,13 @@ import {
EmbedBuilder, EmbedBuilder,
} from "discord.js"; } from "discord.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { collectScannableText, detectSpam } from "./spamDetector.js"; import {
import { trackCrossChannelMessage } from "./rapidSpamTracker.js"; buildImageFingerprint,
collectScannableText,
detectSpam,
isImageOnlyMessage,
} from "./spamDetector.js";
import { trackCrossChannelMessage, trackImageOnlySpam, dedupePriorMessages } from "./rapidSpamTracker.js";
import { getGuildSettings } from "./guildSettings.js"; import { getGuildSettings } from "./guildSettings.js";
import { handleAntispamCommand } from "./commands.js"; import { handleAntispamCommand } from "./commands.js";
import { registerCommands, registerCommandsForGuild } from "./registerCommands.js"; import { registerCommands, registerCommandsForGuild } from "./registerCommands.js";
@@ -138,6 +143,15 @@ async function handleSpam(message, detection, guildSettings, priorMessages = [])
name: "Message preview", name: "Message preview",
value: preview || "(empty)", 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); await sendModLog(guild, guildSettings, embed);
@@ -227,13 +241,30 @@ client.on("messageCreate", async (message) => {
scannableText 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, { const detection = detectSpam(message, {
duplicateAcrossChannels: crossChannel.duplicateAcrossChannels, duplicateAcrossChannels: crossChannel.duplicateAcrossChannels,
duplicateImageAcrossChannels: imageCrossChannel.duplicateAcrossChannels,
}); });
if (!detection.isSpam) return; 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) => { process.on("unhandledRejection", (error) => {
+46 -9
View File
@@ -5,8 +5,18 @@ function normalizeKey(text) {
return text.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 200); return text.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 200);
} }
export function trackCrossChannelMessage(guildId, userId, channelId, messageId, text) { function dedupePriorMessages(priorMessages) {
const normalized = normalizeKey(text); 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) { if (!normalized) {
return { duplicateAcrossChannels: false, priorMessages: [] }; return { duplicateAcrossChannels: false, priorMessages: [] };
} }
@@ -34,13 +44,40 @@ export function trackCrossChannelMessage(guildId, userId, channelId, messageId,
firstSeen: now, firstSeen: now,
}); });
if (recentPosts.size > 5000) { pruneExpiredPosts(now);
for (const [entryKey, entry] of recentPosts) {
if (now - entry.firstSeen > WINDOW_MS) {
recentPosts.delete(entryKey);
}
}
}
return { duplicateAcrossChannels: false, priorMessages: [] }; 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 };
+70 -2
View File
@@ -61,12 +61,61 @@ function hasRolePing(message) {
return ROLE_MENTION_TEST.test(message.content ?? ""); 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 scannableText = collectScannableText(message);
const urls = extractUrls(scannableText); const urls = extractUrls(scannableText);
const hasLink = urls.length > 0; const hasLink = urls.length > 0;
const rolePingReasons = collectRolePingReasons(message); const rolePingReasons = collectRolePingReasons(message);
const rolePing = hasRolePing(message); const rolePing = hasRolePing(message);
const imageOnly = isImageOnlyMessage(message);
const reasons = [...rolePingReasons]; const reasons = [...rolePingReasons];
if (hasLink) { if (hasLink) {
@@ -80,7 +129,26 @@ export function detectSpam(message, { duplicateAcrossChannels = false } = {}) {
reasons.push("same message posted across multiple channels"); 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 { return {
isSpam, isSpam,