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.
@@ -1,16 +1,25 @@
1
1
  "use strict";
2
2
 
3
- const { buildPanel, buildMetricsMessage } = require("../ui/ControlPanel");
3
+ const { buildPanel, buildMetricsMessage, buildEnginePage } = require("../ui/ControlPanel");
4
4
  const { buildLogMessage } = require("../logging/ComponentsV2");
5
5
 
6
- const LAYER_NAMES = ["antiRaid", "antiNuke", "basicSecurity", "aiModeration"];
7
- const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing"];
6
+ const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing", "prefix", "engine"];
7
+ const OWNER_ONLY_COMMANDS = new Set(["engine"]);
8
+
9
+ const LAYER_REASONING = {
10
+ basicSecurity: "watches every message for spam, scam/phishing language, raid-recruitment language, and bad links.",
11
+ antiRaid: "watches joins for rate spikes, alt-account patterns, and account-creation clusters.",
12
+ antiNuke: "watches the audit log for destructive bursts, dangerous permission grants, and channel lockouts.",
13
+ aiModeration: "sends only ambiguous messages (not clean, not clearly bad) to Groq for a second opinion."
14
+ };
8
15
 
9
16
  /**
10
17
  * GlassFrame Protocol registers no slash commands, by design (see
11
18
  * docs/COMMANDS.md). Every command here is a plain prefix command gated on
12
19
  * Manage Server permission, with a short per-user cooldown so a doubled
13
- * keypress can't fire the same command twice in a row.
20
+ * keypress can't fire the same command twice in a row. Each server can set
21
+ * its own prefix (`!gf prefix set`); the default from config.js always
22
+ * still works too, as a fallback.
14
23
  */
