glassframe-protocol 2.0.0 → 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.
@@ -74,24 +74,51 @@ function buildMetricsMessage(frame, guildId, scope = "guild") {
74
74
  .map(([name, ms]) => `${name}: ${ms}ms avg`)
75
75
  .join("\n") || "no samples yet";
76
76
 
77
+ const queueNote = (q) =>
78
+ q.pending > 0
79
+ ? `-# ${q.pending} task(s) queued behind the concurrency cap - normal during a burst, worth raising the cap in config if it's constant.`
80
+ : "-# Nothing queued - everything is running as soon as it's requested.";
81
+
77
82
  fields.push(
78
83
  { 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 }
84
+ { name: "Action queue", value: `${fmtQueue(m.queues.actions)}\n${queueNote(m.queues.actions)}` },
85
+ { name: "AI queue", value: `${fmtQueue(m.queues.ai)}\n${queueNote(m.queues.ai)}` },
86
+ {
87
+ name: "Threat score cache (all servers)",
88
+ value: `${fmtCache(m.caches.threatScores)}\n-# One entry per member with any recent signal, across every server - this decays and self-prunes, so a high size alone isn't a problem.`
89
+ },
90
+ {
91
+ name: "Open case cache (all servers)",
92
+ value: `${fmtCache(m.caches.openCases)}\n-# Members currently inside their debounce window after being actioned - this is what stops a repeat trip from re-punishing or re-logging.`
93
+ },
94
+ {
95
+ name: "Groq verdict cache (all servers)",
96
+ value: `${fmtCache(m.caches.groqVerdicts)}\n-# A high hit rate means repeated/near-identical messages are being caught without a fresh API call each time.`
97
+ },
98
+ { name: "Average latency", value: fmtLatency || "no samples yet" }
85
99
  );
86
100
  } else {
87
101
  const gm = frame.getGuildMetrics(guildId);
88
102
  const statusLines = REAL_LAYERS.map((l) => `${gm.status[l] ? "ACTIVE" : "inactive"} - ${l}`).join("\n");
103
+ const activeCount = REAL_LAYERS.filter((l) => gm.status[l]).length;
89
104
 
90
105
  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) }
106
+ {
107
+ name: "Layers",
108
+ value: `${statusLines}\n-# ${activeCount === 0 ? "Nothing is armed yet - use the panel or autoStart to turn a layer on." : `${activeCount}/4 layers watching this server.`}`
109
+ },
110
+ {
111
+ name: "Flagged members right now",
112
+ value: `${gm.flaggedMembers}\n-# Members with a live risk score above zero. This naturally decays over time on its own - a number that keeps climbing is worth a closer look, one that holds steady usually isn't.`
113
+ },
114
+ {
115
+ name: "Open cases",
116
+ value: `${gm.openCases}\n-# Members currently in their post-action cooldown window. A repeat trip within it escalates the same case rather than opening (or logging) a new one.`
117
+ },
118
+ {
119
+ name: "Whitelisted users",
120
+ value: `${gm.whitelistSize}\n-# Users exempt from punitive action in this server specifically - whitelisting elsewhere doesn't affect this count.`
121
+ }
95
122
  );
96
123
  }
97
124
 
@@ -119,4 +146,107 @@ function buildMetricsMessage(frame, guildId, scope = "guild") {
119
146
  return { flags: MessageFlags.IsComponentsV2, components: [container] };
120
147
  }
121
148
 
