glassframe-protocol 1.2.1 → 2.0.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 +84 -0
- package/GETTING_STARTED.md +6 -4
- package/README.md +20 -11
- package/dist/src/GlassFrame.js +151 -42
- package/dist/src/commands/PrefixRouter.js +6 -25
- package/dist/src/core/Cache.js +10 -0
- package/dist/src/core/Layer.js +28 -23
- package/dist/src/core/VersionInfo.js +1 -1
- package/dist/src/layers/AIModerationLayer.js +3 -4
- package/dist/src/layers/AntiNukeLayer.js +13 -9
- package/dist/src/layers/AntiRaidLayer.js +2 -6
- package/dist/src/layers/BasicSecurityLayer.js +9 -11
- package/dist/src/moderation/PunishmentEngine.js +5 -4
- package/dist/src/ui/ControlPanel.js +65 -1
- package/docs/CACHE_ARCHITECTURE.md +4 -0
- package/docs/COMMANDS.md +24 -10
- package/docs/PROTOCOL_LAYERS.md +17 -11
- package/docs/STATE.md +101 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,90 @@ lives in `src/core/VersionInfo.js` (`VersionInfo.info()` /
|
|
|
14
14
|
`VersionInfo.banner()`), so code can read the current version without
|
|
15
15
|
parsing this file.
|
|
16
16
|
|
|
17
|
+
## [2.0.0] - 2026-08-01
|
|
18
|
+
|
|
19
|
+
**Breaking.** Layer activation and the whitelist were accidentally global
|
|
20
|
+
across every server the bot is in - this release makes them per-guild, the
|
|
21
|
+
way they were always supposed to work, and adds real state persistence.
|
|
22
|
+
|
|
23
|
+
### Fixed (breaking)
|
|
24
|
+
|
|
25
|
+
- **Layer on/off state is now per-guild.** Previously, `Layer.enabled` was
|
|
26
|
+
one shared boolean - arming AntiRaid from one server's control panel
|
|
27
|
+
silently armed it for *every* server the bot serves. `getStatus(guildId)`
|
|
28
|
+
accepted a `guildId` but never actually used it. Both are now genuinely
|
|
29
|
+
per-guild: `Layer.enabledGuilds` is a `Set<guildId>`, and
|
|
30
|
+
`frame.enableLayer(name, guildId)` / `frame.disableLayer(name, guildId)`
|
|
31
|
+
now **require** a `guildId` argument. Calling either without one throws,
|
|
32
|
+
rather than silently doing the wrong thing.
|
|
33
|
+
- **The whitelist is now per-guild.** `frame.whitelist` was a single global
|
|
34
|
+
`Set<userId>` - whitelisting someone in one server exempted them
|
|
35
|
+
everywhere. It's now a `Map<guildId, Set<userId>>`; use the new
|
|
36
|
+
`frame.addToWhitelist(guildId, userId)`,
|
|
37
|
+
`frame.removeFromWhitelist(guildId, userId)`, and
|
|
38
|
+
`frame.isWhitelisted(guildId, userId)` instead of touching the Map
|
|
39
|
+
directly. The constructor's `whitelist: [...]` option still works exactly
|
|
40
|
+
as before from the outside - it now seeds that list into every guild
|
|
41
|
+
(present and future) the first time GlassFrame sees it, rather than
|
|
42
|
+
applying globally.
|
|
43
|
+
- **State now actually persists.** `stateStore` was constructed and then
|
|
44
|
+
never read from or written to anywhere. Every layer toggle and whitelist
|
|
45
|
+
change is now saved per-guild, and restored at startup (and automatically
|
|
46
|
+
for any new guild the bot joins) - see `docs/STATE.md`.
|
|
47
|
+
- Fixed a related bug in `BasicSecurityLayer`: its spam-rate buffer was
|
|
48
|
+
keyed by `userId` alone, so the same user active in two different
|
|
49
|
+
servers could incorrectly share one rate-limit window. Now keyed by
|
|
50
|
+
`guildId:userId`.
|
|
51
|
+
|
|
52
|
+
### Migrating from 1.x
|
|
53
|
+
|
|
54
|
+
**If you only use the built-in control panel and `!gf` commands, nothing in
|
|
55
|
+
your bot's code needs to change** - update the package and you're done; the
|
|
56
|
+
panel and commands already operate within a specific server, they just
|
|
57
|
+
didn't respect that internally before now.
|
|
58
|
+
|
|
59
|
+
**If your own code calls these directly, update the call sites:**
|
|
60
|
+
|
|
61
|
+
| 1.x | 2.0.0 |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `frame.enableLayer(name)` | `frame.enableLayer(name, guildId)` |
|
|
64
|
+
| `frame.disableLayer(name)` | `frame.disableLayer(name, guildId)` |
|
|
65
|
+
| `frame.whitelist.add(userId)` | `frame.addToWhitelist(guildId, userId)` |
|
|
66
|
+
| `frame.whitelist.delete(userId)` | `frame.removeFromWhitelist(guildId, userId)` |
|
|
67
|
+
| `frame.whitelist.has(userId)` | `frame.isWhitelisted(guildId, userId)` |
|
|
68
|
+
| `layer.enabled` | `layer.isEnabled(guildId)` |
|
|
69
|
+
|
|
70
|
+
`options.autoStart` in the constructor is unchanged - still a plain array
|
|
71
|
+
of layer names - but now correctly applies per-guild (to every guild the
|
|
72
|
+
bot is already in, and automatically to any new one it joins) instead of
|
|
73
|
+
turning a layer on everywhere at once.
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- `frame.ready` - a Promise that resolves once every guild the bot was
|
|
78
|
+
already in at construction time has finished restoring its saved state.
|
|
79
|
+
State restoration is async (a real `stateStore` may hit disk/a database),
|
|
80
|
+
so this exists for anyone who wants an explicit guarantee rather than
|
|
81
|
+
relying on the fact that, in practice, it resolves faster than any
|
|
82
|
+
Discord event could arrive.
|
|
83
|
+
- `frame.getGuildMetrics(guildId)` - one server's layer status, whitelist
|
|
84
|
+
size, open-case count, and flagged-member count.
|
|
85
|
+
- `!gf metrics` now shows **this server's** numbers by default instead of
|
|
86
|
+
bot-wide totals, with one button on the message to switch to the global
|
|
87
|
+
(all-servers) view and back. This is a second button, separate from the
|
|
88
|
+
5-button panel - the panel is still exactly 5 buttons; the metrics
|
|
89
|
+
command now has 1 of its own.
|
|
90
|
+
- `ProtocolCache.countByPrefix(prefix)` - powers the per-guild counts above
|
|
91
|
+
by counting non-expired `guildId:userId`-keyed entries for one guild.
|
|
92
|
+
- `docs/STATE.md` - the per-guild state and persistence model in full.
|
|
93
|
+
|
|
94
|
+
## [1.2.1] - 2026-07-31
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- `homepage` and `discord` fields in `package.json` linking to the docs site
|
|
99
|
+
and the support server.
|
|
100
|
+
|
|
17
101
|
## [1.2.0] - 2026-07-31
|
|
18
102
|
|
|
19
103
|
Packaging and licensing changes to make this publishable to npm. No
|
package/GETTING_STARTED.md
CHANGED
|
@@ -79,9 +79,10 @@ toggle gets reported there through Components V2 messages.
|
|
|
79
79
|
|
|
80
80
|
Two ways, pick either or both:
|
|
81
81
|
|
|
82
|
-
- **Code**: list layer names in `autoStart` (see Step 2)
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
- **Code**: list layer names in `autoStart` (see Step 2) - applies to every
|
|
83
|
+
server by default - or call
|
|
84
|
+
`frame.enableLayer("antiNuke", guildId)` / `frame.disableLayer("antiNuke", guildId)`
|
|
85
|
+
for one specific server anywhere after construction.
|
|
85
86
|
- **In Discord**: an admin (Manage Server permission) sends `!gf panel` and
|
|
86
87
|
gets 5 buttons - Activate/Deactivate AntiRaid, AI Moderation, AntiNuke,
|
|
87
88
|
Basic Security, and Full Protocol (all four at once). This is the only
|
|
@@ -119,13 +120,14 @@ Nobody needs this guide again after setup. Server admins use:
|
|
|
119
120
|
| `!gf scan` | Checks current roles for name/permission mismatches |
|
|
120
121
|
| `!gf metrics` | Performance and cache health |
|
|
121
122
|
| `!gf phishing add/remove/list <domain>` | Manage the link blocklist |
|
|
122
|
-
| `!gf whitelist add/remove <userId>` | Exempt a user from punitive action |
|
|
123
|
+
| `!gf whitelist add/remove <userId>` | Exempt a user from punitive action in this server |
|
|
123
124
|
| `!gf help` | Lists all of the above |
|
|
124
125
|
|
|
125
126
|
## Going deeper
|
|
126
127
|
|
|
127
128
|
- `README.md` - full feature overview
|
|
128
129
|
- `docs/PROTOCOL_LAYERS.md` - how a signal becomes an action
|
|
130
|
+
- `docs/STATE.md` - per-server layer/whitelist state and persistence
|
|
129
131
|
- `docs/CACHE_ARCHITECTURE.md` - every internal cache and its TTL
|
|
130
132
|
- `docs/PERFORMANCE.md` - the bounded-concurrency queues and `!gf metrics`
|
|
131
133
|
- `docs/COMMANDS.md` - full command/button reference
|
package/README.md
CHANGED
|
@@ -7,18 +7,23 @@ pipeline instead of each reacting on its own, so the same member never gets
|
|
|
7
7
|
hit by four different modules for one incident, and a single soft flag
|
|
8
8
|
never turns into an auto-ban.
|
|
9
9
|
|
|
10
|
+
Everything is per-server: one bot process can run GlassFrame across many
|
|
11
|
+
Discord servers, each with its own independent set of active layers and its
|
|
12
|
+
own whitelist, persisted across restarts. See `docs/STATE.md`.
|
|
13
|
+
|
|
10
14
|
No slash commands - prefix commands only. No legacy `EmbedBuilder` - every
|
|
11
15
|
log message renders through Discord's Components V2 system. No emoji
|
|
12
16
|
anywhere in code or output.
|
|
13
17
|
|
|
14
18
|
## Install
|
|
15
19
|
|
|
16
|
-
Copy the `glassframe-protocol` folder into your bot project, then:
|
|
17
|
-
|
|
18
20
|
```
|
|
19
|
-
npm install
|
|
21
|
+
npm install glassframe-protocol
|
|
20
22
|
```
|
|
21
23
|
|
|
24
|
+
Or copy the `glassframe-protocol` folder directly into your bot project and
|
|
25
|
+
`npm install` inside it - either works the same way.
|
|
26
|
+
|
|
22
27
|
Requires `discord.js` v14.16+ (Components V2 support and the
|
|
23
28
|
`guildAuditLogEntryCreate` event) and Node.js 18+ (global `fetch`, used only
|
|
24
29
|
by the optional AI layer).
|
|
@@ -56,8 +61,8 @@ client.once("ready", () => {
|
|
|
56
61
|
const frame = new GlassFrame(client, {
|
|
57
62
|
getLogChannel: async (guild) =>
|
|
58
63
|
guild.channels.cache.find((c) => c.name === "security-logs") ?? null,
|
|
59
|
-
whitelist: ["123456789012345678"],
|
|
60
|
-
autoStart: ["basicSecurity"], // layers
|
|
64
|
+
whitelist: ["123456789012345678"], // trusted everywhere, seeded into every guild on first run
|
|
65
|
+
autoStart: ["basicSecurity"], // starting layers for every guild - not a global on/off
|
|
61
66
|
config: {
|
|
62
67
|
antiRaid: { joinThreshold: 8 },
|
|
63
68
|
aiModeration: { apiKeys: [process.env.GROQ_KEY_1, process.env.GROQ_KEY_2].filter(Boolean) }
|
|
@@ -97,18 +102,20 @@ the 5-button control panel and arm layers from there instead of in code.
|
|
|
97
102
|
cooldown on rate limits. See `config.aiModeration` and
|
|
98
103
|
`docs/CACHE_ARCHITECTURE.md` for how verdicts get cached and keys rotated.
|
|
99
104
|
|
|
100
|
-
Every layer starts disabled. Nothing runs until you list it in
|
|
101
|
-
`autoStart`, call `frame.enableLayer(name)`, or arm it from the
|
|
102
|
-
panel.
|
|
105
|
+
Every layer starts disabled, per guild. Nothing runs until you list it in
|
|
106
|
+
`autoStart`, call `frame.enableLayer(name, guildId)`, or arm it from the
|
|
107
|
+
control panel.
|
|
103
108
|
|
|
104
109
|
## Performance under load
|
|
105
110
|
|
|
106
111
|
Every Discord mutation `PunishmentEngine` performs and every outbound Groq
|
|
107
112
|
request go through a bounded-concurrency queue (`frame.actionQueue` /
|
|
108
113
|
`frame.aiQueue`) instead of firing unbounded, so a burst can't trip a rate
|
|
109
|
-
limit by hammering it all at once.
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
limit by hammering it all at once. `!gf metrics` shows this server's numbers
|
|
115
|
+
by default (open cases, flagged members, active layers), with a button on
|
|
116
|
+
the message to switch to bot-wide totals across every server
|
|
117
|
+
(`frame.getMetrics()` / `frame.getGuildMetrics(guildId)`). Full write-up in
|
|
118
|
+
`docs/PERFORMANCE.md`.
|
|
112
119
|
|
|
113
120
|
## How punishment works
|
|
114
121
|
|
|
@@ -126,6 +133,8 @@ open case for that member from a moment ago. Full write-up in
|
|
|
126
133
|
- `GETTING_STARTED.md` - adding GlassFrame to a bot you already have running.
|
|
127
134
|
- `docs/PROTOCOL_LAYERS.md` - full architecture: containment vs. punishment,
|
|
128
135
|
how a signal becomes an action, how to add a fifth layer.
|
|
136
|
+
- `docs/STATE.md` - per-guild layer/whitelist state and how persistence
|
|
137
|
+
across restarts works.
|
|
129
138
|
- `docs/CACHE_ARCHITECTURE.md` - every cache in the library, its key shape,
|
|
130
139
|
its TTL, and how to turn on cache-level debug logging.
|
|
131
140
|
- `docs/PERFORMANCE.md` - the bounded-concurrency queues and internal
|
package/dist/src/GlassFrame.js
CHANGED
|
@@ -23,13 +23,19 @@ const AntiNukeLayer = require("./layers/AntiNukeLayer");
|
|
|
23
23
|
const AIModerationLayer = require("./layers/AIModerationLayer");
|
|
24
24
|
|
|
25
25
|
const PrefixRouter = require("./commands/PrefixRouter");
|
|
26
|
-
const { buildPanel } = require("./ui/ControlPanel");
|
|
26
|
+
const { buildPanel, buildMetricsMessage } = require("./ui/ControlPanel");
|
|
27
|
+
|
|
28
|
+
const LAYER_NAMES = ["basicSecurity", "antiRaid", "antiNuke", "aiModeration"];
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* GlassFrame Protocol - a layered, self-contained Discord security engine.
|
|
30
32
|
* One instance manages every layer, the shared threat/punishment pipeline,
|
|
31
33
|
* logging, the prefix command router, and the 5-button control panel for a
|
|
32
34
|
* discord.js Client. See docs/PROTOCOL_LAYERS.md for the architecture.
|
|
35
|
+
*
|
|
36
|
+
* Everything is per-guild: which layers are active, and the whitelist, are
|
|
37
|
+
* tracked independently for every server the bot is in, and persisted
|
|
38
|
+
* through `stateStore` so they survive a restart. See docs/STATE.md.
|
|
33
39
|
*/
|
|
34
40
|
class GlassFrame extends EventEmitter {
|
|
35
41
|
constructor(client, options = {}) {
|
|
@@ -41,8 +47,13 @@ class GlassFrame extends EventEmitter {
|
|
|
41
47
|
|
|
42
48
|
this.client = client;
|
|
43
49
|
this.config = mergeConfig(defaultConfig, options.config || {});
|
|
44
|
-
this.whitelist = new Set(options.whitelist || []);
|
|
45
50
|
this.stateStore = options.stateStore || new MemoryStateStore();
|
|
51
|
+
this.autoStart = options.autoStart || [];
|
|
52
|
+
// Seed list of user IDs treated as trusted in every guild the bot is
|
|
53
|
+
// in or joins - only applied the first time we see a guild (same rule
|
|
54
|
+
// as autoStart), never overriding a guild's own saved whitelist.
|
|
55
|
+
this.defaultWhitelist = new Set(options.whitelist || []);
|
|
56
|
+
this.whitelist = new Map(); // guildId -> Set<userId>
|
|
46
57
|
this.panelMessages = new Map(); // messageId -> guildId
|
|
47
58
|
this.debug = Boolean(options.debug ?? this.config.cache.debug);
|
|
48
59
|
|
|
@@ -71,14 +82,17 @@ class GlassFrame extends EventEmitter {
|
|
|
71
82
|
debug: this.debug
|
|
72
83
|
});
|
|
73
84
|
|
|
74
|
-
// All four layers start disabled - nothing runs until armed
|
|
75
|
-
// options.autoStart, the control panel, or enableLayer().
|
|
85
|
+
// All four layers start disabled per guild - nothing runs until armed
|
|
86
|
+
// via options.autoStart, the control panel, or enableLayer(name, guildId).
|
|
76
87
|
this.layers = {
|
|
77
88
|
basicSecurity: new BasicSecurityLayer(this),
|
|
78
89
|
antiRaid: new AntiRaidLayer(this),
|
|
79
90
|
antiNuke: new AntiNukeLayer(this),
|
|
80
91
|
aiModeration: new AIModerationLayer(this)
|
|
81
92
|
};
|
|
93
|
+
// Event listeners attach exactly once, ever - per-guild on/off is a
|
|
94
|
+
// gate inside each handler, not an attach/detach cycle. See core/Layer.js.
|
|
95
|
+
for (const layer of Object.values(this.layers)) layer.attach();
|
|
82
96
|
|
|
83
97
|
this.prefixRouter = new PrefixRouter(this);
|
|
84
98
|
this.prefixRouter.attach();
|
|
@@ -86,9 +100,21 @@ class GlassFrame extends EventEmitter {
|
|
|
86
100
|
|
|
87
101
|
if (this.debug) console.log(VersionInfo.banner());
|
|
88
102
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
// Restore saved state (or apply autoStart/whitelist defaults) for every
|
|
104
|
+
// guild the bot is already in. This is async (stateStore.get() may be a
|
|
105
|
+
// real file/DB read), so `ready` lets callers that care about the exact
|
|
106
|
+
// moment restoration finishes await it explicitly - in practice a
|
|
107
|
+
// Discord message can't arrive faster than this resolves, so most code
|
|
108
|
+
// never needs to. Any guild the bot joins *after* this (guildCreate)
|
|
109
|
+
// restores independently and isn't part of `ready`.
|
|
110
|
+
const initialRestores = [...this.client.guilds.cache.values()].map((guild) =>
|
|
111
|
+
this._restoreGuildState(guild.id).catch((err) => this.emit("warning", { layer: "core", message: err.message }))
|
|
112
|
+
);
|
|
113
|
+
this.ready = Promise.all(initialRestores).then(() => undefined);
|
|
114
|
+
|
|
115
|
+
this.client.on("guildCreate", (guild) => {
|
|
116
|
+
this._restoreGuildState(guild.id).catch((err) => this.emit("warning", { layer: "core", message: err.message }));
|
|
117
|
+
});
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
isAuthorized(member) {
|
|
@@ -100,12 +126,28 @@ class GlassFrame extends EventEmitter {
|
|
|
100
126
|
getStatus(guildId) {
|
|
101
127
|
const out = {};
|
|
102
128
|
for (const [name, layer] of Object.entries(this.layers)) {
|
|
103
|
-
out[name] = layer.
|
|
129
|
+
out[name] = layer.isEnabled(guildId);
|
|
104
130
|
}
|
|
105
131
|
return out;
|
|
106
132
|
}
|
|
107
133
|
|
|
108
|
-
|
|
134
|
+
isWhitelisted(guildId, userId) {
|
|
135
|
+
return this.whitelist.get(guildId)?.has(userId) ?? false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
addToWhitelist(guildId, userId) {
|
|
139
|
+
if (!this.whitelist.has(guildId)) this.whitelist.set(guildId, new Set());
|
|
140
|
+
this.whitelist.get(guildId).add(userId);
|
|
141
|
+
this._persistGuildState(guildId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
removeFromWhitelist(guildId, userId) {
|
|
145
|
+
const existed = this.whitelist.get(guildId)?.delete(userId) ?? false;
|
|
146
|
+
if (existed) this._persistGuildState(guildId);
|
|
147
|
+
return existed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One-call health snapshot: event throughput/latency, queue depth, and every cache's stats. Bot-wide, across every guild. */
|
|
109
151
|
getMetrics() {
|
|
110
152
|
return {
|
|
111
153
|
performance: this.performance.snapshot(),
|
|
@@ -121,58 +163,125 @@ class GlassFrame extends EventEmitter {
|
|
|
121
163
|
};
|
|
122
164
|
}
|
|
123
165
|
|
|
124
|
-
|
|
166
|
+
/** Just this guild's numbers - which layers are on, and its slice of the shared threat/case caches. */
|
|
167
|
+
getGuildMetrics(guildId) {
|
|
168
|
+
const prefix = `${guildId}:`;
|
|
169
|
+
return {
|
|
170
|
+
status: this.getStatus(guildId),
|
|
171
|
+
whitelistSize: this.whitelist.get(guildId)?.size || 0,
|
|
172
|
+
flaggedMembers: this.threatEngine.scores.countByPrefix(prefix),
|
|
173
|
+
openCases: this.punishmentEngine.cases.countByPrefix(prefix)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
enableLayer(name, guildId) {
|
|
125
178
|
if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
|
|
126
|
-
|
|
179
|
+
if (!guildId) throw new Error("GlassFrame Protocol: enableLayer(name, guildId) requires a guildId - layers are per-guild.");
|
|
180
|
+
this.layers[name].enable(guildId);
|
|
127
181
|
return this;
|
|
128
182
|
}
|
|
129
183
|
|
|
130
|
-
disableLayer(name) {
|
|
184
|
+
disableLayer(name, guildId) {
|
|
131
185
|
if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
|
|
132
|
-
|
|
186
|
+
if (!guildId) throw new Error("GlassFrame Protocol: disableLayer(name, guildId) requires a guildId - layers are per-guild.");
|
|
187
|
+
this.layers[name].disable(guildId);
|
|
133
188
|
return this;
|
|
134
189
|
}
|
|
135
190
|
|
|
191
|
+
/** Snapshots one guild's layer + whitelist state into stateStore. Fire-and-forget by design (never blocks a handler on disk/network I/O). */
|
|
192
|
+
_persistGuildState(guildId) {
|
|
193
|
+
const state = {
|
|
194
|
+
layers: this.getStatus(guildId),
|
|
195
|
+
whitelist: [...(this.whitelist.get(guildId) || [])]
|
|
196
|
+
};
|
|
197
|
+
Promise.resolve(this.stateStore.set(guildId, state)).catch((err) =>
|
|
198
|
+
this.emit("warning", { layer: "core", message: `failed to persist state for ${guildId}: ${err.message}` })
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Loads a guild's saved state if one exists; otherwise applies the autoStart defaults (and persists that as the new baseline). */
|
|
203
|
+
async _restoreGuildState(guildId) {
|
|
204
|
+
const saved = await this.stateStore.get(guildId);
|
|
205
|
+
|
|
206
|
+
if (saved) {
|
|
207
|
+
for (const name of LAYER_NAMES) {
|
|
208
|
+
if (saved.layers?.[name]) this.layers[name].enable(guildId);
|
|
209
|
+
}
|
|
210
|
+
for (const userId of saved.whitelist || []) {
|
|
211
|
+
if (!this.whitelist.has(guildId)) this.whitelist.set(guildId, new Set());
|
|
212
|
+
this.whitelist.get(guildId).add(userId);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const name of this.autoStart) {
|
|
218
|
+
if (this.layers[name]) this.layers[name].enable(guildId);
|
|
219
|
+
}
|
|
220
|
+
for (const userId of this.defaultWhitelist) {
|
|
221
|
+
this.addToWhitelist(guildId, userId);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
136
225
|
_attachButtonHandler() {
|
|
137
226
|
this.client.on("interactionCreate", async (interaction) => {
|
|
138
|
-
if (!interaction.isButton()
|
|
227
|
+
if (!interaction.isButton()) return;
|
|
139
228
|
|
|
140
229
|
try {
|
|
141
|
-
if (
|
|
142
|
-
await
|
|
143
|
-
|
|
230
|
+
if (interaction.customId.startsWith("gfp_toggle_")) {
|
|
231
|
+
await this._handlePanelButton(interaction);
|
|
232
|
+
} else if (interaction.customId.startsWith("gfp_metrics_")) {
|
|
233
|
+
await this._handleMetricsButton(interaction);
|
|
144
234
|
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
this.emit("warning", { layer: "controlPanel", message: err.message });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
145
240
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
for (const name of Object.keys(this.layers)) {
|
|
152
|
-
if (allOn) this.disableLayer(name);
|
|
153
|
-
else this.enableLayer(name);
|
|
154
|
-
}
|
|
155
|
-
} else if (this.layers[layerKey]) {
|
|
156
|
-
if (this.layers[layerKey].enabled) this.disableLayer(layerKey);
|
|
157
|
-
else this.enableLayer(layerKey);
|
|
158
|
-
} else {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
241
|
+
async _handlePanelButton(interaction) {
|
|
242
|
+
if (!this.isAuthorized(interaction.member)) {
|
|
243
|
+
await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
161
246
|
|
|
162
|
-
|
|
163
|
-
|
|
247
|
+
const guildId = interaction.guild.id;
|
|
248
|
+
const layerKey = interaction.customId.replace("gfp_toggle_", "");
|
|
164
249
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
} catch (err) {
|
|
172
|
-
this.emit("warning", { layer: "controlPanel", message: err.message });
|
|
250
|
+
if (layerKey === "all") {
|
|
251
|
+
const status = this.getStatus(guildId);
|
|
252
|
+
const allOn = Object.keys(this.layers).every((l) => status[l]);
|
|
253
|
+
for (const name of Object.keys(this.layers)) {
|
|
254
|
+
if (allOn) this.disableLayer(name, guildId);
|
|
255
|
+
else this.enableLayer(name, guildId);
|
|
173
256
|
}
|
|
257
|
+
} else if (this.layers[layerKey]) {
|
|
258
|
+
if (this.layers[layerKey].isEnabled(guildId)) this.disableLayer(layerKey, guildId);
|
|
259
|
+
else this.enableLayer(layerKey, guildId);
|
|
260
|
+
} else {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const payload = buildPanel(this, guildId);
|
|
265
|
+
await interaction.update(payload);
|
|
266
|
+
|
|
267
|
+
await this.logger.log(interaction.guild, {
|
|
268
|
+
level: "info",
|
|
269
|
+
title: "Protocol Layer Toggled",
|
|
270
|
+
description: `${interaction.user.tag} updated GlassFrame Protocol layers from the control panel.`,
|
|
271
|
+
dedupeKey: "panel:toggle"
|
|
174
272
|
});
|
|
175
273
|
}
|
|
274
|
+
|
|
275
|
+
async _handleMetricsButton(interaction) {
|
|
276
|
+
if (!this.isAuthorized(interaction.member)) {
|
|
277
|
+
await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const scope = interaction.customId.replace("gfp_metrics_", ""); // "guild" or "global"
|
|
282
|
+
const payload = buildMetricsMessage(this, interaction.guild.id, scope);
|
|
283
|
+
await interaction.update(payload);
|
|
284
|
+
}
|
|
176
285
|
}
|
|
177
286
|
|
|
178
287
|
function mergeConfig(base, override) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const { buildPanel } = require("../ui/ControlPanel");
|
|
3
|
+
const { buildPanel, buildMetricsMessage } = require("../ui/ControlPanel");
|
|
4
4
|
const { buildLogMessage } = require("../logging/ComponentsV2");
|
|
5
5
|
|
|
6
6
|
const LAYER_NAMES = ["antiRaid", "antiNuke", "basicSecurity", "aiModeration"];
|
|
@@ -79,9 +79,9 @@ class PrefixRouter {
|
|
|
79
79
|
`${prefix}panel - open the 5-button control panel`,
|
|
80
80
|
`${prefix}status - show which layers are active`,
|
|
81
81
|
`${prefix}scan - check current roles for name/permission mismatches`,
|
|
82
|
-
`${prefix}metrics -
|
|
82
|
+
`${prefix}metrics - this server's metrics (button on the message switches to global)`,
|
|
83
83
|
`${prefix}phishing add|remove|list <domain> - manage the link blocklist`,
|
|
84
|
-
`${prefix}whitelist add <userId> - exempt a user from
|
|
84
|
+
`${prefix}whitelist add <userId> - exempt a user from punitive action in this server`,
|
|
85
85
|
`${prefix}whitelist remove <userId> - remove an exemption`,
|
|
86
86
|
`${prefix}help - this message`
|
|
87
87
|
].join("\n")
|
|
@@ -97,8 +97,8 @@ class PrefixRouter {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const action = sub.toLowerCase();
|
|
100
|
-
if (action === "add") this.frame.
|
|
101
|
-
else if (action === "remove") this.frame.
|
|
100
|
+
if (action === "add") this.frame.addToWhitelist(message.guild.id, id);
|
|
101
|
+
else if (action === "remove") this.frame.removeFromWhitelist(message.guild.id, id);
|
|
102
102
|
else return;
|
|
103
103
|
|
|
104
104
|
await message.reply({
|
|
@@ -121,26 +121,7 @@ class PrefixRouter {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
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
|
-
});
|
|
124
|
+
const payload = buildMetricsMessage(this.frame, message.guild.id, "guild");
|
|
144
125
|
await message.channel.send(payload);
|
|
145
126
|
}
|
|
146
127
|
|
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;
|
|
@@ -29,20 +29,19 @@ 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;
|
|
47
46
|
|
|
48
47
|
const cfg = this.config;
|
|
@@ -33,22 +33,24 @@ class AntiNukeLayer extends Layer {
|
|
|
33
33
|
this.webhookMessageCounts = new Map(); // webhookId -> timestamps[]
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
attach() {
|
|
37
37
|
this._listen(this.client, "guildAuditLogEntryCreate", (entry, guild) =>
|
|
38
38
|
this._handleEntry(entry, guild).catch(() => {})
|
|
39
39
|
);
|
|
40
40
|
if (this.config.webhookAbuse.enabled) {
|
|
41
41
|
this._listen(this.client, "messageCreate", (message) => this._handleWebhookMessage(message).catch(() => {}));
|
|
42
42
|
}
|
|
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
43
|
}
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
45
|
+
/** Give a guild an immediate role audit the moment AntiNuke is armed for it. */
|
|
46
|
+
enable(guildId) {
|
|
47
|
+
const wasEnabled = this.isEnabled(guildId);
|
|
48
|
+
super.enable(guildId);
|
|
49
|
+
if (!wasEnabled) {
|
|
50
|
+
const guild = this.client.guilds.cache.get(guildId);
|
|
51
|
+
if (guild) this._auditRoles(guild).catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
return this;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
_record(guildId, executorId, type) {
|
|
@@ -66,12 +68,13 @@ class AntiNukeLayer extends Layer {
|
|
|
66
68
|
async _handleEntry(entry, guild) {
|
|
67
69
|
const type = EVENT_MAP[entry.action];
|
|
68
70
|
if (!type) return;
|
|
71
|
+
if (!this.isEnabled(guild.id)) return;
|
|
69
72
|
this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`);
|
|
70
73
|
|
|
71
74
|
const executor = entry.executor;
|
|
72
75
|
if (!executor || executor.id === this.client.user.id) return;
|
|
73
76
|
if (executor.id === guild.ownerId) return;
|
|
74
|
-
if (this.frame.
|
|
77
|
+
if (this.frame.isWhitelisted(guild.id, executor.id)) return;
|
|
75
78
|
|
|
76
79
|
if (type === "roleUpdate" && this.config.watchDangerousGrants) {
|
|
77
80
|
await this._checkDangerousGrant(entry, guild, executor);
|
|
@@ -206,6 +209,7 @@ class AntiNukeLayer extends Layer {
|
|
|
206
209
|
*/
|
|
207
210
|
async _handleWebhookMessage(message) {
|
|
208
211
|
if (!message.webhookId || !message.guild) return;
|
|
212
|
+
if (!this.isEnabled(message.guild.id)) return;
|
|
209
213
|
const info = this.recentWebhooks.get(message.webhookId);
|
|
210
214
|
if (!info) return;
|
|
211
215
|
|
|
@@ -12,15 +12,10 @@ class AntiRaidLayer extends Layer {
|
|
|
12
12
|
this.joinProfiles = new Map(); // guildId -> [{ at, created }] for cluster correlation
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
attach() {
|
|
16
16
|
this._listen(this.client, "guildMemberAdd", (member) => this._handleJoin(member).catch(() => {}));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
onDisable() {
|
|
20
|
-
this.joinTimestamps.clear();
|
|
21
|
-
this.joinProfiles.clear();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
isLockedDown(guildId) {
|
|
25
20
|
const until = this.lockedDown.get(guildId);
|
|
26
21
|
return typeof until === "number" && Date.now() < until;
|
|
@@ -75,6 +70,7 @@ class AntiRaidLayer extends Layer {
|
|
|
75
70
|
|
|
76
71
|
async _handleJoin(member) {
|
|
77
72
|
const { guild, user } = member;
|
|
73
|
+
if (!this.isEnabled(guild.id)) return;
|
|
78
74
|
this.frame.performance.recordEvent("antiRaid.guildMemberAdd");
|
|
79
75
|
const joinCount = this._recordJoin(guild.id);
|
|
80
76
|
const { score: altScore, entropyCheck } = this._altScore(user);
|
|
@@ -6,31 +6,29 @@ const { classifyMessage } = require("../utils/nlpEngine");
|
|
|
6
6
|
class BasicSecurityLayer extends Layer {
|
|
7
7
|
constructor(frame) {
|
|
8
8
|
super("basicSecurity", frame);
|
|
9
|
-
this.buffers = new Map(); // userId -> { timestamps, lastContent, dupCount }
|
|
9
|
+
this.buffers = new Map(); // `guildId:userId` -> { timestamps, lastContent, dupCount }
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
attach() {
|
|
13
13
|
this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
_bufferFor(userId) {
|
|
21
|
-
if (!this.buffers.has(userId)) {
|
|
22
|
-
this.buffers.set(userId, { timestamps: [], lastContent: "", dupCount: 0 });
|
|
16
|
+
_bufferFor(guildId, userId) {
|
|
17
|
+
const key = `${guildId}:${userId}`;
|
|
18
|
+
if (!this.buffers.has(key)) {
|
|
19
|
+
this.buffers.set(key, { timestamps: [], lastContent: "", dupCount: 0 });
|
|
23
20
|
}
|
|
24
|
-
return this.buffers.get(
|
|
21
|
+
return this.buffers.get(key);
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
async _handle(message) {
|
|
28
25
|
if (!message.guild || message.author.bot) return;
|
|
26
|
+
if (!this.isEnabled(message.guild.id)) return;
|
|
29
27
|
const done = this.frame.performance.time("basicSecurity.messageCreate");
|
|
30
28
|
this.frame.performance.recordEvent("basicSecurity.messageCreate");
|
|
31
29
|
|
|
32
30
|
const cfg = this.config;
|
|
33
|
-
const buf = this._bufferFor(message.author.id);
|
|
31
|
+
const buf = this._bufferFor(message.guild.id, message.author.id);
|
|
34
32
|
const now = Date.now();
|
|
35
33
|
|
|
36
34
|
buf.timestamps = buf.timestamps.filter((t) => now - t < cfg.spam.windowMs);
|
|
@@ -30,9 +30,10 @@ class PunishmentEngine extends EventEmitter {
|
|
|
30
30
|
// limits. Falls back to running inline if no queue is supplied.
|
|
31
31
|
this.queue = queue || { push: (task) => task() };
|
|
32
32
|
this.performance = performance || { recordEvent() {}, time: () => () => {} };
|
|
33
|
-
// Accept the same
|
|
34
|
-
// runtime
|
|
35
|
-
|
|
33
|
+
// Accept the same Map the rest of GlassFrame uses (guildId -> Set<userId>),
|
|
34
|
+
// by reference, not a copy, so runtime edits (e.g. !gf whitelist add)
|
|
35
|
+
// apply immediately here too.
|
|
36
|
+
this.whitelist = whitelist;
|
|
36
37
|
this.cases = new ProtocolCache("open-cases", { ttlMs: this.config.caseCooldownMs, debug });
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -47,7 +48,7 @@ class PunishmentEngine extends EventEmitter {
|
|
|
47
48
|
async report({ guild, member, layer, weight, reason }) {
|
|
48
49
|
if (!member) return null;
|
|
49
50
|
const userId = member.id;
|
|
50
|
-
if (this.whitelist.has(userId)) return null;
|
|
51
|
+
if (this.whitelist.get(guild.id)?.has(userId)) return null;
|
|
51
52
|
|
|
52
53
|
const { score, tier } = this.threatEngine.addSignal(guild.id, userId, { layer, weight, reason });
|
|
53
54
|
if (tier === "none") return null;
|
|
@@ -55,4 +55,68 @@ function buildPanel(frame, guildId) {
|
|
|
55
55
|
return { flags: MessageFlags.IsComponentsV2, components: [container] };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* !gf metrics shows THIS SERVER's numbers by default - the one button on
|
|
60
|
+
* this message switches to bot-wide (all servers) and back. This is a
|
|
61
|
+
* second, separate button from the 5-button panel above; it lives on the
|
|
62
|
+
* metrics command's own message, not on the panel.
|
|
63
|
+
*/
|
|
64
|
+
function buildMetricsMessage(frame, guildId, scope = "guild") {
|
|
65
|
+
const isGlobal = scope === "global";
|
|
66
|
+
const fields = [];
|
|
67
|
+
|
|
68
|
+
if (isGlobal) {
|
|
69
|
+
const m = frame.getMetrics();
|
|
70
|
+
const fmtCache = (c) => `size ${c.size} | hits ${c.hits} | misses ${c.misses} | evictions ${c.evictions}`;
|
|
71
|
+
const fmtQueue = (q) => `active ${q.active}/${q.concurrency} | pending ${q.pending} | completed ${q.completed} | failed ${q.failed}`;
|
|
72
|
+
const fmtLatency =
|
|
73
|
+
Object.entries(m.performance.avgLatencyMs)
|
|
74
|
+
.map(([name, ms]) => `${name}: ${ms}ms avg`)
|
|
75
|
+
.join("\n") || "no samples yet";
|
|
76
|
+
|
|
77
|
+
fields.push(
|
|
78
|
+
{ name: "Uptime", value: `${Math.round(m.performance.uptimeMs / 60000)} min` },
|
|
79
|
+
{ name: "Action queue", value: fmtQueue(m.queues.actions) },
|
|
80
|
+
{ name: "AI queue", value: fmtQueue(m.queues.ai) },
|
|
81
|
+
{ name: "Threat score cache (all servers)", value: fmtCache(m.caches.threatScores) },
|
|
82
|
+
{ name: "Open case cache (all servers)", value: fmtCache(m.caches.openCases) },
|
|
83
|
+
{ name: "Groq verdict cache (all servers)", value: fmtCache(m.caches.groqVerdicts) },
|
|
84
|
+
{ name: "Average latency", value: fmtLatency }
|
|
85
|
+
);
|
|
86
|
+
} else {
|
|
87
|
+
const gm = frame.getGuildMetrics(guildId);
|
|
88
|
+
const statusLines = REAL_LAYERS.map((l) => `${gm.status[l] ? "ACTIVE" : "inactive"} - ${l}`).join("\n");
|
|
89
|
+
|
|
90
|
+
fields.push(
|
|
91
|
+
{ name: "Layers", value: statusLines },
|
|
92
|
+
{ name: "Flagged members right now", value: String(gm.flaggedMembers) },
|
|
93
|
+
{ name: "Open cases", value: String(gm.openCases) },
|
|
94
|
+
{ name: "Whitelisted users", value: String(gm.whitelistSize) }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const nextScope = isGlobal ? "guild" : "global";
|
|
99
|
+
const toggleButton = new ButtonBuilder()
|
|
100
|
+
.setCustomId(`gfp_metrics_${nextScope}`)
|
|
101
|
+
.setLabel(isGlobal ? "Show This Server" : "Show Global (All Servers)")
|
|
102
|
+
.setStyle(ButtonStyle.Secondary);
|
|
103
|
+
const row = new ActionRowBuilder().addComponents(toggleButton);
|
|
104
|
+
|
|
105
|
+
const container = new ContainerBuilder().setAccentColor(0x5865f2);
|
|
106
|
+
container.addTextDisplayComponents(
|
|
107
|
+
new TextDisplayBuilder().setContent(
|
|
108
|
+
isGlobal ? "**GlassFrame Protocol - Global Metrics**" : "**GlassFrame Protocol - This Server's Metrics**"
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
112
|
+
container.addTextDisplayComponents(
|
|
113
|
+
new TextDisplayBuilder().setContent(fields.map((f) => `**${f.name}**\n${f.value}`).join("\n\n"))
|
|
114
|
+
);
|
|
115
|
+
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
116
|
+
container.addActionRowComponents(row);
|
|
117
|
+
container.addTextDisplayComponents(new TextDisplayBuilder().setContent("-# GlassFrame Protocol"));
|
|
118
|
+
|
|
119
|
+
return { flags: MessageFlags.IsComponentsV2, components: [container] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { buildPanel, buildMetricsMessage, BUTTONS };
|
|
@@ -15,6 +15,10 @@ watch it operate at runtime - see "Turning on cache logging" below.
|
|
|
15
15
|
- `getStats()` returns hits/misses/sets/evictions/size for any cache at
|
|
16
16
|
runtime, which is useful when you're tuning thresholds in `config.js` and
|
|
17
17
|
want to know whether a value is actually being reused.
|
|
18
|
+
- `countByPrefix(prefix)` counts non-expired entries whose key starts with
|
|
19
|
+
a prefix - this is how `frame.getGuildMetrics(guildId)` gets one server's
|
|
20
|
+
slice of the `threat-scores` and `open-cases` caches below without
|
|
21
|
+
needing a separate per-guild cache for each.
|
|
18
22
|
|
|
19
23
|
## Caches in use
|
|
20
24
|
|
package/docs/COMMANDS.md
CHANGED
|
@@ -9,30 +9,44 @@ running twice or getting a "slow down" reply of its own.
|
|
|
9
9
|
|
|
10
10
|
| Command | Does |
|
|
11
11
|
|---|---|
|
|
12
|
-
| `!gf panel` | Sends the 5-button control panel (see below). |
|
|
13
|
-
| `!gf status` | Shows which of the four layers are currently active
|
|
12
|
+
| `!gf panel` | Sends the 5-button control panel for this server (see below). |
|
|
13
|
+
| `!gf status` | Shows which of the four layers are currently active **in this server**. |
|
|
14
14
|
| `!gf scan` | Runs `RoleAnalyzer.scanRoles()` on demand and reports any role name/permission mismatches. |
|
|
15
|
-
| `!gf metrics` | Shows
|
|
15
|
+
| `!gf metrics` | Shows **this server's** metrics by default - a button on the message switches to bot-wide (all servers) and back. See `docs/PERFORMANCE.md`. |
|
|
16
16
|
| `!gf phishing add <domain>` | Adds a domain to the link blocklist. |
|
|
17
17
|
| `!gf phishing remove <domain>` | Removes a domain from the blocklist. |
|
|
18
18
|
| `!gf phishing list` | Lists every blocked domain. |
|
|
19
|
-
| `!gf whitelist add <userId>` | Exempts a user from
|
|
20
|
-
| `!gf whitelist remove <userId>` | Removes
|
|
19
|
+
| `!gf whitelist add <userId>` | Exempts a user from punitive action **in this server**. |
|
|
20
|
+
| `!gf whitelist remove <userId>` | Removes that exemption for this server. |
|
|
21
21
|
| `!gf help` | Lists these commands. |
|
|
22
22
|
|
|
23
|
+
Everything above operates on the server the command was sent in - arming
|
|
24
|
+
AntiRaid, whitelisting someone, or checking status in one server has no
|
|
25
|
+
effect on any other server the bot is in. See `docs/STATE.md`.
|
|
26
|
+
|
|
23
27
|
## The control panel
|
|
24
28
|
|
|
25
|
-
`!gf panel` sends a message with exactly five buttons in one row
|
|
29
|
+
`!gf panel` sends a message with exactly five buttons in one row, scoped to
|
|
30
|
+
the server it was sent in:
|
|
26
31
|
|
|
27
32
|
1. **Activate/Deactivate AntiRaid**
|
|
28
33
|
2. **Activate/Deactivate AI Moderation**
|
|
29
34
|
3. **Activate/Deactivate AntiNuke**
|
|
30
35
|
4. **Activate/Deactivate Basic Security**
|
|
31
|
-
5. **Activate/Deactivate Full Protocol** - arms or disarms all four at once
|
|
36
|
+
5. **Activate/Deactivate Full Protocol** - arms or disarms all four at once, for this server only
|
|
32
37
|
|
|
33
|
-
Every layer starts disabled - nothing runs until it's
|
|
34
|
-
either from the panel or via
|
|
35
|
-
|
|
38
|
+
Every layer starts disabled in every server - nothing runs until it's
|
|
39
|
+
explicitly activated, either from the panel or via
|
|
40
|
+
`frame.enableLayer("antiRaid", guildId)` in code. Button labels flip
|
|
41
|
+
between Activate/Deactivate based on that server's current state, and the
|
|
36
42
|
panel message updates in place on every click rather than posting a new
|
|
37
43
|
message each time. Clicking a button also drops one aggregated log line in
|
|
38
44
|
the security log channel noting who changed what.
|
|
45
|
+
|
|
46
|
+
## The metrics button
|
|
47
|
+
|
|
48
|
+
`!gf metrics` (and the panel's numbers, indirectly) has one button of its
|
|
49
|
+
own, separate from the panel's five: **Show Global (All Servers)** /
|
|
50
|
+
**Show This Server**, flipping the message between this server's counts
|
|
51
|
+
(open cases, flagged members, active layers, whitelist size) and bot-wide
|
|
52
|
+
totals across every server (queue depth, cache stats, average latency).
|
package/docs/PROTOCOL_LAYERS.md
CHANGED
|
@@ -37,11 +37,14 @@ instead of twenty separate ones.
|
|
|
37
37
|
| `antiNuke` | `guildAuditLogEntryCreate`, plus `messageCreate` only when webhook-abuse watching is on | reverts a dangerous permission grant, deletes an abusive webhook | destructive-action bursts per executor across channels/roles/bans/kicks/webhooks/emoji/stickers |
|
|
38
38
|
| `aiModeration` | `messageCreate` (independently of `basicSecurity`) | - | AI-confirmed category + confidence for gray-zone messages |
|
|
39
39
|
|
|
40
|
-
Each layer extends `src/core/Layer.js
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
Each layer extends `src/core/Layer.js`. Its event listeners attach exactly
|
|
41
|
+
once, ever (`attach()`), rather than on every enable/disable - Discord's
|
|
42
|
+
gateway has no concept of a listener scoped to one guild, so the listener
|
|
43
|
+
always fires and each handler's first real check is
|
|
44
|
+
`if (!this.isEnabled(guildId)) return;`. Enabling or disabling a layer is
|
|
45
|
+
per-guild: `layer.enable(guildId)` / `layer.disable(guildId)` track a
|
|
46
|
+
`Set<guildId>` (`enabledGuilds`), not a single boolean - see
|
|
47
|
+
`docs/STATE.md` for the full per-guild state and persistence model.
|
|
45
48
|
|
|
46
49
|
`basicSecurity` and `aiModeration` both listen to `messageCreate`
|
|
47
50
|
independently rather than one calling into the other - either can be
|
|
@@ -75,15 +78,18 @@ how they respond.
|
|
|
75
78
|
## Why AntiNuke also runs a role audit
|
|
76
79
|
|
|
77
80
|
`RoleAnalyzer.scanRoles()` (role name vs. permission mismatch detection)
|
|
78
|
-
runs automatically once
|
|
79
|
-
via `!gf scan`. It reports flags, never
|
|
80
|
-
anything, to do about a role that looks
|
|
81
|
-
holds dangerous permissions.
|
|
81
|
+
runs automatically once for a server the moment AntiNuke is armed *for
|
|
82
|
+
that server*, and again on demand via `!gf scan`. It reports flags, never
|
|
83
|
+
actions - a human decides what, if anything, to do about a role that looks
|
|
84
|
+
like impersonation bait or quietly holds dangerous permissions.
|
|
82
85
|
|
|
83
86
|
## Adding a fifth layer
|
|
84
87
|
|
|
85
|
-
Extend `Layer`, implement `
|
|
88
|
+
Extend `Layer`, implement `attach()` (register your event listener(s) once;
|
|
89
|
+
gate all real work behind `this.isEnabled(guildId)`), report signals via
|
|
86
90
|
`this.frame.punishmentEngine.report({ guild, member, layer, weight, reason })`,
|
|
87
91
|
register the instance in `GlassFrame`'s `this.layers` map, and add a button
|
|
88
92
|
in `src/ui/ControlPanel.js` if you want it on the panel. Nothing else in the
|
|
89
|
-
pipeline needs to know the new layer exists
|
|
93
|
+
pipeline needs to know the new layer exists - per-guild enable/disable,
|
|
94
|
+
state persistence, and metrics all come from the base `Layer` class for
|
|
95
|
+
free.
|
package/docs/STATE.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Per-Guild State and Persistence
|
|
2
|
+
|
|
3
|
+
One GlassFrame instance can serve many Discord servers at once. Two things
|
|
4
|
+
are tracked independently per server, and both survive a bot restart if you
|
|
5
|
+
give GlassFrame a persistent `stateStore`:
|
|
6
|
+
|
|
7
|
+
- **Which layers are on.** Server A can run AntiRaid + AntiNuke while Server
|
|
8
|
+
B runs nothing, or everything, independently. There is no such thing as
|
|
9
|
+
"GlassFrame is on" globally - only "layer X is on for guild Y."
|
|
10
|
+
- **The whitelist.** Exempting a user in one server does not exempt them
|
|
11
|
+
anywhere else.
|
|
12
|
+
|
|
13
|
+
Everything else that's naturally guild-scoped already was before this -
|
|
14
|
+
threat scores, open cases, join-rate tracking, audit-log burst tracking are
|
|
15
|
+
all keyed by `guildId` internally. The two items above are the ones that
|
|
16
|
+
used to be accidentally global; see `CHANGELOG.md` for the 2.0.0 entry if
|
|
17
|
+
you're upgrading from an earlier version.
|
|
18
|
+
|
|
19
|
+
## How it's stored
|
|
20
|
+
|
|
21
|
+
`Layer.enabledGuilds` is a `Set<guildId>` per layer (not one shared
|
|
22
|
+
boolean). `GlassFrame.whitelist` is a `Map<guildId, Set<userId>>`. Both are
|
|
23
|
+
read through the frame's own methods rather than touched directly:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
frame.enableLayer("antiRaid", guildId);
|
|
27
|
+
frame.disableLayer("antiRaid", guildId);
|
|
28
|
+
frame.getStatus(guildId); // { basicSecurity: true, antiRaid: false, ... }
|
|
29
|
+
|
|
30
|
+
frame.addToWhitelist(guildId, userId);
|
|
31
|
+
frame.removeFromWhitelist(guildId, userId);
|
|
32
|
+
frame.isWhitelisted(guildId, userId);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Persistence across restarts
|
|
36
|
+
|
|
37
|
+
Pass a `stateStore` when constructing GlassFrame - `MemoryStateStore`
|
|
38
|
+
(default, lost on restart) or `JSONFileStateStore` (a single JSON file, no
|
|
39
|
+
database, ARM64/Termux-friendly):
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const { JSONFileStateStore } = require("glassframe-protocol");
|
|
43
|
+
|
|
44
|
+
const frame = new GlassFrame(client, {
|
|
45
|
+
getLogChannel: /* ... */,
|
|
46
|
+
stateStore: new JSONFileStateStore("./glassframe-state.json"),
|
|
47
|
+
autoStart: ["basicSecurity", "antiRaid"]
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every time a layer is toggled or the whitelist changes for a guild,
|
|
52
|
+
GlassFrame writes that guild's full state (which layers are on, its
|
|
53
|
+
whitelist) to the store. At startup, for every guild the bot is already in,
|
|
54
|
+
and again automatically whenever the bot joins a new guild
|
|
55
|
+
(`guildCreate`), GlassFrame:
|
|
56
|
+
|
|
57
|
+
1. Checks the store for saved state for that guild.
|
|
58
|
+
2. If found, restores exactly that - the layers that were on stay on.
|
|
59
|
+
3. If nothing is saved yet (a guild GlassFrame has never seen before),
|
|
60
|
+
applies `autoStart` and the constructor's `whitelist` option (see below)
|
|
61
|
+
as that guild's starting defaults.
|
|
62
|
+
|
|
63
|
+
Restoring is async (a real `stateStore` might read from disk or a
|
|
64
|
+
database), so it can't finish before the constructor returns. In practice
|
|
65
|
+
this resolves well before a Discord event could possibly arrive, so most
|
|
66
|
+
code never needs to think about it - but if you want a guarantee, `await
|
|
67
|
+
frame.ready` after construction; it resolves once every guild the bot was
|
|
68
|
+
already in has been restored (guilds joined later via `guildCreate`
|
|
69
|
+
restore independently and aren't part of this promise, since there's
|
|
70
|
+
nothing they could race against).
|
|
71
|
+
|
|
72
|
+
`autoStart` and the `whitelist` option are only ever *defaults for a
|
|
73
|
+
guild's first run* - once a guild has any saved state (even "everything
|
|
74
|
+
off"), neither applies to it again; the saved state is the source of truth
|
|
75
|
+
from then on.
|
|
76
|
+
|
|
77
|
+
## The `whitelist` constructor option
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
new GlassFrame(client, { getLogChannel: /* ... */, whitelist: ["123456789012345678"] });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This seeds a starting whitelist for every guild - both the ones already in
|
|
84
|
+
`client.guilds.cache` and any the bot joins later - the first time
|
|
85
|
+
GlassFrame sees that guild (i.e., it never overrides a guild's own saved
|
|
86
|
+
whitelist). Think of it as "these users are trusted everywhere by default";
|
|
87
|
+
`frame.removeFromWhitelist(guildId, userId)` still works normally
|
|
88
|
+
per-guild afterward.
|
|
89
|
+
|
|
90
|
+
## Writing your own store
|
|
91
|
+
|
|
92
|
+
Implement two async methods and pass an instance as `stateStore`:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
class MyStore {
|
|
96
|
+
async get(guildId) { /* return the saved state object, or null */ }
|
|
97
|
+
async set(guildId, state) { /* persist `state` for this guildId */ }
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`state` is `{ layers: { basicSecurity: true, antiRaid: false, ... }, whitelist: ["userId1", "userId2"] }`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glassframe-protocol",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A layered, self-contained Discord security engine - AntiRaid, AntiNuke, Basic Security, and optional Groq-powered AI Moderation sharing one threat-scoring and punishment pipeline. Prefix commands only, Components V2 output, no slash commands.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|