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:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user