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 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 };
@@ -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 };
@@ -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 };
@@ -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;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+
3
+ const { Schema } = require("mongoose");
4
+
5
+ const WhitelistSchema = new Schema({
6
+ guildId: { type: String, required: true, index: true },
7
+ domain: { type: String, required: true },
8
+ });
9
+
10
+ module.exports = WhitelistSchema;
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
+ }