glassframe-protocol 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +197 -0
- package/GETTING_STARTED.md +133 -0
- package/LICENSE +56 -0
- package/README.md +145 -0
- package/dist/config.js +138 -0
- package/dist/index.js +13 -0
- package/dist/src/GlassFrame.js +195 -0
- package/dist/src/ai/GroqClient.js +161 -0
- package/dist/src/commands/PrefixRouter.js +177 -0
- package/dist/src/core/Cache.js +98 -0
- package/dist/src/core/EventQueue.js +62 -0
- package/dist/src/core/Layer.js +68 -0
- package/dist/src/core/PerformanceMonitor.js +52 -0
- package/dist/src/core/StateStore.js +69 -0
- package/dist/src/core/ThreatEngine.js +86 -0
- package/dist/src/core/VersionInfo.js +32 -0
- package/dist/src/layers/AIModerationLayer.js +77 -0
- package/dist/src/layers/AntiNukeLayer.js +278 -0
- package/dist/src/layers/AntiRaidLayer.js +144 -0
- package/dist/src/layers/BasicSecurityLayer.js +111 -0
- package/dist/src/logging/ComponentsV2.js +44 -0
- package/dist/src/logging/SmartLogger.js +84 -0
- package/dist/src/moderation/PunishmentEngine.js +175 -0
- package/dist/src/moderation/RoleAnalyzer.js +82 -0
- package/dist/src/security/PhishingDatabase.js +71 -0
- package/dist/src/ui/ControlPanel.js +58 -0
- package/dist/src/utils/nlpEngine.js +149 -0
- package/docs/CACHE_ARCHITECTURE.md +78 -0
- package/docs/COMMANDS.md +38 -0
- package/docs/PERFORMANCE.md +59 -0
- package/docs/PROTOCOL_LAYERS.md +89 -0
- package/docs/PUBLISHING.md +93 -0
- package/examples/basic-usage.js +44 -0
- package/package.json +44 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require("events");
|
|
4
|
+
const ProtocolCache = require("../core/Cache");
|
|
5
|
+
|
|
6
|
+
// Used only to decide whether continued bad behavior mid-case deserves a
|
|
7
|
+
// stronger response - never to decide the response itself (_decideAction
|
|
8
|
+
// still owns that, trust-gating included).
|
|
9
|
+
const ACTION_SEVERITY = { log: 0, alert: 1, quarantine: 2, timeout: 2, kick: 3, ban: 4 };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The only place in GlassFrame Protocol that actually punishes a member.
|
|
13
|
+
* Every layer feeds signals through ThreatEngine; this engine turns a tier
|
|
14
|
+
* into an action, but only after checking: is there already an open case
|
|
15
|
+
* for this member (don't re-punish or re-log mid-resolution), can the bot
|
|
16
|
+
* even act on them (RoleAnalyzer), and does their real trust level call for
|
|
17
|
+
* a softer landing (quarantine instead of ban) regardless of what the
|
|
18
|
+
* ladder alone would say.
|
|
19
|
+
*/
|
|
20
|
+
class PunishmentEngine extends EventEmitter {
|
|
21
|
+
constructor({ config, threatEngine, roleAnalyzer, logger, whitelist, queue, performance, debug = false }) {
|
|
22
|
+
super();
|
|
23
|
+
this.config = config.punishment;
|
|
24
|
+
this.threatEngine = threatEngine;
|
|
25
|
+
this.roleAnalyzer = roleAnalyzer;
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
// Actual Discord mutations (ban/kick/timeout/quarantine) go through this
|
|
28
|
+
// bounded-concurrency queue rather than firing directly, so a burst of
|
|
29
|
+
// simultaneous cases (a raid, a nuke sweep) can't overrun Discord's rate
|
|
30
|
+
// limits. Falls back to running inline if no queue is supplied.
|
|
31
|
+
this.queue = queue || { push: (task) => task() };
|
|
32
|
+
this.performance = performance || { recordEvent() {}, time: () => () => {} };
|
|
33
|
+
// Accept the same Set the rest of GlassFrame uses (not a copy), so
|
|
34
|
+
// runtime whitelist edits (e.g. !gf whitelist add) apply immediately here too.
|
|
35
|
+
this.whitelist = whitelist instanceof Set ? whitelist : new Set(whitelist || []);
|
|
36
|
+
this.cases = new ProtocolCache("open-cases", { ttlMs: this.config.caseCooldownMs, debug });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
caseKey(guildId, userId) {
|
|
40
|
+
return `${guildId}:${userId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A layer reports a signal; this decides whether - and how - to act.
|
|
45
|
+
* { guild, member, layer, weight, reason }
|
|
46
|
+
*/
|
|
47
|
+
async report({ guild, member, layer, weight, reason }) {
|
|
48
|
+
if (!member) return null;
|
|
49
|
+
const userId = member.id;
|
|
50
|
+
if (this.whitelist.has(userId)) return null;
|
|
51
|
+
|
|
52
|
+
const { score, tier } = this.threatEngine.addSignal(guild.id, userId, { layer, weight, reason });
|
|
53
|
+
if (tier === "none") return null;
|
|
54
|
+
|
|
55
|
+
const key = this.caseKey(guild.id, userId);
|
|
56
|
+
const openCase = this.cases.get(key);
|
|
57
|
+
|
|
58
|
+
if (openCase) {
|
|
59
|
+
// Already handling this member - merge in the new reason rather than
|
|
60
|
+
// opening a second incident. If continued behavior has pushed the
|
|
61
|
+
// tier past what the open case was actually acted on at, escalate
|
|
62
|
+
// once (log -> timeout -> kick -> ban); otherwise stay silent so this
|
|
63
|
+
// never turns into repeat actions or repeat log lines for one incident.
|
|
64
|
+
openCase.reasons.push(reason);
|
|
65
|
+
openCase.tier = tier;
|
|
66
|
+
|
|
67
|
+
const trust = this.roleAnalyzer.trustLevel(member);
|
|
68
|
+
const nextAction = this._decideAction(tier, trust);
|
|
69
|
+
if (ACTION_SEVERITY[nextAction] > ACTION_SEVERITY[openCase.action]) {
|
|
70
|
+
openCase.action = nextAction;
|
|
71
|
+
openCase.trust = trust;
|
|
72
|
+
this.cases.set(key, openCase);
|
|
73
|
+
await this._execute(guild, member, nextAction, openCase);
|
|
74
|
+
return openCase;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.cases.set(key, openCase);
|
|
78
|
+
return openCase;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const trust = this.roleAnalyzer.trustLevel(member);
|
|
82
|
+
const action = this._decideAction(tier, trust);
|
|
83
|
+
const record = {
|
|
84
|
+
id: `${key}:${Date.now()}`,
|
|
85
|
+
guildId: guild.id,
|
|
86
|
+
userId,
|
|
87
|
+
tier,
|
|
88
|
+
score,
|
|
89
|
+
trust,
|
|
90
|
+
action,
|
|
91
|
+
reasons: [reason],
|
|
92
|
+
openedAt: Date.now()
|
|
93
|
+
};
|
|
94
|
+
this.cases.set(key, record);
|
|
95
|
+
|
|
96
|
+
await this._execute(guild, member, action, record);
|
|
97
|
+
return record;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_decideAction(tier, trust) {
|
|
101
|
+
if (trust === "PROTECTED") return "alert"; // never auto-punish the owner or protected staff
|
|
102
|
+
if (trust === "HIGH" && (tier === "low" || tier === "medium")) return "log"; // benefit of the doubt on soft signals
|
|
103
|
+
if (trust === "HIGH" || trust === "MEDIUM") return this.config.protectedTrustAction; // quarantine, not kick/ban
|
|
104
|
+
return this.config.ladder[tier] || "log";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async _execute(guild, member, action, record) {
|
|
108
|
+
const botMember = guild.members.me;
|
|
109
|
+
const canAct = this.roleAnalyzer.canModerate(botMember, member);
|
|
110
|
+
let performed = false;
|
|
111
|
+
const done = this.performance.time("punishmentEngine.execute");
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (action === "ban" && canAct) {
|
|
115
|
+
await this.queue.push(() => member.ban({ reason: this._reason(record) }));
|
|
116
|
+
performed = true;
|
|
117
|
+
} else if (action === "kick" && canAct) {
|
|
118
|
+
await this.queue.push(() => member.kick(this._reason(record)));
|
|
119
|
+
performed = true;
|
|
120
|
+
} else if (action === "timeout" && canAct && member.moderatable) {
|
|
121
|
+
await this.queue.push(() => member.timeout(this.config.timeoutDurationMs, this._reason(record)));
|
|
122
|
+
performed = true;
|
|
123
|
+
} else if (action === "quarantine" && canAct) {
|
|
124
|
+
const removable = member.roles.cache.filter((r) => r.id !== guild.id && r.editable);
|
|
125
|
+
await this.queue.push(() => member.roles.remove(removable, this._reason(record)));
|
|
126
|
+
performed = true;
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
record.error = err.message;
|
|
130
|
+
} finally {
|
|
131
|
+
done();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.performance.recordEvent(`punishmentEngine.action.${action}`);
|
|
135
|
+
|
|
136
|
+
if (!performed && action !== "log" && action !== "alert" && !record.error) {
|
|
137
|
+
record.note = "GlassFrame lacks role hierarchy or Discord permission to act - logged for manual review only.";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await this.logger.log(guild, {
|
|
141
|
+
level: action === "alert" ? "alert" : action === "log" ? "info" : "warn",
|
|
142
|
+
title: this._title(action),
|
|
143
|
+
description: `${member.user.tag} (${member.id}) - tier **${record.tier}**, trust **${record.trust}**.`,
|
|
144
|
+
fields: [
|
|
145
|
+
{ name: "Reasons", value: record.reasons.slice(-5).join("\n") },
|
|
146
|
+
{ name: "Outcome", value: record.note || record.error || `action: ${action}` }
|
|
147
|
+
],
|
|
148
|
+
dedupeKey: `punish:${action}:${guild.id}`
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
this.emit("case", record);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_reason(record) {
|
|
155
|
+
return `GlassFrame Protocol (${record.tier}): ${record.reasons.slice(-3).join("; ")}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_title(action) {
|
|
159
|
+
const map = {
|
|
160
|
+
ban: "Member Banned",
|
|
161
|
+
kick: "Member Kicked",
|
|
162
|
+
timeout: "Member Timed Out",
|
|
163
|
+
quarantine: "Member Quarantined",
|
|
164
|
+
alert: "Protected Member Flagged - Review Needed",
|
|
165
|
+
log: "Signal Logged"
|
|
166
|
+
};
|
|
167
|
+
return map[action] || "Action Taken";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
clearCase(guildId, userId) {
|
|
171
|
+
this.cases.delete(this.caseKey(guildId, userId));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = PunishmentEngine;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { PermissionsBitField } = require("discord.js");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads a member's roles - their hierarchy position and their names - to
|
|
7
|
+
* decide what the protocol is allowed, and wise, to do about them. This is
|
|
8
|
+
* what keeps punishment "smart" instead of reflexive: it stops the bot from
|
|
9
|
+
* attempting actions it has no hierarchy for, and stops the protocol from
|
|
10
|
+
* auto-banning someone who turns out to hold a real administrator role.
|
|
11
|
+
*/
|
|
12
|
+
class RoleAnalyzer {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config.roleAnalysis;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Can the bot's member act on the target at all, permission + hierarchy-wise? */
|
|
18
|
+
canModerate(botMember, targetMember) {
|
|
19
|
+
if (!botMember || !targetMember) return false;
|
|
20
|
+
if (targetMember.id === targetMember.guild.ownerId) return false;
|
|
21
|
+
return targetMember.manageable;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** LOW / MEDIUM / HIGH / PROTECTED, based on the member's real permissions and role names. */
|
|
25
|
+
trustLevel(member) {
|
|
26
|
+
if (!member) return "LOW";
|
|
27
|
+
if (member.id === member.guild.ownerId) return "PROTECTED";
|
|
28
|
+
|
|
29
|
+
const perms = member.permissions;
|
|
30
|
+
const hasDangerous = this.config.dangerousPermissions.some((p) => perms.has(PermissionsBitField.Flags[p]));
|
|
31
|
+
const hasTrust = this.config.trustPermissions.some((p) => perms.has(PermissionsBitField.Flags[p]));
|
|
32
|
+
const nameMatch = member.roles.cache.some((role) =>
|
|
33
|
+
this.config.protectedNames.some((n) => role.name.toLowerCase().includes(n))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (hasDangerous && nameMatch) return "PROTECTED";
|
|
37
|
+
if (hasTrust) return "HIGH";
|
|
38
|
+
if (nameMatch) return "MEDIUM";
|
|
39
|
+
return "LOW";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Scans a guild's current roles for name/permission mismatches: a role
|
|
44
|
+
* whose name signals authority ("admin", "staff", ...) but carries no real
|
|
45
|
+
* permissions (impersonation bait), or the opposite - an innocuous-looking
|
|
46
|
+
* role that was just created and quietly holds dangerous permissions.
|
|
47
|
+
* Returns flags only, never actions; a human decides what to do with them.
|
|
48
|
+
*/
|
|
49
|
+
scanRoles(guild) {
|
|
50
|
+
const flags = [];
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
|
|
53
|
+
for (const role of guild.roles.cache.values()) {
|
|
54
|
+
if (role.id === guild.id) continue; // skip @everyone
|
|
55
|
+
|
|
56
|
+
const looksOfficial = this.config.protectedNames.some((n) => role.name.toLowerCase().includes(n));
|
|
57
|
+
const hasDangerousPerms = this.config.dangerousPermissions.some((p) =>
|
|
58
|
+
role.permissions.has(PermissionsBitField.Flags[p])
|
|
59
|
+
);
|
|
60
|
+
const age = now - role.createdTimestamp;
|
|
61
|
+
|
|
62
|
+
if (looksOfficial && !hasDangerousPerms) {
|
|
63
|
+
flags.push({
|
|
64
|
+
roleId: role.id,
|
|
65
|
+
name: role.name,
|
|
66
|
+
reason: "name suggests authority but the role has no real permissions (possible impersonation bait)"
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (!looksOfficial && hasDangerousPerms && age < this.config.newRoleGraceMs) {
|
|
70
|
+
flags.push({
|
|
71
|
+
roleId: role.id,
|
|
72
|
+
name: role.name,
|
|
73
|
+
reason: "newly created role quietly holds dangerous permissions"
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return flags;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = RoleAnalyzer;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const HOMOGRAPH_DIGIT_MAP = { 0: "o", 1: "i", 3: "e", 4: "a", 5: "s", 7: "t" };
|
|
4
|
+
const WATCHED_BRANDS = /(discord|steam|paypal|riot|valve|epicgames|roblox|nitro)/i;
|
|
5
|
+
const SHORTENERS = new Set(["bit.ly", "tinyurl.com", "cutt.ly", "is.gd", "t.co", "shorturl.at", "rb.gy"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fast, expandable domain/pattern reputation checker used by
|
|
9
|
+
* BasicSecurityLayer. Ships with no seed list of "known bad" domains - a
|
|
10
|
+
* hardcoded blocklist goes stale immediately and risks flagging an innocent
|
|
11
|
+
* domain on stale/unverified information. Instead it combines a live,
|
|
12
|
+
* server-editable blocklist (`!gf phishing add/remove/list`) with cheap,
|
|
13
|
+
* general-purpose heuristics that don't depend on any specific domain ever
|
|
14
|
+
* being named in advance.
|
|
15
|
+
*
|
|
16
|
+
* Every check is O(1) Set membership plus a few cheap regexes - this is
|
|
17
|
+
* never the slow part of message handling, which is the point: link
|
|
18
|
+
* checking has to keep up with every message, not just the flagged ones.
|
|
19
|
+
*/
|
|
20
|
+
class PhishingDatabase {
|
|
21
|
+
constructor(seedDomains = []) {
|
|
22
|
+
this.blocked = new Set(seedDomains.map((d) => d.toLowerCase()));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
add(domain) {
|
|
26
|
+
this.blocked.add(String(domain).toLowerCase());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
remove(domain) {
|
|
30
|
+
return this.blocked.delete(String(domain).toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
list() {
|
|
34
|
+
return [...this.blocked];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns a short reason string if a URL looks dangerous, otherwise null. */
|
|
38
|
+
check(url) {
|
|
39
|
+
let hostname;
|
|
40
|
+
try {
|
|
41
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (this.blocked.has(hostname)) return "known malicious domain (server blocklist)";
|
|
47
|
+
|
|
48
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return "raw-IP link (common phishing evasion)";
|
|
49
|
+
if (/xn--/.test(hostname)) return "punycode/homograph domain";
|
|
50
|
+
|
|
51
|
+
const flattened = hostname.replace(/\./g, "");
|
|
52
|
+
if (/[0-9]/.test(flattened)) {
|
|
53
|
+
const desubstituted = flattened.replace(/[0-9]/g, (d) => HOMOGRAPH_DIGIT_MAP[d] || d);
|
|
54
|
+
const match = desubstituted.match(WATCHED_BRANDS);
|
|
55
|
+
// Only flag if THIS specific brand word wasn't already plainly present
|
|
56
|
+
// before substitution - e.g. "paypal-secure5.com" already shows
|
|
57
|
+
// "paypal" outright, so the trailing digit isn't hiding anything and
|
|
58
|
+
// shouldn't trip this rule; "d1scord-nitro.com" only shows "discord"
|
|
59
|
+
// once the "1" is read back as an "i".
|
|
60
|
+
if (match && !flattened.includes(match[0])) {
|
|
61
|
+
return "brand look-alike domain (digit-for-letter substitution)";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (SHORTENERS.has(hostname)) return "shortened link (real destination hidden)";
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = PhishingDatabase;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
ContainerBuilder,
|
|
5
|
+
TextDisplayBuilder,
|
|
6
|
+
SeparatorBuilder,
|
|
7
|
+
SeparatorSpacingSize,
|
|
8
|
+
ActionRowBuilder,
|
|
9
|
+
ButtonBuilder,
|
|
10
|
+
ButtonStyle,
|
|
11
|
+
MessageFlags
|
|
12
|
+
} = require("discord.js");
|
|
13
|
+
|
|
14
|
+
// Exactly five buttons, one row - Discord's own per-row button limit is five,
|
|
15
|
+
// so this is the only layout this panel could ever need.
|
|
16
|
+
const BUTTONS = [
|
|
17
|
+
{ id: "gfp_toggle_antiRaid", layer: "antiRaid", label: "AntiRaid" },
|
|
18
|
+
{ id: "gfp_toggle_aiModeration", layer: "aiModeration", label: "AI Moderation" },
|
|
19
|
+
{ id: "gfp_toggle_antiNuke", layer: "antiNuke", label: "AntiNuke" },
|
|
20
|
+
{ id: "gfp_toggle_basicSecurity", layer: "basicSecurity", label: "Basic Security" },
|
|
21
|
+
{ id: "gfp_toggle_all", layer: "__all__", label: "Full Protocol" }
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const REAL_LAYERS = ["antiRaid", "aiModeration", "antiNuke", "basicSecurity"];
|
|
25
|
+
|
|
26
|
+
function buildPanel(frame, guildId) {
|
|
27
|
+
const status = frame.getStatus(guildId);
|
|
28
|
+
const allOn = REAL_LAYERS.every((l) => status[l]);
|
|
29
|
+
|
|
30
|
+
const row = new ActionRowBuilder().addComponents(
|
|
31
|
+
BUTTONS.map((b) => {
|
|
32
|
+
const active = b.layer === "__all__" ? allOn : status[b.layer];
|
|
33
|
+
return new ButtonBuilder()
|
|
34
|
+
.setCustomId(b.id)
|
|
35
|
+
.setLabel(`${active ? "Deactivate" : "Activate"} ${b.label}`)
|
|
36
|
+
.setStyle(active ? ButtonStyle.Success : ButtonStyle.Secondary);
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const statusLines = REAL_LAYERS.map((l) => {
|
|
41
|
+
const meta = BUTTONS.find((b) => b.layer === l);
|
|
42
|
+
return `${status[l] ? "\u25CF" : "\u25CB"} ${meta.label} - ${status[l] ? "ACTIVE" : "INACTIVE"}`;
|
|
43
|
+
}).join("\n");
|
|
44
|
+
|
|
45
|
+
const container = new ContainerBuilder().setAccentColor(0x5865f2);
|
|
46
|
+
container.addTextDisplayComponents(new TextDisplayBuilder().setContent("**GlassFrame Protocol - Control Panel**"));
|
|
47
|
+
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
48
|
+
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(statusLines));
|
|
49
|
+
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
|
50
|
+
container.addActionRowComponents(row);
|
|
51
|
+
container.addTextDisplayComponents(
|
|
52
|
+
new TextDisplayBuilder().setContent("-# Only server admins (Manage Server) can use these controls.")
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return { flags: MessageFlags.IsComponentsV2, components: [container] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { buildPanel, BUTTONS };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight, dependency-free NLP utilities used across GlassFrame
|
|
5
|
+
* Protocol. No external ML services are called from here; everything runs
|
|
6
|
+
* locally and synchronously so detection stays fast enough to run on every
|
|
7
|
+
* message and every join event. This is the free "first opinion" that
|
|
8
|
+
* AIModerationLayer's gray-zone escalation to Groq builds on top of.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function tokenize(text) {
|
|
12
|
+
return String(text)
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.normalize("NFKD")
|
|
15
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shannonEntropy(str) {
|
|
21
|
+
const s = String(str);
|
|
22
|
+
if (s.length === 0) return 0;
|
|
23
|
+
const freq = {};
|
|
24
|
+
for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
|
|
25
|
+
let entropy = 0;
|
|
26
|
+
for (const count of Object.values(freq)) {
|
|
27
|
+
const p = count / s.length;
|
|
28
|
+
entropy -= p * Math.log2(p);
|
|
29
|
+
}
|
|
30
|
+
return entropy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizedRepeatRatio(str) {
|
|
34
|
+
const s = String(str);
|
|
35
|
+
if (s.length < 2) return 0;
|
|
36
|
+
let repeats = 0;
|
|
37
|
+
for (let i = 1; i < s.length; i++) {
|
|
38
|
+
if (s[i] === s[i - 1]) repeats++;
|
|
39
|
+
}
|
|
40
|
+
return repeats / (s.length - 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Bag-of-words lexicon scorer. Returns a 0..1 confidence score using
|
|
44
|
+
// term-frequency weighting rather than a single keyword hit, which cuts
|
|
45
|
+
// down on false positives from one incidental word.
|
|
46
|
+
function lexiconScore(text, lexicon) {
|
|
47
|
+
const tokens = tokenize(text);
|
|
48
|
+
if (tokens.length === 0) return 0;
|
|
49
|
+
let hits = 0;
|
|
50
|
+
let weight = 0;
|
|
51
|
+
for (const token of tokens) {
|
|
52
|
+
if (lexicon.strong && lexicon.strong.has(token)) {
|
|
53
|
+
hits++;
|
|
54
|
+
weight += 1.0;
|
|
55
|
+
} else if (lexicon.weak && lexicon.weak.has(token)) {
|
|
56
|
+
hits++;
|
|
57
|
+
weight += 0.4;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Bigram check for multi-word patterns (e.g. "free nitro", "steam gift")
|
|
61
|
+
if (lexicon.bigrams) {
|
|
62
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
63
|
+
const bigram = tokens[i] + " " + tokens[i + 1];
|
|
64
|
+
if (lexicon.bigrams.has(bigram)) {
|
|
65
|
+
hits++;
|
|
66
|
+
weight += 1.2;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (hits === 0) return 0;
|
|
71
|
+
const density = hits / tokens.length;
|
|
72
|
+
const score = Math.min(1, weight / Math.max(3, tokens.length) + density * 0.3);
|
|
73
|
+
return score;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SCAM_LEXICON = {
|
|
77
|
+
strong: new Set([
|
|
78
|
+
"nitro", "airdrop", "giveaway", "steam", "crypto", "wallet",
|
|
79
|
+
"verify", "claim", "gift", "free", "double", "investment",
|
|
80
|
+
"seed", "phrase", "otp", "recover", "unlock"
|
|
81
|
+
]),
|
|
82
|
+
weak: new Set([
|
|
83
|
+
"link", "click", "limited", "hurry", "winner", "congratulations",
|
|
84
|
+
"exclusive", "only", "today", "now"
|
|
85
|
+
]),
|
|
86
|
+
bigrams: new Set([
|
|
87
|
+
"free nitro", "steam gift", "double your", "claim now",
|
|
88
|
+
"verify account", "wallet connect", "seed phrase", "limited time",
|
|
89
|
+
"click here", "airdrop event"
|
|
90
|
+
])
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const PHISHING_LEXICON = {
|
|
94
|
+
strong: new Set([
|
|
95
|
+
"login", "signin", "password", "confirm", "suspend", "suspended",
|
|
96
|
+
"locked", "support", "billing", "invoice", "reset", "update"
|
|
97
|
+
]),
|
|
98
|
+
weak: new Set(["account", "security", "immediately", "action", "required", "team"]),
|
|
99
|
+
bigrams: new Set([
|
|
100
|
+
"sign in", "reset password", "confirm identity", "account suspended",
|
|
101
|
+
"verify now", "click below", "update payment"
|
|
102
|
+
])
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const RAID_CALLOUT_LEXICON = {
|
|
106
|
+
strong: new Set(["raid", "nuke", "spam", "flood", "wipe", "destroy", "invite", "bot"]),
|
|
107
|
+
weak: new Set(["server", "join", "everyone", "ping", "mass"]),
|
|
108
|
+
bigrams: new Set(["raid this", "nuke server", "mass ping", "spam bots", "join now"])
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function suspiciousUsernameEntropy(username, floor, ceiling) {
|
|
112
|
+
const cleaned = String(username).replace(/\s+/g, "");
|
|
113
|
+
if (cleaned.length < 3) return { suspicious: false, entropy: 0, reason: null };
|
|
114
|
+
const entropy = shannonEntropy(cleaned);
|
|
115
|
+
const repeatRatio = normalizedRepeatRatio(cleaned);
|
|
116
|
+
|
|
117
|
+
if (repeatRatio > 0.5) return { suspicious: true, entropy, reason: "repeated character pattern" };
|
|
118
|
+
if (entropy < floor) return { suspicious: true, entropy, reason: "low character variety" };
|
|
119
|
+
if (entropy > ceiling) return { suspicious: true, entropy, reason: "high-entropy generated string" };
|
|
120
|
+
return { suspicious: false, entropy, reason: null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function classifyMessage(text) {
|
|
124
|
+
const scam = lexiconScore(text, SCAM_LEXICON);
|
|
125
|
+
const phishing = lexiconScore(text, PHISHING_LEXICON);
|
|
126
|
+
const raidCallout = lexiconScore(text, RAID_CALLOUT_LEXICON);
|
|
127
|
+
return {
|
|
128
|
+
scam,
|
|
129
|
+
phishing,
|
|
130
|
+
raidCallout,
|
|
131
|
+
topLabel: [
|
|
132
|
+
["scam", scam],
|
|
133
|
+
["phishing", phishing],
|
|
134
|
+
["raid_callout", raidCallout]
|
|
135
|
+
].sort((a, b) => b[1] - a[1])[0][0]
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
tokenize,
|
|
141
|
+
shannonEntropy,
|
|
142
|
+
normalizedRepeatRatio,
|
|
143
|
+
lexiconScore,
|
|
144
|
+
suspiciousUsernameEntropy,
|
|
145
|
+
classifyMessage,
|
|
146
|
+
SCAM_LEXICON,
|
|
147
|
+
PHISHING_LEXICON,
|
|
148
|
+
RAID_CALLOUT_LEXICON
|
|
149
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Cache Architecture
|
|
2
|
+
|
|
3
|
+
GlassFrame Protocol keeps all of its state in memory via one shared
|
|
4
|
+
primitive, `ProtocolCache` (`src/core/Cache.js`), rather than scattering
|
|
5
|
+
plain `Map`s through every layer. Every cache instance below is a
|
|
6
|
+
`ProtocolCache`, and every one of them can be put into debug mode so you can
|
|
7
|
+
watch it operate at runtime - see "Turning on cache logging" below.
|
|
8
|
+
|
|
9
|
+
## Why a shared cache primitive
|
|
10
|
+
|
|
11
|
+
- One place to reason about TTL, eviction, and stats instead of five
|
|
12
|
+
slightly-different `Map` patterns spread across layers.
|
|
13
|
+
- Optional debug logging is a constructor flag, not something bolted on
|
|
14
|
+
per-module after the fact.
|
|
15
|
+
- `getStats()` returns hits/misses/sets/evictions/size for any cache at
|
|
16
|
+
runtime, which is useful when you're tuning thresholds in `config.js` and
|
|
17
|
+
want to know whether a value is actually being reused.
|
|
18
|
+
|
|
19
|
+
## Caches in use
|
|
20
|
+
|
|
21
|
+
| Cache | Owner | Key shape | TTL | Purpose |
|
|
22
|
+
|---|---|---|---|---|
|
|
23
|
+
| `threat-scores` | `ThreatEngine` | `guildId:userId` | none (decay-based; pruned once decayed near zero) | The running, decaying risk score every layer contributes signals to. |
|
|
24
|
+
| `open-cases` | `PunishmentEngine` | `guildId:userId` | `punishment.caseCooldownMs` (default 30s) | Prevents re-punishing or re-logging the same member while an incident is still fresh. Expiry length **is** the debounce window. |
|
|
25
|
+
| `nuke-snapshots` | `AntiNukeLayer` | `guildId` | `antiNuke.snapshotTtlMs` (default 1h) | A lightweight forensic snapshot of role/channel names and positions, captured the instant a threshold trips, kept only for reference. |
|
|
26
|
+
| `recent-webhooks` | `AntiNukeLayer` | webhook ID | `antiNuke.webhookAbuse.watchPeriodMs` (default 5m) | Maps a freshly created webhook back to whoever created it, so a message flood through that webhook (which carries no guild member of its own) can still be traced to an executor. |
|
|
27
|
+
| `groq-verdicts` | `GroqClient` | hash of the first 500 characters of the message | `aiModeration.cacheTtlMs` (default 5m) | Stops a burst of identical/near-identical messages from each costing a fresh Groq call. |
|
|
28
|
+
|
|
29
|
+
Two more `Map`s exist outside `ProtocolCache` because they're pure sliding
|
|
30
|
+
windows with no need for hit/miss stats: `AntiRaidLayer.joinTimestamps` /
|
|
31
|
+
`AntiRaidLayer.joinProfiles` (cluster correlation) and
|
|
32
|
+
`AntiNukeLayer.actionLog` / `AntiNukeLayer.webhookMessageCounts`. All four
|
|
33
|
+
are swept lazily - old timestamps are filtered out on the next write rather
|
|
34
|
+
than on a timer, since they're only ever read right after a write.
|
|
35
|
+
|
|
36
|
+
Bounded-concurrency queues (`frame.actionQueue`, `frame.aiQueue`) and the
|
|
37
|
+
internal metrics collector (`frame.performance`) are a separate concern from
|
|
38
|
+
caching - see `docs/PERFORMANCE.md`.
|
|
39
|
+
|
|
40
|
+
## Turning on cache logging
|
|
41
|
+
|
|
42
|
+
Pass `debug: true` when constructing GlassFrame:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const frame = new GlassFrame(client, {
|
|
46
|
+
getLogChannel: (guild) => guild.channels.cache.find((c) => c.name === "security-logs"),
|
|
47
|
+
debug: true
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
(Equivalently, set `cache: { debug: true }` in `config.js` - the
|
|
52
|
+
constructor option takes priority if both are set.)
|
|
53
|
+
|
|
54
|
+
With debug mode on, every cache above prints a line like:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
[GlassFrame:Cache:open-cases] SET 123456789012345678:987654321098765432
|
|
58
|
+
[GlassFrame:Cache:groq-verdicts] HIT h482910335
|
|
59
|
+
[GlassFrame:Cache:threat-scores] EXPIRE 123456789012345678:555555555555555555
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`SmartLogger` has its own separate debug channel for the same flag
|
|
63
|
+
(`[GlassFrame:SmartLogger] merge ...` / `final edit ...`), covering the
|
|
64
|
+
event-aggregation behavior described in `docs/PROTOCOL_LAYERS.md`.
|
|
65
|
+
|
|
66
|
+
This is meant for local debugging while you tune `config.js` - leave it off
|
|
67
|
+
in production, since it writes to `console.log` on every cache operation and
|
|
68
|
+
will get noisy fast on an active server.
|
|
69
|
+
|
|
70
|
+
## Memory growth
|
|
71
|
+
|
|
72
|
+
`threat-scores` is the only cache with no hard TTL, since a member's score
|
|
73
|
+
needs to persist and decay rather than vanish at a fixed time.
|
|
74
|
+
`ThreatEngine` prunes it every 10 minutes, removing any entry whose decayed
|
|
75
|
+
score has fallen under 0.5 **and** hasn't been touched in at least four
|
|
76
|
+
half-lives - by then it's noise, not signal. Every other cache either has a
|
|
77
|
+
real TTL or is a self-trimming sliding window, so none of them grow
|
|
78
|
+
unbounded over a long-running process.
|
package/docs/COMMANDS.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Commands
|
|
2
|
+
|
|
3
|
+
GlassFrame Protocol registers **no slash commands**. Every command is a
|
|
4
|
+
prefix command (default prefix `!gf `, configurable via `config.prefix`),
|
|
5
|
+
requires Manage Server permission (or being the guild owner), and is
|
|
6
|
+
throttled per user per guild by `performance.commandCooldownMs` (default
|
|
7
|
+
3s) - a repeated or accidental double-send is silently ignored rather than
|
|
8
|
+
running twice or getting a "slow down" reply of its own.
|
|
9
|
+
|
|
10
|
+
| Command | Does |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `!gf panel` | Sends the 5-button control panel (see below). |
|
|
13
|
+
| `!gf status` | Shows which of the four layers are currently active. |
|
|
14
|
+
| `!gf scan` | Runs `RoleAnalyzer.scanRoles()` on demand and reports any role name/permission mismatches. |
|
|
15
|
+
| `!gf metrics` | Shows queue depth, cache stats, and average latency - see `docs/PERFORMANCE.md`. |
|
|
16
|
+
| `!gf phishing add <domain>` | Adds a domain to the link blocklist. |
|
|
17
|
+
| `!gf phishing remove <domain>` | Removes a domain from the blocklist. |
|
|
18
|
+
| `!gf phishing list` | Lists every blocked domain. |
|
|
19
|
+
| `!gf whitelist add <userId>` | Exempts a user from all punitive action. |
|
|
20
|
+
| `!gf whitelist remove <userId>` | Removes an exemption. |
|
|
21
|
+
| `!gf help` | Lists these commands. |
|
|
22
|
+
|
|
23
|
+
## The control panel
|
|
24
|
+
|
|
25
|
+
`!gf panel` sends a message with exactly five buttons in one row:
|
|
26
|
+
|
|
27
|
+
1. **Activate/Deactivate AntiRaid**
|
|
28
|
+
2. **Activate/Deactivate AI Moderation**
|
|
29
|
+
3. **Activate/Deactivate AntiNuke**
|
|
30
|
+
4. **Activate/Deactivate Basic Security**
|
|
31
|
+
5. **Activate/Deactivate Full Protocol** - arms or disarms all four at once
|
|
32
|
+
|
|
33
|
+
Every layer starts disabled - nothing runs until it's explicitly activated,
|
|
34
|
+
either from the panel or via `frame.enableLayer("antiRaid")` in code. Button
|
|
35
|
+
labels flip between Activate/Deactivate based on current state, and the
|
|
36
|
+
panel message updates in place on every click rather than posting a new
|
|
37
|
+
message each time. Clicking a button also drops one aggregated log line in
|
|
38
|
+
the security log channel noting who changed what.
|