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
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,150 @@ lives in `src/core/VersionInfo.js` (`VersionInfo.info()` /
|
|
|
14
14
|
`VersionInfo.banner()`), so code can read the current version without
|
|
15
15
|
parsing this file.
|
|
16
16
|
|
|
17
|
+
## [2.2.0] - 2026-08-05
|
|
18
|
+
|
|
19
|
+
Fixes a real bug in message deletion and adds tenure-aware leniency for
|
|
20
|
+
spam detection. No breaking changes.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Protected/trusted members' messages were being deleted anyway.**
|
|
25
|
+
`BasicSecurityLayer` computed trust and used it to gate the @everyone
|
|
26
|
+
check, but every *other* trigger (message rate, duplicate flood, mention
|
|
27
|
+
spam, scam/phishing/raid language, links) deleted the message
|
|
28
|
+
unconditionally, before `PunishmentEngine` ever got a chance to decide
|
|
29
|
+
the sender shouldn't be punished. A real admin posting several messages
|
|
30
|
+
quickly during a busy conversation, or pasting a couple of links, had
|
|
31
|
+
their message removed even though they were never going to be actioned.
|
|
32
|
+
Deletion is now gated the same way punishment is: never for a HIGH/
|
|
33
|
+
PROTECTED-trust sender.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Expanded `roleAnalysis.protectedNames`**: added `co-owner`, `founder`,
|
|
38
|
+
`management`, `manager`, `support`, `helper`, `developer` alongside the
|
|
39
|
+
existing `owner`/`admin`/`administrator`/`moderator`/`mod`/`staff`/
|
|
40
|
+
`discord staff`/`security`.
|
|
41
|
+
- **Established-member leniency** (`RoleAnalyzer.isEstablishedMember()`,
|
|
42
|
+
`roleAnalysis.maturity`, `basicSecurity.establishedMemberLeniency`) - a
|
|
43
|
+
member with an old-enough account (default 6 months) and long-enough
|
|
44
|
+
tenure in the server (default 1 month) needs a much bigger burst before
|
|
45
|
+
a pure rate/duplicate-flood signal fires at all (2.5x by default), that
|
|
46
|
+
signal counts for less when it does (0.3x weight by default), and their
|
|
47
|
+
messages are never deleted for tripping *only* a rate/volume signal.
|
|
48
|
+
This is a separate axis from role-based trust above - an ordinary member
|
|
49
|
+
with no special role can be "established."
|
|
50
|
+
|
|
51
|
+
Deliberately scoped narrow: this only ever softens detection of *sending
|
|
52
|
+
frequently*. Scam/phishing/raid-recruitment language, suspicious links,
|
|
53
|
+
and non-trusted mass pings stay at full sensitivity and still get
|
|
54
|
+
deleted and actioned regardless of how long the account has existed -
|
|
55
|
+
tenure says nothing about whether a specific message is dangerous, and a
|
|
56
|
+
long-standing account can still genuinely raid, spam, or get
|
|
57
|
+
compromised. Modeled on how invisible CAPTCHA works: judge from signals
|
|
58
|
+
already available rather than one blunt threshold, without ever handing
|
|
59
|
+
out blanket immunity.
|
|
60
|
+
|
|
61
|
+
## [2.1.0] - 2026-08-01
|
|
62
|
+
|
|
63
|
+
Additive - nothing from 2.0.0's API changes. New detections, a plugin
|
|
64
|
+
system, per-guild customization, and an owner-only cross-server dashboard.
|
|
65
|
+
|
|
66
|
+
### Added - security detections
|
|
67
|
+
|
|
68
|
+
- **Dangerous role grants to a member.** The existing permission-grant
|
|
69
|
+
watchdog only caught a role's own permissions changing (`RoleUpdate`).
|
|
70
|
+
It missed the other half: someone with `ManageRoles` handing an
|
|
71
|
+
already-dangerous role directly to a member (`MemberRoleUpdate`) without
|
|
72
|
+
ever touching the role's definition. Now watched, gated the same way -
|
|
73
|
+
only flagged when the executor isn't recognized trusted staff.
|
|
74
|
+
- **Channel permission-overwrite abuse.** A nuke doesn't have to delete
|
|
75
|
+
anything - locking @everyone out of a public channel, or opening a
|
|
76
|
+
private one up to @everyone, does equivalent damage through permission
|
|
77
|
+
overwrites. Only @everyone's own overwrite is watched; a role- or
|
|
78
|
+
member-specific overwrite change is routine administration and ignored.
|
|
79
|
+
- **A dedicated @everyone/@here guard** in Basic Security, weighted well
|
|
80
|
+
above regular mention spam (a single mass ping reaches every member at
|
|
81
|
+
once) and skipped for recognized trusted staff, so a real admin's
|
|
82
|
+
announcement isn't flagged.
|
|
83
|
+
- **Raid-recruitment language is now actually used.** `raidCallout` scoring
|
|
84
|
+
existed in the NLP engine already but was never wired into
|
|
85
|
+
`BasicSecurityLayer`'s real decision logic - fixed. Also added a specific
|
|
86
|
+
invite-link + raid-language combo check (the "join this server to raid
|
|
87
|
+
with us" pattern), and expanded the raid lexicon to catch common word
|
|
88
|
+
forms ("raiding", "raids") that the bare-verb-only version missed.
|
|
89
|
+
|
|
90
|
+
### Added - extensibility
|
|
91
|
+
|
|
92
|
+
- **`frame.registerLayer(name, layerInstance)`** - add a custom security
|
|
93
|
+
layer to the same shared pipeline the built-in four use. Must extend
|
|
94
|
+
`core/Layer`; gets per-guild enable/disable, state persistence, and
|
|
95
|
+
`!gf status`/`!gf metrics` visibility for free, since those now iterate
|
|
96
|
+
`frame.layers` dynamically instead of a fixed list. Does not get an
|
|
97
|
+
automatic button on the 5-button panel (that stays fixed at 5).
|
|
98
|
+
- **`frame.registerAction(name, handler)`** - add a punishment action
|
|
99
|
+
beyond ban/kick/timeout/quarantine, referenceable from
|
|
100
|
+
`config.punishment.ladder`. Runs through the same action queue as the
|
|
101
|
+
built-in ones.
|
|
102
|
+
- **Per-guild custom prefix** - `!gf prefix set <newPrefix>` /
|
|
103
|
+
`!gf prefix reset`. `config.prefix` always still works everywhere as a
|
|
104
|
+
fallback; an overlapping custom/default pair resolves by trying the
|
|
105
|
+
longer one first.
|
|
106
|
+
- **Per-layer log channel routing** - `getLogChannel(guild, layerName)` now
|
|
107
|
+
receives the reporting layer's name as an optional second argument, so
|
|
108
|
+
different layers can route to different channels. Fully backward
|
|
109
|
+
compatible - a `getLogChannel` that only takes one parameter keeps
|
|
110
|
+
working unchanged.
|
|
111
|
+
|
|
112
|
+
### Added - the owner-only engine dashboard
|
|
113
|
+
|
|
114
|
+
- **`!gf engine`** - a bot-wide, cross-server dashboard: layer adoption
|
|
115
|
+
across every server, the busiest servers by event volume, AI usage
|
|
116
|
+
(Groq key pool/cooldowns/call volume), queue and cache health, and a live
|
|
117
|
+
feed of the last several things logged anywhere. See `docs/ENGINE.md`.
|
|
118
|
+
Deliberately not listed in `!gf help` - a per-server admin shouldn't know
|
|
119
|
+
it exists, let alone see data about other servers.
|
|
120
|
+
- **Its own permission model**, separate from every other command:
|
|
121
|
+
`options.owners` (a list of Discord user IDs) is required regardless, and
|
|
122
|
+
an optional `config.engine.password` is a second factor on the initial
|
|
123
|
+
command only - page-navigation clicks re-check `isOwner()` but don't
|
|
124
|
+
re-prompt for the password. Wrong guesses count toward a lockout
|
|
125
|
+
(`config.engine.maxAttempts`, `config.engine.lockoutMs`), and the command
|
|
126
|
+
message is deleted immediately either way so the password doesn't sit
|
|
127
|
+
visible in channel history.
|
|
128
|
+
- **5 pages** (Overview, Servers, AI, Performance, Activity Log), navigated
|
|
129
|
+
with a row of tab buttons - this is a second interactive surface,
|
|
130
|
+
entirely separate from the 5-button panel and the metrics command's own
|
|
131
|
+
button.
|
|
132
|
+
- `frame.getEngineReport()` - the raw data behind the dashboard, if you
|
|
133
|
+
want it for your own tooling instead of the rendered message.
|
|
134
|
+
- `PerformanceMonitor.recordEvent(name, guildId)` now optionally tracks
|
|
135
|
+
per-guild activity (`topGuildsByActivity()`) alongside the existing
|
|
136
|
+
by-name counters.
|
|
137
|
+
- `SmartLogger` keeps a bounded in-memory ring buffer (`getRecentLogs()`,
|
|
138
|
+
last 30) of recent events across every guild, powering the Activity Log
|
|
139
|
+
page.
|
|
140
|
+
|
|
141
|
+
### Changed
|
|
142
|
+
|
|
143
|
+
- `!gf help` reorganized into sections (Getting started / Security tools /
|
|
144
|
+
Diagnostics / Customization) with a one-line explanation of what each
|
|
145
|
+
command actually does, instead of a flat list of syntax.
|
|
146
|
+
- `!gf status` now explains what each layer actually watches for, not just
|
|
147
|
+
whether it's on, and iterates every registered layer (including custom
|
|
148
|
+
ones) instead of a fixed four.
|
|
149
|
+
- `!gf metrics` and the global engine view now carry a short plain-language
|
|
150
|
+
explanation under every number - what it means, and whether it's
|
|
151
|
+
generally worth a closer look.
|
|
152
|
+
|
|
153
|
+
### Fixed
|
|
154
|
+
|
|
155
|
+
- `raidCallout` NLP scoring was computed but never checked anywhere.
|
|
156
|
+
- The default `raidCalloutThreshold` (0.5) was miscalibrated against how
|
|
157
|
+
the scoring function actually behaves - lowered to 0.3, validated against
|
|
158
|
+
both a real raid-recruitment example and known-innocuous messages with
|
|
159
|
+
margin on both sides.
|
|
160
|
+
|
|
17
161
|
## [2.0.0] - 2026-08-01
|
|
18
162
|
|
|
19
163
|
**Breaking.** Layer activation and the whitelist were accidentally global
|
package/GETTING_STARTED.md
CHANGED
|
@@ -123,11 +123,28 @@ Nobody needs this guide again after setup. Server admins use:
|
|
|
123
123
|
| `!gf whitelist add/remove <userId>` | Exempt a user from punitive action in this server |
|
|
124
124
|
| `!gf help` | Lists all of the above |
|
|
125
125
|
|
|
126
|
+
## Step 6 (optional) - Your own bot-wide dashboard
|
|
127
|
+
|
|
128
|
+
If you run the bot yourself, `!gf engine` gives you a cross-server view
|
|
129
|
+
(busiest server, AI usage, a live activity feed) that regular server admins
|
|
130
|
+
can't see:
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
const frame = new GlassFrame(client, {
|
|
134
|
+
getLogChannel: /* ... */,
|
|
135
|
+
owners: ["your-discord-user-id"]
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
See `docs/ENGINE.md` for the optional password/lockout system and what
|
|
140
|
+
each of its 5 pages shows.
|
|
141
|
+
|
|
126
142
|
## Going deeper
|
|
127
143
|
|
|
128
144
|
- `README.md` - full feature overview
|
|
129
145
|
- `docs/PROTOCOL_LAYERS.md` - how a signal becomes an action
|
|
130
146
|
- `docs/STATE.md` - per-server layer/whitelist state and persistence
|
|
147
|
+
- `docs/ENGINE.md` - the owner-only bot-wide dashboard
|
|
131
148
|
- `docs/CACHE_ARCHITECTURE.md` - every internal cache and its TTL
|
|
132
149
|
- `docs/PERFORMANCE.md` - the bounded-concurrency queues and `!gf metrics`
|
|
133
150
|
- `docs/COMMANDS.md` - full command/button reference
|
package/README.md
CHANGED
|
@@ -80,8 +80,10 @@ the 5-button control panel and arm layers from there instead of in code.
|
|
|
80
80
|
|
|
81
81
|
## The four layers
|
|
82
82
|
|
|
83
|
-
- **Basic Security** - spam rate, duplicate flood, mention spam,
|
|
84
|
-
|
|
83
|
+
- **Basic Security** - spam rate, duplicate flood, mention spam, a
|
|
84
|
+
dedicated trust-aware @everyone/@here guard, local NLP scam/phishing/
|
|
85
|
+
raid-recruitment scoring (including a specific invite-link +
|
|
86
|
+
raid-language combo check), and link checks via `PhishingDatabase` (a
|
|
85
87
|
server-editable blocklist plus raw-IP/punycode/brand-look-alike/shortener
|
|
86
88
|
heuristics - `!gf phishing add/remove/list`).
|
|
87
89
|
- **AntiRaid** - join-velocity lockdown with automatic verification-level
|
|
@@ -90,12 +92,15 @@ the 5-button control panel and arm layers from there instead of in code.
|
|
|
90
92
|
catches a raid trickling in too slowly to trip the rate counter.
|
|
91
93
|
- **AntiNuke** - audit-log burst detection across channels, roles, bans,
|
|
92
94
|
kicks, webhooks, emoji, and stickers per executor; a dangerous
|
|
93
|
-
permission-grant watchdog
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
(
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
permission-grant watchdog covering both a role's own permissions
|
|
96
|
+
changing *and* a member being quietly handed an already-dangerous role
|
|
97
|
+
(with optional auto-revert); a channel permission-overwrite watchdog
|
|
98
|
+
(catches @everyone getting locked out of, or let into, a channel without
|
|
99
|
+
anything being deleted); a server-identity watchdog (name/icon/vanity URL
|
|
100
|
+
changes); an invite-abuse watchdog (unrestricted invites from non-staff);
|
|
101
|
+
and webhook-flood containment (deletes an abusive webhook and traces it
|
|
102
|
+
back to its creator, since webhook messages carry no guild member for
|
|
103
|
+
other layers to see).
|
|
99
104
|
- **AI Moderation** (optional, off by default) - Groq-powered second opinion
|
|
100
105
|
for messages the local NLP scores as "gray zone": not clean, not clearly
|
|
101
106
|
over threshold. Supports a pool of API keys with automatic per-key
|
|
@@ -104,7 +109,8 @@ the 5-button control panel and arm layers from there instead of in code.
|
|
|
104
109
|
|
|
105
110
|
Every layer starts disabled, per guild. Nothing runs until you list it in
|
|
106
111
|
`autoStart`, call `frame.enableLayer(name, guildId)`, or arm it from the
|
|
107
|
-
control panel.
|
|
112
|
+
control panel. Add your own layer alongside these four with
|
|
113
|
+
`frame.registerLayer()` - see "Extending it" below.
|
|
108
114
|
|
|
109
115
|
## Performance under load
|
|
110
116
|
|
|
@@ -128,17 +134,37 @@ for human review, never auto-banned), and only if there isn't already an
|
|
|
128
134
|
open case for that member from a moment ago. Full write-up in
|
|
129
135
|
`docs/PROTOCOL_LAYERS.md`.
|
|
130
136
|
|
|
137
|
+
## Extending it
|
|
138
|
+
|
|
139
|
+
- **`frame.registerLayer(name, layerInstance)`** - add your own security
|
|
140
|
+
layer to the same pipeline the built-in four use (per-guild enable/state/
|
|
141
|
+
persistence come free from extending `core/Layer`). Doesn't get a button
|
|
142
|
+
on the panel automatically - the panel stays fixed at exactly 5.
|
|
143
|
+
- **`frame.registerAction(name, handler)`** - add a custom punishment
|
|
144
|
+
action beyond ban/kick/timeout/quarantine, referenceable from
|
|
145
|
+
`config.punishment.ladder`.
|
|
146
|
+
- **`!gf prefix set <newPrefix>`** - each server can run its own prefix;
|
|
147
|
+
`config.prefix` always still works everywhere as a fallback.
|
|
148
|
+
- **`getLogChannel(guild, layerName)`** - route different layers to
|
|
149
|
+
different channels, if you want; the second parameter is optional and
|
|
150
|
+
backward compatible.
|
|
151
|
+
- **`!gf engine`** - an owner-only, bot-wide dashboard (busiest server, AI
|
|
152
|
+
usage, live cross-server activity feed), gated separately from every
|
|
153
|
+
other command and not listed in `!gf help`. See `docs/ENGINE.md`.
|
|
154
|
+
|
|
131
155
|
## Docs
|
|
132
156
|
|
|
133
157
|
- `GETTING_STARTED.md` - adding GlassFrame to a bot you already have running.
|
|
134
158
|
- `docs/PROTOCOL_LAYERS.md` - full architecture: containment vs. punishment,
|
|
135
|
-
how a signal becomes an action,
|
|
159
|
+
how a signal becomes an action, `registerLayer`/`registerAction`.
|
|
136
160
|
- `docs/STATE.md` - per-guild layer/whitelist state and how persistence
|
|
137
161
|
across restarts works.
|
|
138
162
|
- `docs/CACHE_ARCHITECTURE.md` - every cache in the library, its key shape,
|
|
139
163
|
its TTL, and how to turn on cache-level debug logging.
|
|
140
164
|
- `docs/PERFORMANCE.md` - the bounded-concurrency queues and internal
|
|
141
165
|
metrics collector, and what `!gf metrics` shows.
|
|
166
|
+
- `docs/ENGINE.md` - the owner-only bot-wide dashboard: the password/lockout
|
|
167
|
+
system and what each of its 5 pages shows.
|
|
142
168
|
- `docs/COMMANDS.md` - the full prefix command reference and the control
|
|
143
169
|
panel's five buttons.
|
|
144
170
|
- `docs/PUBLISHING.md` - publishing this package to npm, and what actually
|
package/dist/config.js
CHANGED
|
@@ -11,8 +11,9 @@ module.exports = {
|
|
|
11
11
|
roleAnalysis: {
|
|
12
12
|
// Role-name fragments (lowercased, substring match) that imply real authority.
|
|
13
13
|
protectedNames: [
|
|
14
|
-
"owner", "
|
|
15
|
-
"staff", "discord staff", "security"
|
|
14
|
+
"owner", "co-owner", "founder", "admin", "administrator",
|
|
15
|
+
"moderator", "mod", "staff", "discord staff", "security",
|
|
16
|
+
"management", "manager", "support", "helper", "developer"
|
|
16
17
|
],
|
|
17
18
|
// Permission names (PermissionsBitField.Flags keys) treated as "dangerous"
|
|
18
19
|
// when granted to a role - watched by AntiNuke's grant watchdog.
|
|
@@ -23,7 +24,16 @@ module.exports = {
|
|
|
23
24
|
// Permission names that mark a member as genuinely trusted staff.
|
|
24
25
|
trustPermissions: ["Administrator", "ManageGuild", "ManageRoles", "BanMembers", "KickMembers"],
|
|
25
26
|
// A role younger than this that already holds a dangerous permission is extra suspicious.
|
|
26
|
-
newRoleGraceMs: 1000 * 60 * 10
|
|
27
|
+
newRoleGraceMs: 1000 * 60 * 10,
|
|
28
|
+
// A DIFFERENT axis from role-based trust above: how long an account
|
|
29
|
+
// and membership has simply existed, with no relation to roles or
|
|
30
|
+
// permissions. Used only to soften pure rate/volume spam detection for
|
|
31
|
+
// members who are just very active - see basicSecurity.establishedMemberLeniency
|
|
32
|
+
// and docs/PROTOCOL_LAYERS.md ("Established-member leniency").
|
|
33
|
+
maturity: {
|
|
34
|
+
minAccountAgeMs: 1000 * 60 * 60 * 24 * 180, // 6 months
|
|
35
|
+
minServerTenureMs: 1000 * 60 * 60 * 24 * 30 // 1 month in this server
|
|
36
|
+
}
|
|
27
37
|
},
|
|
28
38
|
|
|
29
39
|
threatEngine: {
|
|
@@ -44,11 +54,22 @@ module.exports = {
|
|
|
44
54
|
basicSecurity: {
|
|
45
55
|
spam: { windowMs: 6000, messageThreshold: 6, duplicateThreshold: 4 },
|
|
46
56
|
mentionSpam: { maxMentionsPerMessage: 8 },
|
|
47
|
-
nlp: { scamScoreThreshold: 0.62, phishingScoreThreshold: 0.55 },
|
|
57
|
+
nlp: { scamScoreThreshold: 0.62, phishingScoreThreshold: 0.55, raidCalloutThreshold: 0.3 },
|
|
48
58
|
// Seed domains for PhishingDatabase (src/security/PhishingDatabase.js).
|
|
49
59
|
// Empty by default - populate with what you actually see via
|
|
50
60
|
// `!gf phishing add <domain>` rather than shipping unverified blocklists.
|
|
51
|
-
linkGuard: { seedDomains: [] }
|
|
61
|
+
linkGuard: { seedDomains: [] },
|
|
62
|
+
// See roleAnalysis.maturity. Applies ONLY to pure rate/volume signals
|
|
63
|
+
// (message rate, duplicate flood) for established members - content
|
|
64
|
+
// signals (scam/phishing/raid language, links, non-trusted mass pings)
|
|
65
|
+
// stay at full strength no matter how old or long-tenured the sender
|
|
66
|
+
// is, since tenure says nothing about whether a given message is
|
|
67
|
+
// dangerous. Set both multipliers to 1 to disable this and treat
|
|
68
|
+
// every member identically.
|
|
69
|
+
establishedMemberLeniency: {
|
|
70
|
+
thresholdMultiplier: 2.5, // established members need this many times the normal rate before a signal fires at all
|
|
71
|
+
weightMultiplier: 0.3 // and the signal counts for less when it does
|
|
72
|
+
}
|
|
52
73
|
},
|
|
53
74
|
|
|
54
75
|
antiRaid: {
|
|
@@ -83,6 +104,10 @@ module.exports = {
|
|
|
83
104
|
// A brand-new invite with no expiry and no use limit, created by anyone
|
|
84
105
|
// other than the owner, is worth a look the moment it's created.
|
|
85
106
|
watchInviteAbuse: true,
|
|
107
|
+
// Watches @everyone's channel permission overwrites specifically - a
|
|
108
|
+
// channel getting locked out for everyone, or a private channel getting
|
|
109
|
+
// opened to everyone, is a nuke technique that doesn't delete anything.
|
|
110
|
+
watchOverwriteAbuse: true,
|
|
86
111
|
// A webhook that starts pushing a burst of messages within minutes of
|
|
87
112
|
// being created is a classic "nuke via webhook" pattern - this catches
|
|
88
113
|
// it even though the messages themselves look like normal messageCreate
|
|
@@ -134,5 +159,20 @@ module.exports = {
|
|
|
134
159
|
// Default for GlassFrame's debug flag - logs every cache SET/HIT/MISS/EXPIRE to the console.
|
|
135
160
|
// See docs/CACHE_ARCHITECTURE.md. Overridden by the `debug` constructor option if provided.
|
|
136
161
|
debug: false
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// The owner-only `!gf engine` dashboard - see docs/ENGINE.md. Being an
|
|
165
|
+
// owner (options.owners) is required no matter what; a password is an
|
|
166
|
+
// optional second factor on top of that for the initial command only -
|
|
167
|
+
// page-navigation button clicks are still gated by isOwner() but don't
|
|
168
|
+
// re-prompt for the password.
|
|
169
|
+
engine: {
|
|
170
|
+
// Set via an environment variable in your own bot, e.g.
|
|
171
|
+
// process.env.GLASSFRAME_ENGINE_PASSWORD - never hardcode a real
|
|
172
|
+
// password into a committed config.js. Leave null to skip the second
|
|
173
|
+
// factor and rely on the owners list alone.
|
|
174
|
+
password: null,
|
|
175
|
+
maxAttempts: 3,
|
|
176
|
+
lockoutMs: 1000 * 60 * 10
|
|
137
177
|
}
|
|
138
178
|
};
|
package/dist/src/GlassFrame.js
CHANGED
|
@@ -23,7 +23,7 @@ const AntiNukeLayer = require("./layers/AntiNukeLayer");
|
|
|
23
23
|
const AIModerationLayer = require("./layers/AIModerationLayer");
|
|
24
24
|
|
|
25
25
|
const PrefixRouter = require("./commands/PrefixRouter");
|
|
26
|
-
const { buildPanel, buildMetricsMessage } = require("./ui/ControlPanel");
|
|
26
|
+
const { buildPanel, buildMetricsMessage, buildEnginePage } = require("./ui/ControlPanel");
|
|
27
27
|
|
|
28
28
|
const LAYER_NAMES = ["basicSecurity", "antiRaid", "antiNuke", "aiModeration"];
|
|
29
29
|
|
|
@@ -54,8 +54,15 @@ class GlassFrame extends EventEmitter {
|
|
|
54
54
|
// as autoStart), never overriding a guild's own saved whitelist.
|
|
55
55
|
this.defaultWhitelist = new Set(options.whitelist || []);
|
|
56
56
|
this.whitelist = new Map(); // guildId -> Set<userId>
|
|
57
|
+
this.customActions = new Map(); // actionName -> async (member, record) => void, for registerAction()
|
|
58
|
+
this.guildPrefixes = new Map(); // guildId -> custom prefix, for setPrefix()
|
|
57
59
|
this.panelMessages = new Map(); // messageId -> guildId
|
|
58
60
|
this.debug = Boolean(options.debug ?? this.config.cache.debug);
|
|
61
|
+
// Bot-owner user IDs - separate from per-guild Manage Server admins,
|
|
62
|
+
// since cross-server data (busiest server, all-guild activity) isn't
|
|
63
|
+
// something a random single server's admin should see about others.
|
|
64
|
+
this.owners = new Set(options.owners || []);
|
|
65
|
+
this.engineAttempts = new Map(); // userId -> { count, lockedUntil }
|
|
59
66
|
|
|
60
67
|
this.threatEngine = new ThreatEngine(this.config, { debug: this.debug });
|
|
61
68
|
this.roleAnalyzer = new RoleAnalyzer(this.config);
|
|
@@ -77,6 +84,7 @@ class GlassFrame extends EventEmitter {
|
|
|
77
84
|
roleAnalyzer: this.roleAnalyzer,
|
|
78
85
|
logger: this.logger,
|
|
79
86
|
whitelist: this.whitelist,
|
|
87
|
+
customActions: this.customActions,
|
|
80
88
|
queue: this.actionQueue,
|
|
81
89
|
performance: this.performance,
|
|
82
90
|
debug: this.debug
|
|
@@ -123,6 +131,40 @@ class GlassFrame extends EventEmitter {
|
|
|
123
131
|
return member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
/** For the owner-only !gf engine dashboard - separate from per-guild isAuthorized(). */
|
|
135
|
+
isOwner(userId) {
|
|
136
|
+
return this.owners.has(userId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Second factor on top of isOwner(), only for the initial `!gf engine`
|
|
141
|
+
* command - not required again for page-navigation button clicks, since
|
|
142
|
+
* those are already tied to a real, already-verified Discord identity.
|
|
143
|
+
* Returns true with no config.engine.password set (owner check alone is
|
|
144
|
+
* enough). Locks a user out for engine.lockoutMs after
|
|
145
|
+
* engine.maxAttempts wrong guesses, to make brute-forcing impractical.
|
|
146
|
+
*/
|
|
147
|
+
verifyEnginePassword(userId, provided) {
|
|
148
|
+
const cfg = this.config.engine;
|
|
149
|
+
if (!cfg.password) return true;
|
|
150
|
+
|
|
151
|
+
const attempt = this.engineAttempts.get(userId) || { count: 0, lockedUntil: 0 };
|
|
152
|
+
if (Date.now() < attempt.lockedUntil) return false;
|
|
153
|
+
|
|
154
|
+
if (provided === cfg.password) {
|
|
155
|
+
this.engineAttempts.delete(userId);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
attempt.count++;
|
|
160
|
+
if (attempt.count >= cfg.maxAttempts) {
|
|
161
|
+
attempt.lockedUntil = Date.now() + cfg.lockoutMs;
|
|
162
|
+
attempt.count = 0;
|
|
163
|
+
}
|
|
164
|
+
this.engineAttempts.set(userId, attempt);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
126
168
|
getStatus(guildId) {
|
|
127
169
|
const out = {};
|
|
128
170
|
for (const [name, layer] of Object.entries(this.layers)) {
|
|
@@ -174,6 +216,50 @@ class GlassFrame extends EventEmitter {
|
|
|
174
216
|
};
|
|
175
217
|
}
|
|
176
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Everything about the running process at once, across every guild -
|
|
221
|
+
* what's live, what's fast/slow, which server is generating the most
|
|
222
|
+
* work, and the last several things that happened anywhere. Powers the
|
|
223
|
+
* owner-only `!gf engine` command. This is intentionally NOT reachable
|
|
224
|
+
* by a per-guild admin - see isOwner().
|
|
225
|
+
*/
|
|
226
|
+
getEngineReport() {
|
|
227
|
+
const guilds = [...this.client.guilds.cache.values()];
|
|
228
|
+
const layerNames = Object.keys(this.layers);
|
|
229
|
+
|
|
230
|
+
const adoption = {};
|
|
231
|
+
for (const name of layerNames) {
|
|
232
|
+
adoption[name] = guilds.filter((g) => this.layers[name].isEnabled(g.id)).length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const busiest = this.performance.topGuildsByActivity(5).map(([guildId, count]) => {
|
|
236
|
+
const guild = this.client.guilds.cache.get(guildId);
|
|
237
|
+
return { guildId, name: guild ? guild.name : "(left / unknown)", count };
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const recentLogs = this.logger.getRecentLogs(10).map((entry) => ({
|
|
241
|
+
...entry,
|
|
242
|
+
guildName: this.client.guilds.cache.get(entry.guildId)?.name || entry.guildName || entry.guildId
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
version: VersionInfo.info(),
|
|
247
|
+
uptimeMs: Date.now() - this.performance.startedAt,
|
|
248
|
+
guildCount: guilds.length,
|
|
249
|
+
layerAdoption: adoption,
|
|
250
|
+
metrics: this.getMetrics(),
|
|
251
|
+
busiestGuilds: busiest,
|
|
252
|
+
recentLogs,
|
|
253
|
+
ai: {
|
|
254
|
+
available: this.groqClient.available,
|
|
255
|
+
keyCount: this.groqClient.keys.length,
|
|
256
|
+
keysOnCooldown: this.groqClient.keys.filter((k) => k.cooldownUntil > Date.now()).length,
|
|
257
|
+
callsLastMinute: this.groqClient.callTimestamps.length,
|
|
258
|
+
totalCallsRecorded: this.performance.eventCounts["groqClient.classify"] || 0
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
177
263
|
enableLayer(name, guildId) {
|
|
178
264
|
if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
|
|
179
265
|
if (!guildId) throw new Error("GlassFrame Protocol: enableLayer(name, guildId) requires a guildId - layers are per-guild.");
|
|
@@ -188,29 +274,83 @@ class GlassFrame extends EventEmitter {
|
|
|
188
274
|
return this;
|
|
189
275
|
}
|
|
190
276
|
|
|
191
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Adds your own security layer to the same pipeline the built-in four
|
|
279
|
+
* use - it gets per-guild enable/disable, state persistence, and can
|
|
280
|
+
* report signals to the shared ThreatEngine/PunishmentEngine, all for
|
|
281
|
+
* free. `layerInstance` must extend `core/Layer` (see
|
|
282
|
+
* docs/PROTOCOL_LAYERS.md, "Adding a fifth layer"). Custom layers do NOT
|
|
283
|
+
* get a button on the 5-button panel automatically - toggle them via
|
|
284
|
+
* `frame.enableLayer(name, guildId)` in code, or build your own command.
|
|
285
|
+
*/
|
|
286
|
+
registerLayer(name, layerInstance) {
|
|
287
|
+
if (this.layers[name]) throw new Error(`GlassFrame Protocol: a layer named "${name}" already exists`);
|
|
288
|
+
if (typeof layerInstance?.attach !== "function" || typeof layerInstance?.enable !== "function") {
|
|
289
|
+
throw new Error("GlassFrame Protocol: registerLayer() expects an instance extending core/Layer");
|
|
290
|
+
}
|
|
291
|
+
this.layers[name] = layerInstance;
|
|
292
|
+
layerInstance.attach();
|
|
293
|
+
this.emit("layerRegistered", { layer: name });
|
|
294
|
+
return this;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Adds a custom punishment action beyond the built-in
|
|
299
|
+
* ban/kick/timeout/quarantine, so `config.punishment.ladder` can
|
|
300
|
+
* reference it by name. `handler` is `async (member, record) => {}` -
|
|
301
|
+
* `record` is the case object (tier, trust, reasons, ...). Runs through
|
|
302
|
+
* the same bounded-concurrency action queue as the built-in actions.
|
|
303
|
+
*
|
|
304
|
+
* @example
|
|
305
|
+
* frame.registerAction("addMutedRole", async (member) => {
|
|
306
|
+
* const role = member.guild.roles.cache.find((r) => r.name === "Muted");
|
|
307
|
+
* if (role) await member.roles.add(role);
|
|
308
|
+
* });
|
|
309
|
+
* // config: { punishment: { ladder: { medium: "addMutedRole" } } }
|
|
310
|
+
*/
|
|
311
|
+
registerAction(name, handler) {
|
|
312
|
+
if (this.customActions.has(name)) throw new Error(`GlassFrame Protocol: an action named "${name}" is already registered`);
|
|
313
|
+
if (typeof handler !== "function") throw new Error("GlassFrame Protocol: registerAction() expects a function");
|
|
314
|
+
this.customActions.set(name, handler);
|
|
315
|
+
return this;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The prefix this guild actually uses - its own custom one if set, otherwise config.prefix. */
|
|
319
|
+
getPrefix(guildId) {
|
|
320
|
+
return this.guildPrefixes.get(guildId) || this.config.prefix;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Sets this guild's own command prefix. config.prefix keeps working everywhere as a fallback, in case a server forgets its custom one. */
|
|
324
|
+
setPrefix(guildId, prefix) {
|
|
325
|
+
this.guildPrefixes.set(guildId, prefix);
|
|
326
|
+
this._persistGuildState(guildId);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Snapshots one guild's layer + whitelist + prefix state into stateStore. Fire-and-forget by design (never blocks a handler on disk/network I/O). */
|
|
192
330
|
_persistGuildState(guildId) {
|
|
193
331
|
const state = {
|
|
194
332
|
layers: this.getStatus(guildId),
|
|
195
|
-
whitelist: [...(this.whitelist.get(guildId) || [])]
|
|
333
|
+
whitelist: [...(this.whitelist.get(guildId) || [])],
|
|
334
|
+
prefix: this.guildPrefixes.get(guildId) || null
|
|
196
335
|
};
|
|
197
336
|
Promise.resolve(this.stateStore.set(guildId, state)).catch((err) =>
|
|
198
337
|
this.emit("warning", { layer: "core", message: `failed to persist state for ${guildId}: ${err.message}` })
|
|
199
338
|
);
|
|
200
339
|
}
|
|
201
340
|
|
|
202
|
-
/** Loads a guild's saved state if one exists; otherwise applies the autoStart defaults (and persists that as the new baseline). */
|
|
341
|
+
/** Loads a guild's saved state if one exists; otherwise applies the autoStart/whitelist defaults (and persists that as the new baseline). */
|
|
203
342
|
async _restoreGuildState(guildId) {
|
|
204
343
|
const saved = await this.stateStore.get(guildId);
|
|
205
344
|
|
|
206
345
|
if (saved) {
|
|
207
|
-
for (const name of
|
|
346
|
+
for (const name of Object.keys(this.layers)) {
|
|
208
347
|
if (saved.layers?.[name]) this.layers[name].enable(guildId);
|
|
209
348
|
}
|
|
210
349
|
for (const userId of saved.whitelist || []) {
|
|
211
350
|
if (!this.whitelist.has(guildId)) this.whitelist.set(guildId, new Set());
|
|
212
351
|
this.whitelist.get(guildId).add(userId);
|
|
213
352
|
}
|
|
353
|
+
if (saved.prefix) this.guildPrefixes.set(guildId, saved.prefix);
|
|
214
354
|
return;
|
|
215
355
|
}
|
|
216
356
|
|
|
@@ -231,6 +371,8 @@ class GlassFrame extends EventEmitter {
|
|
|
231
371
|
await this._handlePanelButton(interaction);
|
|
232
372
|
} else if (interaction.customId.startsWith("gfp_metrics_")) {
|
|
233
373
|
await this._handleMetricsButton(interaction);
|
|
374
|
+
} else if (interaction.customId.startsWith("gfp_engine_")) {
|
|
375
|
+
await this._handleEngineButton(interaction);
|
|
234
376
|
}
|
|
235
377
|
} catch (err) {
|
|
236
378
|
this.emit("warning", { layer: "controlPanel", message: err.message });
|
|
@@ -267,6 +409,7 @@ class GlassFrame extends EventEmitter {
|
|
|
267
409
|
await this.logger.log(interaction.guild, {
|
|
268
410
|
level: "info",
|
|
269
411
|
title: "Protocol Layer Toggled",
|
|
412
|
+
layer: "controlPanel",
|
|
270
413
|
description: `${interaction.user.tag} updated GlassFrame Protocol layers from the control panel.`,
|
|
271
414
|
dedupeKey: "panel:toggle"
|
|
272
415
|
});
|
|
@@ -282,6 +425,24 @@ class GlassFrame extends EventEmitter {
|
|
|
282
425
|
const payload = buildMetricsMessage(this, interaction.guild.id, scope);
|
|
283
426
|
await interaction.update(payload);
|
|
284
427
|
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Page navigation on the engine dashboard. Gated by isOwner() on the
|
|
431
|
+
* clicking Discord identity - not by the password again, since a button
|
|
432
|
+
* click is already tied to a real, verified Discord session, and
|
|
433
|
+
* re-prompting for a password on every tab click would be unusable.
|
|
434
|
+
*/
|
|
435
|
+
async _handleEngineButton(interaction) {
|
|
436
|
+
if (!this.isOwner(interaction.user.id)) {
|
|
437
|
+
await interaction.reply({ content: "This isn't for you.", ephemeral: true });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const page = interaction.customId.replace("gfp_engine_", "");
|
|
442
|
+
const report = this.getEngineReport();
|
|
443
|
+
const payload = buildEnginePage(report, page);
|
|
444
|
+
await interaction.update(payload);
|
|
445
|
+
}
|
|
285
446
|
}
|
|
286
447
|
|
|
287
448
|
function mergeConfig(base, override) {
|