glassframe-protocol 1.2.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, buildEnginePage } = 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,10 +47,22 @@ 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>
57
+ this.customActions = new Map(); // actionName -> async (member, record) => void, for registerAction()
58
+ this.guildPrefixes = new Map(); // guildId -> custom prefix, for setPrefix()
46
59
  this.panelMessages = new Map(); // messageId -> guildId
47
60
  this.debug = Boolean(options.debug ?? this.config.cache.debug);
61
+ // Bot-owner user IDs - separate from per-guild Manage Server admins,
62
+ // since cross-server data (busiest server, all-guild activity) isn't
63
+ // something a random single server's admin should see about others.
64
+ this.owners = new Set(options.owners || []);
65
+ this.engineAttempts = new Map(); // userId -> { count, lockedUntil }
48
66
 
49
67
  this.threatEngine = new ThreatEngine(this.config, { debug: this.debug });
50
68
  this.roleAnalyzer = new RoleAnalyzer(this.config);
@@ -66,19 +84,23 @@ class GlassFrame extends EventEmitter {
66
84
  roleAnalyzer: this.roleAnalyzer,
67
85
  logger: this.logger,
68
86
  whitelist: this.whitelist,
87
+ customActions: this.customActions,
69
88
  queue: this.actionQueue,
70
89
  performance: this.performance,
71
90
  debug: this.debug
72
91
  });
73
92
 
74
- // All four layers start disabled - nothing runs until armed via
75
- // options.autoStart, the control panel, or enableLayer().
93
+ // All four layers start disabled per guild - nothing runs until armed
94
+ // via options.autoStart, the control panel, or enableLayer(name, guildId).
76
95
  this.layers = {
77
96
  basicSecurity: new BasicSecurityLayer(this),
78
97
  antiRaid: new AntiRaidLayer(this),
79
98
  antiNuke: new AntiNukeLayer(this),
80
99
  aiModeration: new AIModerationLayer(this)
81
100
  };
101
+ // Event listeners attach exactly once, ever - per-guild on/off is a
102
+ // gate inside each handler, not an attach/detach cycle. See core/Layer.js.
103
+ for (const layer of Object.values(this.layers)) layer.attach();
82
104
 
83
105
  this.prefixRouter = new PrefixRouter(this);
84
106
  this.prefixRouter.attach();
@@ -86,9 +108,21 @@ class GlassFrame extends EventEmitter {
86
108
 
87
109
  if (this.debug) console.log(VersionInfo.banner());
88
110
 
89
- for (const name of options.autoStart || []) {
90
- if (this.layers[name]) this.layers[name].enable();
91
- }
111
+ // Restore saved state (or apply autoStart/whitelist defaults) for every
112
+ // guild the bot is already in. This is async (stateStore.get() may be a
113
+ // real file/DB read), so `ready` lets callers that care about the exact
114
+ // moment restoration finishes await it explicitly - in practice a
115
+ // Discord message can't arrive faster than this resolves, so most code
116
+ // never needs to. Any guild the bot joins *after* this (guildCreate)
117
+ // restores independently and isn't part of `ready`.
118
+ const initialRestores = [...this.client.guilds.cache.values()].map((guild) =>
119
+ this._restoreGuildState(guild.id).catch((err) => this.emit("warning", { layer: "core", message: err.message }))
120
+ );
121
+ this.ready = Promise.all(initialRestores).then(() => undefined);
122
+
123
+ this.client.on("guildCreate", (guild) => {
124
+ this._restoreGuildState(guild.id).catch((err) => this.emit("warning", { layer: "core", message: err.message }));
125
+ });
92
126
  }
93
127
 