122
- module.exports = { buildPanel, buildMetricsMessage, BUTTONS };
149
+ const ENGINE_PAGES = [
150
+ { id: "overview", label: "Overview" },
151
+ { id: "servers", label: "Servers" },
152
+ { id: "ai", label: "AI" },
153
+ { id: "performance", label: "Performance" },
154
+ { id: "logs", label: "Activity Log" }
155
+ ];
156
+
157
+ /**
158
+ * The owner-only `!gf engine` dashboard - everything running across every
159
+ * guild at once, split into 5 pages so each one has room to actually go
160
+ * deep instead of cramming everything into one message. Built from
161
+ * `frame.getEngineReport()`. Reaching the FIRST page requires
162
+ * verifyEnginePassword() (see GlassFrame.js) if one is configured;
163
+ * navigating between pages afterward only re-checks isOwner() on the
164
+ * clicking Discord identity, not the password again.
165
+ */
166
+ function buildEnginePage(report, page = "overview") {
167
+ const container = new ContainerBuilder().setAccentColor(0x5865f2);
168
+ container.addTextDisplayComponents(
169
+ new TextDisplayBuilder().setContent(
170
+ `**GlassFrame Protocol - Engine**\n${report.version.name} v${report.version.version} | up ${Math.round(report.uptimeMs / 60000)} min | serving ${report.guildCount} server(s)`
171
+ )
172
+ );
173
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
174
+
175
+ if (page === "overview") {
176
+ const layerLines = Object.entries(report.layerAdoption)
177
+ .map(([name, count]) => `${name}: armed in ${count}/${report.guildCount} server(s)`)
178
+ .join("\n");
179
+ container.addTextDisplayComponents(
180
+ new TextDisplayBuilder().setContent(`**Layer adoption across every server**\n${layerLines}`)
181
+ );
182
+ } else if (page === "servers") {
183
+ const lines = report.busiestGuilds.length
184
+ ? report.busiestGuilds.map((g, i) => `${i + 1}. ${g.name} (${g.guildId}) - ${g.count} events recorded`).join("\n")
185
+ : "No activity recorded yet.";
186
+ container.addTextDisplayComponents(
187
+ new TextDisplayBuilder().setContent(`**Busiest servers, ranked by event volume**\n${lines}`)
188
+ );
189
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
190
+ container.addTextDisplayComponents(
191
+ new TextDisplayBuilder().setContent(
192
+ "-# \"Events\" counts messages/joins/audit entries any layer processed for that server since this process started - it resets on restart, and isn't a measure of how much trouble a server is causing, just how much traffic it has."
193
+ )
194
+ );
195
+ } else if (page === "ai") {
196
+ const lines = [
197
+ `Groq available: ${report.ai.available ? "yes" : "no (no keys configured)"}`,
198
+ `Key pool: ${report.ai.keyCount} total, ${report.ai.keysOnCooldown} currently on cooldown`,
199
+ `Calls in the last minute: ${report.ai.callsLastMinute}`,
200
+ `Total classify() calls this run: ${report.ai.totalCallsRecorded}`,
201
+ `Verdict cache: ${report.metrics.caches.groqVerdicts.size} entries, ${report.metrics.caches.groqVerdicts.hits} hits / ${report.metrics.caches.groqVerdicts.misses} misses`
202
+ ].join("\n");
203
+ container.addTextDisplayComponents(new TextDisplayBuilder().setContent(`**AI moderation usage**\n${lines}`));
204
+ } else if (page === "performance") {
205
+ const m = report.metrics;
206
+ const fmtCache = (c) => `size ${c.size} | hits ${c.hits} | misses ${c.misses} | evictions ${c.evictions}`;
207
+ const fmtQueue = (q) => `active ${q.active}/${q.concurrency} | pending ${q.pending} | completed ${q.completed} | failed ${q.failed}`;
208
+ const latencyLines =
209
+ Object.entries(m.performance.avgLatencyMs)
210
+ .map(([name, ms]) => `${name}: ${ms}ms avg`)
211
+ .join("\n") || "no samples yet";
212
+
213
+ container.addTextDisplayComponents(
214
+ new TextDisplayBuilder().setContent(
215
+ `**Queues**\nAction queue: ${fmtQueue(m.queues.actions)}\nAI queue: ${fmtQueue(m.queues.ai)}`
216
+ )
217
+ );
218
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
219
+ container.addTextDisplayComponents(
220
+ new TextDisplayBuilder().setContent(
221
+ `**Caches**\nThreat scores: ${fmtCache(m.caches.threatScores)}\nOpen cases: ${fmtCache(m.caches.openCases)}\nGroq verdicts: ${fmtCache(m.caches.groqVerdicts)}`
222
+ )
223
+ );
224
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
225
+ container.addTextDisplayComponents(new TextDisplayBuilder().setContent(`**Average latency**\n${latencyLines}`));
226
+ } else if (page === "logs") {
227
+ const lines = report.recentLogs.length
228
+ ? report.recentLogs
229
+ .map((e) => `[${new Date(e.at).toISOString().slice(11, 19)}] ${e.guildName} - ${e.title}${e.layer ? ` (${e.layer})` : ""}`)
230
+ .join("\n")
231
+ : "Nothing logged yet.";
232
+ container.addTextDisplayComponents(
233
+ new TextDisplayBuilder().setContent(`**Recent activity, every server (last ${report.recentLogs.length})**\n${lines}`)
234
+ );
235
+ }
236
+
237
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
238
+ const row = new ActionRowBuilder().addComponents(
239
+ ENGINE_PAGES.map((p) =>
240
+ new ButtonBuilder()
241
+ .setCustomId(`gfp_engine_${p.id}`)
242
+ .setLabel(p.label)
243
+ .setStyle(p.id === page ? ButtonStyle.Success : ButtonStyle.Secondary)
244
+ )
245
+ );
246
+ container.addActionRowComponents(row);
247
+ container.addTextDisplayComponents(new TextDisplayBuilder().setContent("-# GlassFrame Protocol - owner-only view"));
248
+
249
+ return { flags: MessageFlags.IsComponentsV2, components: [container] };
250
+ }
251
+
252
+ module.exports = { buildPanel, buildMetricsMessage, buildEnginePage, ENGINE_PAGES, BUTTONS };
@@ -103,9 +103,9 @@ const PHISHING_LEXICON = {
103
103
  };
