@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +725 -0
  3. package/README.md +80 -0
  4. package/dist/activity-resource.d.ts +3 -0
  5. package/dist/activity-resource.js +114 -0
  6. package/dist/case-link-provider.d.ts +3 -0
  7. package/dist/case-link-provider.js +13 -0
  8. package/dist/configuration.d.ts +22 -0
  9. package/dist/configuration.js +85 -0
  10. package/dist/constants.d.ts +30 -0
  11. package/dist/constants.js +37 -0
  12. package/dist/contracts.d.ts +30 -0
  13. package/dist/contracts.js +51 -0
  14. package/dist/domain.d.ts +61 -0
  15. package/dist/domain.js +274 -0
  16. package/dist/engine/canonicalisation.d.ts +9 -0
  17. package/dist/engine/canonicalisation.js +150 -0
  18. package/dist/engine/compile.d.ts +4 -0
  19. package/dist/engine/compile.js +169 -0
  20. package/dist/engine/confidence.d.ts +13 -0
  21. package/dist/engine/confidence.js +57 -0
  22. package/dist/engine/configuration-cache.d.ts +29 -0
  23. package/dist/engine/configuration-cache.js +115 -0
  24. package/dist/engine/contracts.d.ts +82 -0
  25. package/dist/engine/contracts.js +25 -0
  26. package/dist/engine/index.d.ts +11 -0
  27. package/dist/engine/index.js +11 -0
  28. package/dist/engine/matcher.d.ts +3 -0
  29. package/dist/engine/matcher.js +206 -0
  30. package/dist/engine/observations.d.ts +45 -0
  31. package/dist/engine/observations.js +105 -0
  32. package/dist/engine/operation-id.d.ts +18 -0
  33. package/dist/engine/operation-id.js +24 -0
  34. package/dist/engine/similar-message-window.d.ts +68 -0
  35. package/dist/engine/similar-message-window.js +259 -0
  36. package/dist/engine/term-import.d.ts +15 -0
  37. package/dist/engine/term-import.js +43 -0
  38. package/dist/enhanced-configuration.d.ts +16 -0
  39. package/dist/enhanced-configuration.js +91 -0
  40. package/dist/events.d.ts +4 -0
  41. package/dist/events.js +371 -0
  42. package/dist/health.d.ts +9 -0
  43. package/dist/health.js +45 -0
  44. package/dist/index.d.ts +10 -0
  45. package/dist/index.js +141 -0
  46. package/dist/policy-provider.d.ts +3 -0
  47. package/dist/policy-provider.js +66 -0
  48. package/dist/publication-support.d.ts +14 -0
  49. package/dist/publication-support.js +69 -0
  50. package/dist/publication.d.ts +32 -0
  51. package/dist/publication.js +333 -0
  52. package/dist/receipt-statistics.d.ts +11 -0
  53. package/dist/receipt-statistics.js +58 -0
  54. package/dist/records.d.ts +299 -0
  55. package/dist/records.js +288 -0
  56. package/dist/repository-model.d.ts +54 -0
  57. package/dist/repository-model.js +132 -0
  58. package/dist/repository.d.ts +133 -0
  59. package/dist/repository.js +523 -0
  60. package/dist/resource-cursor.d.ts +5 -0
  61. package/dist/resource-cursor.js +45 -0
  62. package/dist/resource-presentation.d.ts +21 -0
  63. package/dist/resource-presentation.js +111 -0
  64. package/dist/resource-validation.d.ts +6 -0
  65. package/dist/resource-validation.js +80 -0
  66. package/dist/resources.d.ts +4 -0
  67. package/dist/resources.js +319 -0
  68. package/dist/scanner-configuration.d.ts +10 -0
  69. package/dist/scanner-configuration.js +146 -0
  70. package/dist/scanner-provider.d.ts +5 -0
  71. package/dist/scanner-provider.js +310 -0
  72. package/dist/scanner-result-validation.d.ts +3 -0
  73. package/dist/scanner-result-validation.js +61 -0
  74. package/dist/settings-preview.d.ts +6 -0
  75. package/dist/settings-preview.js +84 -0
  76. package/manifest.json +1601 -0
  77. package/migrations/0001_automod_standard.sql +114 -0
  78. package/migrations/0002_automod_enhanced_receipts.sql +40 -0
  79. package/package.json +61 -0