15
24
  class PrefixRouter {
16
25
  constructor(frame) {
@@ -32,16 +41,25 @@ class PrefixRouter {
32
41
  return false;
33
42
  }
34
43
 
44
+ /** Tries this server's custom prefix (if set) and the default, longer one first, so an overlapping pair resolves sensibly. */
45
+ _matchPrefix(message) {
46
+ const custom = this.frame.guildPrefixes.get(message.guild.id);
47
+ const candidates = [custom, this.frame.config.prefix].filter(Boolean).sort((a, b) => b.length - a.length);
48
+ return candidates.find((p) => message.content.toLowerCase().startsWith(p.toLowerCase())) || null;
49
+ }
50
+
35
51
  async _handle(message) {
36
52
  if (!message.guild || message.author.bot) return;
37
- const prefix = this.frame.config.prefix;
38
- if (!message.content.toLowerCase().startsWith(prefix.toLowerCase())) return;
53
+ const prefix = this._matchPrefix(message);
54
+ if (!prefix) return;
39
55
 
40
56
  const args = message.content.slice(prefix.length).trim().split(/\s+/);
41
57
  const command = (args.shift() || "").toLowerCase();
42
58
  if (!COMMANDS.includes(command)) return;
43
59
 
44
- if (!this.frame.isAuthorized(message.member)) {
60
+ if (OWNER_ONLY_COMMANDS.has(command)) {
61
+ if (!this.frame.isOwner(message.author.id)) return; // silent - this command doesn't exist as far as non-owners can tell
62
+ } else if (!this.frame.isAuthorized(message.member)) {
45
63
  await message.reply({ content: "You need Manage Server permission to use GlassFrame Protocol commands." });
46
64
  return;
47
65
  }
@@ -55,6 +73,8 @@ class PrefixRouter {
55
73
  if (command === "scan") return this._scan(message);
56
74
  if (command === "metrics") return this._metrics(message);
57
75
  if (command === "phishing") return this._phishing(message, args);
76
+ if (command === "prefix") return this._prefix(message, args);
77
+ if (command === "engine") return this._engine(message, args);
58
78
  }
59
79
 
60
80
  async _panel(message) {
@@ -63,36 +83,56 @@ class PrefixRouter {
63
83
  this.frame.panelMessages.set(sent.id, message.guild.id);
64
84
  }
65
85
 
86
+ /** Each layer's line explains what it actually watches for, not just on/off - the same reasoning-first approach as metrics. */
66
87
  async _status(message) {
67
88
  const status = this.frame.getStatus(message.guild.id);
68
- const fields = LAYER_NAMES.map((l) => ({ name: l, value: status[l] ? "ACTIVE" : "INACTIVE" }));
89
+ const fields = Object.keys(this.frame.layers).map((name) => ({
90
+ name: `${status[name] ? "ACTIVE" : "INACTIVE"} - ${name}`,
91
+ value: LAYER_REASONING[name] || "a custom layer registered via frame.registerLayer()."
92
+ }));
69
93
  const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Status", fields });
70
94
  await message.channel.send(payload);
71
95
  }
72
96
 
73
97
  async _help(message) {
74
- const prefix = this.frame.config.prefix;
75
- const payload = buildLogMessage({
76
- level: "info",
77
- title: "GlassFrame Protocol - Commands",
78
- description: [
79
- `${prefix}panel - open the 5-button control panel`,
80
- `${prefix}status - show which layers are active`,
81
- `${prefix}scan - check current roles for name/permission mismatches`,
82
- `${prefix}metrics - this server's metrics (button on the message switches to global)`,
83
- `${prefix}phishing add|remove|list <domain> - manage the link blocklist`,
84
- `${prefix}whitelist add <userId> - exempt a user from punitive action in this server`,
85
- `${prefix}whitelist remove <userId> - remove an exemption`,
86
- `${prefix}help - this message`
87
- ].join("\n")
88
- });
98
+ const prefix = this.frame.getPrefix(message.guild.id);
99
+ const sections = [
100
+ {
101
+ name: "Getting started",
102
+ value: [
103
+ `${prefix}panel - the 5-button control panel. Nothing runs until a layer is armed here (or via autoStart in code).`,
104
+ `${prefix}status - which layers are on right now, and what each one actually watches for.`
105
+ ].join("\n")
106
+ },
107
+ {
108
+ name: "Security tools",
109
+ value: [
110
+ `${prefix}scan - checks current roles for name/permission mismatches (impersonation bait, quiet privilege grants).`,
111
+ `${prefix}phishing add|remove|list <domain> - manage the link blocklist Basic Security checks messages against.`,
112
+ `${prefix}whitelist add|remove <userId> - exempt someone from punitive action, in this server only.`
113
+ ].join("\n")
114
+ },
115
+ {
116
+ name: "Diagnostics",
117
+ value: `${prefix}metrics - this server's numbers (open cases, flagged members, whitelist size); one button on the message switches to bot-wide totals across every server.`
118
+ },
119
+ {
120
+ name: "Customization",
121
+ value: [
122
+ `${prefix}prefix set <newPrefix> - change this server's command prefix.`,
123
+ `${prefix}prefix reset - go back to the default (\`${this.frame.config.prefix}\`).`
124
+ ].join("\n")
125
+ }
126
+ ];
127
+ const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Help", fields: sections });
89
128
  await message.channel.send(payload);
90
129
  }
91
130
 
92
131
  async _whitelist(message, args) {
132
+ const prefix = this.frame.getPrefix(message.guild.id);
93
133
  const [sub, id] = args;
94
134
  if (!sub || !id) {
95
- await message.reply({ content: `Usage: ${this.frame.config.prefix}whitelist <add|remove> <userId>` });
135
+ await message.reply({ content: `Usage: ${prefix}whitelist <add|remove> <userId>` });
96
136
  return;
97
137
  }
98
138
 
@@ -102,7 +142,7 @@ class PrefixRouter {
102
142
  else return;
103
143
 
104
144
  await message.reply({
105
- content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list.`
145
+ content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list for this server.`
106
146
  });
107
147
  }
108
148
 
@@ -126,8 +166,8 @@ class PrefixRouter {
126
166
  }
127
167
 
128
168
  async _phishing(message, args) {
169
+ const prefix = this.frame.getPrefix(message.guild.id);
129
170
  const [sub, domain] = args;
130
- const prefix = this.frame.config.prefix;
131
171
 
132
172
  if (!sub || sub.toLowerCase() === "list") {
133
173
  const domains = this.frame.phishingDatabase.list();
@@ -153,6 +193,60 @@ class PrefixRouter {
153
193
  await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
154
194
  }
155
195
  }
196
+
197
+ async _prefix(message, args) {
198
+ const [sub, newPrefix] = args;
199
+ const current = this.frame.getPrefix(message.guild.id);
200
+
201
+ if (!sub) {
202
+ await message.reply({ content: `This server's prefix is currently \`${current}\`. Usage: ${current}prefix set <newPrefix> | reset` });
203
+ return;
204
+ }
205
+
206
+ const action = sub.toLowerCase();
207
+ if (action === "reset") {
208
+ this.frame.guildPrefixes.delete(message.guild.id);
209
+ this.frame._persistGuildState(message.guild.id);
210
+ await message.reply({ content: `Prefix reset to the default: \`${this.frame.config.prefix}\`` });
211
+ return;
212
+ }
213
+
214
+ if (action === "set") {
215
+ if (!newPrefix) {
216
+ await message.reply({ content: `Usage: ${current}prefix set <newPrefix>` });
217
+ return;
218
+ }
219
+ this.frame.setPrefix(message.guild.id, newPrefix);
220
+ await message.reply({
221
+ content: `This server's prefix is now \`${newPrefix}\`. The default \`${this.frame.config.prefix}\` still works too, as a fallback.`
222
+ });
223
+ return;
224
+ }
225
+
226
+ await message.reply({ content: `Usage: ${current}prefix set <newPrefix> | reset` });
227
+ }
228
+
229
+ /**
230
+ * Owner-only, bot-wide dashboard - see frame.getEngineReport(). Not
231
+ * listed in !gf help on purpose. If config.engine.password is set, it's
232
+ * required as `!gf engine <password>`; the command message is deleted
233
+ * either way (right or wrong) so the password never sits visible in
234
+ * channel history. Wrong-password attempts count toward a lockout - see
235
+ * GlassFrame.verifyEnginePassword().
236
+ */
237
+ async _engine(message, args) {
238
+ const passwordOk = this.frame.verifyEnginePassword(message.author.id, args.join(" "));
239
+ try {
240
+ if (message.deletable) await message.delete();
241
+ } catch {
242
+ /* best effort - if the bot can't delete here, that's a permissions gap to fix, not a reason to fail open */
243
+ }
244
+ if (!passwordOk) return;
245
+
246
+ const report = this.frame.getEngineReport();
247
+ const payload = buildEnginePage(report, "overview");
248
+ await message.channel.send(payload);
249
+ }
156
250
  }
157
251
 
158
252
  module.exports = PrefixRouter;
@@ -3,19 +3,31 @@
3
3
  /**
4
4
  * Lightweight internal metrics collector - no external dependency, no
5
5
  * network call, just counters and a small rolling latency sample per event
6
- * type. Exposed via `frame.getMetrics()` / `!gf metrics` so you can see
7
- * whether the protocol is keeping up on a busy server without wiring in a
8
- * separate APM tool.
6
+ * type, plus a lightweight per-guild activity tally. Exposed via
7
+ * `frame.getMetrics()` / `!gf metrics` (per-server and bot-wide) and the
8
+ * owner-only `!gf engine` dashboard, so you can see whether the protocol is
9
+ * keeping up - and which server is generating the most work - without
10
+ * wiring in a separate APM tool.
9
11
  */
10
12
  class PerformanceMonitor {
11
13
  constructor() {
12
14
  this.startedAt = Date.now();
13
15
  this.eventCounts = {};
14
16
  this.latencies = {}; // name -> last 50 durations in ms
17
+ this.guildActivity = new Map(); // guildId -> event count since startup
15
18
  }
16
19
 
17
- recordEvent(name) {
20
+ /** `guildId` is optional - pass it from any per-guild handler to also tally that guild's activity. */
21
+ recordEvent(name, guildId) {
18
22
  this.eventCounts[name] = (this.eventCounts[name] || 0) + 1;
23
+ if (guildId) {
24
+ this.guildActivity.set(guildId, (this.guildActivity.get(guildId) || 0) + 1);
25
+ }
26
+ }
27
+
28
+ /** Guild IDs ranked by recorded activity, busiest first. */
29
+ topGuildsByActivity(limit = 5) {
30
+ return [...this.guildActivity.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
19
31
  }
20
32
 
21
33
  /** Call at the start of a unit of work; call the returned function when it finishes. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- const VERSION = "2.0.0";
3
+ const VERSION = "2.1.0";
4
4
  const NAME = "GlassFrame Protocol";
5
5
  const RELEASED = "2026-07-31";
6
6
 
@@ -43,6 +43,7 @@ class AIModerationLayer extends Layer {
43
43
  if (!message.guild || message.author.bot || !message.content) return;
44
44
  if (!this.isEnabled(message.guild.id)) return;
45
45
  if (!this.groq?.available) return;
46
+ this.frame.performance.recordEvent("aiModeration.messageCreate", message.guild.id);
46
47
 
47
48
  const cfg = this.config;
48
49
  const local = classifyMessage(message.content);
@@ -19,7 +19,10 @@ const EVENT_MAP = {
19
19
  [AuditLogEvent.EmojiCreate]: "emojiCreate",
20
20
  [AuditLogEvent.EmojiDelete]: "emojiDelete",
21
21
  [AuditLogEvent.StickerCreate]: "stickerCreate",
22
- [AuditLogEvent.StickerDelete]: "stickerDelete"
22
+ [AuditLogEvent.StickerDelete]: "stickerDelete",
23
+ [AuditLogEvent.MemberRoleUpdate]: "memberRoleUpdate",
24
+ [AuditLogEvent.ChannelOverwriteCreate]: "channelOverwriteCreate",
25
+ [AuditLogEvent.ChannelOverwriteUpdate]: "channelOverwriteUpdate"
23
26
  };
24
27
 
25
28
  class AntiNukeLayer extends Layer {
@@ -69,7 +72,7 @@ class AntiNukeLayer extends Layer {
69
72
  const type = EVENT_MAP[entry.action];
70
73
  if (!type) return;
71
74
  if (!this.isEnabled(guild.id)) return;
72
- this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`);
75
+ this.frame.performance.recordEvent(`antiNuke.auditEntry.${type}`, guild.id);
73
76
 
74
77
  const executor = entry.executor;
75
78
  if (!executor || executor.id === this.client.user.id) return;
@@ -85,6 +88,12 @@ class AntiNukeLayer extends Layer {
85
88
  if (type === "inviteCreate" && this.config.watchInviteAbuse) {
86
89
  await this._checkInviteAbuse(entry, guild, executor);
87
90
  }
91
+ if (type === "memberRoleUpdate" && this.config.watchDangerousGrants) {
92
+ await this._checkMemberRoleGrant(entry, guild, executor);
93
+ }
94
+ if ((type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") && this.config.watchOverwriteAbuse) {
95
+ await this._checkChannelOverwriteAbuse(entry, guild, executor);
96
+ }
88
97
  if (type === "webhookCreate" && this.config.webhookAbuse.enabled) {
89
98
  this.recentWebhooks.set(
90
99
  entry.targetId,
@@ -93,11 +102,10 @@ class AntiNukeLayer extends Layer {
93
102
  );
94
103
  }
95
104
 
96
- // Invite creation doesn't count toward the generic burst threshold below -
97
- // a legitimate server creates invites routinely, so it's special-cased
98
- // above (and only escalated for non-staff + unrestricted configs)
99
- // instead of contributing to a raw count.
100
- if (type === "inviteCreate") return;
105
+ // These are special-cased above instead of contributing to a raw burst
106
+ // count - a single grant/overwrite change is worth flagging immediately
107
+ // on its own merits, not after N repeats.
108
+ if (type === "inviteCreate" || type === "memberRoleUpdate" || type === "channelOverwriteCreate" || type === "channelOverwriteUpdate") return;
101
109
 
102
110
  const count = this._record(guild.id, executor.id, type);
103
111
  const threshold = this.config.thresholds[type];
@@ -138,6 +146,7 @@ class AntiNukeLayer extends Layer {
138
146
  const role = guild.roles.cache.get(entry.targetId);
139
147
 
140
148
  await this.frame.logger.log(guild, {
149
+ layer: this.name,
141
150
  level: "alert",
142
151
  title: "Dangerous Permission Grant Detected",
143
152
  description: `${executor.tag} granted **${gained.join(", ")}** to role ${role ? role.name : entry.targetId}.`,
@@ -162,6 +171,7 @@ class AntiNukeLayer extends Layer {
162
171
  if (!changed.length) return;
163
172
 
164
173
  await this.frame.logger.log(guild, {
174
+ layer: this.name,
165
175
  level: "warn",
166
176
  title: "Server Identity Changed",
167
177
  description: `${executor.tag} changed: ${changed.map((c) => c.key).join(", ")}.`,
@@ -193,6 +203,7 @@ class AntiNukeLayer extends Layer {
193
203
  if (trust === "HIGH" || trust === "PROTECTED") return;
194
204
 
195
205
  await this.frame.logger.log(guild, {
206
+ layer: this.name,
196
207
  level: "warn",
197
208
  title: "Unrestricted Invite Created By Non-Staff Member",
198
209
  description: `${executor.tag} (trust: ${trust}) created an invite with no expiry and no use limit.`,
@@ -200,6 +211,86 @@ class AntiNukeLayer extends Layer {
200
211
  });
201
212
  }
202
213
 
214
+ /**
215
+ * RoleUpdate (above) catches a role's own permissions changing - this
216
+ * catches the other half: a specific MEMBER being handed a role that
217
+ * already carries dangerous permissions. Missing this meant someone
218
+ * with ManageRoles could quietly promote themselves (or an accomplice)
219
+ * without ever touching a role's definition.
220
+ */
221
+ async _checkMemberRoleGrant(entry, guild, executor) {
222
+ const added = (entry.changes || []).find((c) => c.key === "$add");
223
+ if (!added || !Array.isArray(added.new) || !added.new.length) return;
224
+
225
+ const dangerous = this.frame.config.roleAnalysis.dangerousPermissions;
226
+ const dangerousRoles = added.new
227
+ .map((r) => guild.roles.cache.get(r.id))
228
+ .filter((role) => role && dangerous.some((p) => role.permissions.has(PermissionsBitField.Flags[p])));
229
+ if (!dangerousRoles.length) return;
230
+
231
+ let executorMember = null;
232
+ try {
233
+ executorMember = await guild.members.fetch(executor.id);
234
+ } catch {
235
+ /* left already */
236
+ }
237
+ const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
238
+ if (trust === "HIGH" || trust === "PROTECTED") return;
239
+
240
+ await this.frame.logger.log(guild, {
241
+ layer: this.name,
242
+ level: "alert",
243
+ title: "Dangerous Role Granted To A Member",
244
+ description: `${executor.tag} (trust: ${trust}) gave <@${entry.targetId}> the role(s): ${dangerousRoles
245
+ .map((r) => r.name)
246
+ .join(", ")}.`,
247
+ dedupeKey: `antinuke:member-grant:${entry.targetId}`
248
+ });
249
+ }
250
+
251
+ /**
252
+ * A nuke doesn't have to delete anything - locking @everyone out of a
253
+ * public channel, or opening a private one up to @everyone, does the
254
+ * same damage through permission overwrites instead. Only @everyone's
255
+ * own overwrite is watched here; a role- or member-specific overwrite
256
+ * change is routine channel administration and not worth flagging.
257
+ */
258
+ async _checkChannelOverwriteAbuse(entry, guild, executor) {
259
+ if (!entry.extra || String(entry.extra.id) !== guild.id) return;
260
+
261
+ const allowChange = (entry.changes || []).find((c) => c.key === "allow");
262
+ const denyChange = (entry.changes || []).find((c) => c.key === "deny");
263
+ if (!allowChange && !denyChange) return;
264
+
265
+ const oldAllow = new PermissionsBitField(BigInt(allowChange?.old ?? 0));
266
+ const newAllow = new PermissionsBitField(BigInt(allowChange?.new ?? 0));
267
+ const oldDeny = new PermissionsBitField(BigInt(denyChange?.old ?? 0));
268
+ const newDeny = new PermissionsBitField(BigInt(denyChange?.new ?? 0));
269
+
270
+ const view = PermissionsBitField.Flags.ViewChannel;
271
+ const nowGrantsView = !oldAllow.has(view) && newAllow.has(view);
272
+ const nowBlocksView = !oldDeny.has(view) && newDeny.has(view);
273
+ if (!nowGrantsView && !nowBlocksView) return;
274
+
275
+ let executorMember = null;
276
+ try {
277
+ executorMember = await guild.members.fetch(executor.id);
278
+ } catch {
279
+ /* left already */
280
+ }
281
+ const trust = executorMember ? this.frame.roleAnalyzer.trustLevel(executorMember) : "LOW";
282
+ if (trust === "HIGH" || trust === "PROTECTED") return;
283
+
284
+ const channel = guild.channels.cache.get(entry.targetId);
285
+ await this.frame.logger.log(guild, {
286
+ layer: this.name,
287
+ level: "alert",
288
+ title: nowBlocksView ? "Channel Locked Out For Everyone" : "Private Channel Opened To Everyone",
289
+ description: `${executor.tag} (trust: ${trust}) changed @everyone's access to ${channel ? channel.name : entry.targetId}.`,
290
+ dedupeKey: `antinuke:overwrite:${entry.targetId}`
291
+ });
292
+ }
293
+
203
294
  /**
204
295
  * Webhook messages never carry a guild member, so BasicSecurityLayer's
205
296
  * per-author spam buffer can't see them - a "create webhook, then flood
@@ -232,6 +323,7 @@ class AntiNukeLayer extends Layer {
232
323
  }
233
324
 
234
325
  await this.frame.logger.log(guild, {
326
+ layer: this.name,
235
327
  level: "alert",
236
328
  title: "Webhook Message Flood - Webhook Removed",
237
329
  description: `A recently created webhook sent ${list.length}+ messages inside ${cfg.windowMs / 1000}s and has been deleted.`,
@@ -271,6 +363,7 @@ class AntiNukeLayer extends Layer {
271
363
  if (!flags.length) return;
272
364
 
273
365
  await this.frame.logger.log(guild, {
366
+ layer: this.name,
274
367
  level: "warn",
275
368
  title: "Role Audit - Possible Impersonation/Permission Mismatch",
276
369
  description: flags.map((f) => `**${f.name}** (${f.roleId}) - ${f.reason}`).join("\n"),
@@ -71,7 +71,7 @@ class AntiRaidLayer extends Layer {
71
71
  async _handleJoin(member) {
72
72
  const { guild, user } = member;
73
73
  if (!this.isEnabled(guild.id)) return;
74
- this.frame.performance.recordEvent("antiRaid.guildMemberAdd");
74
+ this.frame.performance.recordEvent("antiRaid.guildMemberAdd", guild.id);
75
75
  const joinCount = this._recordJoin(guild.id);
76
76
  const { score: altScore, entropyCheck } = this._altScore(user);
77
77
  const cluster = this._clusterScore(guild.id, user);
@@ -106,6 +106,7 @@ class AntiRaidLayer extends Layer {
106
106
  }
107
107
 
108
108
  await this.frame.logger.log(guild, {
109
+ layer: this.name,
109
110
  level: "alert",
110
111
  title: "Raid Detected - Lockdown Engaged",
111
112
  description: `${joinCount} joins inside ${this.config.joinWindowMs / 1000}s. Verification level temporarily raised.`,
@@ -129,6 +130,7 @@ class AntiRaidLayer extends Layer {
129
130
  }
130
131
 
131
132
  await this.frame.logger.log(guild, {
133
+ layer: this.name,
132
134
  level: "ok",
133
135
  title: "Lockdown Lifted",
134
136
  description: "Join activity normalized. Verification level restored.",
@@ -25,7 +25,7 @@ class BasicSecurityLayer extends Layer {
25
25
  if (!message.guild || message.author.bot) return;
26
26
  if (!this.isEnabled(message.guild.id)) return;
27
27
  const done = this.frame.performance.time("basicSecurity.messageCreate");
28
- this.frame.performance.recordEvent("basicSecurity.messageCreate");
28
+ this.frame.performance.recordEvent("basicSecurity.messageCreate", message.guild.id);
29
29
 
30
30
  const cfg = this.config;
31
31
  const buf = this._bufferFor(message.guild.id, message.author.id);
@@ -43,6 +43,8 @@ class BasicSecurityLayer extends Layer {
43
43
  const mentionCount = message.mentions.users.size + message.mentions.roles.size;
44
44
  const classification = classifyMessage(message.content || "");
45
45
  const linkFlag = this._checkLinks(message.content || "");
46
+ const hasInvite = this._checkInviteLink(message.content || "");
47
+ const trust = this.frame.roleAnalyzer.trustLevel(message.member);
46
48
 
47
49
  const reasons = [];
48
50
  let weight = 0;
@@ -59,6 +61,14 @@ class BasicSecurityLayer extends Layer {
59
61
  reasons.push(`mention spam (${mentionCount})`);
60
62
  weight += 25;
61
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
+ }
62
72
  if (classification.scam >= cfg.nlp.scamScoreThreshold) {
63
73
  reasons.push(`scam language (${classification.scam.toFixed(2)})`);
64
74
  weight += 35;
@@ -67,6 +77,20 @@ class BasicSecurityLayer extends Layer {
67
77
  reasons.push(`phishing language (${classification.phishing.toFixed(2)})`);
68
78
  weight += 35;
69
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
+ }
70
94
  if (linkFlag) {
71
95
  reasons.push(linkFlag);
72
96
  weight += 30;
@@ -104,6 +128,11 @@ class BasicSecurityLayer extends Layer {
104
128
  }
105
129
  return null;
106
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
+ }
107
136
  }
108
137
 
109
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,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") },