104
104
 
105
105
  const RAID_CALLOUT_LEXICON = {
106
- strong: new Set(["raid", "nuke", "spam", "flood", "wipe", "destroy", "invite", "bot"]),
106
+ strong: new Set(["raid", "raiding", "raids", "raided", "nuke", "nuking", "spam", "spamming", "flood", "wipe", "destroy", "invite", "bot"]),
107
107
  weak: new Set(["server", "join", "everyone", "ping", "mass"]),
108
- bigrams: new Set(["raid this", "nuke server", "mass ping", "spam bots", "join now"])
108
+ bigrams: new Set(["raid this", "nuke server", "mass ping", "spam bots", "join now", "start raiding"])
109
109
  };
110
110
 
111
111
  function suspiciousUsernameEntropy(username, floor, ceiling) {
@@ -37,6 +37,11 @@ windows with no need for hit/miss stats: `AntiRaidLayer.joinTimestamps` /
37
37
  are swept lazily - old timestamps are filtered out on the next write rather
38
38
  than on a timer, since they're only ever read right after a write.
39
39
 
40
+ `SmartLogger.recentLogs` is a fixed-size array (last 30, newest first, not
41
+ a `ProtocolCache`) recording every log event across every guild - it's what
42
+ powers the owner-only `!gf engine` dashboard's Activity Log page. See
43
+ `docs/ENGINE.md`.
44
+
40
45
  Bounded-concurrency queues (`frame.actionQueue`, `frame.aiQueue`) and the
41
46
  internal metrics collector (`frame.performance`) are a separate concern from
42
47
  caching - see `docs/PERFORMANCE.md`.
package/docs/COMMANDS.md CHANGED
@@ -18,7 +18,19 @@ running twice or getting a "slow down" reply of its own.
18
18
  | `!gf phishing list` | Lists every blocked domain. |
19
19
  | `!gf whitelist add <userId>` | Exempts a user from punitive action **in this server**. |
20
20
  | `!gf whitelist remove <userId>` | Removes that exemption for this server. |
21
- | `!gf help` | Lists these commands. |
21
+ | `!gf prefix set <newPrefix>` | Changes this server's own command prefix. |
22
+ | `!gf prefix reset` | Goes back to the default prefix. |
23
+ | `!gf help` | Lists these commands (this list, minus `engine` - see below). |
24
+
25
+ There is also an owner-only `!gf engine` command - deliberately left out of
26
+ `!gf help` and out of this table's normal flow on purpose. See
27
+ `docs/ENGINE.md`.
28
+
29
+ Each server can set its own prefix (`!gf prefix set`) - the default from
30
+ `config.prefix` always still works too, as a fallback, in case a server
31
+ forgets its custom one. If a custom prefix happens to overlap with the
32
+ default (e.g. custom `!` vs. default `!gf `), the longer one is tried
33
+ first.
22
34
 
23
35
  Everything above operates on the server the command was sent in - arming
24
36
  AntiRaid, whitelisting someone, or checking status in one server has no
package/docs/ENGINE.md ADDED
@@ -0,0 +1,83 @@
1
+ # The Engine Dashboard
2
+
3
+ `!gf engine` is a bot-wide, cross-server dashboard for whoever actually runs
4
+ the bot - not a per-server admin tool. It shows things a single server's
5
+ admin shouldn't be able to see about *other* servers (which server is
6
+ busiest, bot-wide AI usage, a live feed of what's happening everywhere), so
7
+ it has its own, separate permission model from every other command.
8
+
9
+ It is deliberately **not** listed in `!gf help` or `docs/COMMANDS.md`'s
10
+ in-Discord command table - a random server admin shouldn't even know it
11
+ exists. You're expected to learn about it from this file.
12
+
13
+ ## Who can use it
14
+
15
+ Two independent checks, not one:
16
+
17
+ 1. **`isOwner(userId)`** - you must be listed in `options.owners` when you
18
+ construct `GlassFrame`. This is required, full stop, for the command
19
+ itself *and* every page-navigation button click afterward.
20
+ 2. **A password** (optional) - if `config.engine.password` is set, the
21
+ initial `!gf engine <password>` command also needs the correct password.
22
+ Page-navigation button clicks after that don't re-prompt for it, since a
23
+ button click is already tied to a real, verified Discord identity - only
24
+ the text-command entry point needs the second factor.
25
+
26
+ ```js
27
+ const frame = new GlassFrame(client, {
28
+ getLogChannel: /* ... */,
29
+ owners: ["your-discord-user-id"],
30
+ config: {
31
+ engine: {
32
+ password: process.env.GLASSFRAME_ENGINE_PASSWORD // never hardcode a real one
33
+ }
34
+ }
35
+ });
36
+ ```
37
+
38
+ Leave `config.engine.password` unset to skip the second factor and rely on
39
+ the owners list alone.
40
+
41
+ ## What happens to the password
42
+
43
+ Typing a password into a normal Discord message means it briefly sits in
44
+ plain text in a channel, visible to anyone watching and to Discord's own
45
+ message history. GlassFrame does two things about that:
46
+
47
+ - The command message is **deleted immediately** after being checked,
48
+ whether the password was right or wrong.
49
+ - Wrong guesses count toward a **lockout** -
50
+ `config.engine.maxAttempts` wrong attempts (default 3) locks that user
51
+ out for `config.engine.lockoutMs` (default 10 minutes), making
52
+ brute-forcing impractical.
53
+
54
+ Use a private channel (or a channel only you can see) for this command
55
+ regardless - deletion happens right after Discord delivers the message, not
56
+ before anyone in the channel could have glimpsed it.
57
+
58
+ ## The five pages
59
+
60
+ One row of tab buttons switches between them; the active tab is highlighted.
61
+
62
+ | Page | Shows |
63
+ |---|---|
64
+ | **Overview** | Version, uptime, guild count, layer adoption across every server |
65
+ | **Servers** | The busiest servers ranked by event volume since this process started |
66
+ | **AI** | Groq key pool status, cooldowns, call volume, verdict cache hit rate |
67
+ | **Performance** | Action/AI queue depth, all three cache stats, average latency per operation |
68
+ | **Activity Log** | The last several things logged anywhere, any server, newest first |
69
+
70
+ All of it comes from one call: `frame.getEngineReport()`, if you want the
71
+ raw data instead of the rendered message (for your own dashboard, an API
72
+ endpoint, whatever).
73
+
74
+ ## "Busiest" and the activity log, precisely
75
+
76
+ - **Busiest** is a count of events (messages/joins/audit entries) any armed
77
+ layer processed for that server, tracked in memory since the process
78
+ started - it resets on restart and isn't a judgment about which server is
79
+ causing trouble, just which one has the most traffic.
80
+ - The **activity log** is the last 30 events logged anywhere (any server,
81
+ any layer), kept in memory by `SmartLogger` - see
82
+ `docs/CACHE_ARCHITECTURE.md`. The dashboard shows the most recent 10 of
83
+ those.
@@ -83,13 +83,52 @@ that server*, and again on demand via `!gf scan`. It reports flags, never
83
83
  actions - a human decides what, if anything, to do about a role that looks
84
84
  like impersonation bait or quietly holds dangerous permissions.
85
85
 
86
- ## Adding a fifth layer
87
-
88
- Extend `Layer`, implement `attach()` (register your event listener(s) once;
89
- gate all real work behind `this.isEnabled(guildId)`), report signals via
90
- `this.frame.punishmentEngine.report({ guild, member, layer, weight, reason })`,
91
- register the instance in `GlassFrame`'s `this.layers` map, and add a button
92
- in `src/ui/ControlPanel.js` if you want it on the panel. Nothing else in the
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.
86
+ ## Adding a layer without editing the library
87
+
88
+ `frame.registerLayer(name, layerInstance)` is the supported way to do this
89
+ now - no need to edit `GlassFrame.js`'s `this.layers` map by hand.
90
+ `layerInstance` must extend `core/Layer`: implement `attach()` (register
91
+ your event listener(s) once - it's called exactly once, at registration),
92
+ gate all real work behind `this.isEnabled(guildId)`, and report signals via
93
+ `this.frame.punishmentEngine.report({ guild, member, layer, weight, reason })`.
94
+ Per-guild enable/disable, state persistence, and `!gf status`/`!gf metrics`
95
+ all pick it up automatically, since they iterate `frame.layers` rather than
96
+ a fixed list. It does **not** get a button on the 5-button panel
97
+ automatically (that stays fixed at exactly 5) - toggle it with
98
+ `frame.enableLayer(name, guildId)` in code, or build your own command.
99
+
100
+ ```js
101
+ const Layer = require("glassframe-protocol/src/core/Layer");
102
+
103
+ class LinkAgeLayer extends Layer {
104
+ constructor(frame) { super("linkAge", frame); }
105
+ attach() {
106
+ this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
107
+ }
108
+ async _handle(message) {
109
+ if (!message.guild || message.author.bot) return;
110
+ if (!this.isEnabled(message.guild.id)) return;
111
+ // ... your detection logic, then:
112
+ // await this.frame.punishmentEngine.report({ guild: message.guild, member: message.member, layer: "linkAge", weight: 40, reason: "..." });
113
+ }
114
+ }
115
+
116
+ frame.registerLayer("linkAge", new LinkAgeLayer(frame));
117
+ ```
118
+
119
+ ## Custom punishment actions
120
+
121
+ `frame.registerAction(name, handler)` adds an action beyond the built-in
122
+ ban/kick/timeout/quarantine, so `config.punishment.ladder` can reference it
123
+ by name. `handler` is `async (member, record) => {}` and runs through the
124
+ same bounded-concurrency action queue as the built-in ones.
125
+
126
+ ```js
127
+ frame.registerAction("addMutedRole", async (member) => {
128
+ const role = member.guild.roles.cache.find((r) => r.name === "Muted");
129
+ if (role) await member.roles.add(role);
130
+ });
131
+ ```
132
+ ```js
133
+ config: { punishment: { ladder: { medium: "addMutedRole" } } }
134
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glassframe-protocol",
3
- "version": "2.0.0",
3
+ "version": "2.1.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": [