glassframe-protocol 1.2.1 → 2.1.0
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/CHANGELOG.md +184 -0
- package/GETTING_STARTED.md +23 -4
- package/README.md +55 -20
- package/dist/config.js +20 -1
- package/dist/src/GlassFrame.js +312 -42
- package/dist/src/commands/PrefixRouter.js +123 -48
- package/dist/src/core/Cache.js +10 -0
- package/dist/src/core/Layer.js +28 -23
- package/dist/src/core/PerformanceMonitor.js +16 -4
- package/dist/src/core/VersionInfo.js +1 -1
- package/dist/src/layers/AIModerationLayer.js +4 -4
- package/dist/src/layers/AntiNukeLayer.js +113 -16
- package/dist/src/layers/AntiRaidLayer.js +5 -7
- package/dist/src/layers/BasicSecurityLayer.js +39 -12
- package/dist/src/logging/SmartLogger.js +33 -3
- package/dist/src/moderation/PunishmentEngine.js +19 -7
- package/dist/src/ui/ControlPanel.js +195 -1
- package/dist/src/utils/nlpEngine.js +2 -2
- package/docs/CACHE_ARCHITECTURE.md +9 -0
- package/docs/COMMANDS.md +37 -11
- package/docs/ENGINE.md +83 -0
- package/docs/PROTOCOL_LAYERS.md +62 -17
- package/docs/STATE.md +101 -0
- package/package.json +1 -1
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const { buildPanel } = require("../ui/ControlPanel");
|
|
3
|
+
const { buildPanel, buildMetricsMessage, buildEnginePage } = require("../ui/ControlPanel");
|
|
4
4
|
const { buildLogMessage } = require("../logging/ComponentsV2");
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
6
|
+
const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing", "prefix", "engine"];
|
|
7
|
+
const OWNER_ONLY_COMMANDS = new Set(["engine"]);
|
|
8
|
+
|
|
9
|
+
const LAYER_REASONING = {
|
|
10
|
+
basicSecurity: "watches every message for spam, scam/phishing language, raid-recruitment language, and bad links.",
|
|
11
|
+
antiRaid: "watches joins for rate spikes, alt-account patterns, and account-creation clusters.",
|
|
12
|
+
antiNuke: "watches the audit log for destructive bursts, dangerous permission grants, and channel lockouts.",
|
|
13
|
+
aiModeration: "sends only ambiguous messages (not clean, not clearly bad) to Groq for a second opinion."
|
|
14
|
+
};
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
17
|
* GlassFrame Protocol registers no slash commands, by design (see
|
|
11
18
|
* docs/COMMANDS.md). Every command here is a plain prefix command gated on
|
|
12
19
|
* Manage Server permission, with a short per-user cooldown so a doubled
|
|
13
|
-
* keypress can't fire the same command twice in a row.
|
|
20
|
+
* keypress can't fire the same command twice in a row. Each server can set
|
|
21
|
+
* its own prefix (`!gf prefix set`); the default from config.js always
|
|
22
|
+
* still works too, as a fallback.
|
|
14
23
|
*/
|
|
15
24
|
class PrefixRouter {
|
|
16
25
|
constructor(frame) {
|
|
@@ -32,16 +41,25 @@ class PrefixRouter {
|
|
|
32
41
|
return false;
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
/** Tries this server's custom prefix (if set) and the default, longer one first, so an overlapping pair resolves sensibly. */
|
|
45
|
+
_matchPrefix(message) {
|
|
46
|
+
const custom = this.frame.guildPrefixes.get(message.guild.id);
|
|
47
|
+
const candidates = [custom, this.frame.config.prefix].filter(Boolean).sort((a, b) => b.length - a.length);
|
|
48
|
+
return candidates.find((p) => message.content.toLowerCase().startsWith(p.toLowerCase())) || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
async _handle(message) {
|
|
36
52
|
if (!message.guild || message.author.bot) return;
|
|
37
|
-
const prefix = this.
|
|
38
|
-
if (!
|
|
53
|
+
const prefix = this._matchPrefix(message);
|
|
54
|
+
if (!prefix) return;
|
|
39
55
|
|
|
40
56
|
const args = message.content.slice(prefix.length).trim().split(/\s+/);
|
|
41
57
|
const command = (args.shift() || "").toLowerCase();
|
|
42
58
|
if (!COMMANDS.includes(command)) return;
|
|
43
59
|
|
|
44
|
-
if (
|
|
60
|
+
if (OWNER_ONLY_COMMANDS.has(command)) {
|
|
61
|
+
if (!this.frame.isOwner(message.author.id)) return; // silent - this command doesn't exist as far as non-owners can tell
|
|
62
|
+
} else if (!this.frame.isAuthorized(message.member)) {
|
|
45
63
|
await message.reply({ content: "You need Manage Server permission to use GlassFrame Protocol commands." });
|
|
46
64
|
return;
|
|
47
65
|
}
|
|
@@ -55,6 +73,8 @@ class PrefixRouter {
|
|
|
55
73
|
if (command === "scan") return this._scan(message);
|
|
56
74
|
if (command === "metrics") return this._metrics(message);
|
|
57
75
|
if (command === "phishing") return this._phishing(message, args);
|
|
76
|
+
if (command === "prefix") return this._prefix(message, args);
|
|
77
|
+
if (command === "engine") return this._engine(message, args);
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
async _panel(message) {
|
|
@@ -63,46 +83,66 @@ class PrefixRouter {
|
|
|
63
83
|
this.frame.panelMessages.set(sent.id, message.guild.id);
|
|
64
84
|
}
|
|
65
85
|
|
|
86
|
+
/** Each layer's line explains what it actually watches for, not just on/off - the same reasoning-first approach as metrics. */
|
|
66
87
|
async _status(message) {
|
|
67
88
|
const status = this.frame.getStatus(message.guild.id);
|
|
68
|
-
const fields =
|
|
89
|
+
const fields = Object.keys(this.frame.layers).map((name) => ({
|
|
90
|
+
name: `${status[name] ? "ACTIVE" : "INACTIVE"} - ${name}`,
|
|
91
|
+
value: LAYER_REASONING[name] || "a custom layer registered via frame.registerLayer()."
|
|
92
|
+
}));
|
|
69
93
|
const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Status", fields });
|
|
70
94
|
await message.channel.send(payload);
|
|
71
95
|
}
|
|
72
96
|
|
|
73
97
|
async _help(message) {
|
|
74
|
-
const prefix = this.frame.
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
const prefix = this.frame.getPrefix(message.guild.id);
|
|
99
|
+
const sections = [
|
|
100
|
+
{
|
|
101
|
+
name: "Getting started",
|
|
102
|
+
value: [
|
|
103
|
+
`${prefix}panel - the 5-button control panel. Nothing runs until a layer is armed here (or via autoStart in code).`,
|
|
104
|
+
`${prefix}status - which layers are on right now, and what each one actually watches for.`
|
|
105
|
+
].join("\n")
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "Security tools",
|
|
109
|
+
value: [
|
|
110
|
+
`${prefix}scan - checks current roles for name/permission mismatches (impersonation bait, quiet privilege grants).`,
|
|
111
|
+
`${prefix}phishing add|remove|list <domain> - manage the link blocklist Basic Security checks messages against.`,
|
|
112
|
+
`${prefix}whitelist add|remove <userId> - exempt someone from punitive action, in this server only.`
|
|
113
|
+
].join("\n")
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "Diagnostics",
|
|
117
|
+
value: `${prefix}metrics - this server's numbers (open cases, flagged members, whitelist size); one button on the message switches to bot-wide totals across every server.`
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "Customization",
|
|
121
|
+
value: [
|
|
122
|
+
`${prefix}prefix set <newPrefix> - change this server's command prefix.`,
|
|
123
|
+
`${prefix}prefix reset - go back to the default (\`${this.frame.config.prefix}\`).`
|
|
124
|
+
].join("\n")
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Help", fields: sections });
|
|
89
128
|
await message.channel.send(payload);
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
async _whitelist(message, args) {
|
|
132
|
+
const prefix = this.frame.getPrefix(message.guild.id);
|
|
93
133
|
const [sub, id] = args;
|
|
94
134
|
if (!sub || !id) {
|
|
95
|
-
await message.reply({ content: `Usage: ${
|
|
135
|
+
await message.reply({ content: `Usage: ${prefix}whitelist <add|remove> <userId>` });
|
|
96
136
|
return;
|
|
97
137
|
}
|
|
98
138
|
|
|
99
139
|
const action = sub.toLowerCase();
|
|
100
|
-
if (action === "add") this.frame.
|
|
101
|
-
else if (action === "remove") this.frame.
|
|
140
|
+
if (action === "add") this.frame.addToWhitelist(message.guild.id, id);
|
|
141
|
+
else if (action === "remove") this.frame.removeFromWhitelist(message.guild.id, id);
|
|
102
142
|
else return;
|
|
103
143
|
|
|
104
144
|
await message.reply({
|
|
105
|
-
content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list.`
|
|
145
|
+
content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list for this server.`
|
|
106
146
|
});
|
|
107
147
|
}
|
|
108
148
|
|
|
@@ -121,32 +161,13 @@ class PrefixRouter {
|
|
|
121
161
|
}
|
|
122
162
|
|
|
123
163
|
async _metrics(message) {
|
|
124
|
-
const
|
|
125
|
-
const fmtCache = (c) => `size ${c.size} | hits ${c.hits} | misses ${c.misses} | evictions ${c.evictions}`;
|
|
126
|
-
const fmtQueue = (q) => `active ${q.active}/${q.concurrency} | pending ${q.pending} | completed ${q.completed} | failed ${q.failed}`;
|
|
127
|
-
const fmtLatency = Object.entries(m.performance.avgLatencyMs)
|
|
128
|
-
.map(([name, ms]) => `${name}: ${ms}ms avg`)
|
|
129
|
-
.join("\n") || "no samples yet";
|
|
130
|
-
|
|
131
|
-
const payload = buildLogMessage({
|
|
132
|
-
level: "info",
|
|
133
|
-
title: "GlassFrame Protocol - Performance",
|
|
134
|
-
description: `Uptime: ${Math.round(m.performance.uptimeMs / 60000)} min`,
|
|
135
|
-
fields: [
|
|
136
|
-
{ name: "Action queue", value: fmtQueue(m.queues.actions) },
|
|
137
|
-
{ name: "AI queue", value: fmtQueue(m.queues.ai) },
|
|
138
|
-
{ name: "Threat score cache", value: fmtCache(m.caches.threatScores) },
|
|
139
|
-
{ name: "Open case cache", value: fmtCache(m.caches.openCases) },
|
|
140
|
-
{ name: "Groq verdict cache", value: fmtCache(m.caches.groqVerdicts) },
|
|
141
|
-
{ name: "Average latency", value: fmtLatency }
|
|
142
|
-
]
|
|
143
|
-
});
|
|
164
|
+
const payload = buildMetricsMessage(this.frame, message.guild.id, "guild");
|
|
144
165
|
await message.channel.send(payload);
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
async _phishing(message, args) {
|
|
169
|
+
const prefix = this.frame.getPrefix(message.guild.id);
|
|
148
170
|
const [sub, domain] = args;
|
|
149
|
-
const prefix = this.frame.config.prefix;
|
|
150
171
|
|
|
151
172
|
if (!sub || sub.toLowerCase() === "list") {
|
|
152
173
|
const domains = this.frame.phishingDatabase.list();
|
|
@@ -172,6 +193,60 @@ class PrefixRouter {
|
|
|
172
193
|
await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
|
|
173
194
|
}
|
|
174
195
|
}
|
|
196
|
+
|
|
197
|
+
async _prefix(message, args) {
|
|
198
|
+
const [sub, newPrefix] = args;
|
|
199
|
+
const current = this.frame.getPrefix(message.guild.id);
|
|
200
|
+
|
|
201
|
+
if (!sub) {
|
|
202
|
+
await message.reply({ content: `This server's prefix is currently \`${current}\`. Usage: ${current}prefix set <newPrefix> | reset` });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const action = sub.toLowerCase();
|
|
207
|
+
if (action === "reset") {
|
|
208
|
+
this.frame.guildPrefixes.delete(message.guild.id);
|
|
209
|
+
this.frame._persistGuildState(message.guild.id);
|
|
210
|
+
await message.reply({ content: `Prefix reset to the default: \`${this.frame.config.prefix}\`` });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (action === "set") {
|
|
215
|
+
if (!newPrefix) {
|
|
216
|
+
await message.reply({ content: `Usage: ${current}prefix set <newPrefix>` });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.frame.setPrefix(message.guild.id, newPrefix);
|
|
220
|
+
await message.reply({
|
|
221
|
+
content: `This server's prefix is now \`${newPrefix}\`. The default \`${this.frame.config.prefix}\` still works too, as a fallback.`
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await message.reply({ content: `Usage: ${current}prefix set <newPrefix> | reset` });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Owner-only, bot-wide dashboard - see frame.getEngineReport(). Not
|
|
231
|
+
* listed in !gf help on purpose. If config.engine.password is set, it's
|
|
232
|
+
* required as `!gf engine <password>`; the command message is deleted
|
|
233
|
+
* either way (right or wrong) so the password never sits visible in
|
|
234
|
+
* channel history. Wrong-password attempts count toward a lockout - see
|
|
235
|
+
* GlassFrame.verifyEnginePassword().
|
|
236
|
+
*/
|
|
237
|
+
async _engine(message, args) {
|
|
238
|
+
const passwordOk = this.frame.verifyEnginePassword(message.author.id, args.join(" "));
|
|
239
|
+
try {
|
|
240
|
+
if (message.deletable) await message.delete();
|
|
241
|
+
} catch {
|
|
242
|
+
/* best effort - if the bot can't delete here, that's a permissions gap to fix, not a reason to fail open */
|
|
243
|
+
}
|
|
244
|
+
if (!passwordOk) return;
|
|
245
|
+
|
|
246
|
+
const report = this.frame.getEngineReport();
|
|
247
|
+
const payload = buildEnginePage(report, "overview");
|
|
248
|
+
await message.channel.send(payload);
|
|
249
|
+
}
|
|
175
250
|
}
|
|
176
251
|
|
|
177
252
|
module.exports = PrefixRouter;
|
package/dist/src/core/Cache.js
CHANGED
|
@@ -84,6 +84,16 @@ class ProtocolCache {
|
|
|
84
84
|
return { ...this.stats, size: this.store.size, name: this.name };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Counts non-expired entries whose key starts with `prefix` - used for per-guild breakdowns of a `guildId:userId`-keyed cache. */
|
|
88
|
+
countByPrefix(prefix) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
let count = 0;
|
|
91
|
+
for (const [key, entry] of this.store) {
|
|
92
|
+
if (key.startsWith(prefix) && now <= entry.expiresAt) count++;
|
|
93
|
+
}
|
|
94
|
+
return count;
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
destroy() {
|
|
88
98
|
if (this._sweeper) clearInterval(this._sweeper);
|
|
89
99
|
}
|
package/dist/src/core/Layer.js
CHANGED
|
@@ -13,18 +13,20 @@ const EventEmitter = require("events");
|
|
|
13
13
|
* verification level, revert a role's permissions) since those are
|
|
14
14
|
* time-critical and aren't about penalizing one member.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Enabled state is per-guild, not global: one bot process can serve many
|
|
17
|
+
* guilds, each with its own independent set of active layers. Discord's
|
|
18
|
+
* gateway doesn't support attaching a listener scoped to a single guild, so
|
|
19
|
+
* the underlying event listener is attached exactly once, ever
|
|
20
|
+
* (attach()), and every handler must gate its actual work behind
|
|
21
|
+
* `this.isEnabled(guildId)` as its first check - a disabled guild does zero
|
|
22
|
+
* work past that check, an enabled one proceeds normally.
|
|
20
23
|
*/
|
|
21
24
|
class Layer extends EventEmitter {
|
|
22
25
|
constructor(name, frame) {
|
|
23
26
|
super();
|
|
24
27
|
this.name = name;
|
|
25
28
|
this.frame = frame;
|
|
26
|
-
this.
|
|
27
|
-
this._listeners = [];
|
|
29
|
+
this.enabledGuilds = new Set();
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
get client() {
|
|
@@ -35,34 +37,37 @@ class Layer extends EventEmitter {
|
|
|
35
37
|
return this.frame.config[this.name] || {};
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
isEnabled(guildId) {
|
|
41
|
+
return this.enabledGuilds.has(guildId);
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
_listen(emitter, event, handler) {
|
|
39
45
|
emitter.on(event, handler);
|
|
40
|
-
this._listeners.push([emitter, event, handler]);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
enable() {
|
|
44
|
-
|
|
45
|
-
this.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
enable(guildId) {
|
|
49
|
+
const already = this.enabledGuilds.has(guildId);
|
|
50
|
+
this.enabledGuilds.add(guildId);
|
|
51
|
+
if (!already) {
|
|
52
|
+
this.frame._persistGuildState(guildId);
|
|
53
|
+
this.frame.emit("layerToggled", { layer: this.name, guildId, enabled: true });
|
|
54
|
+
}
|
|
48
55
|
return this;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
disable() {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
disable(guildId) {
|
|
59
|
+
const had = this.enabledGuilds.delete(guildId);
|
|
60
|
+
if (had) {
|
|
61
|
+
this.frame._persistGuildState(guildId);
|
|
62
|
+
this.frame.emit("layerToggled", { layer: this.name, guildId, enabled: false });
|
|
56
63
|
}
|
|
57
|
-
this._listeners = [];
|
|
58
|
-
this.onDisable();
|
|
59
|
-
this.frame.emit("layerToggled", { layer: this.name, enabled: false });
|
|
60
64
|
return this;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
// Subclasses override
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
// Subclasses override this: attach event listeners exactly once here
|
|
68
|
+
// (called once per layer, at GlassFrame construction). Gate all real work
|
|
69
|
+
// behind `this.isEnabled(guildId)`.
|
|
70
|
+
attach() {}
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
module.exports = Layer;
|
|
@@ -3,19 +3,31 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Lightweight internal metrics collector - no external dependency, no
|
|
5
5
|
* network call, just counters and a small rolling latency sample per event
|
|
6
|
-
* type
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* type, plus a lightweight per-guild activity tally. Exposed via
|
|
7
|
+
* `frame.getMetrics()` / `!gf metrics` (per-server and bot-wide) and the
|
|
8
|
+
* owner-only `!gf engine` dashboard, so you can see whether the protocol is
|
|
9
|
+
* keeping up - and which server is generating the most work - without
|
|
10
|
+
* wiring in a separate APM tool.
|
|
9
11
|
*/
|
|
10
12
|
class PerformanceMonitor {
|
|
11
13
|
constructor() {
|
|
12
14
|
this.startedAt = Date.now();
|
|
13
15
|
this.eventCounts = {};
|
|
14
16
|
this.latencies = {}; // name -> last 50 durations in ms
|
|
17
|
+
this.guildActivity = new Map(); // guildId -> event count since startup
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
/** `guildId` is optional - pass it from any per-guild handler to also tally that guild's activity. */
|
|
21
|
+
recordEvent(name, guildId) {
|
|
18
22
|
this.eventCounts[name] = (this.eventCounts[name] || 0) + 1;
|
|
23
|
+
if (guildId) {
|
|
24
|
+
this.guildActivity.set(guildId, (this.guildActivity.get(guildId) || 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Guild IDs ranked by recorded activity, busiest first. */
|
|
29
|
+
topGuildsByActivity(limit = 5) {
|
|
30
|
+
return [...this.guildActivity.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
/** Call at the start of a unit of work; call the returned function when it finishes. */
|
|
@@ -29,21 +29,21 @@ class AIModerationLayer extends Layer {
|
|
|
29
29
|
return this.frame.groqClient;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
attach() {
|
|
33
33
|
if (!this.groq?.available) {
|
|
34
34
|
this.frame.emit("warning", {
|
|
35
35
|
layer: "aiModeration",
|
|
36
|
-
message: "No Groq API keys configured - layer
|
|
36
|
+
message: "No Groq API keys configured - layer can be armed but will stay idle. Set aiModeration.apiKeys in config."
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
onDisable() {}
|
|
43
|
-
|
|
44
42
|
async _handle(message) {
|
|
45
43
|
if (!message.guild || message.author.bot || !message.content) return;
|
|
44
|
+
if (!this.isEnabled(message.guild.id)) return;
|
|
46
45
|
if (!this.groq?.available) return;
|
|
46
|
+
this.frame.performance.recordEvent("aiModeration.messageCreate", message.guild.id);
|
|
47
47
|
|
|
48
48
|
const cfg = this.config;
|
|
49
49
|
const local = classifyMessage(message.content);
|
|
@@ -19,7 +19,10 @@ const EVENT_MAP = {
|
|
|
19
19
|
[AuditLogEvent.EmojiCreate]: "emojiCreate",
|
|
20
20
|
[AuditLogEvent.EmojiDelete]: "emojiDelete",
|
|
21
21
|
[AuditLogEvent.StickerCreate]: "stickerCreate",
|
|
22
|
-
[AuditLogEvent.StickerDelete]: "stickerDelete"
|
|
22
|
+
[AuditLogEvent.StickerDelete]: "stickerDelete",
|
|
23
|
+
[AuditLogEvent.MemberRoleUpdate]: "memberRoleUpdate",
|
|
24
|
+
[AuditLogEvent.ChannelOverwriteCreate]: "channelOverwriteCreate",
|
|
25
|
+
[AuditLogEvent.ChannelOverwriteUpdate]: "channelOverwriteUpdate"
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
class AntiNukeLayer extends Layer {
|
|
@@ -33,22 +36,24 @@ class AntiNukeLayer extends Layer {
|
|
|
33
36
|
this.webhookMessageCounts = new Map(); // webhookId -> timestamps[]
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
attach() {
|
|
37
40
|
this._listen(this.client, "guildAuditLogEntryCreate", (entry, guild) =>
|
|
38
41
|
this._handleEntry(entry, guild).catch(() => {})
|
|
39
42
|
);
|
|
40
43
|
if (this.config.webhookAbuse.enabled) {
|
|
41
44
|
this._listen(this.client, "messageCreate", (message) => this._handleWebhookMessage(message).catch(() => {}));
|
|
42
45
|
}
|
|
43
|
-
// Give every guild an immediate role audit the moment AntiNuke is armed.
|
|
44
|
-
for (const guild of this.client.guilds.cache.values()) {
|
|
45
|
-
this._auditRoles(guild).catch(() => {});
|
|
46
|
-
}
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
48
|
+
/** Give a guild an immediate role audit the moment AntiNuke is armed for it. */
|
|
49
|
+
enable(guildId) {
|
|
50
|
+
const wasEnabled = this.isEnabled(guildId);
|
|
51
|
+
super.enable(guildId);
|
|
52
|
+
if (!wasEnabled) {
|
|
53
|
+
const guild = this.client.guilds.cache.get(guildId);
|
|
54
|
+
if (guild) this._auditRoles(guild).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
return this;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
_record(guildId, executorId, type) {
|
|
@@ -66,12 +71,13 @@ class AntiNukeLayer extends Layer {
|
|
|
66
71
|
async _handleEntry(entry, guild) {
|
|
67
72
|
const type = EVENT_MAP[entry.action];
|
|
68
73
|
if (!type) return;
|
|
69
|
-
this.
|
|
74
|
+
if (!this.isEnabled(guild.id)) return;
|
|
75
|
+
this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`, guild.id);
|
|
70
76
|
|
|
71
77
|
const executor = entry.executor;
|
|
72
78
|
if (!executor || executor.id === this.client.user.id) return;
|
|
73
79
|
if (executor.id === guild.ownerId) return;
|
|
74
|
-
if (this.frame.
|
|
80
|
+
if (this.frame.isWhitelisted(guild.id, executor.id)) return;
|
|
75
81
|
|
|
76
82
|
if (type === "roleUpdate" && this.config.watchDangerousGrants) {
|
|
77
83
|
await this._checkDangerousGrant(entry, guild, executor);
|
|
@@ -82,6 +88,12 @@ class AntiNukeLayer extends Layer {
|
|
|
82
88
|
if (type === "inviteCreate" && this.config.watchInviteAbuse) {
|
|
83
89
|
await this._checkInviteAbuse(entry, guild, executor);
|
|
84
90
|
}
|
|
91
|
+
if (type === "memberRoleUpdate" && this.config.watchDangerousGrants) {
|
|
92
|
+
await this._checkMemberRoleGrant(entry, guild, executor);
|
|
93
|
+
}
|
|
94
|
+
if ((type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") && this.config.watchOverwriteAbuse) {
|
|
95
|
+
await this._checkChannelOverwriteAbuse(entry, guild, executor);
|
|
96
|
+
}
|
|
85
97
|
if (type === "webhookCreate" && this.config.webhookAbuse.enabled) {
|
|
86
98
|
this.recentWebhooks.set(
|
|
87
99
|
entry.targetId,
|
|
@@ -90,11 +102,10 @@ class AntiNukeLayer extends Layer {
|
|
|
90
102
|
);
|
|
91
103
|
}
|
|
92
104
|
|
|
93
|
-
//
|
|
94
|
-
// a
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
if (type === "inviteCreate") return;
|
|
105
|
+
// These are special-cased above instead of contributing to a raw burst
|
|
106
|
+
// count - a single grant/overwrite change is worth flagging immediately
|
|
107
|
+
// on its own merits, not after N repeats.
|
|
108
|
+
if (type === "inviteCreate" || type === "memberRoleUpdate" || type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") return;
|
|
98
109
|
|
|
99
110
|
const count = this._record(guild.id, executor.id, type);
|
|
100
111
|
const threshold = this.config.thresholds[type];
|
|
@@ -135,6 +146,7 @@ class AntiNukeLayer extends Layer {
|
|
|
135
146
|
const role = guild.roles.cache.get(entry.targetId);
|
|
136
147
|
|
|
137
148
|
await this.frame.logger.log(guild, {
|
|
149
|
+
layer: this.name,
|
|
138
150
|
level: "alert",
|
|
139
151
|
title: "Dangerous Permission Grant Detected",
|
|
140
152
|
description: `${executor.tag} granted **${gained.join(", ")}** to role ${role ? role.name : entry.targetId}.`,
|
|
@@ -159,6 +171,7 @@ class AntiNukeLayer extends Layer {
|
|
|
159
171
|
if (!changed.length) return;
|
|
160
172
|
|
|
161
173
|
await this.frame.logger.log(guild, {
|
|
174
|
+
layer: this.name,
|
|
162
175
|
level: "warn",
|
|
163
176
|
title: "Server Identity Changed",
|
|
164
177
|
description: `${executor.tag} changed: ${changed.map((c) => c.key).join(", ")}.`,
|
|
@@ -190,6 +203,7 @@ class AntiNukeLayer extends Layer {
|
|
|
190
203
|
if (trust === "HIGH" || trust === "PROTECTED") return;
|
|
191
204
|
|
|
192
205
|
await this.frame.logger.log(guild, {
|
|
206
|
+
layer: this.name,
|
|
193
207
|
level: "warn",
|
|
194
208
|
title: "Unrestricted Invite Created By Non-Staff Member",
|
|
195
209
|
description: `${executor.tag} (trust: ${trust}) created an invite with no expiry and no use limit.`,
|
|
@@ -197,6 +211,86 @@ class AntiNukeLayer extends Layer {
|
|
|
197
211
|
});
|
|
198
212
|
}
|
|
199
213
|
|
|
214
|
+
/**
|
|
215
|
+
* RoleUpdate (above) catches a role's own permissions changing - this
|
|
216
|
+
* catches the other half: a specific MEMBER being handed a role that
|
|
217
|
+
* already carries dangerous permissions. Missing this meant someone
|
|
218
|
+
* with ManageRoles could quietly promote themselves (or an accomplice)
|
|
219
|
+
* without ever touching a role's definition.
|
|
220
|
+
*/
|
|
221
|
+
async _checkMemberRoleGrant(entry, guild, executor) {
|
|
222
|
+
const added = (entry.changes || []).find((c) => c.key === "$add");
|
|
223
|
+
if (!added || !Array.isArray(added.new) || !added.new.length) return;
|
|
224
|
+
|
|
225
|
+
const dangerous = this.frame.config.roleAnalysis.dangerousPermissions;
|
|
226
|
+
const dangerousRoles = added.new
|
|
227
|
+
.map((r) => guild.roles.cache.get(r.id))
|
|
228
|
+
.filter((role) => role && dangerous.some((p) => role.permissions.has(PermissionsBitField.Flags[p])));
|
|
229
|
+
if (!dangerousRoles.length) return;
|
|
230
|
+
|
|
231
|
+
let executorMember = null;
|
|
232
|
+
try {
|
|
233
|
+
executorMember = await guild.members.fetch(executor.id);
|
|
234
|
+
} catch {
|
|
235
|
+
/* left already */
|
|
236
|
+
}
|
|
237
|
+
const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
|
|
238
|
+
if (trust === "HIGH" || trust === "PROTECTED") return;
|
|
239
|
+
|
|
240
|
+
await this.frame.logger.log(guild, {
|
|
241
|
+
layer: this.name,
|
|
242
|
+
level: "alert",
|
|
243
|
+
title: "Dangerous Role Granted To A Member",
|
|
244
|
+
description: `${executor.tag} (trust: ${trust}) gave <@${entry.targetId}> the role(s): ${dangerousRoles
|
|
245
|
+
.map((r) => r.name)
|
|
246
|
+
.join(", ")}.`,
|
|
247
|
+
dedupeKey: `antinuke:member-grant:${entry.targetId}`
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A nuke doesn't have to delete anything - locking @everyone out of a
|
|
253
|
+
* public channel, or opening a private one up to @everyone, does the
|
|
254
|
+
* same damage through permission overwrites instead. Only @everyone's
|
|
255
|
+
* own overwrite is watched here; a role- or member-specific overwrite
|
|
256
|
+
* change is routine channel administration and not worth flagging.
|
|
257
|
+
*/
|
|
258
|
+
async _checkChannelOverwriteAbuse(entry, guild, executor) {
|
|
259
|
+
if (!entry.extra || String(entry.extra.id) !== guild.id) return;
|
|
260
|
+
|
|
261
|
+
const allowChange = (entry.changes || []).find((c) => c.key === "allow");
|
|
262
|
+
const denyChange = (entry.changes || []).find((c) => c.key === "deny");
|
|
263
|
+
if (!allowChange && !denyChange) return;
|
|
264
|
+
|
|
265
|
+
const oldAllow = new PermissionsBitField(BigInt(allowChange?.old ?? 0));
|
|
266
|
+
const newAllow = new PermissionsBitField(BigInt(allowChange?.new ?? 0));
|
|
267
|
+
const oldDeny = new PermissionsBitField(BigInt(denyChange?.old ?? 0));
|
|
268
|
+
const newDeny = new PermissionsBitField(BigInt(denyChange?.new ?? 0));
|
|
269
|
+
|
|
270
|
+
const view = PermissionsBitField.Flags.ViewChannel;
|
|
271
|
+
const nowGrantsView = !oldAllow.has(view) && newAllow.has(view);
|
|
272
|
+
const nowBlocksView = !oldDeny.has(view) && newDeny.has(view);
|
|
273
|
+
if (!nowGrantsView && !nowBlocksView) return;
|
|
274
|
+
|
|
275
|
+
let executorMember = null;
|
|
276
|
+
try {
|
|
277
|
+
executorMember = await guild.members.fetch(executor.id);
|
|
278
|
+
} catch {
|
|
279
|
+
/* left already */
|
|
280
|
+
}
|
|
281
|
+
const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
|
|
282
|
+
if (trust === "HIGH" || trust === "PROTECTED") return;
|
|
283
|
+
|
|
284
|
+
const channel = guild.channels.cache.get(entry.targetId);
|
|
285
|
+
await this.frame.logger.log(guild, {
|
|
286
|
+
layer: this.name,
|
|
287
|
+
level: "alert",
|
|
288
|
+
title: nowBlocksView ? "Channel Locked Out For Everyone" : "Private Channel Opened To Everyone",
|
|
289
|
+
description: `${executor.tag} (trust: ${trust}) changed @everyone's access to ${channel ? channel.name : entry.targetId}.`,
|
|
290
|
+
dedupeKey: `antinuke:overwrite:${entry.targetId}`
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
200
294
|
/**
|
|
201
295
|
* Webhook messages never carry a guild member, so BasicSecurityLayer's
|
|
202
296
|
* per-author spam buffer can't see them - a "create webhook, then flood
|
|
@@ -206,6 +300,7 @@ class AntiNukeLayer extends Layer {
|
|
|
206
300
|
*/
|
|
207
301
|
async _handleWebhookMessage(message) {
|
|
208
302
|
if (!message.webhookId || !message.guild) return;
|
|
303
|
+
if (!this.isEnabled(message.guild.id)) return;
|
|
209
304
|
const info = this.recentWebhooks.get(message.webhookId);
|
|
210
305
|
if (!info) return;
|
|
211
306
|
|
|
@@ -228,6 +323,7 @@ class AntiNukeLayer extends Layer {
|
|
|
228
323
|
}
|
|
229
324
|
|
|
230
325
|
await this.frame.logger.log(guild, {
|
|
326
|
+
layer: this.name,
|
|
231
327
|
level: "alert",
|
|
232
328
|
title: "Webhook Message Flood - Webhook Removed",
|
|
233
329
|
description: `A recently created webhook sent ${list.length}+ messages inside ${cfg.windowMs / 1000}s and has been deleted.`,
|
|
@@ -267,6 +363,7 @@ class AntiNukeLayer extends Layer {
|
|
|
267
363
|
if (!flags.length) return;
|
|
268
364
|
|
|
269
365
|
await this.frame.logger.log(guild, {
|
|
366
|
+
layer: this.name,
|
|
270
367
|
level: "warn",
|
|
271
368
|
title: "Role Audit - Possible Impersonation/Permission Mismatch",
|
|
272
369
|
description: flags.map((f) => `**${f.name}** (${f.roleId}) - ${f.reason}`).join("\n"),
|