Add cross-channel spam tracker; refactor detection

Introduce a rapidSpamTracker to detect same messages posted across channels within a short window and return prior message IDs for cleanup. Refactor spam detection to collect scannable text from message content, embeds and attachments, simplify role-ping and link heuristics, and mark spam when a role ping + link is duplicated across channels. Update index to use the tracker/collector, delete prior spam messages, improve moderation flow (ban → kick fallback), report clearer action/error labels, and warn about missing channel permissions. Update commands display text for detection/actions and bump .env.example MIN_ACCOUNT_AGE_DAYS default to 7.
This commit is contained in:
2026-06-16 15:09:02 +12:00
parent 70d72dfef4
commit 9007f210ef
5 changed files with 182 additions and 201 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ DELETE_MESSAGE=true
# Flag role mentions when the role has at least this many members # Flag role mentions when the role has at least this many members
MAX_ROLE_MENTION_SIZE=50 MAX_ROLE_MENTION_SIZE=50
# Extra strict on accounts younger than this many days (0 = disabled) # Extra strict on accounts younger than this many days (0 = disabled; 7 recommended for raid protection)
MIN_ACCOUNT_AGE_DAYS=0 MIN_ACCOUNT_AGE_DAYS=7
# Comma-separated user IDs that bypass checks on all servers (e.g. bot owner) # Comma-separated user IDs that bypass checks on all servers (e.g. bot owner)
TRUSTED_USER_IDS= TRUSTED_USER_IDS=
+10 -3
View File
@@ -139,12 +139,19 @@ export async function handleAntispamCommand(interaction) {
}, },
{ name: "Trusted roles", value: trustedRoles }, { name: "Trusted roles", value: trustedRoles },
{ {
name: "Global defaults", name: "Detection rules",
value: [
"Same message in multiple channels",
"Any role ping (or @everyone/@here)",
"Contains a link",
"(all three required)",
].join("\n"),
},
{
name: "Global actions",
value: [ value: [
`Ban on spam: ${config.banOnSpam}`, `Ban on spam: ${config.banOnSpam}`,
`Delete messages: ${config.deleteMessage}`, `Delete messages: ${config.deleteMessage}`,
`Large role mention threshold: ${config.maxRoleMentionSize}`,
`Min account age (days): ${config.minAccountAgeDays || "disabled"}`,
].join("\n"), ].join("\n"),
} }
); );
+66 -19
View File
@@ -6,7 +6,8 @@ import {
EmbedBuilder, EmbedBuilder,
} from "discord.js"; } from "discord.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { detectSpam } from "./spamDetector.js"; import { collectScannableText, detectSpam } from "./spamDetector.js";
import { trackCrossChannelMessage } 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";
@@ -49,46 +50,79 @@ async function sendModLog(guild, guildSettings, embed) {
}); });
} }
async function handleSpam(message, detection, guildSettings) { async function deleteTrackedMessages(guild, priorMessages) {
for (const { channelId, messageId } of priorMessages) {
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) continue;
const priorMessage = await channel.messages.fetch(messageId).catch(() => null);
if (!priorMessage) continue;
await priorMessage.delete().catch((error) => {
console.error(`Failed to delete prior spam message ${messageId}:`, error.message);
});
}
}
async function handleSpam(message, detection, guildSettings, priorMessages = []) {
const { member, guild, author, channel } = message; const { member, guild, author, channel } = message;
const reasonSummary = detection.reasons.join("; "); const reasonSummary = detection.reasons.join("; ");
if (config.deleteMessage) { if (config.deleteMessage) {
await deleteTrackedMessages(guild, priorMessages);
await message.delete().catch((error) => { await message.delete().catch((error) => {
console.error(`Failed to delete message ${message.id}:`, error.message); console.error(`Failed to delete message ${message.id}:`, error.message);
}); });
} }
let banned = false; let moderationAction = null;
let banError = null; let moderationError = null;
const moderationReason = `Anti-Spam: ${reasonSummary}`.slice(0, 512);
if (config.banOnSpam && member && member.bannable) { if (config.banOnSpam && member?.bannable) {
try { try {
await member.ban({ await member.ban({
reason: `Anti-Spam: ${reasonSummary}`.slice(0, 512), reason: moderationReason,
deleteMessageSeconds: 60 * 60 * 24, deleteMessageSeconds: 60 * 60 * 24,
}); });
banned = true; moderationAction = "banned";
} catch (error) { } catch (error) {
banError = error.message; moderationError = error.message;
console.error(`Failed to ban ${author.tag}:`, error.message); console.error(`Failed to ban ${author.tag}:`, error.message);
} }
} }
if (!moderationAction && config.banOnSpam && member?.kickable) {
try {
await member.kick(moderationReason);
moderationAction = "kicked";
} catch (error) {
moderationError = error.message;
console.error(`Failed to kick ${author.tag}:`, error.message);
}
}
const actionLabel =
moderationAction ??
(config.banOnSpam ? "moderation failed" : "moderation disabled");
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(banned ? 0xed4245 : 0xfaa61a) .setColor(moderationAction ? 0xed4245 : 0xfaa61a)
.setTitle(banned ? "Spam blocked — user banned" : "Spam blocked") .setTitle(moderationAction ? `Spam blocked — user ${moderationAction}` : "Spam blocked")
.setDescription( .setDescription(
[ [
`**User:** ${author.tag} (\`${author.id}\`)`, `**User:** ${author.tag} (\`${author.id}\`)`,
`**Channel:** <#${channel.id}>`, `**Channel:** <#${channel.id}>`,
`**Action:** ${[ `**Action:** ${[
config.deleteMessage ? "message deleted" : null, config.deleteMessage
banned ? "user banned" : config.banOnSpam ? "ban failed" : "ban disabled", ? `message deleted${priorMessages.length > 0 ? ` (+${priorMessages.length} in other channels)` : ""}`
: null,
actionLabel,
] ]
.filter(Boolean) .filter(Boolean)
.join(", ")}`, .join(", ")}`,
banError ? `**Ban error:** ${banError}` : null, moderationError ? `**Moderation error:** ${moderationError}` : null,
"", "",
"**Triggers:**", "**Triggers:**",
detection.reasons.map((r) => `${r}`).join("\n"), detection.reasons.map((r) => `${r}`).join("\n"),
@@ -109,7 +143,7 @@ async function handleSpam(message, detection, guildSettings) {
await sendModLog(guild, guildSettings, embed); await sendModLog(guild, guildSettings, embed);
console.log( console.log(
`[${guild.name}] Spam from ${author.tag}: ${reasonSummary}${ `[${guild.name}] Spam from ${author.tag}: ${reasonSummary}${
banned ? " (banned)" : "" moderationAction ? ` (${moderationAction})` : ""
}` }`
); );
} }
@@ -170,23 +204,36 @@ client.on("messageCreate", async (message) => {
PermissionFlagsBits.ManageMessages, PermissionFlagsBits.ManageMessages,
]; ];
if (config.banOnSpam) { if (config.banOnSpam) {
requiredPerms.push(PermissionFlagsBits.BanMembers); requiredPerms.push(PermissionFlagsBits.BanMembers, PermissionFlagsBits.KickMembers);
} }
if (!message.channel.permissionsFor(me)?.has(requiredPerms, true)) { const channelPerms = message.channel.permissionsFor(me);
if (!channelPerms?.has(requiredPerms, true)) {
const missing = requiredPerms.filter((perm) => !channelPerms?.has(perm, true));
console.warn(
`[${message.guild.name}] Missing permissions in #${message.channel.name}: ${missing.join(", ")}`
);
return; return;
} }
if (isTrusted(message.member, guildSettings)) return; if (isTrusted(message.member, guildSettings)) return;
const scannableText = collectScannableText(message);
const crossChannel = trackCrossChannelMessage(
message.guild.id,
message.author.id,
message.channel.id,
message.id,
scannableText
);
const detection = detectSpam(message, { const detection = detectSpam(message, {
maxRoleMentionSize: config.maxRoleMentionSize, duplicateAcrossChannels: crossChannel.duplicateAcrossChannels,
minAccountAgeDays: config.minAccountAgeDays,
}); });
if (!detection.isSpam) return; if (!detection.isSpam) return;
await handleSpam(message, detection, guildSettings); await handleSpam(message, detection, guildSettings, crossChannel.priorMessages);
}); });
process.on("unhandledRejection", (error) => { process.on("unhandledRejection", (error) => {
+46
View File
@@ -0,0 +1,46 @@
const WINDOW_MS = 150_000; // 2.5 minutes
const recentPosts = new Map();
function normalizeKey(text) {
return text.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 200);
}
export function trackCrossChannelMessage(guildId, userId, channelId, messageId, text) {
const normalized = normalizeKey(text);
if (!normalized) {
return { duplicateAcrossChannels: false, priorMessages: [] };
}
const key = `${guildId}:${userId}:${normalized}`;
const now = Date.now();
const existing = recentPosts.get(key);
if (existing && now - existing.firstSeen < WINDOW_MS) {
const priorMessages = [...existing.messages.entries()].map(([chId, msgId]) => ({
channelId: chId,
messageId: msgId,
}));
existing.channels.add(channelId);
existing.messages.set(channelId, messageId);
const duplicateAcrossChannels = existing.channels.size >= 2;
return { duplicateAcrossChannels, priorMessages: duplicateAcrossChannels ? priorMessages : [] };
}
recentPosts.set(key, {
channels: new Set([channelId]),
messages: new Map([[channelId, messageId]]),
firstSeen: now,
});
if (recentPosts.size > 5000) {
for (const [entryKey, entry] of recentPosts) {
if (now - entry.firstSeen > WINDOW_MS) {
recentPosts.delete(entryKey);
}
}
}
return { duplicateAcrossChannels: false, priorMessages: [] };
}
+55 -174
View File
@@ -1,209 +1,90 @@
const URL_PATTERN = const URL_PATTERN =
/https?:\/\/[^\s<>"{}|\\^`[\]]+|(?:www\.)[a-z0-9-]+(?:\.[a-z0-9-]+)+[^\s]*/gi; /https?:\/\/[^\s<>"{}|\\^`[\]]+|(?:www\.)?[a-z0-9][-a-z0-9]*(?:\.[a-z0-9-]+)+(?:\/[^\s]*)?/gi;
const IMAGE_URL_PATTERN = const ROLE_MENTION_PATTERN = /<@&(\d+)>/g;
/https?:\/\/[^\s]+\.(?:png|jpe?g|gif|webp|bmp)(?:\?[^\s]*)?/gi; const ROLE_MENTION_TEST = /<@&\d+>/;
const DISCORD_INVITE_PATTERN = export function collectScannableText(message) {
/(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\/[a-z0-9-]+/gi; const parts = [message.content ?? ""];
const PHISHING_DOMAIN_FRAGMENTS = [ for (const embed of message.embeds ?? []) {
"discord-gift", if (embed.url) parts.push(embed.url);
"discordgift", if (embed.title) parts.push(embed.title);
"discord-nitro", if (embed.description) parts.push(embed.description);
"discordnitro", for (const field of embed.fields ?? []) {
"discord-app", parts.push(field.name, field.value);
"discordapp", }
"discorcl", }
"discrod",
"dlscord",
"discocrd",
"steamcommunjty",
"steamcommunlty",
"steamcornmunity",
"stearncommunity",
"free-nitro",
"freenitro",
"nitro-free",
"nitrofree",
"airdrop",
"claim-nitro",
"claimnitro",
"verify-account",
"account-verify",
"login-discord",
"discord-login",
];
const PHISHING_KEYWORDS = [ for (const attachment of message.attachments?.values() ?? []) {
"free nitro", if (attachment.url) parts.push(attachment.url);
"claim your nitro", if (attachment.proxyURL) parts.push(attachment.proxyURL);
"claim nitro", }
"nitro giveaway",
"steam gift",
"verify your account",
"account suspended",
"unusual activity",
"click to verify",
"limited time offer",
];
const SUSPICIOUS_TLDS = [ return parts.filter(Boolean).join("\n");
".xyz",
".top",
".click",
".icu",
".buzz",
".monster",
".rest",
".cfd",
".sbs",
".lat",
];
function normalizeText(text) {
return text.toLowerCase().replace(/\s+/g, " ").trim();
} }
function extractUrls(text) { function extractUrls(text) {
return [...text.matchAll(URL_PATTERN)].map((match) => match[0]); return [...text.matchAll(URL_PATTERN)].map((match) => match[0]);
} }
function hasPhishingDomain(url) { function collectRolePingReasons(message) {
const lower = url.toLowerCase();
return PHISHING_DOMAIN_FRAGMENTS.some((fragment) => lower.includes(fragment));
}
function hasSuspiciousTld(url) {
const lower = url.toLowerCase();
return SUSPICIOUS_TLDS.some((tld) => {
const index = lower.indexOf(tld);
if (index === -1) return false;
const after = lower[index + tld.length];
return !after || /[/?#]/.test(after);
});
}
function looksLikeDiscordPhish(url) {
const lower = url.toLowerCase();
if (!lower.includes("discord") && !lower.includes("nitro")) {
return false;
}
return !/(?:^|\/\/)(?:www\.)?discord\.(?:com|gg)(?:\/|$)/i.test(lower);
}
function hasPhishingKeywords(text) {
const normalized = normalizeText(text);
return PHISHING_KEYWORDS.some((keyword) => normalized.includes(keyword));
}
function analyzeUrls(text, context = {}) {
const urls = extractUrls(text);
const reasons = []; const reasons = [];
const { massMention = false, phishingKeywords = false } = context;
for (const url of urls) {
if (hasPhishingDomain(url)) {
reasons.push(`phishing domain in URL: ${url}`);
continue;
}
if (looksLikeDiscordPhish(url)) {
reasons.push(`fake Discord URL: ${url}`);
continue;
}
if (hasSuspiciousTld(url) && (phishingKeywords || massMention)) {
reasons.push(`suspicious TLD in URL: ${url}`);
}
}
const imageUrls = [...text.matchAll(IMAGE_URL_PATTERN)].map((m) => m[0]);
const invites = [...text.matchAll(DISCORD_INVITE_PATTERN)].map((m) => m[0]);
const nonInviteUrls = urls.filter(
(url) => !invites.some((invite) => url.includes(invite))
);
if (imageUrls.length > 0 && nonInviteUrls.length > 0) {
reasons.push("message contains image links with other URLs");
}
return { urls, reasons };
}
function roleMentionSize(role) {
return role.members?.size ?? 0;
}
function hasMassMentionSignals(message, maxRoleMentionSize) {
if (message.mentions.everyone) return true;
return message.mentions.roles.some((role) => {
const size = roleMentionSize(role);
return size === 0 || size >= maxRoleMentionSize;
});
}
export function detectSpam(message, options) {
const { maxRoleMentionSize, minAccountAgeDays } = options;
const content = message.content ?? "";
const reasons = [];
const massMention = hasMassMentionSignals(message, maxRoleMentionSize);
if (message.mentions.everyone) { if (message.mentions.everyone) {
reasons.push("@everyone mention"); reasons.push("@everyone/@here mention");
} }
if (message.mentions.roles.length > 0) { const seenRoleIds = new Set();
for (const role of message.mentions.roles.values()) { for (const role of message.mentions.roles.values()) {
const size = roleMentionSize(role); seenRoleIds.add(role.id);
if (size >= maxRoleMentionSize) {
reasons.push(`large role mention: @${role.name} (${size} members)`);
} else {
reasons.push(`role mention: @${role.name}`); reasons.push(`role mention: @${role.name}`);
} }
}
const content = message.content ?? "";
for (const match of content.matchAll(ROLE_MENTION_PATTERN)) {
const roleId = match[1];
if (seenRoleIds.has(roleId)) continue;
seenRoleIds.add(roleId);
const role = message.guild?.roles.cache.get(roleId);
reasons.push(role ? `role mention: @${role.name}` : `role mention: <@&${roleId}>`);
} }
const phishingKeywords = hasPhishingKeywords(content); return reasons;
const urlAnalysis = analyzeUrls(content, { massMention, phishingKeywords }); }
reasons.push(...urlAnalysis.reasons);
if (phishingKeywords) { function hasRolePing(message) {
reasons.push("phishing keywords in message"); if (message.mentions.everyone) return true;
} if (message.mentions.roles.length > 0) return true;
return ROLE_MENTION_TEST.test(message.content ?? "");
}
const hasRiskyUrl = urlAnalysis.reasons.length > 0; export function detectSpam(message, { duplicateAcrossChannels = false } = {}) {
const hasMassMention = massMention; const scannableText = collectScannableText(message);
const urls = extractUrls(scannableText);
const hasLink = urls.length > 0;
const rolePingReasons = collectRolePingReasons(message);
const rolePing = hasRolePing(message);
const reasons = [...rolePingReasons];
if (hasMassMention && urlAnalysis.urls.length > 0) { if (hasLink) {
reasons.push("mass mention combined with URLs"); const preview = urls.slice(0, 3).join(", ");
}
if (message.mentions.roles.length > 0 && urlAnalysis.urls.length > 0 && hasRiskyUrl) {
reasons.push("role mention combined with suspicious URLs");
}
if (minAccountAgeDays > 0 && message.author.createdTimestamp) {
const ageMs = Date.now() - message.author.createdTimestamp;
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (ageDays < minAccountAgeDays && (hasRiskyUrl || hasMassMention)) {
reasons.push( reasons.push(
`young account (${Math.floor(ageDays)} days old) with spam signals` urls.length > 3 ? `link in message: ${preview} (+${urls.length - 3} more)` : `link in message: ${preview}`
); );
} }
if (duplicateAcrossChannels) {
reasons.push("same message posted across multiple channels");
} }
const uniqueReasons = [...new Set(reasons)]; const isSpam = rolePing && hasLink && duplicateAcrossChannels;
const hasPhishingText = uniqueReasons.some((r) => r.includes("phishing keywords"));
const isSpam =
uniqueReasons.length > 0 &&
(hasRiskyUrl ||
(hasMassMention && urlAnalysis.urls.length > 0) ||
(hasPhishingText && urlAnalysis.urls.length > 0) ||
(minAccountAgeDays > 0 &&
uniqueReasons.some((r) => r.includes("young account"))));
return { return {
isSpam, isSpam,
reasons: uniqueReasons, reasons,
urls: urlAnalysis.urls, urls,
}; };
} }