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
MAX_ROLE_MENTION_SIZE=50
# Extra strict on accounts younger than this many days (0 = disabled)
MIN_ACCOUNT_AGE_DAYS=0
# Extra strict on accounts younger than this many days (0 = disabled; 7 recommended for raid protection)
MIN_ACCOUNT_AGE_DAYS=7
# Comma-separated user IDs that bypass checks on all servers (e.g. bot owner)
TRUSTED_USER_IDS=
+10 -3
View File
@@ -139,12 +139,19 @@ export async function handleAntispamCommand(interaction) {
},
{ 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: [
`Ban on spam: ${config.banOnSpam}`,
`Delete messages: ${config.deleteMessage}`,
`Large role mention threshold: ${config.maxRoleMentionSize}`,
`Min account age (days): ${config.minAccountAgeDays || "disabled"}`,
].join("\n"),
}
);
+66 -19
View File
@@ -6,7 +6,8 @@ import {
EmbedBuilder,
} from "discord.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 { handleAntispamCommand } from "./commands.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 reasonSummary = detection.reasons.join("; ");
if (config.deleteMessage) {
await deleteTrackedMessages(guild, priorMessages);
await message.delete().catch((error) => {
console.error(`Failed to delete message ${message.id}:`, error.message);
});
}
let banned = false;
let banError = null;
let moderationAction = null;
let moderationError = null;
const moderationReason = `Anti-Spam: ${reasonSummary}`.slice(0, 512);
if (config.banOnSpam && member && member.bannable) {
if (config.banOnSpam && member?.bannable) {
try {
await member.ban({
reason: `Anti-Spam: ${reasonSummary}`.slice(0, 512),
reason: moderationReason,
deleteMessageSeconds: 60 * 60 * 24,
});
banned = true;
moderationAction = "banned";
} catch (error) {
banError = error.message;
moderationError = 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()
.setColor(banned ? 0xed4245 : 0xfaa61a)
.setTitle(banned ? "Spam blocked — user banned" : "Spam blocked")
.setColor(moderationAction ? 0xed4245 : 0xfaa61a)
.setTitle(moderationAction ? `Spam blocked — user ${moderationAction}` : "Spam blocked")
.setDescription(
[
`**User:** ${author.tag} (\`${author.id}\`)`,
`**Channel:** <#${channel.id}>`,
`**Action:** ${[
config.deleteMessage ? "message deleted" : null,
banned ? "user banned" : config.banOnSpam ? "ban failed" : "ban disabled",
config.deleteMessage
? `message deleted${priorMessages.length > 0 ? ` (+${priorMessages.length} in other channels)` : ""}`
: null,
actionLabel,
]
.filter(Boolean)
.join(", ")}`,
banError ? `**Ban error:** ${banError}` : null,
moderationError ? `**Moderation error:** ${moderationError}` : null,
"",
"**Triggers:**",
detection.reasons.map((r) => `${r}`).join("\n"),
@@ -109,7 +143,7 @@ async function handleSpam(message, detection, guildSettings) {
await sendModLog(guild, guildSettings, embed);
console.log(
`[${guild.name}] Spam from ${author.tag}: ${reasonSummary}${
banned ? " (banned)" : ""
moderationAction ? ` (${moderationAction})` : ""
}`
);
}
@@ -170,23 +204,36 @@ client.on("messageCreate", async (message) => {
PermissionFlagsBits.ManageMessages,
];
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;
}
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, {
maxRoleMentionSize: config.maxRoleMentionSize,
minAccountAgeDays: config.minAccountAgeDays,
duplicateAcrossChannels: crossChannel.duplicateAcrossChannels,
});
if (!detection.isSpam) return;
await handleSpam(message, detection, guildSettings);
await handleSpam(message, detection, guildSettings, crossChannel.priorMessages);
});
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: [] };
}
+53 -172
View File
@@ -1,209 +1,90 @@
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 =
/https?:\/\/[^\s]+\.(?:png|jpe?g|gif|webp|bmp)(?:\?[^\s]*)?/gi;
const ROLE_MENTION_PATTERN = /<@&(\d+)>/g;
const ROLE_MENTION_TEST = /<@&\d+>/;
const DISCORD_INVITE_PATTERN =
/(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\/[a-z0-9-]+/gi;
export function collectScannableText(message) {
const parts = [message.content ?? ""];
const PHISHING_DOMAIN_FRAGMENTS = [
"discord-gift",
"discordgift",
"discord-nitro",
"discordnitro",
"discord-app",
"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",
];
for (const embed of message.embeds ?? []) {
if (embed.url) parts.push(embed.url);
if (embed.title) parts.push(embed.title);
if (embed.description) parts.push(embed.description);
for (const field of embed.fields ?? []) {
parts.push(field.name, field.value);
}
}
const PHISHING_KEYWORDS = [
"free nitro",
"claim your nitro",
"claim nitro",
"nitro giveaway",
"steam gift",
"verify your account",
"account suspended",
"unusual activity",
"click to verify",
"limited time offer",
];
for (const attachment of message.attachments?.values() ?? []) {
if (attachment.url) parts.push(attachment.url);
if (attachment.proxyURL) parts.push(attachment.proxyURL);
}
const SUSPICIOUS_TLDS = [
".xyz",
".top",
".click",
".icu",
".buzz",
".monster",
".rest",
".cfd",
".sbs",
".lat",
];
function normalizeText(text) {
return text.toLowerCase().replace(/\s+/g, " ").trim();
return parts.filter(Boolean).join("\n");
}
function extractUrls(text) {
return [...text.matchAll(URL_PATTERN)].map((match) => match[0]);
}
function hasPhishingDomain(url) {
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);
function collectRolePingReasons(message) {
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) {
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()) {
const size = roleMentionSize(role);
if (size >= maxRoleMentionSize) {
reasons.push(`large role mention: @${role.name} (${size} members)`);
} else {
seenRoleIds.add(role.id);
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);
const urlAnalysis = analyzeUrls(content, { massMention, phishingKeywords });
reasons.push(...urlAnalysis.reasons);
if (phishingKeywords) {
reasons.push("phishing keywords in message");
return reasons;
}
const hasRiskyUrl = urlAnalysis.reasons.length > 0;
const hasMassMention = massMention;
if (hasMassMention && urlAnalysis.urls.length > 0) {
reasons.push("mass mention combined with URLs");
function hasRolePing(message) {
if (message.mentions.everyone) return true;
if (message.mentions.roles.length > 0) return true;
return ROLE_MENTION_TEST.test(message.content ?? "");
}
if (message.mentions.roles.length > 0 && urlAnalysis.urls.length > 0 && hasRiskyUrl) {
reasons.push("role mention combined with suspicious URLs");
}
export function detectSpam(message, { duplicateAcrossChannels = false } = {}) {
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 (minAccountAgeDays > 0 && message.author.createdTimestamp) {
const ageMs = Date.now() - message.author.createdTimestamp;
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (ageDays < minAccountAgeDays && (hasRiskyUrl || hasMassMention)) {
if (hasLink) {
const preview = urls.slice(0, 3).join(", ");
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 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"))));
const isSpam = rolePing && hasLink && duplicateAcrossChannels;
return {
isSpam,
reasons: uniqueReasons,
urls: urlAnalysis.urls,
reasons,
urls,
};
}