From 7205bcd5ac17625baaee364be1214f5221aa5494 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos <62979495+Bobbybear007@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:10:30 +1200 Subject: [PATCH] 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.) --- Anti-Spam/.env.example | 23 ++ Anti-Spam/.gitignore | 4 + Anti-Spam/README.md | 67 ++++++ Anti-Spam/package-lock.json | 340 +++++++++++++++++++++++++++ Anti-Spam/package.json | 19 ++ Anti-Spam/scripts/deploy-commands.js | 37 +++ Anti-Spam/src/commands.js | 154 ++++++++++++ Anti-Spam/src/config.js | 25 ++ Anti-Spam/src/guildSettings.js | 54 +++++ Anti-Spam/src/index.js | 203 ++++++++++++++++ Anti-Spam/src/registerCommands.js | 33 +++ Anti-Spam/src/spamDetector.js | 209 ++++++++++++++++ 12 files changed, 1168 insertions(+) create mode 100644 Anti-Spam/.env.example create mode 100644 Anti-Spam/.gitignore create mode 100644 Anti-Spam/README.md create mode 100644 Anti-Spam/package-lock.json create mode 100644 Anti-Spam/package.json create mode 100644 Anti-Spam/scripts/deploy-commands.js create mode 100644 Anti-Spam/src/commands.js create mode 100644 Anti-Spam/src/config.js create mode 100644 Anti-Spam/src/guildSettings.js create mode 100644 Anti-Spam/src/index.js create mode 100644 Anti-Spam/src/registerCommands.js create mode 100644 Anti-Spam/src/spamDetector.js diff --git a/Anti-Spam/.env.example b/Anti-Spam/.env.example new file mode 100644 index 0000000..835c6a0 --- /dev/null +++ b/Anti-Spam/.env.example @@ -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= diff --git a/Anti-Spam/.gitignore b/Anti-Spam/.gitignore new file mode 100644 index 0000000..3e6691a --- /dev/null +++ b/Anti-Spam/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +data/ diff --git a/Anti-Spam/README.md b/Anti-Spam/README.md new file mode 100644 index 0000000..a3a6549 --- /dev/null +++ b/Anti-Spam/README.md @@ -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. diff --git a/Anti-Spam/package-lock.json b/Anti-Spam/package-lock.json new file mode 100644 index 0000000..b214319 --- /dev/null +++ b/Anti-Spam/package-lock.json @@ -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 + } + } + } + } +} diff --git a/Anti-Spam/package.json b/Anti-Spam/package.json new file mode 100644 index 0000000..7cebd44 --- /dev/null +++ b/Anti-Spam/package.json @@ -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" + } +} diff --git a/Anti-Spam/scripts/deploy-commands.js b/Anti-Spam/scripts/deploy-commands.js new file mode 100644 index 0000000..df9ceb0 --- /dev/null +++ b/Anti-Spam/scripts/deploy-commands.js @@ -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); +} diff --git a/Anti-Spam/src/commands.js b/Anti-Spam/src/commands.js new file mode 100644 index 0000000..e13b874 --- /dev/null +++ b/Anti-Spam/src/commands.js @@ -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 }); + } +} diff --git a/Anti-Spam/src/config.js b/Anti-Spam/src/config.js new file mode 100644 index 0000000..594c0cd --- /dev/null +++ b/Anti-Spam/src/config.js @@ -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); +} diff --git a/Anti-Spam/src/guildSettings.js b/Anti-Spam/src/guildSettings.js new file mode 100644 index 0000000..b498a8d --- /dev/null +++ b/Anti-Spam/src/guildSettings.js @@ -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 }); +} diff --git a/Anti-Spam/src/index.js b/Anti-Spam/src/index.js new file mode 100644 index 0000000..9ac72a3 --- /dev/null +++ b/Anti-Spam/src/index.js @@ -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); diff --git a/Anti-Spam/src/registerCommands.js b/Anti-Spam/src/registerCommands.js new file mode 100644 index 0000000..5bfd1dd --- /dev/null +++ b/Anti-Spam/src/registerCommands.js @@ -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})`); +} diff --git a/Anti-Spam/src/spamDetector.js b/Anti-Spam/src/spamDetector.js new file mode 100644 index 0000000..2e6a43e --- /dev/null +++ b/Anti-Spam/src/spamDetector.js @@ -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, + }; +}