glassframe-protocol 2.0.0 → 2.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 +144 -0
- package/GETTING_STARTED.md +17 -0
- package/README.md +36 -10
- package/dist/config.js +45 -5
- package/dist/src/GlassFrame.js +166 -5
- package/dist/src/commands/PrefixRouter.js +120 -26
- package/dist/src/core/PerformanceMonitor.js +16 -4
- package/dist/src/core/VersionInfo.js +1 -1
- package/dist/src/layers/AIModerationLayer.js +1 -0
- package/dist/src/layers/AntiNukeLayer.js +100 -7
- package/dist/src/layers/AntiRaidLayer.js +3 -1
- package/dist/src/layers/BasicSecurityLayer.js +73 -12
- package/dist/src/logging/SmartLogger.js +33 -3
- package/dist/src/moderation/PunishmentEngine.js +14 -3
- package/dist/src/moderation/RoleAnalyzer.js +26 -0
- package/dist/src/ui/ControlPanel.js +141 -11
- package/dist/src/utils/nlpEngine.js +2 -2
- package/docs/CACHE_ARCHITECTURE.md +5 -0
- package/docs/COMMANDS.md +13 -1
- package/docs/ENGINE.md +83 -0
- package/docs/PROTOCOL_LAYERS.md +73 -10
- package/package.json +1 -1
|
@@ -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,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 (
|
|
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") },
|
|
@@ -39,6 +39,32 @@ class RoleAnalyzer {
|
|
|
39
39
|
return "LOW";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A DIFFERENT question from trustLevel() above: not "does this member
|
|
44
|
+
* hold real authority," but "has this account and this membership simply
|
|
45
|
+
* existed long enough to not look like a fresh raid/alt account." No
|
|
46
|
+
* relation to roles or permissions - a totally ordinary member with no
|
|
47
|
+
* special role can be "established," and a MEDIUM-trust role holder with
|
|
48
|
+
* a week-old account isn't.
|
|
49
|
+
*
|
|
50
|
+
* This exists to soften pure rate/volume spam detection (see
|
|
51
|
+
* BasicSecurityLayer) for members who are simply very active, the way
|
|
52
|
+
* modern invisible CAPTCHA judges from signals already available instead
|
|
53
|
+
* of a one-size-fits-all challenge - it is deliberately NOT used to
|
|
54
|
+
* soften content-based detection (scam/phishing/raid language, links),
|
|
55
|
+
* since account age says nothing about whether a specific message is
|
|
56
|
+
* dangerous. An established account can still raid or spam; this only
|
|
57
|
+
* ever reduces false positives on "sends a lot of messages," never
|
|
58
|
+
* blanket-exempts anyone from everything else.
|
|
59
|
+
*/
|
|
60
|
+
isEstablishedMember(member) {
|
|
61
|
+
if (!member || !member.user) return false;
|
|
62
|
+
const cfg = this.config.maturity;
|
|
63
|
+
const accountAge = Date.now() - member.user.createdTimestamp;
|
|
64
|
+
const serverTenure = member.joinedTimestamp ? Date.now() - member.joinedTimestamp : 0;
|
|
65
|
+
return accountAge >= cfg.minAccountAgeMs && serverTenure >= cfg.minServerTenureMs;
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
/**
|
|
43
69
|
* Scans a guild's current roles for name/permission mismatches: a role
|
|
44
70
|
* whose name signals authority ("admin", "staff", ...) but carries no real
|
|
@@ -74,24 +74,51 @@ function buildMetricsMessage(frame, guildId, scope = "guild") {
|
|
|
74
74
|
.map(([name, ms]) => `${name}: ${ms}ms avg`)
|
|
75
75
|
.join("\n") || "no samples yet";
|
|
76
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
|
+
|
|
77
82
|
fields.push(
|
|
78
83
|
{ name: "Uptime", value: `${Math.round(m.performance.uptimeMs / 60000)} min` },
|
|
79
|
-
{ name: "Action queue", value: fmtQueue(m.queues.actions) },
|
|
80
|
-
{ name: "AI queue", value: fmtQueue(m.queues.ai) },
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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" }
|
|
85
99
|
);
|
|
86
100
|
} else {
|
|
87
101
|
const gm = frame.getGuildMetrics(guildId);
|
|
88
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;
|
|
89
104
|
|
|
90
105
|
fields.push(
|
|
91
|
-
{
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
122
|
);
|
|
96
123
|
}
|
|
97
124
|
|
|
@@ -119,4 +146,107 @@ function buildMetricsMessage(frame, guildId, scope = "guild") {
|
|
|
119
146
|
return { flags: MessageFlags.IsComponentsV2, components: [container] };
|
|
120
147
|
}
|
|
121
148
|
|
|
122
|
-
|
|
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) {
|
|
@@ -37,6 +37,11 @@ windows with no need for hit/miss stats: `AntiRaidLayer.joinTimestamps` /
|
|
|
37
37
|
are swept lazily - old timestamps are filtered out on the next write rather
|
|
38
38
|
than on a timer, since they're only ever read right after a write.
|
|
39
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
|
+
|
|
40
45
|
Bounded-concurrency queues (`frame.actionQueue`, `frame.aiQueue`) and the
|
|
41
46
|
internal metrics collector (`frame.performance`) are a separate concern from
|
|
42
47
|
caching - see `docs/PERFORMANCE.md`.
|
package/docs/COMMANDS.md
CHANGED
|
@@ -18,7 +18,19 @@ running twice or getting a "slow down" reply of its own.
|
|
|
18
18
|
| `!gf phishing list` | Lists every blocked domain. |
|
|
19
19
|
| `!gf whitelist add <userId>` | Exempts a user from punitive action **in this server**. |
|
|
20
20
|
| `!gf whitelist remove <userId>` | Removes that exemption for this server. |
|
|
21
|
-
| `!gf
|
|
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.
|
|
22
34
|
|
|
23
35
|
Everything above operates on the server the command was sent in - arming
|
|
24
36
|
AntiRaid, whitelisting someone, or checking status in one server has no
|
package/docs/ENGINE.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# The Engine Dashboard
|
|
2
|
+
|
|
3
|
+
`!gf engine` is a bot-wide, cross-server dashboard for whoever actually runs
|
|
4
|
+
the bot - not a per-server admin tool. It shows things a single server's
|
|
5
|
+
admin shouldn't be able to see about *other* servers (which server is
|
|
6
|
+
busiest, bot-wide AI usage, a live feed of what's happening everywhere), so
|
|
7
|
+
it has its own, separate permission model from every other command.
|
|
8
|
+
|
|
9
|
+
It is deliberately **not** listed in `!gf help` or `docs/COMMANDS.md`'s
|
|
10
|
+
in-Discord command table - a random server admin shouldn't even know it
|
|
11
|
+
exists. You're expected to learn about it from this file.
|
|
12
|
+
|
|
13
|
+
## Who can use it
|
|
14
|
+
|
|
15
|
+
Two independent checks, not one:
|
|
16
|
+
|
|
17
|
+
1. **`isOwner(userId)`** - you must be listed in `options.owners` when you
|
|
18
|
+
construct `GlassFrame`. This is required, full stop, for the command
|
|
19
|
+
itself *and* every page-navigation button click afterward.
|
|
20
|
+
2. **A password** (optional) - if `config.engine.password` is set, the
|
|
21
|
+
initial `!gf engine <password>` command also needs the correct password.
|
|
22
|
+
Page-navigation button clicks after that don't re-prompt for it, since a
|
|
23
|
+
button click is already tied to a real, verified Discord identity - only
|
|
24
|
+
the text-command entry point needs the second factor.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const frame = new GlassFrame(client, {
|
|
28
|
+
getLogChannel: /* ... */,
|
|
29
|
+
owners: ["your-discord-user-id"],
|
|
30
|
+
config: {
|
|
31
|
+
engine: {
|
|
32
|
+
password: process.env.GLASSFRAME_ENGINE_PASSWORD // never hardcode a real one
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Leave `config.engine.password` unset to skip the second factor and rely on
|
|
39
|
+
the owners list alone.
|
|
40
|
+
|
|
41
|
+
## What happens to the password
|
|
42
|
+
|
|
43
|
+
Typing a password into a normal Discord message means it briefly sits in
|
|
44
|
+
plain text in a channel, visible to anyone watching and to Discord's own
|
|
45
|
+
message history. GlassFrame does two things about that:
|
|
46
|
+
|
|
47
|
+
- The command message is **deleted immediately** after being checked,
|
|
48
|
+
whether the password was right or wrong.
|
|
49
|
+
- Wrong guesses count toward a **lockout** -
|
|
50
|
+
`config.engine.maxAttempts` wrong attempts (default 3) locks that user
|
|
51
|
+
out for `config.engine.lockoutMs` (default 10 minutes), making
|
|
52
|
+
brute-forcing impractical.
|
|
53
|
+
|
|
54
|
+
Use a private channel (or a channel only you can see) for this command
|
|
55
|
+
regardless - deletion happens right after Discord delivers the message, not
|
|
56
|
+
before anyone in the channel could have glimpsed it.
|
|
57
|
+
|
|
58
|
+
## The five pages
|
|
59
|
+
|
|
60
|
+
One row of tab buttons switches between them; the active tab is highlighted.
|
|
61
|
+
|
|
62
|
+
| Page | Shows |
|
|
63
|
+
|---|---|
|
|
64
|
+
| **Overview** | Version, uptime, guild count, layer adoption across every server |
|
|
65
|
+
| **Servers** | The busiest servers ranked by event volume since this process started |
|
|
66
|
+
| **AI** | Groq key pool status, cooldowns, call volume, verdict cache hit rate |
|
|
67
|
+
| **Performance** | Action/AI queue depth, all three cache stats, average latency per operation |
|
|
68
|
+
| **Activity Log** | The last several things logged anywhere, any server, newest first |
|
|
69
|
+
|
|
70
|
+
All of it comes from one call: `frame.getEngineReport()`, if you want the
|
|
71
|
+
raw data instead of the rendered message (for your own dashboard, an API
|
|
72
|
+
endpoint, whatever).
|
|
73
|
+
|
|
74
|
+
## "Busiest" and the activity log, precisely
|
|
75
|
+
|
|
76
|
+
- **Busiest** is a count of events (messages/joins/audit entries) any armed
|
|
77
|
+
layer processed for that server, tracked in memory since the process
|
|
78
|
+
started - it resets on restart and isn't a judgment about which server is
|
|
79
|
+
causing trouble, just which one has the most traffic.
|
|
80
|
+
- The **activity log** is the last 30 events logged anywhere (any server,
|
|
81
|
+
any layer), kept in memory by `SmartLogger` - see
|
|
82
|
+
`docs/CACHE_ARCHITECTURE.md`. The dashboard shows the most recent 10 of
|
|
83
|
+
those.
|
package/docs/PROTOCOL_LAYERS.md
CHANGED
|
@@ -83,13 +83,76 @@ that server*, and again on demand via `!gf scan`. It reports flags, never
|
|
|
83
83
|
actions - a human decides what, if anything, to do about a role that looks
|
|
84
84
|
like impersonation bait or quietly holds dangerous permissions.
|
|
85
85
|
|
|
86
|
-
## Adding a
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
`
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
state persistence, and
|
|
95
|
-
|
|
86
|
+
## Adding a layer without editing the library
|
|
87
|
+
|
|
88
|
+
`frame.registerLayer(name, layerInstance)` is the supported way to do this
|
|
89
|
+
now - no need to edit `GlassFrame.js`'s `this.layers` map by hand.
|
|
90
|
+
`layerInstance` must extend `core/Layer`: implement `attach()` (register
|
|
91
|
+
your event listener(s) once - it's called exactly once, at registration),
|
|
92
|
+
gate all real work behind `this.isEnabled(guildId)`, and report signals via
|
|
93
|
+
`this.frame.punishmentEngine.report({ guild, member, layer, weight, reason })`.
|
|
94
|
+
Per-guild enable/disable, state persistence, and `!gf status`/`!gf metrics`
|
|
95
|
+
all pick it up automatically, since they iterate `frame.layers` rather than
|
|
96
|
+
a fixed list. It does **not** get a button on the 5-button panel
|
|
97
|
+
automatically (that stays fixed at exactly 5) - toggle it with
|
|
98
|
+
`frame.enableLayer(name, guildId)` in code, or build your own command.
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
const Layer = require("glassframe-protocol/src/core/Layer");
|
|
102
|
+
|
|
103
|
+
class LinkAgeLayer extends Layer {
|
|
104
|
+
constructor(frame) { super("linkAge", frame); }
|
|
105
|
+
attach() {
|
|
106
|
+
this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
|
|
107
|
+
}
|
|
108
|
+
async _handle(message) {
|
|
109
|
+
if (!message.guild || message.author.bot) return;
|
|
110
|
+
if (!this.isEnabled(message.guild.id)) return;
|
|
111
|
+
// ... your detection logic, then:
|
|
112
|
+
// await this.frame.punishmentEngine.report({ guild: message.guild, member: message.member, layer: "linkAge", weight: 40, reason: "..." });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
frame.registerLayer("linkAge", new LinkAgeLayer(frame));
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Custom punishment actions
|
|
120
|
+
|
|
121
|
+
`frame.registerAction(name, handler)` adds an action beyond the built-in
|
|
122
|
+
ban/kick/timeout/quarantine, so `config.punishment.ladder` can reference it
|
|
123
|
+
by name. `handler` is `async (member, record) => {}` and runs through the
|
|
124
|
+
same bounded-concurrency action queue as the built-in ones.
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
frame.registerAction("addMutedRole", async (member) => {
|
|
128
|
+
const role = member.guild.roles.cache.find((r) => r.name === "Muted");
|
|
129
|
+
if (role) await member.roles.add(role);
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
```js
|
|
133
|
+
config: { punishment: { ladder: { medium: "addMutedRole" } } }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Established-member leniency
|
|
137
|
+
|
|
138
|
+
A separate axis from role-based trust, for a question role trust can't
|
|
139
|
+
answer: not "does this member have authority," but "has this account and
|
|
140
|
+
this membership simply existed long enough to not look like a fresh raid
|
|
141
|
+
account." `RoleAnalyzer.isEstablishedMember(member)` checks account age and
|
|
142
|
+
server tenure against `roleAnalysis.maturity` (defaults: 6 months, 1
|
|
143
|
+
month). A totally ordinary member with no special role can be
|
|
144
|
+
"established"; a MEDIUM-trust role holder with a week-old account isn't.
|
|
145
|
+
|
|
146
|
+
`BasicSecurityLayer` is the only place this is used, and only for the two
|
|
147
|
+
pure rate/volume checks (message rate, duplicate flood) -
|
|
148
|
+
`basicSecurity.establishedMemberLeniency` raises the threshold before
|
|
149
|
+
either fires at all and reduces the weight when it does, and neither
|
|
150
|
+
deletes an established member's message on its own. Every content-based
|
|
151
|
+
check (scam/phishing/raid-recruitment language, links, non-trusted mass
|
|
152
|
+
pings) is completely unaffected by tenure - full threshold, full weight,
|
|
153
|
+
message still deleted - since how long an account has existed says nothing
|
|
154
|
+
about whether a specific message is dangerous. This is deliberate: the
|
|
155
|
+
point is recognizing "this is just a very active regular," not granting
|
|
156
|
+
immunity. An established account can still raid, spam, or get compromised,
|
|
157
|
+
and will still be caught the moment it does anything content-wise, not
|
|
158
|
+
just volume-wise.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glassframe-protocol",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A layered, self-contained Discord security engine - AntiRaid, AntiNuke, Basic Security, and optional Groq-powered AI Moderation sharing one threat-scoring and punishment pipeline. Prefix commands only, Components V2 output, no slash commands.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|