glassframe-protocol 2.0.0 → 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 +100 -0
- package/GETTING_STARTED.md +17 -0
- package/README.md +36 -10
- package/dist/config.js +20 -1
- 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 +30 -1
- package/dist/src/logging/SmartLogger.js +33 -3
- package/dist/src/moderation/PunishmentEngine.js +14 -3
- 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 +49 -10
- package/package.json +1 -1
|
@@ -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,52 @@ 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
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glassframe-protocol",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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": [
|