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.
- package/CHANGELOG.md +197 -0
- package/GETTING_STARTED.md +133 -0
- package/LICENSE +56 -0
- package/README.md +145 -0
- package/dist/config.js +138 -0
- package/dist/index.js +13 -0
- package/dist/src/GlassFrame.js +195 -0
- package/dist/src/ai/GroqClient.js +161 -0
- package/dist/src/commands/PrefixRouter.js +177 -0
- package/dist/src/core/Cache.js +98 -0
- package/dist/src/core/EventQueue.js +62 -0
- package/dist/src/core/Layer.js +68 -0
- package/dist/src/core/PerformanceMonitor.js +52 -0
- package/dist/src/core/StateStore.js +69 -0
- package/dist/src/core/ThreatEngine.js +86 -0
- package/dist/src/core/VersionInfo.js +32 -0
- package/dist/src/layers/AIModerationLayer.js +77 -0
- package/dist/src/layers/AntiNukeLayer.js +278 -0
- package/dist/src/layers/AntiRaidLayer.js +144 -0
- package/dist/src/layers/BasicSecurityLayer.js +111 -0
- package/dist/src/logging/ComponentsV2.js +44 -0
- package/dist/src/logging/SmartLogger.js +84 -0
- package/dist/src/moderation/PunishmentEngine.js +175 -0
- package/dist/src/moderation/RoleAnalyzer.js +82 -0
- package/dist/src/security/PhishingDatabase.js +71 -0
- package/dist/src/ui/ControlPanel.js +58 -0
- package/dist/src/utils/nlpEngine.js +149 -0
- package/docs/CACHE_ARCHITECTURE.md +78 -0
- package/docs/COMMANDS.md +38 -0
- package/docs/PERFORMANCE.md +59 -0
- package/docs/PROTOCOL_LAYERS.md +89 -0
- package/docs/PUBLISHING.md +93 -0
- package/examples/basic-usage.js +44 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const GlassFrame = require("./src/GlassFrame");
|
|
4
|
+
const VersionInfo = require("./src/core/VersionInfo");
|
|
5
|
+
const { MemoryStateStore, JSONFileStateStore } = require("./src/core/StateStore");
|
|
6
|
+
|
|
7
|
+
// module.exports IS the class (const GlassFrame = require("glassframe-protocol")),
|
|
8
|
+
// with named properties attached for anyone who prefers destructuring.
|
|
9
|
+
module.exports = GlassFrame;
|
|
10
|
+
module.exports.GlassFrame = GlassFrame;
|
|
11
|
+
module.exports.VersionInfo = VersionInfo;
|
|
12
|
+
module.exports.MemoryStateStore = MemoryStateStore;
|
|
13
|
+
module.exports.JSONFileStateStore = JSONFileStateStore;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require("events");
|
|
4
|
+
const { PermissionsBitField } = require("discord.js");
|
|
5
|
+
|
|
6
|
+
const defaultConfig = require("../config");
|
|
7
|
+
const VersionInfo = require("./core/VersionInfo");
|
|
8
|
+
const ThreatEngine = require("./core/ThreatEngine");
|
|
9
|
+
const { MemoryStateStore } = require("./core/StateStore");
|
|
10
|
+
const EventQueue = require("./core/EventQueue");
|
|
11
|
+
const PerformanceMonitor = require("./core/PerformanceMonitor");
|
|
12
|
+
|
|
13
|
+
const RoleAnalyzer = require("./moderation/RoleAnalyzer");
|
|
14
|
+
const PunishmentEngine = require("./moderation/PunishmentEngine");
|
|
15
|
+
|
|
16
|
+
const SmartLogger = require("./logging/SmartLogger");
|
|
17
|
+
const GroqClient = require("./ai/GroqClient");
|
|
18
|
+
const PhishingDatabase = require("./security/PhishingDatabase");
|
|
19
|
+
|
|
20
|
+
const BasicSecurityLayer = require("./layers/BasicSecurityLayer");
|
|
21
|
+
const AntiRaidLayer = require("./layers/AntiRaidLayer");
|
|
22
|
+
const AntiNukeLayer = require("./layers/AntiNukeLayer");
|
|
23
|
+
const AIModerationLayer = require("./layers/AIModerationLayer");
|
|
24
|
+
|
|
25
|
+
const PrefixRouter = require("./commands/PrefixRouter");
|
|
26
|
+
const { buildPanel } = require("./ui/ControlPanel");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* GlassFrame Protocol - a layered, self-contained Discord security engine.
|
|
30
|
+
* One instance manages every layer, the shared threat/punishment pipeline,
|
|
31
|
+
* logging, the prefix command router, and the 5-button control panel for a
|
|
32
|
+
* discord.js Client. See docs/PROTOCOL_LAYERS.md for the architecture.
|
|
33
|
+
*/
|
|
34
|
+
class GlassFrame extends EventEmitter {
|
|
35
|
+
constructor(client, options = {}) {
|
|
36
|
+
super();
|
|
37
|
+
if (!client) throw new Error("GlassFrame Protocol: a discord.js Client is required");
|
|
38
|
+
if (typeof options.getLogChannel !== "function") {
|
|
39
|
+
throw new Error("GlassFrame Protocol: options.getLogChannel(guild) is required");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.client = client;
|
|
43
|
+
this.config = mergeConfig(defaultConfig, options.config || {});
|
|
44
|
+
this.whitelist = new Set(options.whitelist || []);
|
|
45
|
+
this.stateStore = options.stateStore || new MemoryStateStore();
|
|
46
|
+
this.panelMessages = new Map(); // messageId -> guildId
|
|
47
|
+
this.debug = Boolean(options.debug ?? this.config.cache.debug);
|
|
48
|
+
|
|
49
|
+
this.threatEngine = new ThreatEngine(this.config, { debug: this.debug });
|
|
50
|
+
this.roleAnalyzer = new RoleAnalyzer(this.config);
|
|
51
|
+
this.logger = new SmartLogger(options.getLogChannel, this.config, { debug: this.debug });
|
|
52
|
+
this.performance = new PerformanceMonitor();
|
|
53
|
+
this.phishingDatabase = new PhishingDatabase(this.config.basicSecurity.linkGuard.seedDomains);
|
|
54
|
+
|
|
55
|
+
// Every heavy outbound call (Discord mutations, Groq requests) is routed
|
|
56
|
+
// through a bounded-concurrency queue instead of firing unbounded - see
|
|
57
|
+
// docs/PERFORMANCE.md. Two separate queues since Discord and Groq have
|
|
58
|
+
// entirely independent rate limits.
|
|
59
|
+
this.actionQueue = new EventQueue({ concurrency: this.config.performance.actionConcurrency });
|
|
60
|
+
this.aiQueue = new EventQueue({ concurrency: this.config.performance.aiConcurrency });
|
|
61
|
+
|
|
62
|
+
this.groqClient = new GroqClient(this.config, { debug: this.debug, queue: this.aiQueue, performance: this.performance });
|
|
63
|
+
this.punishmentEngine = new PunishmentEngine({
|
|
64
|
+
config: this.config,
|
|
65
|
+
threatEngine: this.threatEngine,
|
|
66
|
+
roleAnalyzer: this.roleAnalyzer,
|
|
67
|
+
logger: this.logger,
|
|
68
|
+
whitelist: this.whitelist,
|
|
69
|
+
queue: this.actionQueue,
|
|
70
|
+
performance: this.performance,
|
|
71
|
+
debug: this.debug
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// All four layers start disabled - nothing runs until armed via
|
|
75
|
+
// options.autoStart, the control panel, or enableLayer().
|
|
76
|
+
this.layers = {
|
|
77
|
+
basicSecurity: new BasicSecurityLayer(this),
|
|
78
|
+
antiRaid: new AntiRaidLayer(this),
|
|
79
|
+
antiNuke: new AntiNukeLayer(this),
|
|
80
|
+
aiModeration: new AIModerationLayer(this)
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
this.prefixRouter = new PrefixRouter(this);
|
|
84
|
+
this.prefixRouter.attach();
|
|
85
|
+
this._attachButtonHandler();
|
|
86
|
+
|
|
87
|
+
if (this.debug) console.log(VersionInfo.banner());
|
|
88
|
+
|
|
89
|
+
for (const name of options.autoStart || []) {
|
|
90
|
+
if (this.layers[name]) this.layers[name].enable();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
isAuthorized(member) {
|
|
95
|
+
if (!member) return false;
|
|
96
|
+
if (member.id === member.guild.ownerId) return true;
|
|
97
|
+
return member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getStatus(guildId) {
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const [name, layer] of Object.entries(this.layers)) {
|
|
103
|
+
out[name] = layer.enabled;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One-call health snapshot: event throughput/latency, queue depth, and every cache's stats. */
|
|
109
|
+
getMetrics() {
|
|
110
|
+
return {
|
|
111
|
+
performance: this.performance.snapshot(),
|
|
112
|
+
queues: {
|
|
113
|
+
actions: this.actionQueue.getStats(),
|
|
114
|
+
ai: this.aiQueue.getStats()
|
|
115
|
+
},
|
|
116
|
+
caches: {
|
|
117
|
+
threatScores: this.threatEngine.scores.getStats(),
|
|
118
|
+
openCases: this.punishmentEngine.cases.getStats(),
|
|
119
|
+
groqVerdicts: this.groqClient.cache.getStats()
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
enableLayer(name) {
|
|
125
|
+
if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
|
|
126
|
+
this.layers[name].enable();
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
disableLayer(name) {
|
|
131
|
+
if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
|
|
132
|
+
this.layers[name].disable();
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_attachButtonHandler() {
|
|
137
|
+
this.client.on("interactionCreate", async (interaction) => {
|
|
138
|
+
if (!interaction.isButton() || !interaction.customId.startsWith("gfp_toggle_")) return;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
if (!this.isAuthorized(interaction.member)) {
|
|
142
|
+
await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const layerKey = interaction.customId.replace("gfp_toggle_", "");
|
|
147
|
+
|
|
148
|
+
if (layerKey === "all") {
|
|
149
|
+
const status = this.getStatus(interaction.guild.id);
|
|
150
|
+
const allOn = Object.keys(this.layers).every((l) => status[l]);
|
|
151
|
+
for (const name of Object.keys(this.layers)) {
|
|
152
|
+
if (allOn) this.disableLayer(name);
|
|
153
|
+
else this.enableLayer(name);
|
|
154
|
+
}
|
|
155
|
+
} else if (this.layers[layerKey]) {
|
|
156
|
+
if (this.layers[layerKey].enabled) this.disableLayer(layerKey);
|
|
157
|
+
else this.enableLayer(layerKey);
|
|
158
|
+
} else {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const payload = buildPanel(this, interaction.guild.id);
|
|
163
|
+
await interaction.update(payload);
|
|
164
|
+
|
|
165
|
+
await this.logger.log(interaction.guild, {
|
|
166
|
+
level: "info",
|
|
167
|
+
title: "Protocol Layer Toggled",
|
|
168
|
+
description: `${interaction.user.tag} updated GlassFrame Protocol layers from the control panel.`,
|
|
169
|
+
dedupeKey: "panel:toggle"
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.emit("warning", { layer: "controlPanel", message: err.message });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mergeConfig(base, override) {
|
|
179
|
+
const out = { ...base };
|
|
180
|
+
for (const key of Object.keys(override)) {
|
|
181
|
+
if (
|
|
182
|
+
typeof override[key] === "object" &&
|
|
183
|
+
override[key] !== null &&
|
|
184
|
+
!Array.isArray(override[key]) &&
|
|
185
|
+
typeof base[key] === "object"
|
|
186
|
+
) {
|
|
187
|
+
out[key] = mergeConfig(base[key], override[key]);
|
|
188
|
+
} else {
|
|
189
|
+
out[key] = override[key];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = GlassFrame;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const ProtocolCache = require("../core/Cache");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thin client for Groq's OpenAI-compatible chat completions endpoint, built
|
|
7
|
+
* around a pool of API keys rather than one: if a key comes back
|
|
8
|
+
* rate-limited it's benched for a cooldown and the next key in the pool is
|
|
9
|
+
* tried, so one exhausted key doesn't take AI moderation offline. Every
|
|
10
|
+
* verdict is cached briefly so a burst of identical/near-identical spam
|
|
11
|
+
* only costs one real API call (see docs/CACHE_ARCHITECTURE.md).
|
|
12
|
+
*
|
|
13
|
+
* Uses Node's built-in fetch/AbortController - no extra dependency, and
|
|
14
|
+
* consistent with the rest of the library staying dependency-light.
|
|
15
|
+
*/
|
|
16
|
+
class GroqClient {
|
|
17
|
+
constructor(config, { debug = false, queue, performance } = {}) {
|
|
18
|
+
this.config = config.aiModeration;
|
|
19
|
+
this.keys = (this.config.apiKeys || []).map((key) => ({ key, cooldownUntil: 0 }));
|
|
20
|
+
this.keyIndex = 0;
|
|
21
|
+
this.cache = new ProtocolCache("groq-verdicts", { ttlMs: this.config.cacheTtlMs, debug });
|
|
22
|
+
this.callTimestamps = [];
|
|
23
|
+
// Outbound requests go through a bounded-concurrency queue so a wave of
|
|
24
|
+
// gray-zone messages can't fire dozens of simultaneous Groq calls at
|
|
25
|
+
// once; falls back to running inline if no queue is supplied.
|
|
26
|
+
this.queue = queue || { push: (task) => task() };
|
|
27
|
+
this.performance = performance || { recordEvent() {}, time: () => () => {} };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get available() {
|
|
31
|
+
return this.keys.length > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_nextKey() {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (let i = 0; i < this.keys.length; i++) {
|
|
37
|
+
const idx = (this.keyIndex + i) % this.keys.length;
|
|
38
|
+
if (this.keys[idx].cooldownUntil < now) {
|
|
39
|
+
this.keyIndex = (idx + 1) % this.keys.length;
|
|
40
|
+
return this.keys[idx];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_underRateBudget() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
this.callTimestamps = this.callTimestamps.filter((t) => now - t < 60000);
|
|
49
|
+
return this.callTimestamps.length < this.config.maxCallsPerMinute;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_hash(text) {
|
|
53
|
+
let h = 0;
|
|
54
|
+
const s = String(text).slice(0, 500);
|
|
55
|
+
for (let i = 0; i < s.length; i++) {
|
|
56
|
+
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
57
|
+
}
|
|
58
|
+
return `h${h}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns { category, confidence, reason } or null if AI moderation is
|
|
63
|
+
* unavailable, unconfigured, exhausted, or errors out. Callers must treat
|
|
64
|
+
* null as "fall back to local detection", never as "message is clean".
|
|
65
|
+
*/
|
|
66
|
+
async classify(text, context = "") {
|
|
67
|
+
if (!this.available || !text) return null;
|
|
68
|
+
if (!this._underRateBudget()) return null;
|
|
69
|
+
|
|
70
|
+
const cacheKey = this._hash(text);
|
|
71
|
+
const cached = this.cache.get(cacheKey);
|
|
72
|
+
if (cached) return cached;
|
|
73
|
+
|
|
74
|
+
const slot = this._nextKey();
|
|
75
|
+
if (!slot) return null;
|
|
76
|
+
|
|
77
|
+
const done = this.performance.time("groqClient.classify");
|
|
78
|
+
try {
|
|
79
|
+
this.callTimestamps.push(Date.now());
|
|
80
|
+
// Queued rather than fired directly - the request's own timeout clock
|
|
81
|
+
// (below) only starts once it actually leaves the queue, so a busy
|
|
82
|
+
// moment never causes a false request-level timeout.
|
|
83
|
+
const verdict = await this.queue.push(() => this._request(slot, text, context));
|
|
84
|
+
if (verdict) this.cache.set(cacheKey, verdict);
|
|
85
|
+
return verdict;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
} finally {
|
|
89
|
+
done();
|
|
90
|
+
this.performance.recordEvent("groqClient.classify");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async _request(slot, text, context) {
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(this.config.endpoint, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
Authorization: `Bearer ${slot.key}`
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
model: this.config.model,
|
|
107
|
+
temperature: 0,
|
|
108
|
+
max_tokens: 200,
|
|
109
|
+
messages: [
|
|
110
|
+
{
|
|
111
|
+
role: "system",
|
|
112
|
+
content: "You are a Discord moderation classifier. Respond with ONLY compact JSON, no prose, no markdown fences."
|
|
113
|
+
},
|
|
114
|
+
{ role: "user", content: this._buildPrompt(text, context) }
|
|
115
|
+
]
|
|
116
|
+
}),
|
|
117
|
+
signal: controller.signal
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (res.status === 429) {
|
|
121
|
+
slot.cooldownUntil = Date.now() + 60000;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!res.ok) return null;
|
|
125
|
+
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
const raw = data.choices?.[0]?.message?.content || "";
|
|
128
|
+
return this._parse(raw);
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_buildPrompt(text, context) {
|
|
135
|
+
return [
|
|
136
|
+
"Classify this Discord message for moderation purposes.",
|
|
137
|
+
context ? `Context: ${context}` : null,
|
|
138
|
+
`Message: ${JSON.stringify(text.slice(0, 1000))}`,
|
|
139
|
+
'Return JSON exactly like: {"category":"clean|spam|scam|phishing|raid_coordination|harassment|nsfw","confidence":0.0,"reason":"short reason"}'
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_parse(raw) {
|
|
146
|
+
try {
|
|
147
|
+
const cleaned = raw.replace(/```json|```/g, "").trim();
|
|
148
|
+
const parsed = JSON.parse(cleaned);
|
|
149
|
+
if (typeof parsed.category !== "string" || typeof parsed.confidence !== "number") return null;
|
|
150
|
+
return {
|
|
151
|
+
category: parsed.category,
|
|
152
|
+
confidence: Math.max(0, Math.min(1, parsed.confidence)),
|
|
153
|
+
reason: parsed.reason || "AI classification"
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = GroqClient;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { buildPanel } = require("../ui/ControlPanel");
|
|
4
|
+
const { buildLogMessage } = require("../logging/ComponentsV2");
|
|
5
|
+
|
|
6
|
+
const LAYER_NAMES = ["antiRaid", "antiNuke", "basicSecurity", "aiModeration"];
|
|
7
|
+
const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GlassFrame Protocol registers no slash commands, by design (see
|
|
11
|
+
* docs/COMMANDS.md). Every command here is a plain prefix command gated on
|
|
12
|
+
* Manage Server permission, with a short per-user cooldown so a doubled
|
|
13
|
+
* keypress can't fire the same command twice in a row.
|
|
14
|
+
*/
|
|
15
|
+
class PrefixRouter {
|
|
16
|
+
constructor(frame) {
|
|
17
|
+
this.frame = frame;
|
|
18
|
+
this.lastUsed = new Map(); // `guildId:userId` -> timestamp
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
attach() {
|
|
22
|
+
this.frame.client.on("messageCreate", (message) => this._handle(message).catch(() => {}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_onCooldown(message) {
|
|
26
|
+
const key = `${message.guild.id}:${message.author.id}`;
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const last = this.lastUsed.get(key) || 0;
|
|
29
|
+
const cooldownMs = this.frame.config.performance.commandCooldownMs;
|
|
30
|
+
if (now - last < cooldownMs) return true;
|
|
31
|
+
this.lastUsed.set(key, now);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async _handle(message) {
|
|
36
|
+
if (!message.guild || message.author.bot) return;
|
|
37
|
+
const prefix = this.frame.config.prefix;
|
|
38
|
+
if (!message.content.toLowerCase().startsWith(prefix.toLowerCase())) return;
|
|
39
|
+
|
|
40
|
+
const args = message.content.slice(prefix.length).trim().split(/\s+/);
|
|
41
|
+
const command = (args.shift() || "").toLowerCase();
|
|
42
|
+
if (!COMMANDS.includes(command)) return;
|
|
43
|
+
|
|
44
|
+
if (!this.frame.isAuthorized(message.member)) {
|
|
45
|
+
await message.reply({ content: "You need Manage Server permission to use GlassFrame Protocol commands." });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (this._onCooldown(message)) return;
|
|
50
|
+
|
|
51
|
+
if (command === "panel") return this._panel(message);
|
|
52
|
+
if (command === "status") return this._status(message);
|
|
53
|
+
if (command === "help") return this._help(message);
|
|
54
|
+
if (command === "whitelist") return this._whitelist(message, args);
|
|
55
|
+
if (command === "scan") return this._scan(message);
|
|
56
|
+
if (command === "metrics") return this._metrics(message);
|
|
57
|
+
if (command === "phishing") return this._phishing(message, args);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async _panel(message) {
|
|
61
|
+
const payload = buildPanel(this.frame, message.guild.id);
|
|
62
|
+
const sent = await message.channel.send(payload);
|
|
63
|
+
this.frame.panelMessages.set(sent.id, message.guild.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async _status(message) {
|
|
67
|
+
const status = this.frame.getStatus(message.guild.id);
|
|
68
|
+
const fields = LAYER_NAMES.map((l) => ({ name: l, value: status[l] ? "ACTIVE" : "INACTIVE" }));
|
|
69
|
+
const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Status", fields });
|
|
70
|
+
await message.channel.send(payload);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
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 - show performance and cache health`,
|
|
83
|
+
`${prefix}phishing add|remove|list <domain> - manage the link blocklist`,
|
|
84
|
+
`${prefix}whitelist add <userId> - exempt a user from all punitive action`,
|
|
85
|
+
`${prefix}whitelist remove <userId> - remove an exemption`,
|
|
86
|
+
`${prefix}help - this message`
|
|
87
|
+
].join("\n")
|
|
88
|
+
});
|
|
89
|
+
await message.channel.send(payload);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async _whitelist(message, args) {
|
|
93
|
+
const [sub, id] = args;
|
|
94
|
+
if (!sub || !id) {
|
|
95
|
+
await message.reply({ content: `Usage: ${this.frame.config.prefix}whitelist <add|remove> <userId>` });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const action = sub.toLowerCase();
|
|
100
|
+
if (action === "add") this.frame.whitelist.add(id);
|
|
101
|
+
else if (action === "remove") this.frame.whitelist.delete(id);
|
|
102
|
+
else return;
|
|
103
|
+
|
|
104
|
+
await message.reply({
|
|
105
|
+
content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list.`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async _scan(message) {
|
|
110
|
+
const flags = this.frame.roleAnalyzer.scanRoles(message.guild);
|
|
111
|
+
if (!flags.length) {
|
|
112
|
+
await message.reply({ content: "No role name/permission mismatches found." });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const payload = buildLogMessage({
|
|
116
|
+
level: "warn",
|
|
117
|
+
title: "Role Audit Results",
|
|
118
|
+
description: flags.map((f) => `**${f.name}** - ${f.reason}`).join("\n")
|
|
119
|
+
});
|
|
120
|
+
await message.channel.send(payload);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async _metrics(message) {
|
|
124
|
+
const m = this.frame.getMetrics();
|
|
125
|
+
const fmtCache = (c) => `size ${c.size} | hits ${c.hits} | misses ${c.misses} | evictions ${c.evictions}`;
|
|
126
|
+
const fmtQueue = (q) => `active ${q.active}/${q.concurrency} | pending ${q.pending} | completed ${q.completed} | failed ${q.failed}`;
|
|
127
|
+
const fmtLatency = Object.entries(m.performance.avgLatencyMs)
|
|
128
|
+
.map(([name, ms]) => `${name}: ${ms}ms avg`)
|
|
129
|
+
.join("\n") || "no samples yet";
|
|
130
|
+
|
|
131
|
+
const payload = buildLogMessage({
|
|
132
|
+
level: "info",
|
|
133
|
+
title: "GlassFrame Protocol - Performance",
|
|
134
|
+
description: `Uptime: ${Math.round(m.performance.uptimeMs / 60000)} min`,
|
|
135
|
+
fields: [
|
|
136
|
+
{ name: "Action queue", value: fmtQueue(m.queues.actions) },
|
|
137
|
+
{ name: "AI queue", value: fmtQueue(m.queues.ai) },
|
|
138
|
+
{ name: "Threat score cache", value: fmtCache(m.caches.threatScores) },
|
|
139
|
+
{ name: "Open case cache", value: fmtCache(m.caches.openCases) },
|
|
140
|
+
{ name: "Groq verdict cache", value: fmtCache(m.caches.groqVerdicts) },
|
|
141
|
+
{ name: "Average latency", value: fmtLatency }
|
|
142
|
+
]
|
|
143
|
+
});
|
|
144
|
+
await message.channel.send(payload);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async _phishing(message, args) {
|
|
148
|
+
const [sub, domain] = args;
|
|
149
|
+
const prefix = this.frame.config.prefix;
|
|
150
|
+
|
|
151
|
+
if (!sub || sub.toLowerCase() === "list") {
|
|
152
|
+
const domains = this.frame.phishingDatabase.list();
|
|
153
|
+
await message.reply({
|
|
154
|
+
content: domains.length ? `Blocked domains:\n${domains.join("\n")}` : "The phishing blocklist is empty."
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!domain) {
|
|
160
|
+
await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const action = sub.toLowerCase();
|
|
165
|
+
if (action === "add") {
|
|
166
|
+
this.frame.phishingDatabase.add(domain);
|
|
167
|
+
await message.reply({ content: `Added **${domain}** to the phishing blocklist.` });
|
|
168
|
+
} else if (action === "remove") {
|
|
169
|
+
const existed = this.frame.phishingDatabase.remove(domain);
|
|
170
|
+
await message.reply({ content: existed ? `Removed **${domain}** from the blocklist.` : `**${domain}** wasn't on the blocklist.` });
|
|
171
|
+
} else {
|
|
172
|
+
await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = PrefixRouter;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small TTL cache used by every stateful piece of GlassFrame Protocol
|
|
5
|
+
* instead of a bare Map. Centralizing it means every cache in the library
|
|
6
|
+
* gets the same expiry rules, the same stats, and the same optional debug
|
|
7
|
+
* logging for free. See docs/CACHE_ARCHITECTURE.md for the full picture of
|
|
8
|
+
* which cache is used where and why.
|
|
9
|
+
*/
|
|
10
|
+
class ProtocolCache {
|
|
11
|
+
constructor(name, { ttlMs = 5 * 60 * 1000, debug = false, sweepEveryMs = 60000 } = {}) {
|
|
12
|
+
this.name = name;
|
|
13
|
+
this.ttlMs = ttlMs;
|
|
14
|
+
this.debug = debug;
|
|
15
|
+
this.store = new Map();
|
|
16
|
+
this.stats = { hits: 0, misses: 0, sets: 0, evictions: 0, sweeps: 0 };
|
|
17
|
+
this._sweeper = null;
|
|
18
|
+
|
|
19
|
+
if (sweepEveryMs) {
|
|
20
|
+
this._sweeper = setInterval(() => this.sweep(), sweepEveryMs);
|
|
21
|
+
this._sweeper.unref?.();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** ttlOverrideMs of 0 or less means "never expires" for this entry. */
|
|
26
|
+
set(key, value, ttlOverrideMs) {
|
|
27
|
+
const ttl = ttlOverrideMs ?? this.ttlMs;
|
|
28
|
+
this.store.set(key, { value, expiresAt: ttl > 0 ? Date.now() + ttl : Infinity });
|
|
29
|
+
this.stats.sets++;
|
|
30
|
+
this._log("SET", key);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(key) {
|
|
35
|
+
const entry = this.store.get(key);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
this.stats.misses++;
|
|
38
|
+
this._log("MISS", key);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (Date.now() > entry.expiresAt) {
|
|
42
|
+
this.store.delete(key);
|
|
43
|
+
this.stats.misses++;
|
|
44
|
+
this.stats.evictions++;
|
|
45
|
+
this._log("EXPIRE", key);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
this.stats.hits++;
|
|
49
|
+
this._log("HIT", key);
|
|
50
|
+
return entry.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
has(key) {
|
|
54
|
+
return this.get(key) !== undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
delete(key) {
|
|
58
|
+
const existed = this.store.delete(key);
|
|
59
|
+
if (existed) this._log("DELETE", key);
|
|
60
|
+
return existed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
sweep() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
let removed = 0;
|
|
66
|
+
for (const [key, entry] of this.store) {
|
|
67
|
+
if (now > entry.expiresAt) {
|
|
68
|
+
this.store.delete(key);
|
|
69
|
+
removed++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.stats.evictions += removed;
|
|
73
|
+
this.stats.sweeps++;
|
|
74
|
+
if (removed) this._log("SWEEP", `${removed} expired`);
|
|
75
|
+
return removed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clear() {
|
|
79
|
+
this.store.clear();
|
|
80
|
+
this._log("CLEAR", "all keys");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getStats() {
|
|
84
|
+
return { ...this.stats, size: this.store.size, name: this.name };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
destroy() {
|
|
88
|
+
if (this._sweeper) clearInterval(this._sweeper);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_log(op, key) {
|
|
92
|
+
if (this.debug) {
|
|
93
|
+
console.log(`[GlassFrame:Cache:${this.name}] ${op} ${key}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = ProtocolCache;
|