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:
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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,7 +44,14 @@ export function trackCrossChannelMessage(guildId, userId, channelId, messageId,
|
|||||||
firstSeen: now,
|
firstSeen: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recentPosts.size > 5000) {
|
pruneExpiredPosts(now);
|
||||||
|
|
||||||
|
return { duplicateAcrossChannels: false, priorMessages: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpiredPosts(now) {
|
||||||
|
if (recentPosts.size <= 5000) return;
|
||||||
|
|
||||||
for (const [entryKey, entry] of recentPosts) {
|
for (const [entryKey, entry] of recentPosts) {
|
||||||
if (now - entry.firstSeen > WINDOW_MS) {
|
if (now - entry.firstSeen > WINDOW_MS) {
|
||||||
recentPosts.delete(entryKey);
|
recentPosts.delete(entryKey);
|
||||||
@@ -42,5 +59,25 @@ export function trackCrossChannelMessage(guildId, userId, channelId, messageId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { duplicateAcrossChannels: false, priorMessages: [] };
|
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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user