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:
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
|
||||||
reasons.push("phishing keywords in message");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRiskyUrl = urlAnalysis.reasons.length > 0;
|
function hasRolePing(message) {
|
||||||
const hasMassMention = massMention;
|
if (message.mentions.everyone) return true;
|
||||||
|
if (message.mentions.roles.length > 0) return true;
|
||||||
if (hasMassMention && urlAnalysis.urls.length > 0) {
|
return ROLE_MENTION_TEST.test(message.content ?? "");
|
||||||
reasons.push("mass mention combined with URLs");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.mentions.roles.length > 0 && urlAnalysis.urls.length > 0 && hasRiskyUrl) {
|
export function detectSpam(message, { duplicateAcrossChannels = false } = {}) {
|
||||||
reasons.push("role mention combined with suspicious URLs");
|
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) {
|
if (hasLink) {
|
||||||
const ageMs = Date.now() - message.author.createdTimestamp;
|
const preview = urls.slice(0, 3).join(", ");
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user