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