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: "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",
+23 -34
View File
@@ -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) => {
+8 -7
View File
@@ -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))
+82 -19
View File
@@ -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,