Add Anti-Spam Discord bot
Introduce a new Anti-Spam Discord bot project. Adds project metadata and scripts (package.json, .env.example, .gitignore, README, deploy-commands.js) and the full src implementation: command registration and handling, per-guild settings persistence, configuration parsing, spam detection logic (phishing URLs, suspicious TLDs, mass/role mentions, image+URL combos, new account heuristics), and the bot entrypoint (index.js) that enforces permissions, logs actions, and optionally bans offenders. README explains setup, required intents, and usage; per-server settings are stored under data/guild-settings.json. (package-lock.json added for dependency resolution.)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
PermissionFlagsBits,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
} from "discord.js";
|
||||
import {
|
||||
getGuildSettings,
|
||||
setGuildSettings,
|
||||
addTrustedRole,
|
||||
removeTrustedRole,
|
||||
} from "./guildSettings.js";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const antispamCommands = [
|
||||
new SlashCommandBuilder()
|
||||
.setName("antispam")
|
||||
.setDescription("Configure anti-spam for this server")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("set-log")
|
||||
.setDescription("Set the moderation log channel for this server")
|
||||
.addChannelOption((opt) =>
|
||||
opt
|
||||
.setName("channel")
|
||||
.setDescription("Channel for spam action logs")
|
||||
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("add-trusted-role")
|
||||
.setDescription("Exempt a role from spam checks on this server")
|
||||
.addRoleOption((opt) =>
|
||||
opt.setName("role").setDescription("Role to trust").setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("remove-trusted-role")
|
||||
.setDescription("Remove a trusted role exemption")
|
||||
.addRoleOption((opt) =>
|
||||
opt.setName("role").setDescription("Role to remove").setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub.setName("status").setDescription("Show anti-spam settings for this server")
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("enable")
|
||||
.setDescription("Enable anti-spam protection on this server")
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName("disable")
|
||||
.setDescription("Disable anti-spam protection on this server")
|
||||
),
|
||||
].map((command) => command.toJSON());
|
||||
|
||||
export async function handleAntispamCommand(interaction) {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({
|
||||
content: "This command can only be used in a server.",
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = interaction.guildId;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "set-log") {
|
||||
const channel = interaction.options.getChannel("channel");
|
||||
setGuildSettings(guildId, { logChannelId: channel.id });
|
||||
await interaction.reply({
|
||||
content: `Moderation logs will be sent to ${channel}.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "add-trusted-role") {
|
||||
const role = interaction.options.getRole("role");
|
||||
addTrustedRole(guildId, role.id);
|
||||
await interaction.reply({
|
||||
content: `Trusted role added: ${role}. Members with this role will bypass spam checks.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "remove-trusted-role") {
|
||||
const role = interaction.options.getRole("role");
|
||||
removeTrustedRole(guildId, role.id);
|
||||
await interaction.reply({
|
||||
content: `Trusted role removed: ${role}.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "enable") {
|
||||
setGuildSettings(guildId, { enabled: true });
|
||||
await interaction.reply({
|
||||
content: "Anti-spam protection is now **enabled** for this server.",
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "disable") {
|
||||
setGuildSettings(guildId, { enabled: false });
|
||||
await interaction.reply({
|
||||
content: "Anti-spam protection is now **disabled** for this server.",
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "status") {
|
||||
const settings = getGuildSettings(guildId);
|
||||
const trustedRoles =
|
||||
settings.trustedRoleIds.size > 0
|
||||
? [...settings.trustedRoleIds].map((id) => `<@&${id}>`).join(", ")
|
||||
: "None";
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("Anti-Spam Settings")
|
||||
.setColor(settings.enabled ? 0x57f287 : 0xed4245)
|
||||
.addFields(
|
||||
{ name: "Status", value: settings.enabled ? "Enabled" : "Disabled", inline: true },
|
||||
{
|
||||
name: "Log channel",
|
||||
value: settings.logChannelId ? `<#${settings.logChannelId}>` : "Not set",
|
||||
inline: true,
|
||||
},
|
||||
{ name: "Trusted roles", value: trustedRoles },
|
||||
{
|
||||
name: "Global defaults",
|
||||
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"),
|
||||
}
|
||||
);
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import "dotenv/config";
|
||||
|
||||
function parseIdList(value) {
|
||||
if (!value?.trim()) return new Set();
|
||||
return new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
token: process.env.DISCORD_TOKEN,
|
||||
banOnSpam: process.env.BAN_ON_SPAM !== "false",
|
||||
deleteMessage: process.env.DELETE_MESSAGE !== "false",
|
||||
maxRoleMentionSize: Number(process.env.MAX_ROLE_MENTION_SIZE) || 50,
|
||||
minAccountAgeDays: Number(process.env.MIN_ACCOUNT_AGE_DAYS) || 0,
|
||||
trustedUserIds: parseIdList(process.env.TRUSTED_USER_IDS),
|
||||
};
|
||||
|
||||
if (!config.token) {
|
||||
console.error("Missing DISCORD_TOKEN in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const SETTINGS_FILE = path.join(DATA_DIR, "guild-settings.json");
|
||||
|
||||
function loadAll() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveAll(data) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
let cache = loadAll();
|
||||
|
||||
export function getGuildSettings(guildId) {
|
||||
const stored = cache[guildId] ?? {};
|
||||
return {
|
||||
enabled: stored.enabled !== false,
|
||||
logChannelId: stored.logChannelId ?? null,
|
||||
trustedRoleIds: new Set(stored.trustedRoleIds ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export function setGuildSettings(guildId, updates) {
|
||||
const current = cache[guildId] ?? {};
|
||||
const next = { ...current, ...updates };
|
||||
|
||||
if (updates.trustedRoleIds instanceof Set) {
|
||||
next.trustedRoleIds = [...updates.trustedRoleIds];
|
||||
}
|
||||
|
||||
cache[guildId] = next;
|
||||
saveAll(cache);
|
||||
return getGuildSettings(guildId);
|
||||
}
|
||||
|
||||
export function addTrustedRole(guildId, roleId) {
|
||||
const settings = getGuildSettings(guildId);
|
||||
settings.trustedRoleIds.add(roleId);
|
||||
return setGuildSettings(guildId, { trustedRoleIds: settings.trustedRoleIds });
|
||||
}
|
||||
|
||||
export function removeTrustedRole(guildId, roleId) {
|
||||
const settings = getGuildSettings(guildId);
|
||||
settings.trustedRoleIds.delete(roleId);
|
||||
return setGuildSettings(guildId, { trustedRoleIds: settings.trustedRoleIds });
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Client,
|
||||
GatewayIntentBits,
|
||||
Partials,
|
||||
PermissionFlagsBits,
|
||||
EmbedBuilder,
|
||||
} from "discord.js";
|
||||
import { config } from "./config.js";
|
||||
import { detectSpam } from "./spamDetector.js";
|
||||
import { getGuildSettings } from "./guildSettings.js";
|
||||
import { handleAntispamCommand } from "./commands.js";
|
||||
import { registerCommands, registerCommandsForGuild } from "./registerCommands.js";
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Message, Partials.Channel],
|
||||
});
|
||||
|
||||
function isTrusted(member, guildSettings) {
|
||||
if (!member) return false;
|
||||
|
||||
if (config.trustedUserIds.has(member.id)) return true;
|
||||
|
||||
if (
|
||||
member.permissions.has(PermissionFlagsBits.Administrator) ||
|
||||
member.permissions.has(PermissionFlagsBits.ManageGuild)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return member.roles.cache.some((role) => guildSettings.trustedRoleIds.has(role.id));
|
||||
}
|
||||
|
||||
async function sendModLog(guild, guildSettings, embed) {
|
||||
if (!guildSettings.logChannelId) return;
|
||||
|
||||
const channel = await guild.channels
|
||||
.fetch(guildSettings.logChannelId)
|
||||
.catch(() => null);
|
||||
|
||||
if (!channel?.isTextBased()) return;
|
||||
|
||||
await channel.send({ embeds: [embed] }).catch((error) => {
|
||||
console.error(`Failed to send mod log in ${guild.name}:`, error.message);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSpam(message, detection, guildSettings) {
|
||||
const { member, guild, author, channel } = message;
|
||||
const reasonSummary = detection.reasons.join("; ");
|
||||
|
||||
if (config.deleteMessage) {
|
||||
await message.delete().catch((error) => {
|
||||
console.error(`Failed to delete message ${message.id}:`, error.message);
|
||||
});
|
||||
}
|
||||
|
||||
let banned = false;
|
||||
let banError = null;
|
||||
|
||||
if (config.banOnSpam && member && member.bannable) {
|
||||
try {
|
||||
await member.ban({
|
||||
reason: `Anti-Spam: ${reasonSummary}`.slice(0, 512),
|
||||
deleteMessageSeconds: 60 * 60 * 24,
|
||||
});
|
||||
banned = true;
|
||||
} catch (error) {
|
||||
banError = error.message;
|
||||
console.error(`Failed to ban ${author.tag}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(banned ? 0xed4245 : 0xfaa61a)
|
||||
.setTitle(banned ? "Spam blocked — user banned" : "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",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}`,
|
||||
banError ? `**Ban error:** ${banError}` : null,
|
||||
"",
|
||||
"**Triggers:**",
|
||||
detection.reasons.map((r) => `• ${r}`).join("\n"),
|
||||
]
|
||||
.filter((line) => line !== null)
|
||||
.join("\n")
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
if (message.content) {
|
||||
const preview = message.content.slice(0, 1000);
|
||||
embed.addFields({
|
||||
name: "Message preview",
|
||||
value: preview || "(empty)",
|
||||
});
|
||||
}
|
||||
|
||||
await sendModLog(guild, guildSettings, embed);
|
||||
console.log(
|
||||
`[${guild.name}] Spam from ${author.tag}: ${reasonSummary}${
|
||||
banned ? " (banned)" : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
client.once("ready", async () => {
|
||||
console.log(`Logged in as ${client.user.tag}`);
|
||||
console.log(`Watching ${client.guilds.cache.size} server(s). Ban on spam: ${config.banOnSpam}`);
|
||||
|
||||
try {
|
||||
await registerCommands(client, config.token);
|
||||
console.log("Slash commands ready. Type /antispam in a server (requires Manage Server).");
|
||||
} catch (error) {
|
||||
console.error("Failed to register slash commands:", error.rawBody ?? error.message);
|
||||
console.error(
|
||||
"If commands still do not appear, re-invite the bot with the applications.commands scope."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
client.on("guildCreate", async (guild) => {
|
||||
try {
|
||||
await registerCommandsForGuild(guild, config.token, client.user.id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to register commands in ${guild.name}:`, error.rawBody ?? error.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (!interaction.isChatInputCommand() || interaction.commandName !== "antispam") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleAntispamCommand(interaction);
|
||||
} catch (error) {
|
||||
console.error("Command error:", error.message);
|
||||
const reply = { content: "Something went wrong running that command.", ephemeral: true };
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp(reply).catch(() => {});
|
||||
} else {
|
||||
await interaction.reply(reply).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on("messageCreate", async (message) => {
|
||||
if (!message.guild || message.author.bot) return;
|
||||
|
||||
const guildSettings = getGuildSettings(message.guild.id);
|
||||
if (!guildSettings.enabled) return;
|
||||
|
||||
const me = message.guild.members.me;
|
||||
if (!me) return;
|
||||
|
||||
const requiredPerms = [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.ReadMessageHistory,
|
||||
PermissionFlagsBits.ManageMessages,
|
||||
];
|
||||
if (config.banOnSpam) {
|
||||
requiredPerms.push(PermissionFlagsBits.BanMembers);
|
||||
}
|
||||
|
||||
if (!message.channel.permissionsFor(me)?.has(requiredPerms, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTrusted(message.member, guildSettings)) return;
|
||||
|
||||
const detection = detectSpam(message, {
|
||||
maxRoleMentionSize: config.maxRoleMentionSize,
|
||||
minAccountAgeDays: config.minAccountAgeDays,
|
||||
});
|
||||
|
||||
if (!detection.isSpam) return;
|
||||
|
||||
await handleSpam(message, detection, guildSettings);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (error) => {
|
||||
if (error?.message?.includes("disallowed intents")) {
|
||||
console.error("\nDisallowed intents — enable this in the Discord Developer Portal:");
|
||||
console.error(" https://discord.com/developers/applications");
|
||||
console.error(" → Your app → Bot → Privileged Gateway Intents");
|
||||
console.error(" → Turn ON: Message Content Intent\n");
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
client.login(config.token);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { antispamCommands } from "./commands.js";
|
||||
|
||||
export async function registerCommands(client, token) {
|
||||
const rest = new REST().setToken(token);
|
||||
const appId = client.user.id;
|
||||
|
||||
await rest.put(Routes.applicationCommands(appId), { body: antispamCommands });
|
||||
console.log(`Registered ${antispamCommands.length} global command(s).`);
|
||||
|
||||
let guildCount = 0;
|
||||
for (const guild of client.guilds.cache.values()) {
|
||||
await rest.put(Routes.applicationGuildCommands(appId, guild.id), {
|
||||
body: antispamCommands,
|
||||
});
|
||||
guildCount += 1;
|
||||
console.log(`Registered commands in guild: ${guild.name} (${guild.id})`);
|
||||
}
|
||||
|
||||
if (guildCount === 0) {
|
||||
console.warn("Bot is not in any servers yet — invite it, then restart or run npm run deploy-commands.");
|
||||
}
|
||||
|
||||
return guildCount;
|
||||
}
|
||||
|
||||
export async function registerCommandsForGuild(guild, token, appId) {
|
||||
const rest = new REST().setToken(token);
|
||||
await rest.put(Routes.applicationGuildCommands(appId, guild.id), {
|
||||
body: antispamCommands,
|
||||
});
|
||||
console.log(`Registered commands in new guild: ${guild.name} (${guild.id})`);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
const URL_PATTERN =
|
||||
/https?:\/\/[^\s<>"{}|\\^`[\]]+|(?:www\.)[a-z0-9-]+(?:\.[a-z0-9-]+)+[^\s]*/gi;
|
||||
|
||||
const IMAGE_URL_PATTERN =
|
||||
/https?:\/\/[^\s]+\.(?:png|jpe?g|gif|webp|bmp)(?:\?[^\s]*)?/gi;
|
||||
|
||||
const DISCORD_INVITE_PATTERN =
|
||||
/(?:https?:\/\/)?(?:www\.)?(?:discord\.gg|discord(?:app)?\.com\/invite)\/[a-z0-9-]+/gi;
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
const SUSPICIOUS_TLDS = [
|
||||
".xyz",
|
||||
".top",
|
||||
".click",
|
||||
".icu",
|
||||
".buzz",
|
||||
".monster",
|
||||
".rest",
|
||||
".cfd",
|
||||
".sbs",
|
||||
".lat",
|
||||
];
|
||||
|
||||
function normalizeText(text) {
|
||||
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
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);
|
||||
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");
|
||||
}
|
||||
|
||||
if (message.mentions.roles.length > 0) {
|
||||
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 {
|
||||
reasons.push(`role mention: @${role.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const phishingKeywords = hasPhishingKeywords(content);
|
||||
const urlAnalysis = analyzeUrls(content, { massMention, phishingKeywords });
|
||||
reasons.push(...urlAnalysis.reasons);
|
||||
|
||||
if (phishingKeywords) {
|
||||
reasons.push("phishing keywords in message");
|
||||
}
|
||||
|
||||
const hasRiskyUrl = urlAnalysis.reasons.length > 0;
|
||||
const hasMassMention = massMention;
|
||||
|
||||
if (hasMassMention && urlAnalysis.urls.length > 0) {
|
||||
reasons.push("mass mention combined with URLs");
|
||||
}
|
||||
|
||||
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(
|
||||
`young account (${Math.floor(ageDays)} days old) with spam signals`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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"))));
|
||||
|
||||
return {
|
||||
isSpam,
|
||||
reasons: uniqueReasons,
|
||||
urls: urlAnalysis.urls,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user