glassframe-protocol 1.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.
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+
3
+ const EventEmitter = require("events");
4
+ const ProtocolCache = require("../core/Cache");
5
+
6
+ // Used only to decide whether continued bad behavior mid-case deserves a
7
+ // stronger response - never to decide the response itself (_decideAction
8
+ // still owns that, trust-gating included).
9
+ const ACTION_SEVERITY = { log: 0, alert: 1, quarantine: 2, timeout: 2, kick: 3, ban: 4 };
10
+
11
+ /**
12
+ * The only place in GlassFrame Protocol that actually punishes a member.
13
+ * Every layer feeds signals through ThreatEngine; this engine turns a tier
14
+ * into an action, but only after checking: is there already an open case
15
+ * for this member (don't re-punish or re-log mid-resolution), can the bot
16
+ * even act on them (RoleAnalyzer), and does their real trust level call for
17
+ * a softer landing (quarantine instead of ban) regardless of what the
18
+ * ladder alone would say.
19
+ */
20
+ class PunishmentEngine extends EventEmitter {
21
+ constructor({ config, threatEngine, roleAnalyzer, logger, whitelist, queue, performance, debug = false }) {
22
+ super();
23
+ this.config = config.punishment;
24
+ this.threatEngine = threatEngine;
25
+ this.roleAnalyzer = roleAnalyzer;
26
+ this.logger = logger;
27
+ // Actual Discord mutations (ban/kick/timeout/quarantine) go through this
28
+ // bounded-concurrency queue rather than firing directly, so a burst of
29
+ // simultaneous cases (a raid, a nuke sweep) can't overrun Discord's rate
30
+ // limits. Falls back to running inline if no queue is supplied.
31
+ this.queue = queue || { push: (task) => task() };
32
+ 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 || []);
36
+ this.cases = new ProtocolCache("open-cases", { ttlMs: this.config.caseCooldownMs, debug });
37
+ }
38
+
39
+ caseKey(guildId, userId) {
40
+ return `${guildId}:${userId}`;
41
+ }
42
+
43
+ /**
44
+ * A layer reports a signal; this decides whether - and how - to act.
45
+ * { guild, member, layer, weight, reason }
46
+ */
47
+ async report({ guild, member, layer, weight, reason }) {
48
+ if (!member) return null;
49
+ const userId = member.id;
50
+ if (this.whitelist.has(userId)) return null;
51
+
52
+ const { score, tier } = this.threatEngine.addSignal(guild.id, userId, { layer, weight, reason });
53
+ if (tier === "none") return null;
54
+
55
+ const key = this.caseKey(guild.id, userId);
56
+ const openCase = this.cases.get(key);
57
+
58
+ if (openCase) {
59
+ // Already handling this member - merge in the new reason rather than
60
+ // opening a second incident. If continued behavior has pushed the
61
+ // tier past what the open case was actually acted on at, escalate
62
+ // once (log -> timeout -> kick -> ban); otherwise stay silent so this
63
+ // never turns into repeat actions or repeat log lines for one incident.
64
+ openCase.reasons.push(reason);
65
+ openCase.tier = tier;
66
+
67
+ const trust = this.roleAnalyzer.trustLevel(member);
68
+ const nextAction = this._decideAction(tier, trust);
69
+ if (ACTION_SEVERITY[nextAction] > ACTION_SEVERITY[openCase.action]) {
70
+ openCase.action = nextAction;
71
+ openCase.trust = trust;
72
+ this.cases.set(key, openCase);
73
+ await this._execute(guild, member, nextAction, openCase);
74
+ return openCase;
75
+ }
76
+
77
+ this.cases.set(key, openCase);
78
+ return openCase;
79
+ }
80
+
81
+ const trust = this.roleAnalyzer.trustLevel(member);
82
+ const action = this._decideAction(tier, trust);
83
+ const record = {
84
+ id: `${key}:${Date.now()}`,
85
+ guildId: guild.id,
86
+ userId,
87
+ tier,
88
+ score,
89
+ trust,
90
+ action,
91
+ reasons: [reason],
92
+ openedAt: Date.now()
93
+ };
94
+ this.cases.set(key, record);
95
+
96
+ await this._execute(guild, member, action, record);
97
+ return record;
98
+ }
99
+
100
+ _decideAction(tier, trust) {
101
+ if (trust === "PROTECTED") return "alert"; // never auto-punish the owner or protected staff
102
+ if (trust === "HIGH" && (tier === "low" || tier === "medium")) return "log"; // benefit of the doubt on soft signals
103
+ if (trust === "HIGH" || trust === "MEDIUM") return this.config.protectedTrustAction; // quarantine, not kick/ban
104
+ return this.config.ladder[tier] || "log";
105
+ }
106
+
107
+ async _execute(guild, member, action, record) {
108
+ const botMember = guild.members.me;
109
+ const canAct = this.roleAnalyzer.canModerate(botMember, member);
110
+ let performed = false;
111
+ const done = this.performance.time("punishmentEngine.execute");
112
+
113
+ try {
114
+ if (action === "ban" && canAct) {
115
+ await this.queue.push(() => member.ban({ reason: this._reason(record) }));
116
+ performed = true;
117
+ } else if (action === "kick" && canAct) {
118
+ await this.queue.push(() => member.kick(this._reason(record)));
119
+ performed = true;
120
+ } else if (action === "timeout" && canAct && member.moderatable) {
121
+ await this.queue.push(() => member.timeout(this.config.timeoutDurationMs, this._reason(record)));
122
+ performed = true;
123
+ } else if (action === "quarantine" && canAct) {
124
+ const removable = member.roles.cache.filter((r) => r.id !== guild.id && r.editable);
125
+ await this.queue.push(() => member.roles.remove(removable, this._reason(record)));
126
+ performed = true;
127
+ }
128
+ } catch (err) {
129
+ record.error = err.message;
130
+ } finally {
131
+ done();
132
+ }
133
+
134
+ this.performance.recordEvent(`punishmentEngine.action.${action}`);
135
+
136
+ if (!performed && action !== "log" && action !== "alert" && !record.error) {
137
+ record.note = "GlassFrame lacks role hierarchy or Discord permission to act - logged for manual review only.";
138
+ }
139
+
140
+ await this.logger.log(guild, {
141
+ level: action === "alert" ? "alert" : action === "log" ? "info" : "warn",
142
+ title: this._title(action),
143
+ description: `${member.user.tag} (${member.id}) - tier **${record.tier}**, trust **${record.trust}**.`,
144
+ fields: [
145
+ { name: "Reasons", value: record.reasons.slice(-5).join("\n") },
146
+ { name: "Outcome", value: record.note || record.error || `action: ${action}` }
147
+ ],
148
+ dedupeKey: `punish:${action}:${guild.id}`
149
+ });
150
+
151
+ this.emit("case", record);
152
+ }
153
+
154
+ _reason(record) {
155
+ return `GlassFrame Protocol (${record.tier}): ${record.reasons.slice(-3).join("; ")}`;
156
+ }
157
+
158
+ _title(action) {
159
+ const map = {
160
+ ban: "Member Banned",
161
+ kick: "Member Kicked",
162
+ timeout: "Member Timed Out",
163
+ quarantine: "Member Quarantined",
164
+ alert: "Protected Member Flagged - Review Needed",
165
+ log: "Signal Logged"
166
+ };
167
+ return map[action] || "Action Taken";
168
+ }
169
+
170
+ clearCase(guildId, userId) {
171
+ this.cases.delete(this.caseKey(guildId, userId));
172
+ }
173
+ }
174
+
175
+ module.exports = PunishmentEngine;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+
3
+ const { PermissionsBitField } = require("discord.js");
4
+
5
+ /**
6
+ * Reads a member's roles - their hierarchy position and their names - to
7
+ * decide what the protocol is allowed, and wise, to do about them. This is
8
+ * what keeps punishment "smart" instead of reflexive: it stops the bot from
9
+ * attempting actions it has no hierarchy for, and stops the protocol from
10
+ * auto-banning someone who turns out to hold a real administrator role.
11
+ */
12
+ class RoleAnalyzer {
13
+ constructor(config) {
14
+ this.config = config.roleAnalysis;
15
+ }
16
+
17
+ /** Can the bot's member act on the target at all, permission + hierarchy-wise? */
18
+ canModerate(botMember, targetMember) {
19
+ if (!botMember || !targetMember) return false;
20
+ if (targetMember.id === targetMember.guild.ownerId) return false;
21
+ return targetMember.manageable;
22
+ }
23
+
24
+ /** LOW / MEDIUM / HIGH / PROTECTED, based on the member's real permissions and role names. */
25
+ trustLevel(member) {
26
+ if (!member) return "LOW";
27
+ if (member.id === member.guild.ownerId) return "PROTECTED";
28
+
29
+ const perms = member.permissions;
30
+ const hasDangerous = this.config.dangerousPermissions.some((p) => perms.has(PermissionsBitField.Flags[p]));
31
+ const hasTrust = this.config.trustPermissions.some((p) => perms.has(PermissionsBitField.Flags[p]));
32
+ const nameMatch = member.roles.cache.some((role) =>
33
+ this.config.protectedNames.some((n) => role.name.toLowerCase().includes(n))
34
+ );
35
+
36
+ if (hasDangerous && nameMatch) return "PROTECTED";
37
+ if (hasTrust) return "HIGH";
38
+ if (nameMatch) return "MEDIUM";
39
+ return "LOW";
40
+ }
41
+
42
+ /**
43
+ * Scans a guild's current roles for name/permission mismatches: a role
44
+ * whose name signals authority ("admin", "staff", ...) but carries no real
45
+ * permissions (impersonation bait), or the opposite - an innocuous-looking
46
+ * role that was just created and quietly holds dangerous permissions.
47
+ * Returns flags only, never actions; a human decides what to do with them.
48
+ */
49
+ scanRoles(guild) {
50
+ const flags = [];
51
+ const now = Date.now();
52
+
53
+ for (const role of guild.roles.cache.values()) {
54
+ if (role.id === guild.id) continue; // skip @everyone
55
+
56
+ const looksOfficial = this.config.protectedNames.some((n) => role.name.toLowerCase().includes(n));
57
+ const hasDangerousPerms = this.config.dangerousPermissions.some((p) =>
58
+ role.permissions.has(PermissionsBitField.Flags[p])
59
+ );
60
+ const age = now - role.createdTimestamp;
61
+
62
+ if (looksOfficial && !hasDangerousPerms) {
63
+ flags.push({
64
+ roleId: role.id,
65
+ name: role.name,
66
+ reason: "name suggests authority but the role has no real permissions (possible impersonation bait)"
67
+ });
68
+ }
69
+ if (!looksOfficial && hasDangerousPerms && age < this.config.newRoleGraceMs) {
70
+ flags.push({
71
+ roleId: role.id,
72
+ name: role.name,
73
+ reason: "newly created role quietly holds dangerous permissions"
74
+ });
75
+ }
76
+ }
77
+
78
+ return flags;
79
+ }
80
+ }
81
+
82
+ module.exports = RoleAnalyzer;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+
3
+ const HOMOGRAPH_DIGIT_MAP = { 0: "o", 1: "i", 3: "e", 4: "a", 5: "s", 7: "t" };
4
+ const WATCHED_BRANDS = /(discord|steam|paypal|riot|valve|epicgames|roblox|nitro)/i;
5
+ const SHORTENERS = new Set(["bit.ly", "tinyurl.com", "cutt.ly", "is.gd", "t.co", "shorturl.at", "rb.gy"]);
6
+
7
+ /**
8
+ * Fast, expandable domain/pattern reputation checker used by
9
+ * BasicSecurityLayer. Ships with no seed list of "known bad" domains - a
10
+ * hardcoded blocklist goes stale immediately and risks flagging an innocent
11
+ * domain on stale/unverified information. Instead it combines a live,
12
+ * server-editable blocklist (`!gf phishing add/remove/list`) with cheap,
13
+ * general-purpose heuristics that don't depend on any specific domain ever
14
+ * being named in advance.
15
+ *
16
+ * Every check is O(1) Set membership plus a few cheap regexes - this is
17
+ * never the slow part of message handling, which is the point: link
18
+ * checking has to keep up with every message, not just the flagged ones.
19
+ */
20
+ class PhishingDatabase {
21
+ constructor(seedDomains = []) {
22
+ this.blocked = new Set(seedDomains.map((d) => d.toLowerCase()));
23
+ }
24
+
25
+ add(domain) {
26
+ this.blocked.add(String(domain).toLowerCase());
27
+ }
28
+
29
+ remove(domain) {
30
+ return this.blocked.delete(String(domain).toLowerCase());
31
+ }
32
+
33
+ list() {
34
+ return [...this.blocked];
35
+ }
36
+
37
+ /** Returns a short reason string if a URL looks dangerous, otherwise null. */
38
+ check(url) {
39
+ let hostname;
40
+ try {
41
+ hostname = new URL(url).hostname.toLowerCase();
42
+ } catch {
43
+ return null;
44
+ }
45
+
46
+ if (this.blocked.has(hostname)) return "known malicious domain (server blocklist)";
47
+
48
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return "raw-IP link (common phishing evasion)";
49
+ if (/xn--/.test(hostname)) return "punycode/homograph domain";
50
+
51
+ const flattened = hostname.replace(/\./g, "");
52
+ if (/[0-9]/.test(flattened)) {
53
+ const desubstituted = flattened.replace(/[0-9]/g, (d) => HOMOGRAPH_DIGIT_MAP[d] || d);
54
+ const match = desubstituted.match(WATCHED_BRANDS);
55
+ // Only flag if THIS specific brand word wasn't already plainly present
56
+ // before substitution - e.g. "paypal-secure5.com" already shows
57
+ // "paypal" outright, so the trailing digit isn't hiding anything and
58
+ // shouldn't trip this rule; "d1scord-nitro.com" only shows "discord"
59
+ // once the "1" is read back as an "i".
60
+ if (match && !flattened.includes(match[0])) {
61
+ return "brand look-alike domain (digit-for-letter substitution)";
62
+ }
63
+ }
64
+
65
+ if (SHORTENERS.has(hostname)) return "shortened link (real destination hidden)";
66
+
67
+ return null;
68
+ }
69
+ }
70
+
71
+ module.exports = PhishingDatabase;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+
3
+ const {
4
+ ContainerBuilder,
5
+ TextDisplayBuilder,
6
+ SeparatorBuilder,
7
+ SeparatorSpacingSize,
8
+ ActionRowBuilder,
9
+ ButtonBuilder,
10
+ ButtonStyle,
11
+ MessageFlags
12
+ } = require("discord.js");
13
+
14
+ // Exactly five buttons, one row - Discord's own per-row button limit is five,
15
+ // so this is the only layout this panel could ever need.
16
+ const BUTTONS = [
17
+ { id: "gfp_toggle_antiRaid", layer: "antiRaid", label: "AntiRaid" },
18
+ { id: "gfp_toggle_aiModeration", layer: "aiModeration", label: "AI Moderation" },
19
+ { id: "gfp_toggle_antiNuke", layer: "antiNuke", label: "AntiNuke" },
20
+ { id: "gfp_toggle_basicSecurity", layer: "basicSecurity", label: "Basic Security" },
21
+ { id: "gfp_toggle_all", layer: "__all__", label: "Full Protocol" }
22
+ ];
23
+
24
+ const REAL_LAYERS = ["antiRaid", "aiModeration", "antiNuke", "basicSecurity"];
25
+
26
+ function buildPanel(frame, guildId) {
27
+ const status = frame.getStatus(guildId);
28
+ const allOn = REAL_LAYERS.every((l) => status[l]);
29
+
30
+ const row = new ActionRowBuilder().addComponents(
31
+ BUTTONS.map((b) => {
32
+ const active = b.layer === "__all__" ? allOn : status[b.layer];
33
+ return new ButtonBuilder()
34
+ .setCustomId(b.id)
35
+ .setLabel(`${active ? "Deactivate" : "Activate"} ${b.label}`)
36
+ .setStyle(active ? ButtonStyle.Success : ButtonStyle.Secondary);
37
+ })
38
+ );
39
+
40
+ const statusLines = REAL_LAYERS.map((l) => {
41
+ const meta = BUTTONS.find((b) => b.layer === l);
42
+ return `${status[l] ? "\u25CF" : "\u25CB"} ${meta.label} - ${status[l] ? "ACTIVE" : "INACTIVE"}`;
43
+ }).join("\n");
44
+
45
+ const container = new ContainerBuilder().setAccentColor(0x5865f2);
46
+ container.addTextDisplayComponents(new TextDisplayBuilder().setContent("**GlassFrame Protocol - Control Panel**"));
47
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
48
+ container.addTextDisplayComponents(new TextDisplayBuilder().setContent(statusLines));
49
+ container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
50
+ container.addActionRowComponents(row);
51
+ container.addTextDisplayComponents(
52
+ new TextDisplayBuilder().setContent("-# Only server admins (Manage Server) can use these controls.")
53
+ );
54
+
55
+ return { flags: MessageFlags.IsComponentsV2, components: [container] };
56
+ }
57
+
58
+ module.exports = { buildPanel, BUTTONS };
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Lightweight, dependency-free NLP utilities used across GlassFrame
5
+ * Protocol. No external ML services are called from here; everything runs
6
+ * locally and synchronously so detection stays fast enough to run on every
7
+ * message and every join event. This is the free "first opinion" that
8
+ * AIModerationLayer's gray-zone escalation to Groq builds on top of.
9
+ */
10
+
11
+ function tokenize(text) {
12
+ return String(text)
13
+ .toLowerCase()
14
+ .normalize("NFKD")
15
+ .replace(/[^a-z0-9\s]/g, " ")
16
+ .split(/\s+/)
17
+ .filter(Boolean);
18
+ }
19
+
20
+ function shannonEntropy(str) {
21
+ const s = String(str);
22
+ if (s.length === 0) return 0;
23
+ const freq = {};
24
+ for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
25
+ let entropy = 0;
26
+ for (const count of Object.values(freq)) {
27
+ const p = count / s.length;
28
+ entropy -= p * Math.log2(p);
29
+ }
30
+ return entropy;
31
+ }
32
+
33
+ function normalizedRepeatRatio(str) {
34
+ const s = String(str);
35
+ if (s.length < 2) return 0;
36
+ let repeats = 0;
37
+ for (let i = 1; i < s.length; i++) {
38
+ if (s[i] === s[i - 1]) repeats++;
39
+ }
40
+ return repeats / (s.length - 1);
41
+ }
42
+
43
+ // Bag-of-words lexicon scorer. Returns a 0..1 confidence score using
44
+ // term-frequency weighting rather than a single keyword hit, which cuts
45
+ // down on false positives from one incidental word.
46
+ function lexiconScore(text, lexicon) {
47
+ const tokens = tokenize(text);
48
+ if (tokens.length === 0) return 0;
49
+ let hits = 0;
50
+ let weight = 0;
51
+ for (const token of tokens) {
52
+ if (lexicon.strong && lexicon.strong.has(token)) {
53
+ hits++;
54
+ weight += 1.0;
55
+ } else if (lexicon.weak && lexicon.weak.has(token)) {
56
+ hits++;
57
+ weight += 0.4;
58
+ }
59
+ }
60
+ // Bigram check for multi-word patterns (e.g. "free nitro", "steam gift")
61
+ if (lexicon.bigrams) {
62
+ for (let i = 0; i < tokens.length - 1; i++) {
63
+ const bigram = tokens[i] + " " + tokens[i + 1];
64
+ if (lexicon.bigrams.has(bigram)) {
65
+ hits++;
66
+ weight += 1.2;
67
+ }
68
+ }
69
+ }
70
+ if (hits === 0) return 0;
71
+ const density = hits / tokens.length;
72
+ const score = Math.min(1, weight / Math.max(3, tokens.length) + density * 0.3);
73
+ return score;
74
+ }
75
+
76
+ const SCAM_LEXICON = {
77
+ strong: new Set([
78
+ "nitro", "airdrop", "giveaway", "steam", "crypto", "wallet",
79
+ "verify", "claim", "gift", "free", "double", "investment",
80
+ "seed", "phrase", "otp", "recover", "unlock"
81
+ ]),
82
+ weak: new Set([
83
+ "link", "click", "limited", "hurry", "winner", "congratulations",
84
+ "exclusive", "only", "today", "now"
85
+ ]),
86
+ bigrams: new Set([
87
+ "free nitro", "steam gift", "double your", "claim now",
88
+ "verify account", "wallet connect", "seed phrase", "limited time",
89
+ "click here", "airdrop event"
90
+ ])
91
+ };
92
+
93
+ const PHISHING_LEXICON = {
94
+ strong: new Set([
95
+ "login", "signin", "password", "confirm", "suspend", "suspended",
96
+ "locked", "support", "billing", "invoice", "reset", "update"
97
+ ]),
98
+ weak: new Set(["account", "security", "immediately", "action", "required", "team"]),
99
+ bigrams: new Set([
100
+ "sign in", "reset password", "confirm identity", "account suspended",
101
+ "verify now", "click below", "update payment"
102
+ ])
103
+ };
104
+
105
+ const RAID_CALLOUT_LEXICON = {
106
+ strong: new Set(["raid", "nuke", "spam", "flood", "wipe", "destroy", "invite", "bot"]),
107
+ weak: new Set(["server", "join", "everyone", "ping", "mass"]),
108
+ bigrams: new Set(["raid this", "nuke server", "mass ping", "spam bots", "join now"])
109
+ };
110
+
111
+ function suspiciousUsernameEntropy(username, floor, ceiling) {
112
+ const cleaned = String(username).replace(/\s+/g, "");
113
+ if (cleaned.length < 3) return { suspicious: false, entropy: 0, reason: null };
114
+ const entropy = shannonEntropy(cleaned);
115
+ const repeatRatio = normalizedRepeatRatio(cleaned);
116
+
117
+ if (repeatRatio > 0.5) return { suspicious: true, entropy, reason: "repeated character pattern" };
118
+ if (entropy < floor) return { suspicious: true, entropy, reason: "low character variety" };
119
+ if (entropy > ceiling) return { suspicious: true, entropy, reason: "high-entropy generated string" };
120
+ return { suspicious: false, entropy, reason: null };
121
+ }
122
+
123
+ function classifyMessage(text) {
124
+ const scam = lexiconScore(text, SCAM_LEXICON);
125
+ const phishing = lexiconScore(text, PHISHING_LEXICON);
126
+ const raidCallout = lexiconScore(text, RAID_CALLOUT_LEXICON);
127
+ return {
128
+ scam,
129
+ phishing,
130
+ raidCallout,
131
+ topLabel: [
132
+ ["scam", scam],
133
+ ["phishing", phishing],
134
+ ["raid_callout", raidCallout]
135
+ ].sort((a, b) => b[1] - a[1])[0][0]
136
+ };
137
+ }
138
+
139
+ module.exports = {
140
+ tokenize,
141
+ shannonEntropy,
142
+ normalizedRepeatRatio,
143
+ lexiconScore,
144
+ suspiciousUsernameEntropy,
145
+ classifyMessage,
146
+ SCAM_LEXICON,
147
+ PHISHING_LEXICON,
148
+ RAID_CALLOUT_LEXICON
149
+ };
@@ -0,0 +1,78 @@
1
+ # Cache Architecture
2
+
3
+ GlassFrame Protocol keeps all of its state in memory via one shared
4
+ primitive, `ProtocolCache` (`src/core/Cache.js`), rather than scattering
5
+ plain `Map`s through every layer. Every cache instance below is a
6
+ `ProtocolCache`, and every one of them can be put into debug mode so you can
7
+ watch it operate at runtime - see "Turning on cache logging" below.
8
+
9
+ ## Why a shared cache primitive
10
+
11
+ - One place to reason about TTL, eviction, and stats instead of five
12
+ slightly-different `Map` patterns spread across layers.
13
+ - Optional debug logging is a constructor flag, not something bolted on
14
+ per-module after the fact.
15
+ - `getStats()` returns hits/misses/sets/evictions/size for any cache at
16
+ runtime, which is useful when you're tuning thresholds in `config.js` and
17
+ want to know whether a value is actually being reused.
18
+
19
+ ## Caches in use
20
+
21
+ | Cache | Owner | Key shape | TTL | Purpose |
22
+ |---|---|---|---|---|
23
+ | `threat-scores` | `ThreatEngine` | `guildId:userId` | none (decay-based; pruned once decayed near zero) | The running, decaying risk score every layer contributes signals to. |
24
+ | `open-cases` | `PunishmentEngine` | `guildId:userId` | `punishment.caseCooldownMs` (default 30s) | Prevents re-punishing or re-logging the same member while an incident is still fresh. Expiry length **is** the debounce window. |
25
+ | `nuke-snapshots` | `AntiNukeLayer` | `guildId` | `antiNuke.snapshotTtlMs` (default 1h) | A lightweight forensic snapshot of role/channel names and positions, captured the instant a threshold trips, kept only for reference. |
26
+ | `recent-webhooks` | `AntiNukeLayer` | webhook ID | `antiNuke.webhookAbuse.watchPeriodMs` (default 5m) | Maps a freshly created webhook back to whoever created it, so a message flood through that webhook (which carries no guild member of its own) can still be traced to an executor. |
27
+ | `groq-verdicts` | `GroqClient` | hash of the first 500 characters of the message | `aiModeration.cacheTtlMs` (default 5m) | Stops a burst of identical/near-identical messages from each costing a fresh Groq call. |
28
+
29
+ Two more `Map`s exist outside `ProtocolCache` because they're pure sliding
30
+ windows with no need for hit/miss stats: `AntiRaidLayer.joinTimestamps` /
31
+ `AntiRaidLayer.joinProfiles` (cluster correlation) and
32
+ `AntiNukeLayer.actionLog` / `AntiNukeLayer.webhookMessageCounts`. All four
33
+ are swept lazily - old timestamps are filtered out on the next write rather
34
+ than on a timer, since they're only ever read right after a write.
35
+
36
+ Bounded-concurrency queues (`frame.actionQueue`, `frame.aiQueue`) and the
37
+ internal metrics collector (`frame.performance`) are a separate concern from
38
+ caching - see `docs/PERFORMANCE.md`.
39
+
40
+ ## Turning on cache logging
41
+
42
+ Pass `debug: true` when constructing GlassFrame:
43
+
44
+ ```js
45
+ const frame = new GlassFrame(client, {
46
+ getLogChannel: (guild) => guild.channels.cache.find((c) => c.name === "security-logs"),
47
+ debug: true
48
+ });
49
+ ```
50
+
51
+ (Equivalently, set `cache: { debug: true }` in `config.js` - the
52
+ constructor option takes priority if both are set.)
53
+
54
+ With debug mode on, every cache above prints a line like:
55
+
56
+ ```
57
+ [GlassFrame:Cache:open-cases] SET 123456789012345678:987654321098765432
58
+ [GlassFrame:Cache:groq-verdicts] HIT h482910335
59
+ [GlassFrame:Cache:threat-scores] EXPIRE 123456789012345678:555555555555555555
60
+ ```
61
+
62
+ `SmartLogger` has its own separate debug channel for the same flag
63
+ (`[GlassFrame:SmartLogger] merge ...` / `final edit ...`), covering the
64
+ event-aggregation behavior described in `docs/PROTOCOL_LAYERS.md`.
65
+
66
+ This is meant for local debugging while you tune `config.js` - leave it off
67
+ in production, since it writes to `console.log` on every cache operation and
68
+ will get noisy fast on an active server.
69
+
70
+ ## Memory growth
71
+
72
+ `threat-scores` is the only cache with no hard TTL, since a member's score
73
+ needs to persist and decay rather than vanish at a fixed time.
74
+ `ThreatEngine` prunes it every 10 minutes, removing any entry whose decayed
75
+ score has fallen under 0.5 **and** hasn't been touched in at least four
76
+ half-lives - by then it's noise, not signal. Every other cache either has a
77
+ real TTL or is a self-trimming sliding window, so none of them grow
78
+ unbounded over a long-running process.
@@ -0,0 +1,38 @@
1
+ # Commands
2
+
3
+ GlassFrame Protocol registers **no slash commands**. Every command is a
4
+ prefix command (default prefix `!gf `, configurable via `config.prefix`),
5
+ requires Manage Server permission (or being the guild owner), and is
6
+ throttled per user per guild by `performance.commandCooldownMs` (default
7
+ 3s) - a repeated or accidental double-send is silently ignored rather than
8
+ running twice or getting a "slow down" reply of its own.
9
+
10
+ | Command | Does |
11
+ |---|---|
12
+ | `!gf panel` | Sends the 5-button control panel (see below). |
13
+ | `!gf status` | Shows which of the four layers are currently active. |
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`. |
16
+ | `!gf phishing add <domain>` | Adds a domain to the link blocklist. |
17
+ | `!gf phishing remove <domain>` | Removes a domain from the blocklist. |
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. |
22
+
23
+ ## The control panel
24
+
25
+ `!gf panel` sends a message with exactly five buttons in one row:
26
+
27
+ 1. **Activate/Deactivate AntiRaid**
28
+ 2. **Activate/Deactivate AI Moderation**
29
+ 3. **Activate/Deactivate AntiNuke**
30
+ 4. **Activate/Deactivate Basic Security**
31
+ 5. **Activate/Deactivate Full Protocol** - arms or disarms all four at once
32
+
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
36
+ panel message updates in place on every click rather than posting a new
37
+ message each time. Clicking a button also drops one aggregated log line in
38
+ the security log channel noting who changed what.