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.
- package/CHANGELOG.md +184 -0
- package/GETTING_STARTED.md +23 -4
- package/README.md +55 -20
- package/dist/config.js +20 -1
- package/dist/src/GlassFrame.js +312 -42
- package/dist/src/commands/PrefixRouter.js +123 -48
- package/dist/src/core/Cache.js +10 -0
- package/dist/src/core/Layer.js +28 -23
- package/dist/src/core/PerformanceMonitor.js +16 -4
- package/dist/src/core/VersionInfo.js +1 -1
- package/dist/src/layers/AIModerationLayer.js +4 -4
- package/dist/src/layers/AntiNukeLayer.js +113 -16
- package/dist/src/layers/AntiRaidLayer.js +5 -7
- package/dist/src/layers/BasicSecurityLayer.js +39 -12
- package/dist/src/logging/SmartLogger.js +33 -3
- package/dist/src/moderation/PunishmentEngine.js +19 -7
- package/dist/src/ui/ControlPanel.js +195 -1
- package/dist/src/utils/nlpEngine.js +2 -2
- package/docs/CACHE_ARCHITECTURE.md +9 -0
- package/docs/COMMANDS.md +37 -11
- package/docs/ENGINE.md +83 -0
- package/docs/PROTOCOL_LAYERS.md +62 -17
- package/docs/STATE.md +101 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
12
|
+
attach() {
|
|
13
13
|
this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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(
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
34
|
-
// runtime
|
|
35
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
| `!gf whitelist remove <userId>` | Removes
|
|
21
|
-
| `!gf
|
|
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
|
|
34
|
-
either from the panel or via
|
|
35
|
-
|
|
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).
|