adb-plugin-aegis 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/commands/antimod.js +258 -0
- package/commands/antiraid.js +125 -0
- package/index.js +229 -0
- package/lib/getConfig.js +46 -0
- package/lib/logAction.js +84 -0
- package/lib/raidTracker.js +39 -0
- package/lib/spamTracker.js +66 -0
- package/models/config.js +27 -0
- package/models/log.js +18 -0
- package/models/whitelist.js +10 -0
- package/package.json +37 -0
- package/plugin.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Aegis
|
|
2
|
+
|
|
3
|
+
Automated threat detection and mitigation for Discord servers, built as a plugin for [Advanced Discord Bot](https://github.com/dead/Advanced-Discord-Bot).
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
|
|
7
|
+
| Module | Description | Default |
|
|
8
|
+
|--------|-------------|---------|
|
|
9
|
+
| **Anti-Raid** | Detects mass joins and auto-locks the server | Off |
|
|
10
|
+
| **Anti-Spam** | Detects duplicate messages and mass mentions | Off |
|
|
11
|
+
| **Anti-Link** | Deletes messages containing URLs or Discord invites | Off |
|
|
12
|
+
| **Anti-Alt** | Blocks or flags accounts below a minimum age | Off |
|
|
13
|
+
|
|
14
|
+
All modules are off by default. Enable each one independently per guild.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# In your ADB plugins directory
|
|
20
|
+
npm install adb-plugin-aegis
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then add `"adb-plugin-aegis"` to your ADB plugin list.
|
|
24
|
+
|
|
25
|
+
## Required Bot Permissions
|
|
26
|
+
|
|
27
|
+
- Manage Channels (for lockdown)
|
|
28
|
+
- Kick Members / Ban Members (for anti-alt/spam enforcement)
|
|
29
|
+
- Moderate Members (timeout, for spam mute)
|
|
30
|
+
- Manage Messages (to delete spam/links)
|
|
31
|
+
- Set Verification Level (for raid lockdown)
|
|
32
|
+
- Send Messages + Embed Links (for log channel output)
|
|
33
|
+
|
|
34
|
+
## Configuration Commands
|
|
35
|
+
|
|
36
|
+
All `/antimod` subcommands require **Administrator** permission.
|
|
37
|
+
`/antiraid lockdown` requires **Moderate Members** permission.
|
|
38
|
+
|
|
39
|
+
### Setup
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
/antimod setup Show overview and module list
|
|
43
|
+
/antimod status Show all module states and current settings
|
|
44
|
+
/antimod log #channel Set the channel for Aegis log embeds
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Anti-Raid
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
/antimod raid on
|
|
51
|
+
/antimod raid off
|
|
52
|
+
/antimod raid threshold <joins> <seconds>
|
|
53
|
+
— e.g. /antimod raid threshold 10 10 (10 joins in 10s)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
When the threshold is hit, Aegis automatically:
|
|
57
|
+
1. Sets server verification to **HIGH**
|
|
58
|
+
2. Denies `SendMessages` for `@everyone` in all text channels
|
|
59
|
+
3. Logs to the configured log channel
|
|
60
|
+
|
|
61
|
+
To lift manually:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
/antiraid lockdown
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Anti-Spam
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
/antimod spam on
|
|
71
|
+
/antimod spam off
|
|
72
|
+
/antimod spam action <warn|mute|kick|ban>
|
|
73
|
+
— "mute" applies a 10-minute timeout (default)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Triggers on N identical messages in a window, or a single message with N+ mentions.
|
|
77
|
+
|
|
78
|
+
### Anti-Link
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
/antimod link on
|
|
82
|
+
/antimod link off
|
|
83
|
+
/antimod link whitelist <domain>
|
|
84
|
+
— e.g. /antimod link whitelist github.com
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Discord invite links (`discord.gg/...`) are always blocked when the module is on, regardless of the whitelist.
|
|
88
|
+
Whitelisted domains are stored per guild and persist across restarts.
|
|
89
|
+
|
|
90
|
+
### Anti-Alt
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
/antimod alt on
|
|
94
|
+
/antimod alt off
|
|
95
|
+
/antimod alt age <days> — minimum account age (default: 7 days)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Actions:
|
|
99
|
+
- **flag** (default) — logs the join to the log channel; member is not removed
|
|
100
|
+
- **kick** — immediately kicks the member with a reason
|
|
101
|
+
|
|
102
|
+
## Development / Testing
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm install
|
|
106
|
+
npm test
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Tests run without a real Discord connection or database (in-memory mocks).
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { PermissionFlagsBits } = (() => {
|
|
4
|
+
try {
|
|
5
|
+
return require("discord.js");
|
|
6
|
+
} catch {
|
|
7
|
+
return { PermissionFlagsBits: { Administrator: 8n } };
|
|
8
|
+
}
|
|
9
|
+
})();
|
|
10
|
+
|
|
11
|
+
const { getGuildConfig, setGuildConfig } = require("../lib/getConfig");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Factory — returns the /antimod command object.
|
|
15
|
+
* @param {object} ctx
|
|
16
|
+
* @param {object} AegisLog - mongoose model
|
|
17
|
+
* @param {object} Whitelist - mongoose model
|
|
18
|
+
*/
|
|
19
|
+
module.exports = function createAntimodCommand(ctx, AegisLog, Whitelist) {
|
|
20
|
+
const data = {
|
|
21
|
+
name: "antimod",
|
|
22
|
+
description: "Configure Aegis automated moderation modules",
|
|
23
|
+
defaultMemberPermissions: String(PermissionFlagsBits.Administrator),
|
|
24
|
+
options: [
|
|
25
|
+
{
|
|
26
|
+
name: "setup",
|
|
27
|
+
type: 1, // SUB_COMMAND
|
|
28
|
+
description: "Show Aegis setup overview",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "status",
|
|
32
|
+
type: 1,
|
|
33
|
+
description: "Show all module states and key settings",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "log",
|
|
37
|
+
type: 1,
|
|
38
|
+
description: "Set the log channel for Aegis events",
|
|
39
|
+
options: [
|
|
40
|
+
{
|
|
41
|
+
name: "channel",
|
|
42
|
+
type: 7, // CHANNEL
|
|
43
|
+
description: "Channel to send logs to",
|
|
44
|
+
required: true,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "raid",
|
|
50
|
+
type: 2, // SUB_COMMAND_GROUP
|
|
51
|
+
description: "Anti-raid module settings",
|
|
52
|
+
options: [
|
|
53
|
+
{ name: "on", type: 1, description: "Enable anti-raid" },
|
|
54
|
+
{ name: "off", type: 1, description: "Disable anti-raid" },
|
|
55
|
+
{
|
|
56
|
+
name: "threshold",
|
|
57
|
+
type: 1,
|
|
58
|
+
description: "Set join threshold for raid detection",
|
|
59
|
+
options: [
|
|
60
|
+
{ name: "joins", type: 4, required: true, description: "Number of joins to trigger lockdown" },
|
|
61
|
+
{ name: "seconds", type: 4, required: true, description: "Time window in seconds" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "spam",
|
|
68
|
+
type: 2,
|
|
69
|
+
description: "Anti-spam module settings",
|
|
70
|
+
options: [
|
|
71
|
+
{ name: "on", type: 1, description: "Enable anti-spam" },
|
|
72
|
+
{ name: "off", type: 1, description: "Disable anti-spam" },
|
|
73
|
+
{
|
|
74
|
+
name: "action",
|
|
75
|
+
type: 1,
|
|
76
|
+
description: "Set action taken on spammers",
|
|
77
|
+
options: [
|
|
78
|
+
{
|
|
79
|
+
name: "type",
|
|
80
|
+
type: 3, // STRING
|
|
81
|
+
required: true,
|
|
82
|
+
description: "Action to take",
|
|
83
|
+
choices: [
|
|
84
|
+
{ name: "warn", value: "warn" },
|
|
85
|
+
{ name: "mute (10 min timeout)", value: "mute" },
|
|
86
|
+
{ name: "kick", value: "kick" },
|
|
87
|
+
{ name: "ban", value: "ban" },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "link",
|
|
96
|
+
type: 2,
|
|
97
|
+
description: "Anti-link module settings",
|
|
98
|
+
options: [
|
|
99
|
+
{ name: "on", type: 1, description: "Enable anti-link" },
|
|
100
|
+
{ name: "off", type: 1, description: "Disable anti-link" },
|
|
101
|
+
{
|
|
102
|
+
name: "whitelist",
|
|
103
|
+
type: 1,
|
|
104
|
+
description: "Add a domain to the link whitelist",
|
|
105
|
+
options: [
|
|
106
|
+
{
|
|
107
|
+
name: "domain",
|
|
108
|
+
type: 3,
|
|
109
|
+
required: true,
|
|
110
|
+
description: "Domain to allow (e.g. example.com)",
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "alt",
|
|
118
|
+
type: 2,
|
|
119
|
+
description: "Anti-alt (new account) module settings",
|
|
120
|
+
options: [
|
|
121
|
+
{ name: "on", type: 1, description: "Enable anti-alt" },
|
|
122
|
+
{ name: "off", type: 1, description: "Disable anti-alt" },
|
|
123
|
+
{
|
|
124
|
+
name: "age",
|
|
125
|
+
type: 1,
|
|
126
|
+
description: "Set minimum account age in days",
|
|
127
|
+
options: [
|
|
128
|
+
{ name: "days", type: 4, required: true, description: "Minimum account age in days" },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
async function execute(interaction) {
|
|
137
|
+
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
|
138
|
+
return interaction.reply({ content: "You need Administrator permission to use this command.", ephemeral: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const guildId = interaction.guildId;
|
|
142
|
+
const group = interaction.options.getSubcommandGroup(false);
|
|
143
|
+
const sub = interaction.options.getSubcommand();
|
|
144
|
+
|
|
145
|
+
// Top-level subcommands (no group)
|
|
146
|
+
if (!group) {
|
|
147
|
+
if (sub === "setup") {
|
|
148
|
+
return interaction.reply({
|
|
149
|
+
content:
|
|
150
|
+
"**Aegis Setup**\n\nUse the following commands to configure each module:\n" +
|
|
151
|
+
"• `/antimod raid on` — enable raid detection\n" +
|
|
152
|
+
"• `/antimod spam on` — enable spam detection\n" +
|
|
153
|
+
"• `/antimod link on` — enable link filtering\n" +
|
|
154
|
+
"• `/antimod alt on` — enable new-account blocking\n" +
|
|
155
|
+
"• `/antimod log #channel` — set the log channel\n\n" +
|
|
156
|
+
"All modules are **off by default**. Use `/antimod status` to see current settings.",
|
|
157
|
+
ephemeral: true,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (sub === "status") {
|
|
162
|
+
const config = await getGuildConfig(ctx, guildId);
|
|
163
|
+
const bool = (v) => (v ? "✅ ON" : "❌ OFF");
|
|
164
|
+
return interaction.reply({
|
|
165
|
+
content:
|
|
166
|
+
`**Aegis Status** for ${interaction.guild?.name ?? guildId}\n\n` +
|
|
167
|
+
`**Anti-Raid:** ${bool(config.raid_enabled)} | Threshold: ${config.raid_joins} joins / ${config.raid_seconds}s | Lockdown: ${config.raid_lockdown_active ? "ACTIVE" : "inactive"}\n` +
|
|
168
|
+
`**Anti-Spam:** ${bool(config.spam_enabled)} | Threshold: ${config.spam_messages} msgs / ${config.spam_seconds}s | Action: ${config.spam_action}\n` +
|
|
169
|
+
`**Anti-Link:** ${bool(config.link_enabled)} | Block invites: ${bool(config.link_block_invites)}\n` +
|
|
170
|
+
`**Anti-Alt:** ${bool(config.alt_enabled)} | Min age: ${config.alt_min_age_days}d | Action: ${config.alt_action}\n` +
|
|
171
|
+
`**Log channel:** ${config.log_channel_id ? `<#${config.log_channel_id}>` : "not set"}`,
|
|
172
|
+
ephemeral: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (sub === "log") {
|
|
177
|
+
const channel = interaction.options.getChannel("channel");
|
|
178
|
+
await setGuildConfig(ctx, guildId, { log_channel_id: channel.id });
|
|
179
|
+
return interaction.reply({ content: `Log channel set to <#${channel.id}>.`, ephemeral: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Grouped subcommands
|
|
184
|
+
if (group === "raid") {
|
|
185
|
+
if (sub === "on") {
|
|
186
|
+
await setGuildConfig(ctx, guildId, { raid_enabled: true });
|
|
187
|
+
return interaction.reply({ content: "Anti-raid **enabled**.", ephemeral: true });
|
|
188
|
+
}
|
|
189
|
+
if (sub === "off") {
|
|
190
|
+
await setGuildConfig(ctx, guildId, { raid_enabled: false });
|
|
191
|
+
return interaction.reply({ content: "Anti-raid **disabled**.", ephemeral: true });
|
|
192
|
+
}
|
|
193
|
+
if (sub === "threshold") {
|
|
194
|
+
const joins = interaction.options.getInteger("joins");
|
|
195
|
+
const seconds = interaction.options.getInteger("seconds");
|
|
196
|
+
await setGuildConfig(ctx, guildId, { raid_joins: joins, raid_seconds: seconds });
|
|
197
|
+
return interaction.reply({ content: `Raid threshold set: **${joins} joins** in **${seconds}s**.`, ephemeral: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (group === "spam") {
|
|
202
|
+
if (sub === "on") {
|
|
203
|
+
await setGuildConfig(ctx, guildId, { spam_enabled: true });
|
|
204
|
+
return interaction.reply({ content: "Anti-spam **enabled**.", ephemeral: true });
|
|
205
|
+
}
|
|
206
|
+
if (sub === "off") {
|
|
207
|
+
await setGuildConfig(ctx, guildId, { spam_enabled: false });
|
|
208
|
+
return interaction.reply({ content: "Anti-spam **disabled**.", ephemeral: true });
|
|
209
|
+
}
|
|
210
|
+
if (sub === "action") {
|
|
211
|
+
const type = interaction.options.getString("type");
|
|
212
|
+
await setGuildConfig(ctx, guildId, { spam_action: type });
|
|
213
|
+
return interaction.reply({ content: `Spam action set to **${type}**.`, ephemeral: true });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (group === "link") {
|
|
218
|
+
if (sub === "on") {
|
|
219
|
+
await setGuildConfig(ctx, guildId, { link_enabled: true });
|
|
220
|
+
return interaction.reply({ content: "Anti-link **enabled**.", ephemeral: true });
|
|
221
|
+
}
|
|
222
|
+
if (sub === "off") {
|
|
223
|
+
await setGuildConfig(ctx, guildId, { link_enabled: false });
|
|
224
|
+
return interaction.reply({ content: "Anti-link **disabled**.", ephemeral: true });
|
|
225
|
+
}
|
|
226
|
+
if (sub === "whitelist") {
|
|
227
|
+
const rawDomain = interaction.options.getString("domain");
|
|
228
|
+
const domain = rawDomain.toLowerCase().replace(/^https?:\/\//, "").split("/")[0];
|
|
229
|
+
const existing = await Whitelist.findOne({ guildId, domain });
|
|
230
|
+
if (existing) {
|
|
231
|
+
return interaction.reply({ content: `\`${domain}\` is already whitelisted.`, ephemeral: true });
|
|
232
|
+
}
|
|
233
|
+
await Whitelist.create({ guildId, domain });
|
|
234
|
+
return interaction.reply({ content: `Domain \`${domain}\` added to the link whitelist.`, ephemeral: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (group === "alt") {
|
|
239
|
+
if (sub === "on") {
|
|
240
|
+
await setGuildConfig(ctx, guildId, { alt_enabled: true });
|
|
241
|
+
return interaction.reply({ content: "Anti-alt **enabled**.", ephemeral: true });
|
|
242
|
+
}
|
|
243
|
+
if (sub === "off") {
|
|
244
|
+
await setGuildConfig(ctx, guildId, { alt_enabled: false });
|
|
245
|
+
return interaction.reply({ content: "Anti-alt **disabled**.", ephemeral: true });
|
|
246
|
+
}
|
|
247
|
+
if (sub === "age") {
|
|
248
|
+
const days = interaction.options.getInteger("days");
|
|
249
|
+
await setGuildConfig(ctx, guildId, { alt_min_age_days: days });
|
|
250
|
+
return interaction.reply({ content: `Minimum account age set to **${days} days**.`, ephemeral: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return interaction.reply({ content: "Unknown subcommand.", ephemeral: true });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { data, execute };
|
|
258
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { PermissionFlagsBits } = (() => {
|
|
4
|
+
try {
|
|
5
|
+
return require("discord.js");
|
|
6
|
+
} catch {
|
|
7
|
+
return { PermissionFlagsBits: { ModerateMembers: 1n << 40n } };
|
|
8
|
+
}
|
|
9
|
+
})();
|
|
10
|
+
|
|
11
|
+
const { getGuildConfig, setGuildConfig } = require("../lib/getConfig");
|
|
12
|
+
const { logAction } = require("../lib/logAction");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Factory — returns the /antiraid command object.
|
|
16
|
+
* @param {object} ctx
|
|
17
|
+
*/
|
|
18
|
+
module.exports = function createAntiraidCommand(ctx) {
|
|
19
|
+
const data = {
|
|
20
|
+
name: "antiraid",
|
|
21
|
+
description: "Manually toggle raid lockdown for this server",
|
|
22
|
+
defaultMemberPermissions: String(PermissionFlagsBits.ModerateMembers),
|
|
23
|
+
options: [
|
|
24
|
+
{
|
|
25
|
+
name: "lockdown",
|
|
26
|
+
type: 1, // SUB_COMMAND
|
|
27
|
+
description: "Toggle server lockdown on or off",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function execute(interaction) {
|
|
33
|
+
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers)) {
|
|
34
|
+
return interaction.reply({ content: "You need the Moderate Members permission to use this command.", ephemeral: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const guild = interaction.guild;
|
|
38
|
+
const guildId = interaction.guildId;
|
|
39
|
+
const sub = interaction.options.getSubcommand();
|
|
40
|
+
|
|
41
|
+
if (sub !== "lockdown") {
|
|
42
|
+
return interaction.reply({ content: "Unknown subcommand.", ephemeral: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await interaction.deferReply({ ephemeral: true });
|
|
46
|
+
|
|
47
|
+
const config = await getGuildConfig(ctx, guildId);
|
|
48
|
+
|
|
49
|
+
if (!config.raid_lockdown_active) {
|
|
50
|
+
// --- Activate lockdown ---
|
|
51
|
+
try {
|
|
52
|
+
await guild.setVerificationLevel(3); // HIGH
|
|
53
|
+
} catch (err) {
|
|
54
|
+
ctx.logger.warn("[Aegis] Could not set verification level", err);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const textChannels = guild.channels.cache.filter(
|
|
58
|
+
(c) => c.isTextBased?.() && typeof c.permissionOverwrites?.edit === "function"
|
|
59
|
+
);
|
|
60
|
+
for (const [, channel] of textChannels) {
|
|
61
|
+
await channel.permissionOverwrites
|
|
62
|
+
.edit(guild.roles.everyone, { SendMessages: false })
|
|
63
|
+
.catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await setGuildConfig(ctx, guildId, { raid_lockdown_active: true });
|
|
67
|
+
await logAction(ctx, null, guildId, {
|
|
68
|
+
module: "raid",
|
|
69
|
+
action: "lockdown_on",
|
|
70
|
+
targetUserId: null,
|
|
71
|
+
reason: `Manual lockdown by ${interaction.user.tag}`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (config.log_channel_id) {
|
|
75
|
+
const logCh = guild.channels.cache.get(config.log_channel_id);
|
|
76
|
+
if (logCh?.isTextBased?.()) {
|
|
77
|
+
await logCh
|
|
78
|
+
.send(`🔒 **[Aegis] Manual lockdown activated** by ${interaction.user}. Use \`/antiraid lockdown\` to lift.`)
|
|
79
|
+
.catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.logger.warn(`[Aegis] Manual lockdown activated in guild ${guildId} by ${interaction.user.tag}`);
|
|
84
|
+
return interaction.editReply("🔒 **Lockdown activated.** All text channels locked, verification level set to HIGH.");
|
|
85
|
+
} else {
|
|
86
|
+
// --- Lift lockdown ---
|
|
87
|
+
try {
|
|
88
|
+
await guild.setVerificationLevel(2); // MEDIUM
|
|
89
|
+
} catch (err) {
|
|
90
|
+
ctx.logger.warn("[Aegis] Could not reset verification level", err);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const textChannels = guild.channels.cache.filter(
|
|
94
|
+
(c) => c.isTextBased?.() && typeof c.permissionOverwrites?.edit === "function"
|
|
95
|
+
);
|
|
96
|
+
for (const [, channel] of textChannels) {
|
|
97
|
+
await channel.permissionOverwrites
|
|
98
|
+
.edit(guild.roles.everyone, { SendMessages: null }) // inherit
|
|
99
|
+
.catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await setGuildConfig(ctx, guildId, { raid_lockdown_active: false });
|
|
103
|
+
await logAction(ctx, null, guildId, {
|
|
104
|
+
module: "raid",
|
|
105
|
+
action: "lockdown_off",
|
|
106
|
+
targetUserId: null,
|
|
107
|
+
reason: `Lockdown lifted by ${interaction.user.tag}`,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (config.log_channel_id) {
|
|
111
|
+
const logCh = guild.channels.cache.get(config.log_channel_id);
|
|
112
|
+
if (logCh?.isTextBased?.()) {
|
|
113
|
+
await logCh
|
|
114
|
+
.send(`🔓 **[Aegis] Lockdown lifted** by ${interaction.user}.`)
|
|
115
|
+
.catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
ctx.logger.info(`[Aegis] Lockdown lifted in guild ${guildId} by ${interaction.user.tag}`);
|
|
120
|
+
return interaction.editReply("🔓 **Lockdown lifted.** Text channels unlocked, verification level restored to MEDIUM.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { data, execute };
|
|
125
|
+
};
|
package/index.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const cron = require("node-cron");
|
|
4
|
+
const { getGuildConfig, setGuildConfig } = require("./lib/getConfig");
|
|
5
|
+
const { recordJoin, isRaid, clearGuild, joinLog } = require("./lib/raidTracker");
|
|
6
|
+
const { recordMessage, isSpam, clearUser, userLog } = require("./lib/spamTracker");
|
|
7
|
+
const { logAction } = require("./lib/logAction");
|
|
8
|
+
const createAntimodCommand = require("./commands/antimod");
|
|
9
|
+
const createAntiraidCommand = require("./commands/antiraid");
|
|
10
|
+
const LogSchema = require("./models/log");
|
|
11
|
+
const WhitelistSchema = require("./models/whitelist");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Main plugin load function — called by ADB when the plugin is activated.
|
|
15
|
+
* @param {object} ctx - ADB plugin context
|
|
16
|
+
*/
|
|
17
|
+
async function load(ctx) {
|
|
18
|
+
// Register mongoose models (namespaced automatically by ADB)
|
|
19
|
+
const AegisLog = ctx.defineModel("log", LogSchema);
|
|
20
|
+
const Whitelist = ctx.defineModel("whitelist", WhitelistSchema);
|
|
21
|
+
|
|
22
|
+
// Register slash commands
|
|
23
|
+
ctx.registerCommand(createAntimodCommand(ctx, AegisLog, Whitelist));
|
|
24
|
+
ctx.registerCommand(createAntiraidCommand(ctx));
|
|
25
|
+
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
27
|
+
// Event: guildMemberAdd — anti-alt + anti-raid
|
|
28
|
+
// -------------------------------------------------------------------------
|
|
29
|
+
ctx.registerEvent("guildMemberAdd", async (member) => {
|
|
30
|
+
const guildId = member.guild.id;
|
|
31
|
+
let config;
|
|
32
|
+
try {
|
|
33
|
+
config = await getGuildConfig(ctx, guildId);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
ctx.logger.error("[Aegis] guildMemberAdd: failed to load config", err);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Anti-alt ---
|
|
40
|
+
if (config.alt_enabled) {
|
|
41
|
+
const accountAgeDays = (Date.now() - member.user.createdAt.getTime()) / 86400000;
|
|
42
|
+
if (accountAgeDays < config.alt_min_age_days) {
|
|
43
|
+
if (config.alt_action === "kick") {
|
|
44
|
+
await member
|
|
45
|
+
.kick(`[Aegis] Account too new (${Math.floor(accountAgeDays)}d old, min ${config.alt_min_age_days}d)`)
|
|
46
|
+
.catch(() => {});
|
|
47
|
+
await logAction(ctx, AegisLog, guildId, {
|
|
48
|
+
module: "alt",
|
|
49
|
+
action: "kick",
|
|
50
|
+
targetUserId: member.id,
|
|
51
|
+
reason: `Account age: ${Math.floor(accountAgeDays)}d (min ${config.alt_min_age_days}d)`,
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
await logAction(ctx, AegisLog, guildId, {
|
|
55
|
+
module: "alt",
|
|
56
|
+
action: "flag",
|
|
57
|
+
targetUserId: member.id,
|
|
58
|
+
reason: `Account age: ${Math.floor(accountAgeDays)}d (min ${config.alt_min_age_days}d)`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Anti-raid ---
|
|
65
|
+
if (config.raid_enabled && !config.raid_lockdown_active) {
|
|
66
|
+
const times = recordJoin(guildId);
|
|
67
|
+
if (isRaid(times, config.raid_joins, config.raid_seconds)) {
|
|
68
|
+
clearGuild(guildId);
|
|
69
|
+
await triggerLockdown(ctx, member.guild, guildId, config, AegisLog);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
// Event: messageCreate — anti-link + anti-spam
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
ctx.registerEvent("messageCreate", async (message) => {
|
|
78
|
+
if (message.author.bot || !message.guild) return;
|
|
79
|
+
|
|
80
|
+
const guildId = message.guild.id;
|
|
81
|
+
let config;
|
|
82
|
+
try {
|
|
83
|
+
config = await getGuildConfig(ctx, guildId);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
ctx.logger.error("[Aegis] messageCreate: failed to load config", err);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Anti-link ---
|
|
90
|
+
if (config.link_enabled) {
|
|
91
|
+
const hasInvite = /discord\.gg\/[a-zA-Z0-9]+/.test(message.content);
|
|
92
|
+
const urlMatches = message.content.match(/https?:\/\/[^\s]+/gi);
|
|
93
|
+
|
|
94
|
+
let blocked = false;
|
|
95
|
+
|
|
96
|
+
if (hasInvite && config.link_block_invites) blocked = true;
|
|
97
|
+
|
|
98
|
+
if (!blocked && urlMatches) {
|
|
99
|
+
const whitelist = await Whitelist.find({ guildId }).lean();
|
|
100
|
+
const allowedDomains = whitelist.map((w) => w.domain.toLowerCase());
|
|
101
|
+
|
|
102
|
+
const domains = urlMatches.map((url) => {
|
|
103
|
+
try {
|
|
104
|
+
return new URL(url).hostname.toLowerCase();
|
|
105
|
+
} catch {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (domains.some((d) => d && !allowedDomains.includes(d))) blocked = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (blocked) {
|
|
114
|
+
await message.delete().catch(() => {});
|
|
115
|
+
await logAction(ctx, AegisLog, guildId, {
|
|
116
|
+
module: "link",
|
|
117
|
+
action: "delete",
|
|
118
|
+
targetUserId: message.author.id,
|
|
119
|
+
reason: "Blocked link or invite",
|
|
120
|
+
});
|
|
121
|
+
return; // skip spam check for this message
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Anti-spam ---
|
|
126
|
+
if (config.spam_enabled) {
|
|
127
|
+
const messages = recordMessage(guildId, message.author.id, message.content);
|
|
128
|
+
if (isSpam(messages, config.spam_messages, config.spam_seconds)) {
|
|
129
|
+
clearUser(guildId, message.author.id);
|
|
130
|
+
await message.delete().catch(() => {});
|
|
131
|
+
|
|
132
|
+
const member = message.member;
|
|
133
|
+
if (member) {
|
|
134
|
+
const action = config.spam_action;
|
|
135
|
+
if (action === "mute") {
|
|
136
|
+
await member.timeout(10 * 60 * 1000, "[Aegis] Spam detected").catch(() => {});
|
|
137
|
+
} else if (action === "kick") {
|
|
138
|
+
await member.kick("[Aegis] Spam detected").catch(() => {});
|
|
139
|
+
} else if (action === "ban") {
|
|
140
|
+
await member.ban({ reason: "[Aegis] Spam detected" }).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
// "warn" action: message deleted, no further punishment
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await logAction(ctx, AegisLog, guildId, {
|
|
146
|
+
module: "spam",
|
|
147
|
+
action: config.spam_action,
|
|
148
|
+
targetUserId: message.author.id,
|
|
149
|
+
reason: "Spam detected",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
// Cleanup cron — prune in-memory trackers every 5 minutes
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
const cleanupTask = cron.schedule("*/5 * * * *", () => {
|
|
159
|
+
const cutoff = Date.now() - 60 * 1000;
|
|
160
|
+
|
|
161
|
+
// Prune raidTracker
|
|
162
|
+
for (const [guildId, times] of joinLog.entries()) {
|
|
163
|
+
const filtered = times.filter((t) => t > cutoff);
|
|
164
|
+
if (filtered.length === 0) joinLog.delete(guildId);
|
|
165
|
+
else joinLog.set(guildId, filtered);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Prune spamTracker
|
|
169
|
+
for (const [key, entry] of userLog.entries()) {
|
|
170
|
+
const filtered = entry.messages.filter((m) => m.ts > cutoff);
|
|
171
|
+
if (filtered.length === 0) userLog.delete(key);
|
|
172
|
+
else entry.messages = filtered;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
// Cleanup on unload
|
|
178
|
+
// -------------------------------------------------------------------------
|
|
179
|
+
ctx.hooks.on("onPluginUnload", ({ pluginName }) => {
|
|
180
|
+
if (pluginName === "adb-plugin-aegis") {
|
|
181
|
+
cleanupTask.stop();
|
|
182
|
+
ctx.logger.info("[Aegis] Cleanup task stopped on unload");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
ctx.logger.info("[Aegis] Plugin loaded — anti-raid, anti-spam, anti-link, anti-alt ready");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Internal helper — activate server lockdown
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
async function triggerLockdown(ctx, guild, guildId, config, AegisLog) {
|
|
193
|
+
try {
|
|
194
|
+
await guild.setVerificationLevel(3).catch(() => {}); // HIGH
|
|
195
|
+
|
|
196
|
+
const textChannels = guild.channels.cache.filter(
|
|
197
|
+
(c) => c.isTextBased?.() && typeof c.permissionOverwrites?.edit === "function"
|
|
198
|
+
);
|
|
199
|
+
for (const [, channel] of textChannels) {
|
|
200
|
+
await channel.permissionOverwrites
|
|
201
|
+
.edit(guild.roles.everyone, { SendMessages: false })
|
|
202
|
+
.catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await setGuildConfig(ctx, guildId, { raid_lockdown_active: true });
|
|
206
|
+
|
|
207
|
+
await logAction(ctx, AegisLog, guildId, {
|
|
208
|
+
module: "raid",
|
|
209
|
+
action: "lockdown",
|
|
210
|
+
targetUserId: null,
|
|
211
|
+
reason: "Raid detected — auto lockdown",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (config.log_channel_id) {
|
|
215
|
+
const ch = guild.channels.cache.get(config.log_channel_id);
|
|
216
|
+
if (ch?.isTextBased?.()) {
|
|
217
|
+
await ch
|
|
218
|
+
.send("🔒 **[Aegis] Raid detected — lockdown activated.** Use `/antiraid lockdown` to lift.")
|
|
219
|
+
.catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
ctx.logger.warn(`[Aegis] Lockdown triggered in guild ${guildId}`);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
ctx.logger.error(`[Aegis] Lockdown failed in guild ${guildId}`, err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { load };
|
package/lib/getConfig.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULTS = {
|
|
4
|
+
log_channel_id: null,
|
|
5
|
+
// module enabled flags
|
|
6
|
+
raid_enabled: false,
|
|
7
|
+
spam_enabled: false,
|
|
8
|
+
link_enabled: false,
|
|
9
|
+
alt_enabled: false,
|
|
10
|
+
// raid
|
|
11
|
+
raid_joins: 10,
|
|
12
|
+
raid_seconds: 10,
|
|
13
|
+
raid_lockdown_active: false,
|
|
14
|
+
// spam
|
|
15
|
+
spam_messages: 5,
|
|
16
|
+
spam_seconds: 3,
|
|
17
|
+
spam_action: "mute",
|
|
18
|
+
// link
|
|
19
|
+
link_block_invites: true,
|
|
20
|
+
// alt
|
|
21
|
+
alt_min_age_days: 7,
|
|
22
|
+
alt_action: "flag",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load per-guild plugin config merged with defaults.
|
|
27
|
+
* @param {object} ctx - ADB plugin context
|
|
28
|
+
* @param {string} guildId
|
|
29
|
+
* @returns {Promise<object>}
|
|
30
|
+
*/
|
|
31
|
+
async function getGuildConfig(ctx, guildId) {
|
|
32
|
+
const record = await ctx.db.getPluginConfig(guildId, "adb-plugin-aegis");
|
|
33
|
+
return Object.assign({}, DEFAULTS, record?.data || {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Patch per-guild plugin config (shallow merge over existing data).
|
|
38
|
+
* @param {object} ctx - ADB plugin context
|
|
39
|
+
* @param {string} guildId
|
|
40
|
+
* @param {object} patch - fields to update
|
|
41
|
+
*/
|
|
42
|
+
async function setGuildConfig(ctx, guildId, patch) {
|
|
43
|
+
await ctx.db.updatePluginConfig(guildId, "adb-plugin-aegis", patch);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { getGuildConfig, setGuildConfig, DEFAULTS };
|
package/lib/logAction.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { EmbedBuilder } = (() => {
|
|
4
|
+
try {
|
|
5
|
+
return require("discord.js");
|
|
6
|
+
} catch {
|
|
7
|
+
// discord.js is a peer dep — not available in test harness; provide stub
|
|
8
|
+
return { EmbedBuilder: null };
|
|
9
|
+
}
|
|
10
|
+
})();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Log a moderation action to the database and, if configured, post an embed
|
|
14
|
+
* to the guild's log channel.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} ctx - ADB plugin context
|
|
17
|
+
* @param {object} AegisLog - mongoose model for Aegis logs (may be null in lockdown path)
|
|
18
|
+
* @param {string} guildId
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string} opts.module - "raid" | "spam" | "link" | "alt"
|
|
21
|
+
* @param {string} opts.action - action taken
|
|
22
|
+
* @param {string|null} opts.targetUserId
|
|
23
|
+
* @param {string} opts.reason
|
|
24
|
+
*/
|
|
25
|
+
async function logAction(ctx, AegisLog, guildId, { module, action, targetUserId, reason }) {
|
|
26
|
+
// Persist to DB
|
|
27
|
+
if (AegisLog) {
|
|
28
|
+
try {
|
|
29
|
+
await AegisLog.create({
|
|
30
|
+
guildId,
|
|
31
|
+
module,
|
|
32
|
+
action,
|
|
33
|
+
targetUserId: targetUserId || null,
|
|
34
|
+
reason: reason || "",
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
ctx.logger.error("[Aegis] Failed to save log entry", err);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Post to log channel if configured
|
|
42
|
+
try {
|
|
43
|
+
const { getGuildConfig } = require("./getConfig");
|
|
44
|
+
const config = await getGuildConfig(ctx, guildId);
|
|
45
|
+
if (!config.log_channel_id) return;
|
|
46
|
+
|
|
47
|
+
const guild = ctx.client.guilds?.cache?.get(guildId);
|
|
48
|
+
if (!guild) return;
|
|
49
|
+
|
|
50
|
+
const channel = guild.channels?.cache?.get(config.log_channel_id);
|
|
51
|
+
if (!channel?.isTextBased()) return;
|
|
52
|
+
|
|
53
|
+
const moduleColors = {
|
|
54
|
+
raid: 0xff4444,
|
|
55
|
+
spam: 0xff8800,
|
|
56
|
+
link: 0xffcc00,
|
|
57
|
+
alt: 0x8844ff,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (EmbedBuilder) {
|
|
61
|
+
const embed = new EmbedBuilder()
|
|
62
|
+
.setColor(moduleColors[module] || 0x888888)
|
|
63
|
+
.setTitle(`[Aegis] ${module.toUpperCase()} — ${action}`)
|
|
64
|
+
.addFields(
|
|
65
|
+
{ name: "Guild", value: guildId, inline: true },
|
|
66
|
+
{ name: "Module", value: module, inline: true },
|
|
67
|
+
{ name: "Action", value: action, inline: true },
|
|
68
|
+
{ name: "Target", value: targetUserId ? `<@${targetUserId}>` : "N/A", inline: true },
|
|
69
|
+
{ name: "Reason", value: reason || "N/A", inline: false }
|
|
70
|
+
)
|
|
71
|
+
.setTimestamp();
|
|
72
|
+
await channel.send({ embeds: [embed] }).catch(() => {});
|
|
73
|
+
} else {
|
|
74
|
+
// Fallback plain text (e.g., if EmbedBuilder stub)
|
|
75
|
+
await channel
|
|
76
|
+
.send(`**[Aegis] ${module.toUpperCase()} | ${action}** — ${reason} (${targetUserId ? `<@${targetUserId}>` : "N/A"})`)
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
ctx.logger.error("[Aegis] Failed to post log embed", err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { logAction };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Map<guildId, Array<timestamp>>
|
|
4
|
+
const joinLog = new Map();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record a join for a guild and return the current timestamp array.
|
|
8
|
+
* @param {string} guildId
|
|
9
|
+
* @returns {number[]} array of join timestamps for this guild
|
|
10
|
+
*/
|
|
11
|
+
function recordJoin(guildId) {
|
|
12
|
+
const times = joinLog.get(guildId) || [];
|
|
13
|
+
times.push(Date.now());
|
|
14
|
+
joinLog.set(guildId, times);
|
|
15
|
+
return times;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check whether the join timestamps constitute a raid.
|
|
20
|
+
* @param {number[]} joinTimes - array of timestamps (ms)
|
|
21
|
+
* @param {number} threshold - minimum joins to trigger
|
|
22
|
+
* @param {number} windowSeconds - sliding window size
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
function isRaid(joinTimes, threshold, windowSeconds) {
|
|
26
|
+
const cutoff = Date.now() - windowSeconds * 1000;
|
|
27
|
+
const recent = joinTimes.filter((t) => t > cutoff);
|
|
28
|
+
return recent.length >= threshold;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Clear join log for a guild (e.g., after lockdown triggered).
|
|
33
|
+
* @param {string} guildId
|
|
34
|
+
*/
|
|
35
|
+
function clearGuild(guildId) {
|
|
36
|
+
joinLog.delete(guildId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { recordJoin, isRaid, clearGuild, joinLog };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Map<`${guildId}:${userId}`, { messages: Array<{content: string, ts: number}> }>
|
|
4
|
+
const userLog = new Map();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record a message for a user in a guild.
|
|
8
|
+
* Prunes entries older than 60 seconds to keep memory bounded.
|
|
9
|
+
* @param {string} guildId
|
|
10
|
+
* @param {string} userId
|
|
11
|
+
* @param {string} content
|
|
12
|
+
* @returns {{ content: string, ts: number }[]} current message array for this user
|
|
13
|
+
*/
|
|
14
|
+
function recordMessage(guildId, userId, content) {
|
|
15
|
+
const key = `${guildId}:${userId}`;
|
|
16
|
+
const entry = userLog.get(key) || { messages: [] };
|
|
17
|
+
const cutoff = Date.now() - 60 * 1000;
|
|
18
|
+
// prune messages older than 60s
|
|
19
|
+
entry.messages = entry.messages.filter((m) => m.ts > cutoff);
|
|
20
|
+
entry.messages.push({ content, ts: Date.now() });
|
|
21
|
+
userLog.set(key, entry);
|
|
22
|
+
return entry.messages;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Determine whether a user's recent messages constitute spam.
|
|
27
|
+
* Triggers on:
|
|
28
|
+
* - >= threshold identical message contents within the window, OR
|
|
29
|
+
* - any single message with >= threshold user/role mentions
|
|
30
|
+
* @param {{ content: string, ts: number }[]} messages
|
|
31
|
+
* @param {number} threshold
|
|
32
|
+
* @param {number} windowSeconds
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isSpam(messages, threshold, windowSeconds) {
|
|
36
|
+
const cutoff = Date.now() - windowSeconds * 1000;
|
|
37
|
+
const recent = messages.filter((m) => m.ts > cutoff);
|
|
38
|
+
|
|
39
|
+
// Check for duplicate messages
|
|
40
|
+
const counts = new Map();
|
|
41
|
+
for (const m of recent) {
|
|
42
|
+
const normalized = m.content.trim().toLowerCase();
|
|
43
|
+
counts.set(normalized, (counts.get(normalized) || 0) + 1);
|
|
44
|
+
if (counts.get(normalized) >= threshold) return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check for mass mentions in the most recent message
|
|
48
|
+
const latest = messages[messages.length - 1];
|
|
49
|
+
if (latest) {
|
|
50
|
+
const mentionCount = (latest.content.match(/<@[!&]?\d+>/g) || []).length;
|
|
51
|
+
if (mentionCount >= threshold) return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clear message log for a user in a guild.
|
|
59
|
+
* @param {string} guildId
|
|
60
|
+
* @param {string} userId
|
|
61
|
+
*/
|
|
62
|
+
function clearUser(guildId, userId) {
|
|
63
|
+
userLog.delete(`${guildId}:${userId}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { recordMessage, isSpam, clearUser, userLog };
|
package/models/config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Guild configuration is stored via ctx.db.getPluginConfig / ctx.db.updatePluginConfig.
|
|
4
|
+
// No separate mongoose model is needed for config; this file is a placeholder
|
|
5
|
+
// that documents the shape of the stored data blob.
|
|
6
|
+
//
|
|
7
|
+
// Stored under: plugin_adb-plugin-aegis_config (internal ADB key)
|
|
8
|
+
//
|
|
9
|
+
// Shape (see lib/getConfig.js DEFAULTS for all fields):
|
|
10
|
+
// {
|
|
11
|
+
// log_channel_id: string | null,
|
|
12
|
+
// raid_enabled: boolean,
|
|
13
|
+
// spam_enabled: boolean,
|
|
14
|
+
// link_enabled: boolean,
|
|
15
|
+
// alt_enabled: boolean,
|
|
16
|
+
// raid_joins: number,
|
|
17
|
+
// raid_seconds: number,
|
|
18
|
+
// raid_lockdown_active: boolean,
|
|
19
|
+
// spam_messages: number,
|
|
20
|
+
// spam_seconds: number,
|
|
21
|
+
// spam_action: "warn" | "mute" | "kick" | "ban",
|
|
22
|
+
// link_block_invites: boolean,
|
|
23
|
+
// alt_min_age_days: number,
|
|
24
|
+
// alt_action: "flag" | "kick",
|
|
25
|
+
// }
|
|
26
|
+
|
|
27
|
+
module.exports = {};
|
package/models/log.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { Schema } = require("mongoose");
|
|
4
|
+
|
|
5
|
+
const LogSchema = new Schema({
|
|
6
|
+
guildId: { type: String, required: true, index: true },
|
|
7
|
+
module: {
|
|
8
|
+
type: String,
|
|
9
|
+
enum: ["raid", "spam", "link", "alt"],
|
|
10
|
+
required: true,
|
|
11
|
+
},
|
|
12
|
+
action: { type: String, required: true },
|
|
13
|
+
targetUserId: { type: String, default: null },
|
|
14
|
+
reason: { type: String, default: "" },
|
|
15
|
+
triggeredAt: { type: Date, default: Date.now },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
module.exports = LogSchema;
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "adb-plugin-aegis",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Automated threat detection for Advanced Discord Bot — anti-raid, anti-spam, anti-link, anti-alt",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"plugin.json",
|
|
9
|
+
"commands",
|
|
10
|
+
"models",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node test/local-harness.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"adb-plugin",
|
|
20
|
+
"discord",
|
|
21
|
+
"moderation",
|
|
22
|
+
"automod",
|
|
23
|
+
"anti-raid"
|
|
24
|
+
],
|
|
25
|
+
"author": "dead",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"node-cron": "^3.0.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"discord.js": ">=14.0.0",
|
|
32
|
+
"mongoose": ">=7.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"mongoose": "^8.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "adb-plugin-aegis",
|
|
3
|
+
"displayName": "Aegis",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Automated threat detection: anti-raid, anti-spam, anti-link, anti-alt. Each module independently toggleable.",
|
|
6
|
+
"author": "dead",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"requiresRestart": false,
|
|
9
|
+
"permissions": ["db.read", "db.write", "commands.register"],
|
|
10
|
+
"configSchema": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {
|
|
13
|
+
"log_channel_id": { "type": "string", "default": null },
|
|
14
|
+
"raid_joins": { "type": "number", "default": 10 },
|
|
15
|
+
"raid_seconds": { "type": "number", "default": 10 },
|
|
16
|
+
"spam_messages": { "type": "number", "default": 5 },
|
|
17
|
+
"spam_seconds": { "type": "number", "default": 3 },
|
|
18
|
+
"spam_action": { "type": "string", "enum": ["warn", "mute", "kick", "ban"], "default": "mute" },
|
|
19
|
+
"alt_min_age_days": { "type": "number", "default": 7 },
|
|
20
|
+
"alt_action": { "type": "string", "enum": ["flag", "kick"], "default": "flag" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|