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