94
128
  isAuthorized(member) {
@@ -97,15 +131,65 @@ class GlassFrame extends EventEmitter {
97
131
  return member.permissions.has(PermissionsBitField.Flags.ManageGuild);
98
132
  }
99
133
 
134
+ /** For the owner-only !gf engine dashboard - separate from per-guild isAuthorized(). */
135
+ isOwner(userId) {
136
+ return this.owners.has(userId);
137
+ }
138
+
139
+ /**
140
+ * Second factor on top of isOwner(), only for the initial `!gf engine`
141
+ * command - not required again for page-navigation button clicks, since
142
+ * those are already tied to a real, already-verified Discord identity.
143
+ * Returns true with no config.engine.password set (owner check alone is
144
+ * enough). Locks a user out for engine.lockoutMs after
145
+ * engine.maxAttempts wrong guesses, to make brute-forcing impractical.
146
+ */
147
+ verifyEnginePassword(userId, provided) {
148
+ const cfg = this.config.engine;
149
+ if (!cfg.password) return true;
150
+
151
+ const attempt = this.engineAttempts.get(userId) || { count: 0, lockedUntil: 0 };
152
+ if (Date.now() < attempt.lockedUntil) return false;
153
+
154
+ if (provided === cfg.password) {
155
+ this.engineAttempts.delete(userId);
156
+ return true;
157
+ }
158
+
159
+ attempt.count++;
160
+ if (attempt.count >= cfg.maxAttempts) {
161
+ attempt.lockedUntil = Date.now() + cfg.lockoutMs;
162
+ attempt.count = 0;
163
+ }
164
+ this.engineAttempts.set(userId, attempt);
165
+ return false;
166
+ }
167
+
100
168
  getStatus(guildId) {
101
169
  const out = {};
102
170
  for (const [name, layer] of Object.entries(this.layers)) {
103
- out[name] = layer.enabled;
171
+ out[name] = layer.isEnabled(guildId);
104
172
  }
105
173
  return out;
106
174
  }
107
175
 
108
- /** One-call health snapshot: event throughput/latency, queue depth, and every cache's stats. */
176
+ isWhitelisted(guildId, userId) {
177
+ return this.whitelist.get(guildId)?.has(userId) ?? false;
178
+ }
179
+
180
+ addToWhitelist(guildId, userId) {
181
+ if (!this.whitelist.has(guildId)) this.whitelist.set(guildId, new Set());
182
+ this.whitelist.get(guildId).add(userId);
183
+ this._persistGuildState(guildId);
184
+ }
185
+
186
+ removeFromWhitelist(guildId, userId) {
187
+ const existed = this.whitelist.get(guildId)?.delete(userId) ?? false;
188
+ if (existed) this._persistGuildState(guildId);
189
+ return existed;
190
+ }
191
+
192
+ /** One-call health snapshot: event throughput/latency, queue depth, and every cache's stats. Bot-wide, across every guild. */
109
193
  getMetrics() {
110
194
  return {
111
195
  performance: this.performance.snapshot(),
@@ -121,58 +205,244 @@ class GlassFrame extends EventEmitter {
121
205
  };
122
206
  }
123
207
 
124
- enableLayer(name) {
208
+ /** Just this guild's numbers - which layers are on, and its slice of the shared threat/case caches. */
209
+ getGuildMetrics(guildId) {
210
+ const prefix = `${guildId}:`;
211
+ return {
212
+ status: this.getStatus(guildId),
213
+ whitelistSize: this.whitelist.get(guildId)?.size || 0,
214
+ flaggedMembers: this.threatEngine.scores.countByPrefix(prefix),
215
+ openCases: this.punishmentEngine.cases.countByPrefix(prefix)
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Everything about the running process at once, across every guild -
221
+ * what's live, what's fast/slow, which server is generating the most
222
+ * work, and the last several things that happened anywhere. Powers the
223
+ * owner-only `!gf engine` command. This is intentionally NOT reachable
224
+ * by a per-guild admin - see isOwner().
225
+ */
226
+ getEngineReport() {
227
+ const guilds = [...this.client.guilds.cache.values()];
228
+ const layerNames = Object.keys(this.layers);
229
+
230
+ const adoption = {};
231
+ for (const name of layerNames) {
232
+ adoption[name] = guilds.filter((g) => this.layers[name].isEnabled(g.id)).length;
233
+ }
234
+
235
+ const busiest = this.performance.topGuildsByActivity(5).map(([guildId, count]) => {
236
+ const guild = this.client.guilds.cache.get(guildId);
237
+ return { guildId, name: guild ? guild.name : "(left / unknown)", count };
238
+ });
239
+
240
+ const recentLogs = this.logger.getRecentLogs(10).map((entry) => ({
241
+ ...entry,
242
+ guildName: this.client.guilds.cache.get(entry.guildId)?.name || entry.guildName || entry.guildId
243
+ }));
244
+
245
+ return {
246
+ version: VersionInfo.info(),
247
+ uptimeMs: Date.now() - this.performance.startedAt,
248
+ guildCount: guilds.length,
249
+ layerAdoption: adoption,
250
+ metrics: this.getMetrics(),
251
+ busiestGuilds: busiest,
252
+ recentLogs,
253
+ ai: {
254
+ available: this.groqClient.available,
255
+ keyCount: this.groqClient.keys.length,
256
+ keysOnCooldown: this.groqClient.keys.filter((k) => k.cooldownUntil > Date.now()).length,
257
+ callsLastMinute: this.groqClient.callTimestamps.length,
258
+ totalCallsRecorded: this.performance.eventCounts["groqClient.classify"] || 0
259
+ }
260
+ };
261
+ }
262
+
263
+ enableLayer(name, guildId) {
125
264
  if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
126
- this.layers[name].enable();
265
+ if (!guildId) throw new Error("GlassFrame Protocol: enableLayer(name, guildId) requires a guildId - layers are per-guild.");
266
+ this.layers[name].enable(guildId);
127
267
  return this;
128
268
  }
129
269
 
130
- disableLayer(name) {
270
+ disableLayer(name, guildId) {
131
271
  if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
132
- this.layers[name].disable();
272
+ if (!guildId) throw new Error("GlassFrame Protocol: disableLayer(name, guildId) requires a guildId - layers are per-guild.");
273
+ this.layers[name].disable(guildId);
274
+ return this;
275
+ }
276
+
277
+ /**
278
+ * Adds your own security layer to the same pipeline the built-in four
279
+ * use - it gets per-guild enable/disable, state persistence, and can
280
+ * report signals to the shared ThreatEngine/PunishmentEngine, all for
281
+ * free. `layerInstance` must extend `core/Layer` (see
282
+ * docs/PROTOCOL_LAYERS.md, "Adding a fifth layer"). Custom layers do NOT
283
+ * get a button on the 5-button panel automatically - toggle them via
284
+ * `frame.enableLayer(name, guildId)` in code, or build your own command.
285
+ */
286
+ registerLayer(name, layerInstance) {
287
+ if (this.layers[name]) throw new Error(`GlassFrame Protocol: a layer named "${name}" already exists`);
288
+ if (typeof layerInstance?.attach !== "function" || typeof layerInstance?.enable !== "function") {
289
+ throw new Error("GlassFrame Protocol: registerLayer() expects an instance extending core/Layer");
290
+ }
291
+ this.layers[name] = layerInstance;
292
+ layerInstance.attach();
293
+ this.emit("layerRegistered", { layer: name });
294
+ return this;
295
+ }
296
+
297
+ /**
298
+ * Adds a custom punishment action beyond the built-in
299
+ * ban/kick/timeout/quarantine, so `config.punishment.ladder` can
300
+ * reference it by name. `handler` is `async (member, record) => {}` -
301
+ * `record` is the case object (tier, trust, reasons, ...). Runs through
302
+ * the same bounded-concurrency action queue as the built-in actions.
303
+ *
304
+ * @example
305
+ * frame.registerAction("addMutedRole", async (member) => {
306
+ * const role = member.guild.roles.cache.find((r) => r.name === "Muted");
307
+ * if (role) await member.roles.add(role);
308
+ * });
309
+ * // config: { punishment: { ladder: { medium: "addMutedRole" } } }
310
+ */
311
+ registerAction(name, handler) {
312
+ if (this.customActions.has(name)) throw new Error(`GlassFrame Protocol: an action named "${name}" is already registered`);
313
+ if (typeof handler !== "function") throw new Error("GlassFrame Protocol: registerAction() expects a function");
314
+ this.customActions.set(name, handler);
133
315
  return this;
134
316
  }
135
317
 
318
+ /** The prefix this guild actually uses - its own custom one if set, otherwise config.prefix. */
319
+ getPrefix(guildId) {
320
+ return this.guildPrefixes.get(guildId) || this.config.prefix;
321
+ }
322
+
323
+ /** Sets this guild's own command prefix. config.prefix keeps working everywhere as a fallback, in case a server forgets its custom one. */
324
+ setPrefix(guildId, prefix) {
325
+ this.guildPrefixes.set(guildId, prefix);
326
+ this._persistGuildState(guildId);
327
+ }
328
+
329
+ /** Snapshots one guild's layer + whitelist + prefix state into stateStore. Fire-and-forget by design (never blocks a handler on disk/network I/O). */
330
+ _persistGuildState(guildId) {
331
+ const state = {
332
+ layers: this.getStatus(guildId),
333
+ whitelist: [...(this.whitelist.get(guildId) || [])],
334
+ prefix: this.guildPrefixes.get(guildId) || null
335
+ };
336
+ Promise.resolve(this.stateStore.set(guildId, state)).catch((err) =>
337
+ this.emit("warning", { layer: "core", message: `failed to persist state for ${guildId}: ${err.message}` })
338
+ );
339
+ }
340
+
341
+ /** Loads a guild's saved state if one exists; otherwise applies the autoStart/whitelist defaults (and persists that as the new baseline). */
342
+ async _restoreGuildState(guildId) {
343
+ const saved = await this.stateStore.get(guildId);
344
+
345
+ if (saved) {
346
+ for (const name of Object.keys(this.layers)) {
347
+ if (saved.layers?.[name]) this.layers[name].enable(guildId);
348
+ }
349
+ for (const userId of saved.whitelist || []) {
350
+ if (!this.whitelist.has(guildId)) this.whitelist.set(guildId, new Set());
351
+ this.whitelist.get(guildId).add(userId);
352
+ }
353
+ if (saved.prefix) this.guildPrefixes.set(guildId, saved.prefix);
354
+ return;
355
+ }
356
+
357
+ for (const name of this.autoStart) {
358
+ if (this.layers[name]) this.layers[name].enable(guildId);
359
+ }
360
+ for (const userId of this.defaultWhitelist) {
361
+ this.addToWhitelist(guildId, userId);
362
+ }
363
+ }
364
+
136
365
  _attachButtonHandler() {
137
366
  this.client.on("interactionCreate", async (interaction) => {
138
- if (!interaction.isButton() || !interaction.customId.startsWith("gfp_toggle_")) return;
367
+ if (!interaction.isButton()) return;
139
368
 
140
369
  try {
141
- if (!this.isAuthorized(interaction.member)) {
142
- await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
143
- return;
370
+ if (interaction.customId.startsWith("gfp_toggle_")) {
371
+ await this._handlePanelButton(interaction);
372
+ } else if (interaction.customId.startsWith("gfp_metrics_")) {
373
+ await this._handleMetricsButton(interaction);
374
+ } else if (interaction.customId.startsWith("gfp_engine_")) {
375
+ await this._handleEngineButton(interaction);
144
376
  }
377
+ } catch (err) {
378
+ this.emit("warning", { layer: "controlPanel", message: err.message });
379
+ }
380
+ });
381
+ }
145
382
 
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
- }
383
+ async _handlePanelButton(interaction) {
384
+ if (!this.isAuthorized(interaction.member)) {
385
+ await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
386
+ return;
387
+ }
161
388
 
162
- const payload = buildPanel(this, interaction.guild.id);
163
- await interaction.update(payload);
389
+ const guildId = interaction.guild.id;
390
+ const layerKey = interaction.customId.replace("gfp_toggle_", "");
164
391
 
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 });
392
+ if (layerKey === "all") {
393
+ const status = this.getStatus(guildId);
394
+ const allOn = Object.keys(this.layers).every((l) => status[l]);
395
+ for (const name of Object.keys(this.layers)) {
396
+ if (allOn) this.disableLayer(name, guildId);
397
+ else this.enableLayer(name, guildId);
173
398
  }
399
+ } else if (this.layers[layerKey]) {
400
+ if (this.layers[layerKey].isEnabled(guildId)) this.disableLayer(layerKey, guildId);
401
+ else this.enableLayer(layerKey, guildId);
402
+ } else {
403
+ return;
404
+ }
405
+
406
+ const payload = buildPanel(this, guildId);
407
+ await interaction.update(payload);
408
+
409
+ await this.logger.log(interaction.guild, {
410
+ level: "info",
411
+ title: "Protocol Layer Toggled",
412
+ layer: "controlPanel",
413
+ description: `${interaction.user.tag} updated GlassFrame Protocol layers from the control panel.`,
414
+ dedupeKey: "panel:toggle"
174
415
  });
175
416
  }
417
+
418
+ async _handleMetricsButton(interaction) {
419
+ if (!this.isAuthorized(interaction.member)) {
420
+ await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
421
+ return;
422
+ }
423
+
424
+ const scope = interaction.customId.replace("gfp_metrics_", ""); // "guild" or "global"
425
+ const payload = buildMetricsMessage(this, interaction.guild.id, scope);
426
+ await interaction.update(payload);
427
+ }
428
+
429
+ /**
430
+ * Page navigation on the engine dashboard. Gated by isOwner() on the
431
+ * clicking Discord identity - not by the password again, since a button
432
+ * click is already tied to a real, verified Discord session, and
433
+ * re-prompting for a password on every tab click would be unusable.
434
+ */
435
+ async _handleEngineButton(interaction) {
436
+ if (!this.isOwner(interaction.user.id)) {
437
+ await interaction.reply({ content: "This isn't for you.", ephemeral: true });
438
+ return;
439
+ }
440
+
441
+ const page = interaction.customId.replace("gfp_engine_", "");
442
+ const report = this.getEngineReport();
443
+ const payload = buildEnginePage(report, page);
444
+ await interaction.update(payload);
445
+ }
176
446
  }
177
447
 
178
448
  function mergeConfig(base, override) {