package/dist/domain.js ADDED
@@ -0,0 +1,274 @@
1
+ import { AUTOMOD_TIMEOUT_SECONDS, } from "@helyx/sdk";
2
+ import { createHash } from "node:crypto";
3
+ import { AUTOMOD_LIMITS } from "./constants.js";
4
+ const RULE_KINDS = [
5
+ "keyword",
6
+ "keyword_preset",
7
+ "spam",
8
+ "mention_spam",
9
+ ];
10
+ const CATEGORIES = ["low", "medium", "high", "critical"];
11
+ export function parseRuleDraft(value) {
12
+ const kind = oneOf(value.ruleKind, RULE_KINDS, "rule kind", "keyword");
13
+ const keywordFilter = lines(value.keywordFilter, AUTOMOD_LIMITS.termsPerRule);
14
+ const regexPatterns = lines(value.regexPatterns, AUTOMOD_LIMITS.regexPerRule);
15
+ const allowList = lines(value.allowList, AUTOMOD_LIMITS.allowListEntries);
16
+ const presets = [
17
+ value.presetProfanity === true ? "profanity" : null,
18
+ value.presetSexualContent === true ? "sexual_content" : null,
19
+ value.presetSlurs === true ? "slurs" : null,
20
+ ].filter((item) => Boolean(item));
21
+ const draft = {
22
+ name: boundedString(value.name, 1, AUTOMOD_LIMITS.ruleNameCharacters, "rule name"),
23
+ category: oneOf(value.category, CATEGORIES, "category", "medium"),
24
+ kind,
25
+ keywordFilter,
26
+ regexPatterns,
27
+ allowList,
28
+ enhancedNormalisedMatching: value.enhancedNormalisedMatching !== false,
29
+ enhancedFuzzyMatching: value.enhancedFuzzyMatching === true,
30
+ presets,
31
+ mentionTotalLimit: integer(value.mentionTotalLimit, 1, 50, 5, "mention limit"),
32
+ mentionRaidProtectionEnabled: value.mentionRaidProtectionEnabled === true,
33
+ blockMessage: value.blockMessage !== false,
34
+ blockExplanation: optionalString(value.blockExplanation, 150, "block explanation"),
35
+ alertChannelId: optionalDiscordId(value.alertChannelId, "alert channel"),
36
+ nativeTimeoutSeconds: timeout(value.nativeTimeoutSeconds),
37
+ ignoredRoleIds: discordIds(value.ignoredRoleIds, AUTOMOD_LIMITS.ignoredRoles, "ignored roles"),
38
+ ignoredChannelIds: discordIds(value.ignoredChannelIds, AUTOMOD_LIMITS.ignoredChannels, "ignored channels"),
39
+ openViolationThread: value.openViolationThread === true,
40
+ actionPolicy: {
41
+ first: action(value, "first"),
42
+ second: action(value, "second"),
43
+ thirdAndLater: action(value, "third"),
44
+ },
45
+ };
46
+ validateDraft(draft);
47
+ return draft;
48
+ }
49
+ export function toNativeDefinition(input) {
50
+ const trigger = nativeTrigger(input.draft);
51
+ const actions = [];
52
+ if (input.draft.blockMessage)
53
+ actions.push({
54
+ type: "block_message",
55
+ ...(input.draft.blockExplanation
56
+ ? { customMessage: input.draft.blockExplanation }
57
+ : {}),
58
+ });
59
+ const alertChannelId = input.draft.alertChannelId ?? input.defaultAlertChannelId;
60
+ if (alertChannelId)
61
+ actions.push({ type: "send_alert_message", channelId: alertChannelId });
62
+ if (!input.draft.blockMessage && !alertChannelId)
63
+ throw new Error("A rule must block messages or send a Discord alert.");
64
+ if (input.draft.nativeTimeoutSeconds)
65
+ actions.push({
66
+ type: "timeout",
67
+ durationSeconds: input.draft.nativeTimeoutSeconds,
68
+ });
69
+ return {
70
+ name: operationMarkedName(input.ruleId, input.draft.name),
71
+ enabled: input.enabled,
72
+ eventType: "message_send",
73
+ trigger,
74
+ actions,
75
+ exemptRoleIds: mergedIds(input.globalIgnoredRoleIds, input.draft.ignoredRoleIds, AUTOMOD_LIMITS.ignoredRoles),
76
+ exemptChannelIds: mergedIds(input.globalIgnoredChannelIds, input.draft.ignoredChannelIds, AUTOMOD_LIMITS.ignoredChannels),
77
+ };
78
+ }
79
+ export function canonicalRuleFingerprint(rule) {
80
+ return createHash("sha256").update(JSON.stringify(rule)).digest("hex");
81
+ }
82
+ export function enforcementClaimKey(input) {
83
+ return createHash("sha256")
84
+ .update(JSON.stringify([
85
+ input.guildId,
86
+ input.configurationRevision,
87
+ input.subjectUserId,
88
+ input.messageId,
89
+ input.channelId,
90
+ input.messageId === null ? input.sourceEventId : null,
91
+ ]))
92
+ .digest("hex");
93
+ }
94
+ export function enforcementClaimFingerprint(input) {
95
+ return createHash("sha256")
96
+ .update(JSON.stringify([
97
+ "helyx.automod-enforcement-claim/v2",
98
+ input.claimKey,
99
+ input.logicalRuleId,
100
+ input.ruleRevision,
101
+ input.actionFamily,
102
+ ]))
103
+ .digest("hex");
104
+ }
105
+ export function enforcementActionFamily(policy) {
106
+ return [
107
+ policy.first.type,
108
+ policy.second.type,
109
+ policy.thirdAndLater.type,
110
+ ].join(":");
111
+ }
112
+ export function policyFingerprint(input) {
113
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex");
114
+ }
115
+ function validateDraft(draft) {
116
+ if (draft.kind === "keyword" &&
117
+ draft.keywordFilter.length + draft.regexPatterns.length < 1)
118
+ throw new Error("A keyword rule requires at least one keyword or safe regex.");
119
+ if (draft.kind === "keyword_preset" && draft.presets.length < 1)
120
+ throw new Error("A preset rule requires at least one Discord preset.");
121
+ if (draft.kind !== "keyword" && draft.regexPatterns.length)
122
+ throw new Error("Only keyword rules accept regex patterns.");
123
+ if (draft.nativeTimeoutSeconds &&
124
+ draft.kind !== "keyword" &&
125
+ draft.kind !== "mention_spam")
126
+ throw new Error("Discord timeout is unavailable for this rule type.");
127
+ for (const pattern of draft.regexPatterns)
128
+ if (!safeRustPattern(pattern))
129
+ throw new Error("A regex uses syntax that Discord Auto Moderation does not safely support.");
130
+ }
131
+ function nativeTrigger(draft) {
132
+ if (draft.kind === "keyword")
133
+ return {
134
+ type: "keyword",
135
+ keywordFilter: draft.keywordFilter,
136
+ regexPatterns: draft.regexPatterns,
137
+ allowList: draft.allowList,
138
+ };
139
+ if (draft.kind === "keyword_preset")
140
+ return {
141
+ type: "keyword_preset",
142
+ presets: draft.presets,
143
+ allowList: draft.allowList,
144
+ };
145
+ if (draft.kind === "mention_spam")
146
+ return {
147
+ type: "mention_spam",
148
+ mentionTotalLimit: draft.mentionTotalLimit,
149
+ mentionRaidProtectionEnabled: draft.mentionRaidProtectionEnabled,
150
+ };
151
+ return { type: "spam" };
152
+ }
153
+ function action(value, prefix) {
154
+ const type = oneOf(value[`${prefix}Action`], [
155
+ "case_only",
156
+ "delete_message",
157
+ "warn",
158
+ "timeout",
159
+ "kick",
160
+ "ban",
161
+ "demote",
162
+ ], `${prefix} action`, "case_only");
163
+ if (type === "timeout")
164
+ return {
165
+ type,
166
+ durationSeconds: timeout(value[`${prefix}TimeoutSeconds`]) ?? 300,
167
+ };
168
+ if (type === "ban")
169
+ return {
170
+ type,
171
+ deleteMessageSeconds: numericChoice(value[`${prefix}BanDeleteMessageSeconds`], [0, 3_600, 21_600, 86_400, 259_200, 604_800], `${prefix} ban deletion duration`, 0),
172
+ };
173
+ if (type === "demote") {
174
+ const roleIds = discordIds(value[`${prefix}DemoteRoleIds`], AUTOMOD_LIMITS.demotionRoles, `${prefix} demotion roles`);
175
+ if (!roleIds.length)
176
+ throw new Error("Demotion requires at least one role.");
177
+ return { type, roleIds };
178
+ }
179
+ return { type };
180
+ }
181
+ function operationMarkedName(ruleId, name) {
182
+ return `${nativeRuleOwnershipMarker(ruleId)} ${name}`.slice(0, 100);
183
+ }
184
+ export function nativeRuleOwnershipMarker(ruleId) {
185
+ const marker = createHash("sha256").update(ruleId).digest("hex").slice(0, 10);
186
+ return `[Helyx:${marker}]`;
187
+ }
188
+ function safeRustPattern(pattern) {
189
+ return (pattern.length <= AUTOMOD_LIMITS.regexCharacters &&
190
+ !/\\[1-9]|\(\?[=!<]|\(\?>|\(\?[a-zA-Z-]+\)/u.test(pattern));
191
+ }
192
+ function lines(value, maximum) {
193
+ if (value === undefined || value === null || value === "")
194
+ return [];
195
+ if (typeof value !== "string")
196
+ throw new Error("Rule lists must be text.");
197
+ const result = [
198
+ ...new Set(value
199
+ .split(/\r?\n|,/u)
200
+ .map((item) => item.trim())
201
+ .filter(Boolean)),
202
+ ];
203
+ if (result.length > maximum ||
204
+ result.some((item) => item.length > AUTOMOD_LIMITS.termCharacters))
205
+ throw new Error("A rule list exceeds its supported size.");
206
+ return result;
207
+ }
208
+ function boundedString(value, minimum, maximum, name) {
209
+ if (typeof value !== "string" ||
210
+ value.trim().length < minimum ||
211
+ value.length > maximum)
212
+ throw new Error(`Auto Moderation contains an invalid ${name}.`);
213
+ return value.trim();
214
+ }
215
+ function optionalString(value, maximum, name) {
216
+ if (value === undefined || value === null || value === "")
217
+ return null;
218
+ return boundedString(value, 1, maximum, name);
219
+ }
220
+ function optionalDiscordId(value, name) {
221
+ if (value === undefined || value === null || value === "")
222
+ return null;
223
+ if (typeof value !== "string" || !/^\d{17,20}$/u.test(value))
224
+ throw new Error(`Auto Moderation contains an invalid ${name}.`);
225
+ return value;
226
+ }
227
+ function discordIds(value, maximum, name) {
228
+ if (value === undefined || value === null)
229
+ return [];
230
+ if (!Array.isArray(value) ||
231
+ value.length > maximum ||
232
+ value.some((id) => typeof id !== "string" || !/^\d{17,20}$/u.test(id)))
233
+ throw new Error(`Auto Moderation contains invalid ${name}.`);
234
+ return [...new Set(value)].sort();
235
+ }
236
+ function timeout(value) {
237
+ if (value === undefined || value === null || value === 0 || value === "0")
238
+ return null;
239
+ const numeric = typeof value === "string" ? Number(value) : value;
240
+ if (!AUTOMOD_TIMEOUT_SECONDS.includes(numeric))
241
+ throw new Error("Auto Moderation contains an invalid mute duration.");
242
+ return numeric;
243
+ }
244
+ function integer(value, minimum, maximum, fallback, name) {
245
+ if (value === undefined)
246
+ return fallback;
247
+ if (!Number.isInteger(value) ||
248
+ value < minimum ||
249
+ value > maximum)
250
+ throw new Error(`Auto Moderation contains an invalid ${name}.`);
251
+ return value;
252
+ }
253
+ function oneOf(value, values, name, fallback) {
254
+ if (value === undefined)
255
+ return fallback;
256
+ if (!values.includes(value))
257
+ throw new Error(`Auto Moderation contains an invalid ${name}.`);
258
+ return value;
259
+ }
260
+ function numericChoice(value, values, name, fallback) {
261
+ if (value === undefined)
262
+ return fallback;
263
+ const numeric = typeof value === "string" && /^\d+$/u.test(value) ? Number(value) : value;
264
+ if (!values.includes(numeric))
265
+ throw new Error(`Auto Moderation contains an invalid ${name}.`);
266
+ return numeric;
267
+ }
268
+ function mergedIds(left, right, maximum) {
269
+ const values = [...new Set([...left, ...right])].sort();
270
+ if (values.length > maximum)
271
+ throw new Error("Combined global and rule exemptions exceed Discord's limit.");
272
+ return values;
273
+ }
274
+ //# sourceMappingURL=domain.js.map
@@ -0,0 +1,9 @@
1
+ import type { EnhancedTransformEvidence } from "./contracts.js";
2
+ export interface CanonicalText {
3
+ foldedTokens: readonly string[];
4
+ normalisedTokens: readonly string[];
5
+ evidence: readonly EnhancedTransformEvidence[];
6
+ }
7
+ export declare function canonicaliseText(value: string): CanonicalText;
8
+ export declare function canonicalTermKey(value: string): string;
9
+ //# sourceMappingURL=canonicalisation.d.ts.map
@@ -0,0 +1,150 @@
1
+ const TOKEN = /[\p{L}\p{N}]+/gu;
2
+ const ALPHANUMERIC = /[\p{L}\p{N}]/u;
3
+ const FOLDABLE_SEPARATORS = new Set([
4
+ "<",
5
+ ">",
6
+ "{",
7
+ "}",
8
+ "[",
9
+ "]",
10
+ "(",
11
+ ")",
12
+ ".",
13
+ ",",
14
+ "_",
15
+ "|",
16
+ "/",
17
+ "\\",
18
+ "-",
19
+ ]);
20
+ const VISUAL_MAP = Object.freeze({
21
+ а: "a",
22
+ е: "e",
23
+ і: "i",
24
+ ј: "j",
25
+ о: "o",
26
+ р: "p",
27
+ с: "c",
28
+ х: "x",
29
+ у: "y",
30
+ ɑ: "a",
31
+ ο: "o",
32
+ });
33
+ const LEET_MAP = Object.freeze({
34
+ "0": "o",
35
+ "1": "i",
36
+ "3": "e",
37
+ "4": "a",
38
+ "5": "s",
39
+ "7": "t",
40
+ "@": "a",
41
+ $: "s",
42
+ });
43
+ export function canonicaliseText(value) {
44
+ const evidence = new Set();
45
+ const nfkc = value.normalize("NFKC");
46
+ if (nfkc !== value)
47
+ evidence.add("unicode_nfkc");
48
+ const folded = fullCaseFold(nfkc);
49
+ if (folded !== nfkc)
50
+ evidence.add("case_fold");
51
+ const withoutIgnorables = [...folded]
52
+ .filter((character) => !isDefaultIgnorable(character.codePointAt(0)))
53
+ .join("");
54
+ if (withoutIgnorables !== folded)
55
+ evidence.add("default_ignorable");
56
+ const foldedTokens = tokens(folded);
57
+ const mapped = mapCharacters(withoutIgnorables, evidence);
58
+ const joined = foldSeparators(mapped.value, evidence);
59
+ const compacted = compactRepeats(joined, evidence);
60
+ return {
61
+ foldedTokens,
62
+ normalisedTokens: tokens(compacted),
63
+ evidence: [...evidence].sort(),
64
+ };
65
+ }
66
+ export function canonicalTermKey(value) {
67
+ return canonicaliseText(value).normalisedTokens.join(" ");
68
+ }
69
+ function fullCaseFold(value) {
70
+ return value
71
+ .toLocaleLowerCase("und")
72
+ .replaceAll("ß", "ss")
73
+ .replaceAll("ς", "σ")
74
+ .replaceAll("i\u0307", "i");
75
+ }
76
+ function tokens(value) {
77
+ return [...value.matchAll(TOKEN)].map(([token]) => token);
78
+ }
79
+ function mapCharacters(value, evidence) {
80
+ let result = "";
81
+ for (const character of value) {
82
+ const visual = VISUAL_MAP[character];
83
+ if (visual) {
84
+ evidence.add("confusable");
85
+ result += visual;
86
+ continue;
87
+ }
88
+ const leet = LEET_MAP[character];
89
+ if (leet) {
90
+ evidence.add("leetspeak");
91
+ result += leet;
92
+ continue;
93
+ }
94
+ result += character;
95
+ }
96
+ return { value: result };
97
+ }
98
+ function foldSeparators(value, evidence) {
99
+ const characters = [...value];
100
+ return characters
101
+ .map((character, index) => {
102
+ if (!FOLDABLE_SEPARATORS.has(character))
103
+ return character;
104
+ const previous = characters[index - 1] ?? "";
105
+ const next = characters[index + 1] ?? "";
106
+ if (ALPHANUMERIC.test(previous) && ALPHANUMERIC.test(next)) {
107
+ evidence.add("separator");
108
+ return "";
109
+ }
110
+ return " ";
111
+ })
112
+ .join("");
113
+ }
114
+ function compactRepeats(value, evidence) {
115
+ let result = "";
116
+ let previous = "";
117
+ let count = 0;
118
+ for (const character of value) {
119
+ if (character === previous)
120
+ count += 1;
121
+ else {
122
+ previous = character;
123
+ count = 1;
124
+ }
125
+ if (count <= 2)
126
+ result += character;
127
+ else
128
+ evidence.add("repeat_compaction");
129
+ }
130
+ return result;
131
+ }
132
+ function isDefaultIgnorable(codePoint) {
133
+ return (codePoint === 0x00ad ||
134
+ codePoint === 0x034f ||
135
+ codePoint === 0x061c ||
136
+ codePoint === 0x115f ||
137
+ codePoint === 0x1160 ||
138
+ codePoint === 0x17b4 ||
139
+ codePoint === 0x17b5 ||
140
+ between(codePoint, 0x180b, 0x180f) ||
141
+ between(codePoint, 0x200b, 0x200f) ||
142
+ between(codePoint, 0x202a, 0x202e) ||
143
+ between(codePoint, 0x2060, 0x206f) ||
144
+ codePoint === 0xfeff ||
145
+ between(codePoint, 0xfe00, 0xfe0f));
146
+ }
147
+ function between(value, minimum, maximum) {
148
+ return value >= minimum && value <= maximum;
149
+ }
150
+ //# sourceMappingURL=canonicalisation.js.map
@@ -0,0 +1,4 @@
1
+ import type { AutoModerationScannerConfigurationV1 } from "@helyx/sdk";
2
+ import { type CompiledEnhancedConfiguration } from "./contracts.js";
3
+ export declare function compileEnhancedConfiguration(source: AutoModerationScannerConfigurationV1): CompiledEnhancedConfiguration;
4
+ //# sourceMappingURL=compile.d.ts.map
@@ -0,0 +1,169 @@
1
+ import { canonicaliseText, canonicalTermKey } from "./canonicalisation.js";
2
+ import { boundedThreshold } from "./confidence.js";
3
+ import { ENHANCED_ENGINE_LIMITS, ENHANCED_ENGINE_VERSION, ENHANCED_SCORING_VERSION, EnhancedConfigurationError, } from "./contracts.js";
4
+ export function compileEnhancedConfiguration(source) {
5
+ const issues = [];
6
+ const serialized = JSON.stringify(source);
7
+ const bytes = new TextEncoder().encode(serialized).byteLength;
8
+ const ownedSource = deepFreeze(JSON.parse(serialized));
9
+ if (bytes > ENHANCED_ENGINE_LIMITS.configurationBytes)
10
+ issue(issues, "configuration_too_large", "$", "Enhanced configuration exceeds the one MiB compiled-input limit.");
11
+ if (ownedSource.engineVersion !== ENHANCED_ENGINE_VERSION)
12
+ issue(issues, "unsupported_engine_version", "engineVersion", `Enhanced engine version ${ownedSource.engineVersion} is unsupported.`);
13
+ if (ownedSource.scoringVersion !== ENHANCED_SCORING_VERSION)
14
+ issue(issues, "unsupported_scoring_version", "scoringVersion", `Enhanced scoring version ${ownedSource.scoringVersion} is unsupported.`);
15
+ validateIdentity(ownedSource, issues);
16
+ validateThresholds(ownedSource.categoryThresholds, issues);
17
+ if (ownedSource.rules.length > ENHANCED_ENGINE_LIMITS.rules)
18
+ issue(issues, "limit_exceeded", "rules", "Enhanced configuration contains too many rules.");
19
+ let termCount = 0;
20
+ let fuzzyTermCount = 0;
21
+ const owners = new Map();
22
+ const ruleIds = new Set();
23
+ const rules = ownedSource.rules.map((rule, ruleIndex) => {
24
+ if (ruleIds.has(rule.ruleId))
25
+ issue(issues, "invalid_rule", `rules[${ruleIndex}].ruleId`, "Enhanced rule IDs must be unique.");
26
+ ruleIds.add(rule.ruleId);
27
+ termCount += rule.terms.length;
28
+ fuzzyTermCount += rule.terms.filter((term) => term.fuzzyMatching).length;
29
+ return compileRule(rule, ruleIndex, owners, issues);
30
+ });
31
+ if (termCount > ENHANCED_ENGINE_LIMITS.totalTerms)
32
+ issue(issues, "limit_exceeded", "rules", "Enhanced configuration contains too many terms.");
33
+ if (fuzzyTermCount > ENHANCED_ENGINE_LIMITS.fuzzyTerms)
34
+ issue(issues, "limit_exceeded", "rules", `Enhanced fuzzy matching is limited to ${ENHANCED_ENGINE_LIMITS.fuzzyTerms} explicitly opted-in terms.`);
35
+ if (issues.length)
36
+ throw new EnhancedConfigurationError(issues);
37
+ return Object.freeze({
38
+ source: ownedSource,
39
+ rules: Object.freeze(rules),
40
+ estimatedBytes: bytes,
41
+ fuzzyTermCount,
42
+ });
43
+ }
44
+ function validateIdentity(source, issues) {
45
+ for (const [path, value] of [
46
+ ["guildId", source.guildId],
47
+ ["configurationRevision", source.configurationRevision],
48
+ ["fingerprint", source.fingerprint],
49
+ ["refreshAfter", source.refreshAfter],
50
+ ["expiresAt", source.expiresAt],
51
+ ])
52
+ if (!value)
53
+ issue(issues, "invalid_rule", path, `Enhanced configuration requires ${path}.`);
54
+ }
55
+ function compileRule(rule, ruleIndex, owners, issues) {
56
+ const path = `rules[${ruleIndex}]`;
57
+ if (!rule.ruleId || !rule.name || !rule.categoryId)
58
+ issue(issues, "invalid_rule", path, "Enhanced rules require stable IDs, names and category IDs.");
59
+ if (rule.terms.length > ENHANCED_ENGINE_LIMITS.termsPerRule)
60
+ issue(issues, "limit_exceeded", `${path}.terms`, "Enhanced rule contains too many terms.");
61
+ const seenIds = new Set();
62
+ const terms = rule.terms.map((term, termIndex) => {
63
+ if (seenIds.has(term.termId))
64
+ issue(issues, "invalid_term", `${path}.terms[${termIndex}].termId`, "Term IDs must be unique inside a rule.");
65
+ seenIds.add(term.termId);
66
+ registerOwner(rule, term, owners, issues, `${path}.terms[${termIndex}]`);
67
+ return compileTerm(term, `${path}.terms[${termIndex}]`, issues);
68
+ });
69
+ return Object.freeze({ ...rule, terms: Object.freeze(terms) });
70
+ }
71
+ function compileTerm(term, path, issues) {
72
+ const canonical = canonicaliseText(term.value);
73
+ const contextual = term.kind === "similar_message";
74
+ if (!term.termId ||
75
+ !term.value.trim() ||
76
+ (!contextual &&
77
+ term.kind !== "safe_regex" &&
78
+ term.value.length > ENHANCED_ENGINE_LIMITS.termCharacters) ||
79
+ (!contextual &&
80
+ term.kind !== "safe_regex" &&
81
+ canonical.normalisedTokens.length === 0))
82
+ issue(issues, "invalid_term", path, "Enhanced terms require an ID and one to 60 useful characters.");
83
+ if (term.kind === "word" && canonical.foldedTokens.length !== 1)
84
+ issue(issues, "invalid_term", path, "Whole-word terms must contain exactly one token.");
85
+ if (term.kind === "wildcard" && !validWildcard(term.value))
86
+ issue(issues, "invalid_term", path, "Wildcards must contain one token and use stars only at the edges.");
87
+ if (contextual &&
88
+ (term.value !== "cross-channel-similar-message" ||
89
+ term.normalisedMatching ||
90
+ term.fuzzyMatching ||
91
+ term.allowList.length))
92
+ issue(issues, "invalid_term", path, "Similar-message detectors require the canonical contextual policy.");
93
+ if (term.allowList.length > ENHANCED_ENGINE_LIMITS.allowListEntries)
94
+ issue(issues, "limit_exceeded", `${path}.allowList`, "Enhanced term allow list is too large.");
95
+ let minimumConfidence = null;
96
+ if (term.minimumConfidence !== undefined)
97
+ try {
98
+ minimumConfidence = boundedThreshold(term.minimumConfidence);
99
+ }
100
+ catch {
101
+ issue(issues, "invalid_threshold", `${path}.minimumConfidence`, "Term confidence threshold must be from 60 to 95.");
102
+ }
103
+ return Object.freeze({
104
+ ...term,
105
+ foldedTokens: Object.freeze(canonical.foldedTokens),
106
+ normalisedTokens: Object.freeze(canonical.normalisedTokens),
107
+ allowList: Object.freeze(term.allowList.map(compileAllowance)),
108
+ minimumConfidence,
109
+ nativeCoverage: term.kind === "safe_regex"
110
+ ? "regex"
111
+ : term.kind === "similar_message"
112
+ ? "spam"
113
+ : "keyword",
114
+ enhancedAvailable: term.kind !== "safe_regex",
115
+ });
116
+ }
117
+ function compileAllowance(value) {
118
+ const canonical = canonicaliseText(value);
119
+ return Object.freeze({
120
+ foldedTokens: Object.freeze(canonical.foldedTokens),
121
+ normalisedTokens: Object.freeze(canonical.normalisedTokens),
122
+ });
123
+ }
124
+ function registerOwner(rule, term, owners, issues, path) {
125
+ const key = canonicalTermKey(term.value.replaceAll("*", ""));
126
+ if (term.kind === "safe_regex" || term.kind === "similar_message")
127
+ return;
128
+ if (!key)
129
+ return;
130
+ const current = owners.get(key);
131
+ if (current && current.severity !== rule.severity)
132
+ issue(issues, "canonical_term_conflict", path, `Canonical term conflicts with ${current.ruleId}/${current.termId}; choose one severity owner explicitly.`);
133
+ else if (!current)
134
+ owners.set(key, {
135
+ severity: rule.severity,
136
+ ruleId: rule.ruleId,
137
+ termId: term.termId,
138
+ });
139
+ }
140
+ function validateThresholds(thresholds, issues) {
141
+ for (const severity of ["low", "medium", "high", "critical"])
142
+ try {
143
+ boundedThreshold(thresholds[severity]);
144
+ }
145
+ catch {
146
+ issue(issues, "invalid_threshold", `categoryThresholds.${severity}`, "Category confidence thresholds must be from 60 to 95.");
147
+ }
148
+ }
149
+ function validWildcard(value) {
150
+ const stars = [...value].filter((character) => character === "*").length;
151
+ if (stars < 1 ||
152
+ stars > 2 ||
153
+ (!value.startsWith("*") && !value.endsWith("*")))
154
+ return false;
155
+ const body = value.replace(/^\*/u, "").replace(/\*$/u, "");
156
+ return (!body.includes("*") && canonicaliseText(body).normalisedTokens.length === 1);
157
+ }
158
+ function issue(issues, code, path, message) {
159
+ issues.push({ code, path, message });
160
+ }
161
+ function deepFreeze(value) {
162
+ if (value && typeof value === "object") {
163
+ Object.freeze(value);
164
+ for (const nested of Object.values(value))
165
+ deepFreeze(nested);
166
+ }
167
+ return value;
168
+ }
169
+ //# sourceMappingURL=compile.js.map
@@ -0,0 +1,13 @@
1
+ import type { AutoModerationConfidenceClass, AutoModerationSeverity } from "@helyx/sdk";
2
+ import type { EnhancedTransformEvidence } from "./contracts.js";
3
+ export declare function confidenceThresholds(input: {
4
+ profile: "strict" | "balanced" | "sensitive" | "custom";
5
+ custom: number;
6
+ overridesEnabled: boolean;
7
+ overrides: Readonly<Record<AutoModerationSeverity, number>>;
8
+ }): Readonly<Record<AutoModerationSeverity, number>>;
9
+ export declare function transformedConfidence(evidence: readonly EnhancedTransformEvidence[]): number;
10
+ export declare function fuzzyConfidence(termLength: number, distance: number): number;
11
+ export declare function confidenceClass(score: number): AutoModerationConfidenceClass;
12
+ export declare function boundedThreshold(value: number): number;
13
+ //# sourceMappingURL=confidence.d.ts.map
@@ -0,0 +1,57 @@
1
+ const TRANSFORM_SCORES = Object.freeze({
2
+ case_fold: 100,
3
+ unicode_nfkc: 98,
4
+ default_ignorable: 96,
5
+ confusable: 94,
6
+ leetspeak: 92,
7
+ separator: 91,
8
+ repeat_compaction: 90,
9
+ });
10
+ export function confidenceThresholds(input) {
11
+ const inherited = input.profile === "strict"
12
+ ? 90
13
+ : input.profile === "sensitive"
14
+ ? 70
15
+ : input.profile === "custom"
16
+ ? boundedThreshold(input.custom)
17
+ : 80;
18
+ return Object.freeze({
19
+ low: input.overridesEnabled
20
+ ? boundedThreshold(input.overrides.low)
21
+ : inherited,
22
+ medium: input.overridesEnabled
23
+ ? boundedThreshold(input.overrides.medium)
24
+ : inherited,
25
+ high: input.overridesEnabled
26
+ ? boundedThreshold(input.overrides.high)
27
+ : inherited,
28
+ critical: input.overridesEnabled
29
+ ? boundedThreshold(input.overrides.critical)
30
+ : inherited,
31
+ });
32
+ }
33
+ export function transformedConfidence(evidence) {
34
+ return evidence.reduce((lowest, item) => Math.min(lowest, TRANSFORM_SCORES[item] ?? 100), 100);
35
+ }
36
+ export function fuzzyConfidence(termLength, distance) {
37
+ if (distance <= 0)
38
+ return 100;
39
+ return Math.max(60, 100 - Math.round((72 * distance) / termLength));
40
+ }
41
+ export function confidenceClass(score) {
42
+ if (score === 100)
43
+ return "exact";
44
+ if (score >= 96)
45
+ return "native_equivalent";
46
+ if (score >= 90)
47
+ return "high";
48
+ if (score >= 80)
49
+ return "medium";
50
+ return "low";
51
+ }
52
+ export function boundedThreshold(value) {
53
+ if (!Number.isInteger(value) || value < 60 || value > 95)
54
+ throw new Error("Enhanced confidence thresholds must be from 60 to 95.");
55
+ return value;
56
+ }
57
+ //# sourceMappingURL=confidence.js.map