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:
Andrew Zambazos
2026-06-12 08:10:30 +12:00
commit 7205bcd5ac
12 changed files with 1168 additions and 0 deletions
+154
View File
@@ -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 });
}
}
+25
View File
@@ -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);
}
+54
View File
@@ -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 });
}
+203
View File
@@ -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);
+33
View File
@@ -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})`);
}
+209
View File
@@ -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,
};
}