glassframe-protocol 2.0.0 → 2.2.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.
@@ -2,12 +2,23 @@
2
2
 
3
3
  const { buildLogMessage } = require("./ComponentsV2");
4
4
 
5
+ const RECENT_LOG_LIMIT = 30;
6
+
5
7
  /**
6
8
  * Every GlassFrame alert goes through one gate that:
7
9
  * (a) merges near-duplicate events inside a short window into a single,
8
- * updated message instead of one message per event, and
10
+ * updated message instead of one message per event,
9
11
  * (b) never sends to a guild's log channel faster than a configured floor,
10
- * so a burst never turns into a wall of messages or a Discord 429.
12
+ * so a burst never turns into a wall of messages or a Discord 429, and
13
+ * (c) keeps a small in-memory record of the most recent events across
14
+ * EVERY guild, for the owner-only `!gf engine` dashboard - see
15
+ * getRecentLogs() below.
16
+ *
17
+ * `getLogChannel(guild, layerName)` receives the reporting layer's name as
18
+ * an optional second argument, so you can route different layers to
19
+ * different channels if you want. A `getLogChannel` that only takes one
20
+ * parameter (the common case) keeps working unchanged - JS simply ignores
21
+ * the extra argument.
11
22
  */
12
23
  class SmartLogger {
13
24
  constructor(getLogChannel, config, { debug = false } = {}) {
@@ -16,10 +27,12 @@ class SmartLogger {
16
27
  this.debug = debug;
17
28
  this.pending = new Map(); // dedupeKey -> { count, payload, sent }
18
29
  this.lastSend = new Map(); // guildId -> timestamp
30
+ this.recentLogs = []; // bounded ring buffer, newest first - see getRecentLogs()
19
31
  }
20
32
 
21
33
  async log(guild, payload) {
22
34
  const key = payload.dedupeKey ? `${guild.id}:${payload.dedupeKey}` : null;
35
+ this._recordRecent(guild, payload);
23
36
 
24
37
  if (key && this.pending.has(key)) {
25
38
  const entry = this.pending.get(key);
@@ -56,7 +69,7 @@ class SmartLogger {
56
69
 
57
70
  async _deliver(guild, payload, count) {
58
71
  await this._respectRateFloor(guild.id);
59
- const channel = await this.getLogChannel(guild);
72
+ const channel = await this.getLogChannel(guild, payload.layer);
60
73
  if (!channel) return null;
61
74
 
62
75
  const message = buildLogMessage({ ...payload, count });
@@ -76,6 +89,23 @@ class SmartLogger {
76
89
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
77
90
  }
78
91
 
92
+ _recordRecent(guild, payload) {
93
+ this.recentLogs.unshift({
94
+ at: Date.now(),
95
+ guildId: guild.id,
96
+ guildName: guild.name,
97
+ level: payload.level || "info",
98
+ title: payload.title || "",
99
+ layer: payload.layer || null
100
+ });
101
+ if (this.recentLogs.length > RECENT_LOG_LIMIT) this.recentLogs.length = RECENT_LOG_LIMIT;
102
+ }
103
+
104
+ /** The most recent log events across every guild, newest first - powers the owner-only `!gf engine` command. */
105
+ getRecentLogs(limit = 10) {
106
+ return this.recentLogs.slice(0, limit);
107
+ }
108
+
79
109
  _debug(msg) {
80
110
  if (this.debug) console.log(`[GlassFrame:SmartLogger] ${msg}`);
81
111
  }
@@ -5,8 +5,11 @@ const ProtocolCache = require("../core/Cache");
5
5
 
6
6
  // Used only to decide whether continued bad behavior mid-case deserves a
7
7
  // stronger response - never to decide the response itself (_decideAction
8
- // still owns that, trust-gating included).
8
+ // still owns that, trust-gating included). Custom actions (registerAction)
9
+ // default to 2 - the same tier as quarantine/timeout - since there's no
10
+ // way to know their real severity automatically.
9
11
  const ACTION_SEVERITY = { log: 0, alert: 1, quarantine: 2, timeout: 2, kick: 3, ban: 4 };
12
+ const severityOf = (action) => ACTION_SEVERITY[action] ?? 2;
10
13
 
11
14
  /**
12
15
  * The only place in GlassFrame Protocol that actually punishes a member.
@@ -18,12 +21,16 @@ const ACTION_SEVERITY = { log: 0, alert: 1, quarantine: 2, timeout: 2, kick: 3,
18
21
  * ladder alone would say.
19
22
  */
20
23
  class PunishmentEngine extends EventEmitter {
21
- constructor({ config, threatEngine, roleAnalyzer, logger, whitelist, queue, performance, debug = false }) {
24
+ constructor({ config, threatEngine, roleAnalyzer, logger, whitelist, customActions, queue, performance, debug = false }) {
22
25
  super();
23
26
  this.config = config.punishment;
24
27
  this.threatEngine = threatEngine;
25
28
  this.roleAnalyzer = roleAnalyzer;
26
29
  this.logger = logger;
30
+ // actionName -> async (member, record) => void, registered via
31
+ // frame.registerAction() so config.punishment.ladder can reference
32
+ // custom actions beyond ban/kick/timeout/quarantine.
33
+ this.customActions = customActions || new Map();
27
34
  // Actual Discord mutations (ban/kick/timeout/quarantine) go through this
28
35
  // bounded-concurrency queue rather than firing directly, so a burst of
29
36
  // simultaneous cases (a raid, a nuke sweep) can't overrun Discord's rate
@@ -67,7 +74,7 @@ class PunishmentEngine extends EventEmitter {
67
74
 
68
75
  const trust = this.roleAnalyzer.trustLevel(member);
69
76
  const nextAction = this._decideAction(tier, trust);
70
- if (ACTION_SEVERITY[nextAction] > ACTION_SEVERITY[openCase.action]) {
77
+ if (severityOf(nextAction) > severityOf(openCase.action)) {
71
78
  openCase.action = nextAction;
72
79
  openCase.trust = trust;
73
80
  this.cases.set(key, openCase);
@@ -125,6 +132,9 @@ class PunishmentEngine extends EventEmitter {
125
132
  const removable = member.roles.cache.filter((r) => r.id !== guild.id && r.editable);
126
133
  await this.queue.push(() => member.roles.remove(removable, this._reason(record)));
127
134
  performed = true;
135
+ } else if (this.customActions.has(action) && canAct) {
136
+ await this.queue.push(() => this.customActions.get(action)(member, record));
137
+ performed = true;
128
138
  }
129
139
  } catch (err) {
130
140
  record.error = err.message;
@@ -141,6 +151,7 @@ class PunishmentEngine extends EventEmitter {
141
151
  await this.logger.log(guild, {
142
152
  level: action === "alert" ? "alert" : action === "log" ? "info" : "warn",
143
153
  title: this._title(action),
154
+ layer: "punishmentEngine",
144
155
  description: `${member.user.tag} (${member.id}) - tier **${record.tier}**, trust **${record.trust}**.`,
145
156
  fields: [
146
157
  { name: "Reasons", value: record.reasons.slice(-5).join("\n") },
@@ -39,6 +39,32 @@ class RoleAnalyzer {
39
39
  return "LOW";
40
40
  }
41
41
 
42
+ /**
43
+ * A DIFFERENT question from trustLevel() above: not "does this member
44
+ * hold real authority," but "has this account and this membership simply
45
+ * existed long enough to not look like a fresh raid/alt account." No
46
+ * relation to roles or permissions - a totally ordinary member with no
47
+ * special role can be "established," and a MEDIUM-trust role holder with
48
+ * a week-old account isn't.
49
+ *
50
+ * This exists to soften pure rate/volume spam detection (see
51
+ * BasicSecurityLayer) for members who are simply very active, the way
52
+ * modern invisible CAPTCHA judges from signals already available instead
53
+ * of a one-size-fits-all challenge - it is deliberately NOT used to
54
+ * soften content-based detection (scam/phishing/raid language, links),
55
+ * since account age says nothing about whether a specific message is
56
+ * dangerous. An established account can still raid or spam; this only
57
+ * ever reduces false positives on "sends a lot of messages," never
58
+ * blanket-exempts anyone from everything else.
59
+ */
60
+ isEstablishedMember(member) {
61
+ if (!member || !member.user) return false;
62
+ const cfg = this.config.maturity;
63
+ const accountAge = Date.now() - member.user.createdTimestamp;
64
+ const serverTenure = member.joinedTimestamp ? Date.now() - member.joinedTimestamp : 0;
65
+ return accountAge >= cfg.minAccountAgeMs && serverTenure >= cfg.minServerTenureMs;
66
+ }
67
+
42
68
  /**
43
69
  * Scans a guild's current roles for name/permission mismatches: a role
44
70
  * whose name signals authority ("admin", "staff", ...) but carries no real
@@ -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,76 @@ 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
+ ```
135
+
136
+ ## Established-member leniency
137
+
138
+ A separate axis from role-based trust, for a question role trust can't
139
+ answer: not "does this member have authority," but "has this account and
140
+ this membership simply existed long enough to not look like a fresh raid
141
+ account." `RoleAnalyzer.isEstablishedMember(member)` checks account age and
142
+ server tenure against `roleAnalysis.maturity` (defaults: 6 months, 1
143
+ month). A totally ordinary member with no special role can be
144
+ "established"; a MEDIUM-trust role holder with a week-old account isn't.
145
+
146
+ `BasicSecurityLayer` is the only place this is used, and only for the two
147
+ pure rate/volume checks (message rate, duplicate flood) -
148
+ `basicSecurity.establishedMemberLeniency` raises the threshold before
149
+ either fires at all and reduces the weight when it does, and neither
150
+ deletes an established member's message on its own. Every content-based
151
+ check (scam/phishing/raid-recruitment language, links, non-trusted mass
152
+ pings) is completely unaffected by tenure - full threshold, full weight,
153
+ message still deleted - since how long an account has existed says nothing
154
+ about whether a specific message is dangerous. This is deliberate: the
155
+ point is recognizing "this is just a very active regular," not granting
156
+ immunity. An established account can still raid, spam, or get compromised,
157
+ and will still be caught the moment it does anything content-wise, not
158
+ just volume-wise.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glassframe-protocol",
3
- "version": "2.0.0",
3
+ "version": "2.2.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": [