@helyx/module-automod 1.0.1
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 +30 -0
- package/LICENSE +725 -0
- package/README.md +80 -0
- package/dist/activity-resource.d.ts +3 -0
- package/dist/activity-resource.js +114 -0
- package/dist/case-link-provider.d.ts +3 -0
- package/dist/case-link-provider.js +13 -0
- package/dist/configuration.d.ts +22 -0
- package/dist/configuration.js +85 -0
- package/dist/constants.d.ts +30 -0
- package/dist/constants.js +37 -0
- package/dist/contracts.d.ts +30 -0
- package/dist/contracts.js +51 -0
- package/dist/domain.d.ts +61 -0
- package/dist/domain.js +274 -0
- package/dist/engine/canonicalisation.d.ts +9 -0
- package/dist/engine/canonicalisation.js +150 -0
- package/dist/engine/compile.d.ts +4 -0
- package/dist/engine/compile.js +169 -0
- package/dist/engine/confidence.d.ts +13 -0
- package/dist/engine/confidence.js +57 -0
- package/dist/engine/configuration-cache.d.ts +29 -0
- package/dist/engine/configuration-cache.js +115 -0
- package/dist/engine/contracts.d.ts +82 -0
- package/dist/engine/contracts.js +25 -0
- package/dist/engine/index.d.ts +11 -0
- package/dist/engine/index.js +11 -0
- package/dist/engine/matcher.d.ts +3 -0
- package/dist/engine/matcher.js +206 -0
- package/dist/engine/observations.d.ts +45 -0
- package/dist/engine/observations.js +105 -0
- package/dist/engine/operation-id.d.ts +18 -0
- package/dist/engine/operation-id.js +24 -0
- package/dist/engine/similar-message-window.d.ts +68 -0
- package/dist/engine/similar-message-window.js +259 -0
- package/dist/engine/term-import.d.ts +15 -0
- package/dist/engine/term-import.js +43 -0
- package/dist/enhanced-configuration.d.ts +16 -0
- package/dist/enhanced-configuration.js +91 -0
- package/dist/events.d.ts +4 -0
- package/dist/events.js +371 -0
- package/dist/health.d.ts +9 -0
- package/dist/health.js +45 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +141 -0
- package/dist/policy-provider.d.ts +3 -0
- package/dist/policy-provider.js +66 -0
- package/dist/publication-support.d.ts +14 -0
- package/dist/publication-support.js +69 -0
- package/dist/publication.d.ts +32 -0
- package/dist/publication.js +333 -0
- package/dist/receipt-statistics.d.ts +11 -0
- package/dist/receipt-statistics.js +58 -0
- package/dist/records.d.ts +299 -0
- package/dist/records.js +288 -0
- package/dist/repository-model.d.ts +54 -0
- package/dist/repository-model.js +132 -0
- package/dist/repository.d.ts +133 -0
- package/dist/repository.js +523 -0
- package/dist/resource-cursor.d.ts +5 -0
- package/dist/resource-cursor.js +45 -0
- package/dist/resource-presentation.d.ts +21 -0
- package/dist/resource-presentation.js +111 -0
- package/dist/resource-validation.d.ts +6 -0
- package/dist/resource-validation.js +80 -0
- package/dist/resources.d.ts +4 -0
- package/dist/resources.js +319 -0
- package/dist/scanner-configuration.d.ts +10 -0
- package/dist/scanner-configuration.js +146 -0
- package/dist/scanner-provider.d.ts +5 -0
- package/dist/scanner-provider.js +310 -0
- package/dist/scanner-result-validation.d.ts +3 -0
- package/dist/scanner-result-validation.js +61 -0
- package/dist/settings-preview.d.ts +6 -0
- package/dist/settings-preview.js +84 -0
- package/manifest.json +1601 -0
- package/migrations/0001_automod_standard.sql +114 -0
- package/migrations/0002_automod_enhanced_receipts.sql +40 -0
- package/package.json +61 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type CompiledEnhancedConfiguration, type EnhancedScanResult } from "./contracts.js";
|
|
2
|
+
export interface SimilarMessageWindowOptions {
|
|
3
|
+
ttlMs: number;
|
|
4
|
+
occurrenceThreshold: number;
|
|
5
|
+
similarityThreshold: number;
|
|
6
|
+
maximumMembers: number;
|
|
7
|
+
maximumFingerprints: number;
|
|
8
|
+
maximumBytes: number;
|
|
9
|
+
maximumFeaturesPerMessage: number;
|
|
10
|
+
}
|
|
11
|
+
export interface SimilarMessageWindowMetrics {
|
|
12
|
+
members: number;
|
|
13
|
+
fingerprints: number;
|
|
14
|
+
bytes: number;
|
|
15
|
+
evictions: number;
|
|
16
|
+
overflows: number;
|
|
17
|
+
}
|
|
18
|
+
export declare class SimilarMessageWindow {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(secret: Uint8Array, options?: Partial<SimilarMessageWindowOptions>);
|
|
21
|
+
observe(input: {
|
|
22
|
+
guildId: string;
|
|
23
|
+
memberId: string;
|
|
24
|
+
channelId: string;
|
|
25
|
+
content: string;
|
|
26
|
+
observedAtMs: number;
|
|
27
|
+
}): {
|
|
28
|
+
outcome: "recorded";
|
|
29
|
+
count: number;
|
|
30
|
+
} | {
|
|
31
|
+
outcome: "similar_message";
|
|
32
|
+
count: number;
|
|
33
|
+
similarity: number;
|
|
34
|
+
confidenceScore: number;
|
|
35
|
+
} | {
|
|
36
|
+
outcome: "ignored";
|
|
37
|
+
reason: "insufficient_content" | "message_size_limit";
|
|
38
|
+
};
|
|
39
|
+
metrics(): SimilarMessageWindowMetrics;
|
|
40
|
+
clear(): void;
|
|
41
|
+
}
|
|
42
|
+
export declare function scanSimilarMessage(configuration: CompiledEnhancedConfiguration, window: SimilarMessageWindow, input: {
|
|
43
|
+
guildId: string;
|
|
44
|
+
memberId: string;
|
|
45
|
+
channelId: string;
|
|
46
|
+
parentChannelId: string | null;
|
|
47
|
+
memberRoleIds: readonly string[];
|
|
48
|
+
content: string;
|
|
49
|
+
observedAtMs: number;
|
|
50
|
+
source: "user" | "bot" | "webhook" | "system" | "unknown";
|
|
51
|
+
}): EnhancedScanResult;
|
|
52
|
+
/**
|
|
53
|
+
* Runs the stateless detectors and the bounded cross-channel window for the
|
|
54
|
+
* same observation. Contextual state is updated even when a stronger direct
|
|
55
|
+
* match wins, so Hosted Ecko and the embedded scanner retain identical spam
|
|
56
|
+
* semantics.
|
|
57
|
+
*/
|
|
58
|
+
export declare function scanEnhancedMessageWithContext(configuration: CompiledEnhancedConfiguration, window: SimilarMessageWindow, input: {
|
|
59
|
+
guildId: string;
|
|
60
|
+
memberId: string;
|
|
61
|
+
channelId: string;
|
|
62
|
+
parentChannelId: string | null;
|
|
63
|
+
memberRoleIds: readonly string[];
|
|
64
|
+
content: string;
|
|
65
|
+
observedAtMs: number;
|
|
66
|
+
source: "user" | "bot" | "webhook" | "system" | "unknown";
|
|
67
|
+
}): EnhancedScanResult;
|
|
68
|
+
//# sourceMappingURL=similar-message-window.d.ts.map
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { canonicaliseText } from "./canonicalisation.js";
|
|
3
|
+
import { confidenceClass } from "./confidence.js";
|
|
4
|
+
import { scanEnhancedMessage } from "./matcher.js";
|
|
5
|
+
import { ENHANCED_ENGINE_LIMITS, } from "./contracts.js";
|
|
6
|
+
const DEFAULT_OPTIONS = Object.freeze({
|
|
7
|
+
ttlMs: 5 * 60_000,
|
|
8
|
+
occurrenceThreshold: 3,
|
|
9
|
+
similarityThreshold: 0.8,
|
|
10
|
+
maximumMembers: 10_000,
|
|
11
|
+
maximumFingerprints: 50_000,
|
|
12
|
+
maximumBytes: 32 * 1_024 * 1_024,
|
|
13
|
+
maximumFeaturesPerMessage: 64,
|
|
14
|
+
});
|
|
15
|
+
export class SimilarMessageWindow {
|
|
16
|
+
#secret;
|
|
17
|
+
#options;
|
|
18
|
+
#members = new Map();
|
|
19
|
+
#sequence = 0;
|
|
20
|
+
#fingerprints = 0;
|
|
21
|
+
#bytes = 0;
|
|
22
|
+
#evictions = 0;
|
|
23
|
+
#overflows = 0;
|
|
24
|
+
constructor(secret, options = {}) {
|
|
25
|
+
if (secret.byteLength < 32)
|
|
26
|
+
throw new Error("Similar-message fingerprint keys require 32 bytes.");
|
|
27
|
+
this.#secret = new Uint8Array(secret);
|
|
28
|
+
this.#options = validateOptions({ ...DEFAULT_OPTIONS, ...options });
|
|
29
|
+
}
|
|
30
|
+
observe(input) {
|
|
31
|
+
this.#prune(input.observedAtMs);
|
|
32
|
+
if (input.content.length > ENHANCED_ENGINE_LIMITS.messageCharacters ||
|
|
33
|
+
new TextEncoder().encode(input.content).byteLength >
|
|
34
|
+
ENHANCED_ENGINE_LIMITS.messageUtf8Bytes)
|
|
35
|
+
return { outcome: "ignored", reason: "message_size_limit" };
|
|
36
|
+
const canonical = canonicaliseText(input.content).normalisedTokens;
|
|
37
|
+
const rawFeatures = shingles(canonical, this.#options.maximumFeaturesPerMessage);
|
|
38
|
+
if (!rawFeatures.length)
|
|
39
|
+
return { outcome: "ignored", reason: "insufficient_content" };
|
|
40
|
+
const features = rawFeatures.map((feature) => this.#hash(`f:${feature}`));
|
|
41
|
+
const digest = this.#hash(`m:${canonical.join(" ")}`);
|
|
42
|
+
const channelDigest = this.#hash(`c:${input.channelId}`);
|
|
43
|
+
const memberKey = this.#hash(`s:${input.guildId}:${input.memberId}`);
|
|
44
|
+
const window = this.#members.get(memberKey) ?? {
|
|
45
|
+
key: memberKey,
|
|
46
|
+
records: [],
|
|
47
|
+
accessed: 0,
|
|
48
|
+
};
|
|
49
|
+
const comparable = window.records
|
|
50
|
+
.map((record) => ({
|
|
51
|
+
record,
|
|
52
|
+
similarity: record.digest === digest ? 1 : jaccard(record.features, features),
|
|
53
|
+
}))
|
|
54
|
+
.filter(({ similarity }) => similarity >= this.#options.similarityThreshold);
|
|
55
|
+
const highest = comparable.reduce((maximum, item) => Math.max(maximum, item.similarity), 0);
|
|
56
|
+
const record = this.#record(digest, features, channelDigest, input.observedAtMs);
|
|
57
|
+
window.records.push(record);
|
|
58
|
+
window.accessed = record.accessed;
|
|
59
|
+
this.#members.set(memberKey, window);
|
|
60
|
+
this.#fingerprints += 1;
|
|
61
|
+
this.#bytes += record.bytes;
|
|
62
|
+
this.#evictToBounds();
|
|
63
|
+
const count = comparable.length + 1;
|
|
64
|
+
const channelCount = new Set([
|
|
65
|
+
channelDigest,
|
|
66
|
+
...comparable.map(({ record }) => record.channelDigest),
|
|
67
|
+
]).size;
|
|
68
|
+
return count >= this.#options.occurrenceThreshold && channelCount >= 2
|
|
69
|
+
? {
|
|
70
|
+
outcome: "similar_message",
|
|
71
|
+
count,
|
|
72
|
+
similarity: highest,
|
|
73
|
+
confidenceScore: Math.min(95, Math.max(60, Math.round(highest * 100))),
|
|
74
|
+
}
|
|
75
|
+
: { outcome: "recorded", count };
|
|
76
|
+
}
|
|
77
|
+
metrics() {
|
|
78
|
+
return {
|
|
79
|
+
members: this.#members.size,
|
|
80
|
+
fingerprints: this.#fingerprints,
|
|
81
|
+
bytes: this.#bytes,
|
|
82
|
+
evictions: this.#evictions,
|
|
83
|
+
overflows: this.#overflows,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
clear() {
|
|
87
|
+
this.#members.clear();
|
|
88
|
+
this.#fingerprints = 0;
|
|
89
|
+
this.#bytes = 0;
|
|
90
|
+
}
|
|
91
|
+
#hash(value) {
|
|
92
|
+
return createHmac("sha256", this.#secret)
|
|
93
|
+
.update(value)
|
|
94
|
+
.digest("base64url")
|
|
95
|
+
.slice(0, 22);
|
|
96
|
+
}
|
|
97
|
+
#record(digest, features, channelDigest, observedAtMs) {
|
|
98
|
+
return {
|
|
99
|
+
digest,
|
|
100
|
+
features,
|
|
101
|
+
channelDigest,
|
|
102
|
+
observedAtMs,
|
|
103
|
+
accessed: ++this.#sequence,
|
|
104
|
+
bytes: 64 + digest.length + channelDigest.length + features.length * 22,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
#prune(observedAtMs) {
|
|
108
|
+
for (const [memberKey, window] of this.#members) {
|
|
109
|
+
const retained = [];
|
|
110
|
+
for (const record of window.records)
|
|
111
|
+
if (record.observedAtMs + this.#options.ttlMs > observedAtMs)
|
|
112
|
+
retained.push(record);
|
|
113
|
+
else
|
|
114
|
+
this.#removeRecord(record);
|
|
115
|
+
if (retained.length)
|
|
116
|
+
window.records = retained;
|
|
117
|
+
else
|
|
118
|
+
this.#members.delete(memberKey);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
#evictToBounds() {
|
|
122
|
+
while (this.#members.size > this.#options.maximumMembers ||
|
|
123
|
+
this.#fingerprints > this.#options.maximumFingerprints ||
|
|
124
|
+
this.#bytes > this.#options.maximumBytes) {
|
|
125
|
+
this.#overflows += 1;
|
|
126
|
+
const oldest = [...this.#members.values()].sort((left, right) => left.accessed - right.accessed || left.key.localeCompare(right.key))[0];
|
|
127
|
+
if (!oldest)
|
|
128
|
+
return;
|
|
129
|
+
const record = oldest.records.shift();
|
|
130
|
+
if (record)
|
|
131
|
+
this.#removeRecord(record);
|
|
132
|
+
if (!oldest.records.length)
|
|
133
|
+
this.#members.delete(oldest.key);
|
|
134
|
+
else
|
|
135
|
+
oldest.accessed = oldest.records[0].accessed;
|
|
136
|
+
this.#evictions += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
#removeRecord(record) {
|
|
140
|
+
this.#fingerprints -= 1;
|
|
141
|
+
this.#bytes -= record.bytes;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function scanSimilarMessage(configuration, window, input) {
|
|
145
|
+
if (input.source !== "user")
|
|
146
|
+
return { outcome: "not_scannable", reason: `source_${input.source}` };
|
|
147
|
+
const detectors = configuration.rules.flatMap((rule) => rule.terms
|
|
148
|
+
.filter((term) => term.kind === "similar_message")
|
|
149
|
+
.map((term) => ({ rule, term })));
|
|
150
|
+
const eligible = detectors.flatMap(({ rule, term }) => ruleExempt(rule, input) ? [] : [{ rule, term }]);
|
|
151
|
+
if (!eligible.length)
|
|
152
|
+
return detectors.length
|
|
153
|
+
? { outcome: "exempt", reason: "all_rules_exempt" }
|
|
154
|
+
: { outcome: "not_detected", reason: "no_similar_message_rule" };
|
|
155
|
+
const observed = window.observe(input);
|
|
156
|
+
if (observed.outcome !== "similar_message")
|
|
157
|
+
return {
|
|
158
|
+
outcome: observed.outcome === "ignored" ? "not_scannable" : "not_detected",
|
|
159
|
+
reason: observed.outcome === "ignored" ? observed.reason : "window_not_met",
|
|
160
|
+
};
|
|
161
|
+
const detections = eligible
|
|
162
|
+
.map(({ rule, term }) => ({
|
|
163
|
+
rule,
|
|
164
|
+
term,
|
|
165
|
+
threshold: Math.max(configuration.source.categoryThresholds[rule.severity], term.minimumConfidence ?? 60),
|
|
166
|
+
}))
|
|
167
|
+
.filter(({ threshold }) => observed.confidenceScore >= threshold)
|
|
168
|
+
.sort((left, right) => severityRank(right.rule.severity) - severityRank(left.rule.severity) ||
|
|
169
|
+
left.rule.ruleId.localeCompare(right.rule.ruleId) ||
|
|
170
|
+
left.term.termId.localeCompare(right.term.termId));
|
|
171
|
+
const selected = detections[0];
|
|
172
|
+
if (!selected)
|
|
173
|
+
return { outcome: "not_detected", reason: "below_threshold" };
|
|
174
|
+
return {
|
|
175
|
+
outcome: "detected",
|
|
176
|
+
detection: {
|
|
177
|
+
ruleId: selected.rule.ruleId,
|
|
178
|
+
categoryId: selected.rule.categoryId,
|
|
179
|
+
termId: selected.term.termId,
|
|
180
|
+
severity: selected.rule.severity,
|
|
181
|
+
method: "similar_message",
|
|
182
|
+
confidenceScore: observed.confidenceScore,
|
|
183
|
+
confidenceClass: confidenceClass(observed.confidenceScore),
|
|
184
|
+
effectiveThreshold: selected.threshold,
|
|
185
|
+
nativeCoverage: "spam",
|
|
186
|
+
evidence: [],
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Runs the stateless detectors and the bounded cross-channel window for the
|
|
192
|
+
* same observation. Contextual state is updated even when a stronger direct
|
|
193
|
+
* match wins, so Hosted Ecko and the embedded scanner retain identical spam
|
|
194
|
+
* semantics.
|
|
195
|
+
*/
|
|
196
|
+
export function scanEnhancedMessageWithContext(configuration, window, input) {
|
|
197
|
+
const direct = scanEnhancedMessage(configuration, input);
|
|
198
|
+
const contextual = scanSimilarMessage(configuration, window, input);
|
|
199
|
+
return direct.outcome === "detected" ? direct : contextual;
|
|
200
|
+
}
|
|
201
|
+
function shingles(tokens, maximum) {
|
|
202
|
+
if (!tokens.length)
|
|
203
|
+
return [];
|
|
204
|
+
const values = new Set();
|
|
205
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
206
|
+
values.add(tokens[index]);
|
|
207
|
+
if (index + 1 < tokens.length)
|
|
208
|
+
values.add(`${tokens[index]} ${tokens[index + 1]}`);
|
|
209
|
+
if (values.size >= maximum)
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
return [...values].sort().slice(0, maximum);
|
|
213
|
+
}
|
|
214
|
+
function jaccard(left, right) {
|
|
215
|
+
const leftSet = new Set(left);
|
|
216
|
+
const rightSet = new Set(right);
|
|
217
|
+
let intersection = 0;
|
|
218
|
+
for (const value of leftSet)
|
|
219
|
+
if (rightSet.has(value))
|
|
220
|
+
intersection += 1;
|
|
221
|
+
const union = leftSet.size + rightSet.size - intersection;
|
|
222
|
+
return union ? intersection / union : 0;
|
|
223
|
+
}
|
|
224
|
+
function validateOptions(options) {
|
|
225
|
+
if (!Number.isInteger(options.ttlMs) ||
|
|
226
|
+
options.ttlMs < 1 ||
|
|
227
|
+
options.ttlMs > 10 * 60_000 ||
|
|
228
|
+
!Number.isInteger(options.occurrenceThreshold) ||
|
|
229
|
+
options.occurrenceThreshold < 2 ||
|
|
230
|
+
options.similarityThreshold < 0.5 ||
|
|
231
|
+
options.similarityThreshold > 1 ||
|
|
232
|
+
!Number.isInteger(options.maximumMembers) ||
|
|
233
|
+
options.maximumMembers < 1 ||
|
|
234
|
+
!Number.isInteger(options.maximumFingerprints) ||
|
|
235
|
+
options.maximumFingerprints < options.maximumMembers ||
|
|
236
|
+
!Number.isInteger(options.maximumBytes) ||
|
|
237
|
+
options.maximumBytes < 1_024 ||
|
|
238
|
+
!Number.isInteger(options.maximumFeaturesPerMessage) ||
|
|
239
|
+
options.maximumFeaturesPerMessage < 1 ||
|
|
240
|
+
options.maximumFeaturesPerMessage > 128)
|
|
241
|
+
throw new Error("Similar-message window bounds are invalid.");
|
|
242
|
+
return Object.freeze(options);
|
|
243
|
+
}
|
|
244
|
+
function ruleExempt(rule, input) {
|
|
245
|
+
return (rule.exemptChannelIds.includes(input.channelId) ||
|
|
246
|
+
(input.parentChannelId !== null &&
|
|
247
|
+
rule.exemptChannelIds.includes(input.parentChannelId)) ||
|
|
248
|
+
input.memberRoleIds.some((roleId) => rule.exemptRoleIds.includes(roleId)));
|
|
249
|
+
}
|
|
250
|
+
function severityRank(value) {
|
|
251
|
+
return value === "critical"
|
|
252
|
+
? 4
|
|
253
|
+
: value === "high"
|
|
254
|
+
? 3
|
|
255
|
+
: value === "medium"
|
|
256
|
+
? 2
|
|
257
|
+
: 1;
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=similar-message-window.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface EnhancedTermImportResult {
|
|
2
|
+
accepted: readonly string[];
|
|
3
|
+
duplicateCount: number;
|
|
4
|
+
rejected: readonly {
|
|
5
|
+
sourceIndex: number;
|
|
6
|
+
value: string;
|
|
7
|
+
code: "term_too_long" | "term_limit" | "empty_canonical_term";
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
export declare function parseEnhancedTermImport(content: string, options?: {
|
|
11
|
+
maximumBytes?: number;
|
|
12
|
+
maximumTerms?: number;
|
|
13
|
+
maximumTermCharacters?: number;
|
|
14
|
+
}): EnhancedTermImportResult;
|
|
15
|
+
//# sourceMappingURL=term-import.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { canonicalTermKey } from "./canonicalisation.js";
|
|
2
|
+
export function parseEnhancedTermImport(content, options = {}) {
|
|
3
|
+
const maximumBytes = options.maximumBytes ?? 4_000;
|
|
4
|
+
const maximumTerms = options.maximumTerms ?? 1_000;
|
|
5
|
+
const maximumTermCharacters = options.maximumTermCharacters ?? 60;
|
|
6
|
+
if (new TextEncoder().encode(content).byteLength > maximumBytes)
|
|
7
|
+
throw new Error("The import exceeds the configured UTF-8 byte limit.");
|
|
8
|
+
const accepted = [];
|
|
9
|
+
const rejected = [];
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
let duplicateCount = 0;
|
|
12
|
+
const entries = content.split(/\r?\n|,/u);
|
|
13
|
+
for (const [sourceIndex, raw] of entries.entries()) {
|
|
14
|
+
const value = raw.trim();
|
|
15
|
+
if (!value)
|
|
16
|
+
continue;
|
|
17
|
+
const canonical = canonicalTermKey(value.replaceAll("*", ""));
|
|
18
|
+
if (!canonical) {
|
|
19
|
+
rejected.push({ sourceIndex, value, code: "empty_canonical_term" });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (value.length > maximumTermCharacters) {
|
|
23
|
+
rejected.push({ sourceIndex, value, code: "term_too_long" });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (seen.has(canonical)) {
|
|
27
|
+
duplicateCount += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
seen.add(canonical);
|
|
31
|
+
if (accepted.length >= maximumTerms) {
|
|
32
|
+
rejected.push({ sourceIndex, value, code: "term_limit" });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
accepted.push(value);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
accepted: Object.freeze(accepted),
|
|
39
|
+
duplicateCount,
|
|
40
|
+
rejected: Object.freeze(rejected),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=term-import.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AutoModerationScannerConfigurationV1 } from "@helyx/sdk";
|
|
2
|
+
import type { AutoModerationConfiguration } from "./configuration.js";
|
|
3
|
+
import type { AutoModerationRuleDraft } from "./domain.js";
|
|
4
|
+
export interface EnhancedConfigurationRuleSource {
|
|
5
|
+
ruleId: string;
|
|
6
|
+
draft: AutoModerationRuleDraft;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildEnhancedScannerConfiguration(input: {
|
|
9
|
+
guildId: string;
|
|
10
|
+
configurationRevision: string;
|
|
11
|
+
configuration: AutoModerationConfiguration;
|
|
12
|
+
rules: readonly EnhancedConfigurationRuleSource[];
|
|
13
|
+
refreshAfter: string;
|
|
14
|
+
expiresAt: string;
|
|
15
|
+
}): AutoModerationScannerConfigurationV1;
|
|
16
|
+
//# sourceMappingURL=enhanced-configuration.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { ENHANCED_ENGINE_VERSION, ENHANCED_SCORING_VERSION, confidenceThresholds, } from "./engine/index.js";
|
|
3
|
+
export function buildEnhancedScannerConfiguration(input) {
|
|
4
|
+
const rules = input.rules
|
|
5
|
+
.filter(({ draft }) => draft.kind === "keyword" || draft.kind === "spam")
|
|
6
|
+
.map(({ ruleId, draft }) => scannerRule(ruleId, draft, input.configuration))
|
|
7
|
+
.sort((left, right) => left.ruleId.localeCompare(right.ruleId));
|
|
8
|
+
const categoryThresholds = confidenceThresholds({
|
|
9
|
+
profile: input.configuration.enhancedSensitivityProfile,
|
|
10
|
+
custom: input.configuration.enhancedMinimumConfidence,
|
|
11
|
+
overridesEnabled: input.configuration.enhancedCategoryOverridesEnabled,
|
|
12
|
+
overrides: {
|
|
13
|
+
low: input.configuration.enhancedLowMinimumConfidence,
|
|
14
|
+
medium: input.configuration.enhancedMediumMinimumConfidence,
|
|
15
|
+
high: input.configuration.enhancedHighMinimumConfidence,
|
|
16
|
+
critical: input.configuration.enhancedCriticalMinimumConfidence,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const canonical = {
|
|
20
|
+
guildId: input.guildId,
|
|
21
|
+
configurationRevision: input.configurationRevision,
|
|
22
|
+
engineVersion: ENHANCED_ENGINE_VERSION,
|
|
23
|
+
scoringVersion: ENHANCED_SCORING_VERSION,
|
|
24
|
+
categoryThresholds,
|
|
25
|
+
rules,
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
...canonical,
|
|
29
|
+
fingerprint: createHash("sha256")
|
|
30
|
+
.update(JSON.stringify(canonical))
|
|
31
|
+
.digest("hex"),
|
|
32
|
+
refreshAfter: input.refreshAfter,
|
|
33
|
+
expiresAt: input.expiresAt,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function scannerRule(ruleId, draft, configuration) {
|
|
37
|
+
const terms = draft.kind === "spam"
|
|
38
|
+
? [similarMessageTerm(ruleId)]
|
|
39
|
+
: [...draft.keywordFilter, ...draft.regexPatterns]
|
|
40
|
+
.map((value, index) => {
|
|
41
|
+
const kind = index >= draft.keywordFilter.length
|
|
42
|
+
? "safe_regex"
|
|
43
|
+
: termKind(value);
|
|
44
|
+
return {
|
|
45
|
+
termId: stableTermId(ruleId, kind, value),
|
|
46
|
+
kind,
|
|
47
|
+
value,
|
|
48
|
+
allowList: [...draft.allowList].sort(),
|
|
49
|
+
normalisedMatching: draft.enhancedNormalisedMatching !== false,
|
|
50
|
+
fuzzyMatching: (kind === "word" || kind === "phrase") &&
|
|
51
|
+
draft.enhancedFuzzyMatching === true,
|
|
52
|
+
};
|
|
53
|
+
})
|
|
54
|
+
.sort((left, right) => left.termId.localeCompare(right.termId));
|
|
55
|
+
return {
|
|
56
|
+
ruleId,
|
|
57
|
+
name: draft.name,
|
|
58
|
+
categoryId: draft.category,
|
|
59
|
+
severity: draft.category,
|
|
60
|
+
terms,
|
|
61
|
+
exemptRoleIds: merged(configuration.defaultExemptRoleIds, draft.ignoredRoleIds),
|
|
62
|
+
exemptChannelIds: merged(configuration.defaultExemptChannelIds, draft.ignoredChannelIds),
|
|
63
|
+
actionPolicy: draft.actionPolicy,
|
|
64
|
+
openViolationThread: draft.openViolationThread,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function similarMessageTerm(ruleId) {
|
|
68
|
+
return {
|
|
69
|
+
termId: stableTermId(ruleId, "similar_message", "cross-channel-similar-message"),
|
|
70
|
+
kind: "similar_message",
|
|
71
|
+
value: "cross-channel-similar-message",
|
|
72
|
+
allowList: [],
|
|
73
|
+
normalisedMatching: false,
|
|
74
|
+
fuzzyMatching: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function termKind(value) {
|
|
78
|
+
if (value.startsWith("*") || value.endsWith("*"))
|
|
79
|
+
return "wildcard";
|
|
80
|
+
return /\s/u.test(value.trim()) ? "phrase" : "word";
|
|
81
|
+
}
|
|
82
|
+
function stableTermId(ruleId, kind, value) {
|
|
83
|
+
return createHash("sha256")
|
|
84
|
+
.update(JSON.stringify([ruleId, kind, value]))
|
|
85
|
+
.digest("hex")
|
|
86
|
+
.slice(0, 24);
|
|
87
|
+
}
|
|
88
|
+
function merged(left, right) {
|
|
89
|
+
return [...new Set([...left, ...right])].sort();
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=enhanced-configuration.js.map
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type EventContributions } from "@helyx/sdk";
|
|
2
|
+
import type { AutoModerationPublicationService } from "./publication.js";
|
|
3
|
+
export declare function createAutoModerationEvents(publication: AutoModerationPublicationService): EventContributions;
|
|
4
|
+
//# sourceMappingURL=events.d.ts.map
|