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 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
@@ -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), or call
83
- `frame.enableLayer("antiNuke")` / `frame.disableLayer("antiNuke")`
84
- anywhere after construction.
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 not listed here start OFF until armed from the panel
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 control
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. `frame.getMetrics()` / `!gf metrics`
110
- gives you queue depth, cache stats, and average latency in one snapshot.
111
- Full write-up in `docs/PERFORMANCE.md`.
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
@@ -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 via
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
- for (const name of options.autoStart || []) {
90
- if (this.layers[name]) this.layers[name].enable();
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.enabled;
129
+ out[name] = layer.isEnabled(guildId);
104
130
  }
105
131
  return out;
106
132
  }
107
133
 
108
- /** One-call health snapshot: event throughput/latency, queue depth, and every cache's stats. */
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
- enableLayer(name) {
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
- this.layers[name].enable();
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
- this.layers[name].disable();
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() || !interaction.customId.startsWith("gfp_toggle_")) return;
227
+ if (!interaction.isButton()) return;
139
228
 
140
229
  try {
141
- if (!this.isAuthorized(interaction.member)) {
142
- await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
143
- return;
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
- const layerKey = interaction.customId.replace("gfp_toggle_", "");
147
-
148
- if (layerKey === "all") {
149
- const status = this.getStatus(interaction.guild.id);
150
- const allOn = Object.keys(this.layers).every((l) => status[l]);
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
- const payload = buildPanel(this, interaction.guild.id);
163
- await interaction.update(payload);
247
+ const guildId = interaction.guild.id;
248
+ const layerKey = interaction.customId.replace("gfp_toggle_", "");
164
249
 
165
- await this.logger.log(interaction.guild, {
166
- level: "info",
167
- title: "Protocol Layer Toggled",
168
- description: `${interaction.user.tag} updated GlassFrame Protocol layers from the control panel.`,
169
- dedupeKey: "panel:toggle"
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 - show performance and cache health`,
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 all punitive action`,
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.whitelist.add(id);
101
- else if (action === "remove") this.frame.whitelist.delete(id);
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 m = this.frame.getMetrics();
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
 
@@ -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
  }
@@ -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
- * enable()/disable() are the only lifecycle a layer needs to implement -
17
- * disable() fully detaches every listener the layer attached, so a disabled
18
- * layer does zero work rather than quietly early-returning inside a handler
19
- * that's still subscribed.
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.enabled = false;
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
- if (this.enabled) return this;
45
- this.enabled = true;
46
- this.onEnable();
47
- this.frame.emit("layerToggled", { layer: this.name, enabled: true });
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
- if (!this.enabled) return this;
53
- this.enabled = false;
54
- for (const [emitter, event, handler] of this._listeners) {
55
- emitter.removeListener(event, handler);
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 these two hooks.
64
- onEnable() {}
65
- onDisable() {}
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;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- const VERSION = "1.2.1";
3
+ const VERSION = "2.0.0";
4
4
  const NAME = "GlassFrame Protocol";
5
5
  const RELEASED = "2026-07-31";
6
6
 
@@ -29,20 +29,19 @@ class AIModerationLayer extends Layer {
29
29
  return this.frame.groqClient;
30
30
  }
31
31
 
32
- onEnable() {
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 is active but will stay idle. Set aiModeration.apiKeys in config."
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
- onEnable() {
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
- onDisable() {
50
- this.actionLog.clear();
51
- this.webhookMessageCounts.clear();
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.whitelist.has(executor.id)) return;
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
- onEnable() {
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
- onEnable() {
12
+ attach() {
13
13
  this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
14
14
  }
15
15
 
16
- onDisable() {
17
- this.buffers.clear();
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(userId);
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 Set the rest of GlassFrame uses (not a copy), so
34
- // runtime whitelist edits (e.g. !gf whitelist add) apply immediately here too.
35
- this.whitelist = whitelist instanceof Set ? whitelist : new Set(whitelist || []);
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
- module.exports = { buildPanel, BUTTONS };
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 queue depth, cache stats, and average latency - see `docs/PERFORMANCE.md`. |
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 all punitive action. |
20
- | `!gf whitelist remove <userId>` | Removes an exemption. |
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 explicitly activated,
34
- either from the panel or via `frame.enableLayer("antiRaid")` in code. Button
35
- labels flip between Activate/Deactivate based on current state, and the
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).
@@ -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`, which handles enabling/disabling
41
- cleanly: `enable()` attaches the layer's listeners, `disable()` detaches
42
- every one of them (not just an early-return check inside the handler), so a
43
- disabled layer truly does no work rather than quietly skipping its own
44
- logic on every event.
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, the moment AntiNuke is armed, and again on demand
79
- via `!gf scan`. It reports flags, never actions - a human decides what, if
80
- anything, to do about a role that looks like impersonation bait or quietly
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 `onEnable()` / `onDisable()`, report signals via
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": "1.2.1",
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": [