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.
@@ -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,7 +70,8 @@ class AntiRaidLayer extends Layer {
75
70
 
76
71
  async _handleJoin(member) {
77
72
  const { guild, user } = member;
78
- this.frame.performance.recordEvent("antiRaid.guildMemberAdd");
73
+ if (!this.isEnabled(guild.id)) return;
74
+ this.frame.performance.recordEvent("antiRaid.guildMemberAdd", guild.id);
79
75
  const joinCount = this._recordJoin(guild.id);
80
76
  const { score: altScore, entropyCheck } = this._altScore(user);
81
77
  const cluster = this._clusterScore(guild.id, user);
@@ -110,6 +106,7 @@ class AntiRaidLayer extends Layer {
110
106
  }
111
107
 
112
108
  await this.frame.logger.log(guild, {
109
+ layer: this.name,
113
110
  level: "alert",
114
111
  title: "Raid Detected - Lockdown Engaged",
115
112
  description: `${joinCount} joins inside ${this.config.joinWindowMs / 1000}s. Verification level temporarily raised.`,
@@ -133,6 +130,7 @@ class AntiRaidLayer extends Layer {
133
130
  }
134
131
 
135
132
  await this.frame.logger.log(guild, {
133
+ layer: this.name,
136
134
  level: "ok",
137
135
  title: "Lockdown Lifted",
138
136
  description: "Join activity normalized. Verification level restored.",
@@ -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
- this.frame.performance.recordEvent("basicSecurity.messageCreate");
28
+ this.frame.performance.recordEvent("basicSecurity.messageCreate", message.guild.id);
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);
@@ -45,6 +43,8 @@ class BasicSecurityLayer extends Layer {
45
43
  const mentionCount = message.mentions.users.size + message.mentions.roles.size;
46
44
  const classification = classifyMessage(message.content || "");
47
45
  const linkFlag = this._checkLinks(message.content || "");
46
+ const hasInvite = this._checkInviteLink(message.content || "");
47
+ const trust = this.frame.roleAnalyzer.trustLevel(message.member);
48
48
 
49
49
  const reasons = [];
50
50
  let weight = 0;
@@ -61,6 +61,14 @@ class BasicSecurityLayer extends Layer {
61
61
  reasons.push(`mention spam (${mentionCount})`);
62
62
  weight += 25;
63
63
  }
64
+ // A single @everyone/@here reaches every member at once - much higher
65
+ // impact than several individual mentions, so it's weighted heavier and
66
+ // checked separately. Recognized trusted staff are exempt, since a real
67
+ // admin's announcement ping is routine, not an attack.
68
+ if (message.mentions.everyone && trust !== "HIGH" && trust !== "PROTECTED") {
69
+ reasons.push("@everyone/@here mention from a non-trusted member");
70
+ weight += 50;
71
+ }
64
72
  if (classification.scam >= cfg.nlp.scamScoreThreshold) {
65
73
  reasons.push(`scam language (${classification.scam.toFixed(2)})`);
66
74
  weight += 35;
@@ -69,6 +77,20 @@ class BasicSecurityLayer extends Layer {
69
77
  reasons.push(`phishing language (${classification.phishing.toFixed(2)})`);
70
78
  weight += 35;
71
79
  }
80
+ if (classification.raidCallout >= cfg.nlp.raidCalloutThreshold) {
81
+ reasons.push(`raid-recruitment language (${classification.raidCallout.toFixed(2)})`);
82
+ weight += 35;
83
+ }
84
+ // The specific pattern in a lot of real raid-tool spam: an invite link
85
+ // to another server, paired with language recruiting people to raid
86
+ // with it. Neither signal alone is damning - plenty of normal messages
87
+ // share an invite, plenty of normal messages mention "raid" in passing
88
+ // - but together, from a non-trusted member, it's a strong, specific
89
+ // signal worth weighting heavily on its own.
90
+ if (hasInvite && classification.raidCallout >= cfg.nlp.raidCalloutThreshold && trust !== "HIGH" && trust !== "PROTECTED") {
91
+ reasons.push("Discord invite link paired with raid-recruitment language");
92
+ weight += 50;
93
+ }
72
94
  if (linkFlag) {
73
95
  reasons.push(linkFlag);
74
96
  weight += 30;
@@ -106,6 +128,11 @@ class BasicSecurityLayer extends Layer {
106
128
  }
107
129
  return null;
108
130
  }
131
+
132
+ /** True if the message contains a Discord server invite link, of any of the URL forms Discord accepts. */
133
+ _checkInviteLink(text) {
134
+ return /(?:discord\.gg|discord(?:app)?\.com\/invite)\/[a-z0-9-]+/i.test(text);
135
+ }
109
136
  }
110
137
 
111
138
  module.exports = BasicSecurityLayer;
@@ -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,21 +21,26 @@ 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
30
37
  // limits. Falls back to running inline if no queue is supplied.
31
38
  this.queue = queue || { push: (task) => task() };
32
39
  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 || []);
40
+ // Accept the same Map the rest of GlassFrame uses (guildId -> Set<userId>),
41
+ // by reference, not a copy, so runtime edits (e.g. !gf whitelist add)
42
+ // apply immediately here too.
43
+ this.whitelist = whitelist;
36
44
  this.cases = new ProtocolCache("open-cases", { ttlMs: this.config.caseCooldownMs, debug });
37
45
  }
38
46
 
@@ -47,7 +55,7 @@ class PunishmentEngine extends EventEmitter {
47
55
  async report({ guild, member, layer, weight, reason }) {
48
56
  if (!member) return null;
49
57
  const userId = member.id;
50
- if (this.whitelist.has(userId)) return null;
58
+ if (this.whitelist.get(guild.id)?.has(userId)) return null;
51
59
 
52
60
  const { score, tier } = this.threatEngine.addSignal(guild.id, userId, { layer, weight, reason });
53
61
  if (tier === "none") return null;
@@ -66,7 +74,7 @@ class PunishmentEngine extends EventEmitter {
66
74
 
67
75
  const trust = this.roleAnalyzer.trustLevel(member);
68
76
  const nextAction = this._decideAction(tier, trust);
69
- if (ACTION_SEVERITY[nextAction] > ACTION_SEVERITY[openCase.action]) {
77
+ if (severityOf(nextAction) > severityOf(openCase.action)) {
70
78
  openCase.action = nextAction;
71
79
  openCase.trust = trust;
72
80
  this.cases.set(key, openCase);
@@ -124,6 +132,9 @@ class PunishmentEngine extends EventEmitter {
124
132
  const removable = member.roles.cache.filter((r) => r.id !== guild.id && r.editable);
125
133
  await this.queue.push(() => member.roles.remove(removable, this._reason(record)));
126
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;
127
138
  }
128
139
  } catch (err) {
129
140
  record.error = err.message;
@@ -140,6 +151,7 @@ class PunishmentEngine extends EventEmitter {
140
151
  await this.logger.log(guild, {
141
152
  level: action === "alert" ? "alert" : action === "log" ? "info" : "warn",
142
153
  title: this._title(action),
154
+ layer: "punishmentEngine",
143
155
  description: `${member.user.tag} (${member.id}) - tier **${record.tier}**, trust **${record.trust}**.`,
144
156
  fields: [
145
157
  { name: "Reasons", value: record.reasons.slice(-5).join("\n") },
@@ -55,4 +55,198 @@ 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
+ 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
+
82
+ fields.push(
83
+ { name: "Uptime", value: `${Math.round(m.performance.uptimeMs / 60000)} min` },
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" }
99
+ );
100
+ } else {
101
+ const gm = frame.getGuildMetrics(guildId);
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;
104
+
105
+ fields.push(
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
+ }
122
+ );
123
+ }
124
+
125
+ const nextScope = isGlobal ? "guild" : "global";
126
+ const toggleButton = new ButtonBuilder()
127
+ .setCustomId(`gfp_metrics_${nextScope}`)
128
+ .setLabel(isGlobal ? "Show This Server" : "Show Global (All Servers)")
129
+ .setStyle(ButtonStyle.Secondary);
130
+ const row = new ActionRowBuilder().addComponents(toggleButton);
131
+
132
+ const container = new ContainerBuilder().setAccentColor(0x5865f2);
133
+ container.addTextDisplayComponents(
134
+ new TextDisplayBuilder().setContent(
135
+ isGlobal ? "**GlassFrame Protocol - Global Metrics**" : "**GlassFrame Protocol - This Server's Metrics**"
136
+ )
137
+ );
138
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
139
+ container.addTextDisplayComponents(
140
+ new TextDisplayBuilder().setContent(fields.map((f) => `**${f.name}**\n${f.value}`).join("\n\n"))
141
+ );
142
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
143
+ container.addActionRowComponents(row);
144
+ container.addTextDisplayComponents(new TextDisplayBuilder().setContent("-# GlassFrame Protocol"));
145
+
146
+ return { flags: MessageFlags.IsComponentsV2, components: [container] };
147
+ }
148
+
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) {
@@ -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
 
@@ -33,6 +37,11 @@ windows with no need for hit/miss stats: `AntiRaidLayer.joinTimestamps` /
33
37
  are swept lazily - old timestamps are filtered out on the next write rather
34
38
  than on a timer, since they're only ever read right after a write.
35
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
+
36
45
  Bounded-concurrency queues (`frame.actionQueue`, `frame.aiQueue`) and the
37
46
  internal metrics collector (`frame.performance`) are a separate concern from
38
47
  caching - see `docs/PERFORMANCE.md`.
package/docs/COMMANDS.md CHANGED
@@ -9,30 +9,56 @@ 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. |
21
- | `!gf help` | Lists these commands. |
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
+ | `!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.
34
+
35
+ Everything above operates on the server the command was sent in - arming
36
+ AntiRaid, whitelisting someone, or checking status in one server has no
37
+ effect on any other server the bot is in. See `docs/STATE.md`.
22
38
 
23
39
  ## The control panel
24
40
 
25
- `!gf panel` sends a message with exactly five buttons in one row:
41
+ `!gf panel` sends a message with exactly five buttons in one row, scoped to
42
+ the server it was sent in:
26
43
 
27
44
  1. **Activate/Deactivate AntiRaid**
28
45
  2. **Activate/Deactivate AI Moderation**
29
46
  3. **Activate/Deactivate AntiNuke**
30
47
  4. **Activate/Deactivate Basic Security**
31
- 5. **Activate/Deactivate Full Protocol** - arms or disarms all four at once
48
+ 5. **Activate/Deactivate Full Protocol** - arms or disarms all four at once, for this server only
32
49
 
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
50
+ Every layer starts disabled in every server - nothing runs until it's
51
+ explicitly activated, either from the panel or via
52
+ `frame.enableLayer("antiRaid", guildId)` in code. Button labels flip
53
+ between Activate/Deactivate based on that server's current state, and the
36
54
  panel message updates in place on every click rather than posting a new
37
55
  message each time. Clicking a button also drops one aggregated log line in
38
56
  the security log channel noting who changed what.
57
+
58
+ ## The metrics button
59
+
60
+ `!gf metrics` (and the panel's numbers, indirectly) has one button of its
61
+ own, separate from the panel's five: **Show Global (All Servers)** /
62
+ **Show This Server**, flipping the message between this server's counts
63
+ (open cases, flagged members, active layers, whitelist size) and bot-wide
64
+ totals across every server (queue depth, cache stats, average latency).