Refactor cross-channel tracking and spam detection

Replace per-case trackers with a unified key-based tracker and tighten detection logic. Introduces buildMessageTrackingKeys/collectTrackableText to canonicalize text, extract invite codes, include image fingerprints and dedupe keys; replaces trackCrossChannelMessage/trackImageOnlySpam with trackCrossChannelKeys that tracks multiple normalized keys. Simplifies detectSpam to use trackable content and invite-aware link handling, updates message preview/attachment embedding, and adjusts command/help text to reflect the new "same content across channels" rule. Overall reduces duplicate-prior merging and streamlines cross-channel spam handling.
This commit is contained in:
2026-06-21 21:38:22 +12:00
parent ef4d2aeb40
commit 024f1082ef
4 changed files with 114 additions and 64 deletions
+1 -4
View File
@@ -140,10 +140,7 @@ export async function handleAntispamCommand(interaction) {
{ name: "Trusted roles", value: trustedRoles }, { name: "Trusted roles", value: trustedRoles },
{ {
name: "Detection rules", name: "Detection rules",
value: [ value: "Same content posted across multiple channels within 2.5 minutes",
"Text spam: same message in multiple channels + role ping + link (all three)",
"Image spam: image-only message posted across multiple channels",
].join("\n"),
}, },
{ {
name: "Global actions", name: "Global actions",
+14 -25
View File
@@ -7,12 +7,11 @@ import {
} from "discord.js"; } from "discord.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { import {
buildImageFingerprint, buildMessageTrackingKeys,
collectScannableText, collectTrackableText,
detectSpam, detectSpam,
isImageOnlyMessage,
} from "./spamDetector.js"; } from "./spamDetector.js";
import { trackCrossChannelMessage, trackImageOnlySpam, dedupePriorMessages } from "./rapidSpamTracker.js"; import { trackCrossChannelKeys } 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";
@@ -143,6 +142,13 @@ async function handleSpam(message, detection, guildSettings, priorMessages = [])
name: "Message preview", name: "Message preview",
value: preview || "(empty)", value: preview || "(empty)",
}); });
} else {
const trackable = collectTrackableText(message);
if (trackable) {
embed.addFields({
name: "Message preview",
value: trackable.slice(0, 1000),
});
} else if (message.attachments.size > 0) { } else if (message.attachments.size > 0) {
const attachmentPreview = [...message.attachments.values()] const attachmentPreview = [...message.attachments.values()]
.slice(0, 3) .slice(0, 3)
@@ -153,6 +159,7 @@ async function handleSpam(message, detection, guildSettings, priorMessages = [])
value: attachmentPreview || "(none)", value: attachmentPreview || "(none)",
}); });
} }
}
await sendModLog(guild, guildSettings, embed); await sendModLog(guild, guildSettings, embed);
console.log( console.log(
@@ -232,39 +239,21 @@ client.on("messageCreate", async (message) => {
if (isTrusted(message.member, guildSettings)) return; if (isTrusted(message.member, guildSettings)) return;
const scannableText = collectScannableText(message); const crossChannel = trackCrossChannelKeys(
const crossChannel = trackCrossChannelMessage(
message.guild.id, message.guild.id,
message.author.id, message.author.id,
message.channel.id, message.channel.id,
message.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, { const detection = detectSpam(message, {
duplicateAcrossChannels: crossChannel.duplicateAcrossChannels, duplicateAcrossChannels: crossChannel.duplicateAcrossChannels,
duplicateImageAcrossChannels: imageCrossChannel.duplicateAcrossChannels,
}); });
if (!detection.isSpam) return; if (!detection.isSpam) return;
const priorMessages = dedupePriorMessages([ await handleSpam(message, detection, guildSettings, crossChannel.priorMessages);
...crossChannel.priorMessages,
...imageCrossChannel.priorMessages,
]);
await handleSpam(message, detection, guildSettings, priorMessages);
}); });
process.on("unhandledRejection", (error) => { process.on("unhandledRejection", (error) => {
+8 -7
View File
@@ -63,15 +63,16 @@ export function trackCrossChannelMessage(guildId, userId, channelId, messageId,
return trackCrossChannelPost(guildId, userId, channelId, messageId, normalizeKey(text)); return trackCrossChannelPost(guildId, userId, channelId, messageId, normalizeKey(text));
} }
export function trackImageOnlySpam(guildId, userId, channelId, messageId, fingerprint) { export function trackCrossChannelKeys(guildId, userId, channelId, messageId, keys) {
const trackers = [trackCrossChannelPost(guildId, userId, channelId, messageId, "imgonly")]; const uniqueKeys = [...new Set(keys.map((key) => normalizeKey(key)).filter(Boolean))];
if (uniqueKeys.length === 0) {
if (fingerprint) { return { duplicateAcrossChannels: false, priorMessages: [] };
trackers.push(
trackCrossChannelPost(guildId, userId, channelId, messageId, `imgfp:${fingerprint}`)
);
} }
const trackers = uniqueKeys.map((key) =>
trackCrossChannelPost(guildId, userId, channelId, messageId, key)
);
const duplicateAcrossChannels = trackers.some((tracker) => tracker.duplicateAcrossChannels); const duplicateAcrossChannels = trackers.some((tracker) => tracker.duplicateAcrossChannels);
const priorMessages = duplicateAcrossChannels const priorMessages = duplicateAcrossChannels
? dedupePriorMessages(trackers.flatMap((tracker) => tracker.priorMessages)) ? dedupePriorMessages(trackers.flatMap((tracker) => tracker.priorMessages))
+82 -19
View File
@@ -1,10 +1,15 @@
const URL_PATTERN = const URL_PATTERN =
/https?:\/\/[^\s<>"{}|\\^`[\]]+|(?:www\.)?[a-z0-9][-a-z0-9]*(?:\.[a-z0-9-]+)+(?:\/[^\s]*)?/gi; /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_PATTERN = /<@&(\d+)>/g;
const ROLE_MENTION_TEST = /<@&\d+>/; const ROLE_MENTION_TEST = /<@&\d+>/;
export function collectScannableText(message) { export function collectTrackableText(message) {
const parts = [message.content ?? ""]; const parts = [message.content ?? ""];
for (const embed of message.embeds ?? []) { 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() ?? []) { for (const attachment of message.attachments?.values() ?? []) {
if (attachment.url) parts.push(attachment.url); if (attachment.url) parts.push(attachment.url);
if (attachment.proxyURL) parts.push(attachment.proxyURL); if (attachment.proxyURL) parts.push(attachment.proxyURL);
@@ -28,6 +39,64 @@ function extractUrls(text) {
return [...text.matchAll(URL_PATTERN)].map((match) => match[0]); 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) { function collectRolePingReasons(message) {
const reasons = []; const reasons = [];
@@ -106,27 +175,27 @@ export function isImageOnlyMessage(message) {
return true; return true;
} }
export function detectSpam( export function detectSpam(message, { duplicateAcrossChannels = false } = {}) {
message, const trackableText = collectTrackableText(message);
{ duplicateAcrossChannels = false, duplicateImageAcrossChannels = false } = {} const urls = extractUrls(trackableText);
) { const inviteCodes = extractInviteCodes(trackableText);
const scannableText = collectScannableText(message); const hasLink = messageHasLink(trackableText);
const urls = extractUrls(scannableText);
const hasLink = urls.length > 0;
const rolePingReasons = collectRolePingReasons(message); const rolePingReasons = collectRolePingReasons(message);
const rolePing = hasRolePing(message);
const imageOnly = isImageOnlyMessage(message); const imageOnly = isImageOnlyMessage(message);
const reasons = [...rolePingReasons]; const reasons = [...rolePingReasons];
if (hasLink) { 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( 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) { if (duplicateAcrossChannels) {
reasons.push("same message posted across multiple channels"); reasons.push("same content posted across multiple channels");
} }
if (imageOnly) { if (imageOnly) {
@@ -142,13 +211,7 @@ export function detectSpam(
); );
} }
if (duplicateImageAcrossChannels) { const isSpam = duplicateAcrossChannels && hasTrackableContent(message);
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,