glassframe-protocol 1.2.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +184 -0
- package/GETTING_STARTED.md +23 -4
- package/README.md +55 -20
- package/dist/config.js +20 -1
- package/dist/src/GlassFrame.js +312 -42
- package/dist/src/commands/PrefixRouter.js +123 -48
- package/dist/src/core/Cache.js +10 -0
- package/dist/src/core/Layer.js +28 -23
- package/dist/src/core/PerformanceMonitor.js +16 -4
- package/dist/src/core/VersionInfo.js +1 -1
- package/dist/src/layers/AIModerationLayer.js +4 -4
- package/dist/src/layers/AntiNukeLayer.js +113 -16
- package/dist/src/layers/AntiRaidLayer.js +5 -7
- package/dist/src/layers/BasicSecurityLayer.js +39 -12
- package/dist/src/logging/SmartLogger.js +33 -3
- package/dist/src/moderation/PunishmentEngine.js +19 -7
- package/dist/src/ui/ControlPanel.js +195 -1
- package/dist/src/utils/nlpEngine.js +2 -2
- package/docs/CACHE_ARCHITECTURE.md +9 -0
- package/docs/COMMANDS.md +37 -11
- package/docs/ENGINE.md +83 -0
- package/docs/PROTOCOL_LAYERS.md +62 -17
- package/docs/STATE.md +101 -0
- package/package.json +1 -1
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, 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
|
|
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
|
-
|
|
90
|
-
|
|
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.
|
|
171
|
+
out[name] = layer.isEnabled(guildId);
|
|
104
172
|
}
|
|
105
173
|
return out;
|
|
106
174
|
}
|
|
107
175
|
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
367
|
+
if (!interaction.isButton()) return;
|
|
139
368
|
|
|
140
369
|
try {
|
|
141
|
-
if (
|
|
142
|
-
await
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
163
|
-
|
|
389
|
+
const guildId = interaction.guild.id;
|
|
390
|
+
const layerKey = interaction.customId.replace("gfp_toggle_", "");
|
|
164
391
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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) {
|