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
+23
View File
@@ -0,0 +1,23 @@
DISCORD_TOKEN=your_bot_token_here
# Application ID from Developer Portal → General Information (needed for deploy-commands)
APPLICATION_ID=your_application_id_here
# Optional: deploy commands instantly to one server instead of waiting for global propagation
DEPLOY_GUILD_ID=
# Global defaults applied to every server (use /antispam in each server for per-server settings)
# Ban users when spam is detected (true/false)
BAN_ON_SPAM=true
# Delete message before banning
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
# Comma-separated user IDs that bypass checks on all servers (e.g. bot owner)
TRUSTED_USER_IDS=
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
*.log
data/
+67
View File
@@ -0,0 +1,67 @@
# Anti-Spam Discord Bot
Automatically deletes phishing and mass-mention spam, then bans the sender. Works across **multiple servers** from a single bot instance.
## What it catches
- Phishing URLs (fake Discord / Nitro / Steam links, typosquat domains)
- Suspicious TLDs combined with spam patterns
- Messages with image links plus other URLs
- `@everyone` and large role mentions (default: roles with 50+ members)
- Mass mentions paired with links
- Optional: stricter rules for very new accounts
## Setup
1. Create a bot at the [Discord Developer Portal](https://discord.com/developers/applications).
2. Under **Bot → Privileged Gateway Intents**, enable **Message Content Intent**.
(Required — the bot cannot read messages without this. You do **not** need Server Members Intent.)
3. Invite the bot using a URL that includes the **`applications.commands`** scope, plus `Ban Members`, `Manage Messages`, `Read Message History`, and `View Channels`.
In the Developer Portal: **OAuth2 → URL Generator** → scopes: `bot` + `applications.commands`.
4. Copy `.env.example` to `.env` and set `DISCORD_TOKEN` and `APPLICATION_ID`.
5. Install and run:
```bash
npm install
npm start
```
6. In each server, run `/antispam set-log` to choose a mod log channel.
### Commands not showing?
- `/antispam` is only visible to users with **Manage Server** permission.
- Restart the bot after inviting it — commands are registered per-server on startup.
- Or deploy instantly: set `DEPLOY_GUILD_ID` to your server ID in `.env`, then run `npm run deploy-commands`.
- Re-invite the bot if it was added without the `applications.commands` scope.
## Per-server configuration
Use `/antispam` in any server (requires **Manage Server** permission):
| Command | Description |
|---------|-------------|
| `/antispam set-log` | Set the moderation log channel for this server |
| `/antispam add-trusted-role` | Exempt a role from spam checks |
| `/antispam remove-trusted-role` | Remove a trusted role |
| `/antispam status` | View this server's settings |
| `/antispam enable` / `/antispam disable` | Turn protection on or off per server |
Settings are stored in `data/guild-settings.json` on disk.
## Global configuration (`.env`)
These apply to every server the bot is in:
| Variable | Default | Purpose |
|----------|---------|---------|
| `MAX_ROLE_MENTION_SIZE` | `50` | Flag role pings at or above this size |
| `BAN_ON_SPAM` | `true` | Ban offenders (set `false` to only delete) |
| `TRUSTED_USER_IDS` | — | User IDs that bypass checks on all servers |
Admins and users with **Manage Server** are always trusted.
## Notes
- The bot needs a role **above** spammers in each server's role list to ban them.
- Test in a private channel first with `BAN_ON_SPAM=false` if you want delete-only mode.
+340
View File
@@ -0,0 +1,340 @@
{
"name": "anti-spam-bot",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "anti-spam-bot",
"version": "1.0.0",
"dependencies": {
"discord.js": "^14.19.3",
"dotenv": "^16.5.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@discordjs/builders": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.40",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz",
"integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.2.0",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.5",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.40",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "6.24.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/discord-api-types": {
"version": "0.38.48",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz",
"integrity": "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/discord.js": {
"version": "14.26.4",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz",
"integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/builders": "^1.14.1",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.1",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.40",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "6.24.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT"
},
"node_modules/magic-bytes.js": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT"
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/undici": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "anti-spam-bot",
"version": "1.0.0",
"description": "Discord bot that detects phishing spam and bans offenders",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"deploy-commands": "node scripts/deploy-commands.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"discord.js": "^14.19.3",
"dotenv": "^16.5.0"
}
}
+37
View File
@@ -0,0 +1,37 @@
import "dotenv/config";
import { REST, Routes } from "discord.js";
import { antispamCommands } from "../src/commands.js";
const token = process.env.DISCORD_TOKEN;
const appId = process.env.APPLICATION_ID?.trim();
if (!token) {
console.error("Missing DISCORD_TOKEN in .env");
process.exit(1);
}
if (!appId) {
console.error("Missing APPLICATION_ID in .env");
console.error("Find it in the Developer Portal → General Information → Application ID");
process.exit(1);
}
const guildId = process.env.DEPLOY_GUILD_ID?.trim();
const rest = new REST().setToken(token);
try {
if (guildId) {
await rest.put(Routes.applicationGuildCommands(appId, guildId), {
body: antispamCommands,
});
console.log(`Deployed ${antispamCommands.length} command(s) to guild ${guildId} (instant).`);
} else {
await rest.put(Routes.applicationCommands(appId), { body: antispamCommands });
console.log(`Deployed ${antispamCommands.length} global command(s).`);
console.log("Global commands can take up to an hour to appear in Discord.");
console.log("For instant commands, set DEPLOY_GUILD_ID in .env and run again.");
}
} catch (error) {
console.error("Deploy failed:", error.rawBody ?? error.message);
process.exit(1);
}
+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,
};
}