@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
@@ -0,0 +1,29 @@
1
+ import type { CompiledEnhancedConfiguration } from "./contracts.js";
2
+ export interface CompiledConfigurationCacheOptions {
3
+ maximumEntries: number;
4
+ maximumBytes: number;
5
+ maximumEntryBytes: number;
6
+ }
7
+ export interface CompiledConfigurationCacheMetrics {
8
+ entries: number;
9
+ bytes: number;
10
+ hits: number;
11
+ misses: number;
12
+ evictions: number;
13
+ rejectedOversize: number;
14
+ }
15
+ export declare class CompiledConfigurationCache {
16
+ #private;
17
+ constructor(options?: Partial<CompiledConfigurationCacheOptions>);
18
+ get(input: {
19
+ guildId: string;
20
+ configurationRevision: string;
21
+ fingerprint: string;
22
+ }): CompiledEnhancedConfiguration | null;
23
+ put(value: CompiledEnhancedConfiguration): boolean;
24
+ invalidateGuild(guildId: string): number;
25
+ invalidateRevision(guildId: string, configurationRevision: string): number;
26
+ clear(): void;
27
+ metrics(): CompiledConfigurationCacheMetrics;
28
+ }
29
+ //# sourceMappingURL=configuration-cache.d.ts.map
@@ -0,0 +1,115 @@
1
+ const DEFAULT_OPTIONS = Object.freeze({
2
+ maximumEntries: 1_000,
3
+ maximumBytes: 64 * 1_024 * 1_024,
4
+ maximumEntryBytes: 1_048_576,
5
+ });
6
+ export class CompiledConfigurationCache {
7
+ #options;
8
+ #entries = new Map();
9
+ #sequence = 0;
10
+ #bytes = 0;
11
+ #hits = 0;
12
+ #misses = 0;
13
+ #evictions = 0;
14
+ #rejectedOversize = 0;
15
+ constructor(options = {}) {
16
+ this.#options = validateOptions({ ...DEFAULT_OPTIONS, ...options });
17
+ }
18
+ get(input) {
19
+ const entry = this.#entries.get(key(input));
20
+ if (!entry) {
21
+ this.#misses += 1;
22
+ return null;
23
+ }
24
+ this.#hits += 1;
25
+ entry.accessed = ++this.#sequence;
26
+ return entry.value;
27
+ }
28
+ put(value) {
29
+ if (value.estimatedBytes > this.#options.maximumEntryBytes) {
30
+ this.#rejectedOversize += 1;
31
+ return false;
32
+ }
33
+ const identity = {
34
+ guildId: value.source.guildId,
35
+ configurationRevision: value.source.configurationRevision,
36
+ fingerprint: value.source.fingerprint,
37
+ };
38
+ const entryKey = key(identity);
39
+ const previous = this.#entries.get(entryKey);
40
+ if (previous)
41
+ this.#bytes -= previous.bytes;
42
+ this.#entries.set(entryKey, {
43
+ key: entryKey,
44
+ guildId: identity.guildId,
45
+ configurationRevision: identity.configurationRevision,
46
+ value,
47
+ bytes: value.estimatedBytes,
48
+ accessed: ++this.#sequence,
49
+ });
50
+ this.#bytes += value.estimatedBytes;
51
+ this.#evictToBounds();
52
+ return this.#entries.has(entryKey);
53
+ }
54
+ invalidateGuild(guildId) {
55
+ return this.#removeWhere((entry) => entry.guildId === guildId);
56
+ }
57
+ invalidateRevision(guildId, configurationRevision) {
58
+ return this.#removeWhere((entry) => entry.guildId === guildId &&
59
+ entry.configurationRevision === configurationRevision);
60
+ }
61
+ clear() {
62
+ this.#entries.clear();
63
+ this.#bytes = 0;
64
+ }
65
+ metrics() {
66
+ return {
67
+ entries: this.#entries.size,
68
+ bytes: this.#bytes,
69
+ hits: this.#hits,
70
+ misses: this.#misses,
71
+ evictions: this.#evictions,
72
+ rejectedOversize: this.#rejectedOversize,
73
+ };
74
+ }
75
+ #evictToBounds() {
76
+ while (this.#entries.size > this.#options.maximumEntries ||
77
+ this.#bytes > this.#options.maximumBytes) {
78
+ const oldest = [...this.#entries.values()].sort((left, right) => left.accessed - right.accessed || left.key.localeCompare(right.key))[0];
79
+ if (!oldest)
80
+ return;
81
+ this.#entries.delete(oldest.key);
82
+ this.#bytes -= oldest.bytes;
83
+ this.#evictions += 1;
84
+ }
85
+ }
86
+ #removeWhere(predicate) {
87
+ let removed = 0;
88
+ for (const [entryKey, entry] of this.#entries)
89
+ if (predicate(entry)) {
90
+ this.#entries.delete(entryKey);
91
+ this.#bytes -= entry.bytes;
92
+ removed += 1;
93
+ }
94
+ return removed;
95
+ }
96
+ }
97
+ function key(input) {
98
+ return JSON.stringify([
99
+ input.guildId,
100
+ input.configurationRevision,
101
+ input.fingerprint,
102
+ ]);
103
+ }
104
+ function validateOptions(options) {
105
+ if (!Number.isInteger(options.maximumEntries) ||
106
+ options.maximumEntries < 1 ||
107
+ !Number.isInteger(options.maximumBytes) ||
108
+ options.maximumBytes < 1 ||
109
+ !Number.isInteger(options.maximumEntryBytes) ||
110
+ options.maximumEntryBytes < 1 ||
111
+ options.maximumEntryBytes > options.maximumBytes)
112
+ throw new Error("Compiled configuration cache bounds are invalid.");
113
+ return Object.freeze(options);
114
+ }
115
+ //# sourceMappingURL=configuration-cache.js.map
@@ -0,0 +1,82 @@
1
+ import type { AutoModerationConfidenceClass, AutoModerationDetectionMethod, AutoModerationScannerConfigurationV1, AutoModerationScannerRuleV1, AutoModerationScannerTermKind, AutoModerationSeverity } from "@helyx/sdk";
2
+ export declare const ENHANCED_ENGINE_VERSION = "helyx-automod-engine/1";
3
+ export declare const ENHANCED_SCORING_VERSION = "helyx-automod-score/1";
4
+ export declare const ENHANCED_ENGINE_LIMITS: Readonly<{
5
+ rules: 100;
6
+ termsPerRule: 1000;
7
+ totalTerms: 4000;
8
+ fuzzyTerms: 250;
9
+ termCharacters: 60;
10
+ allowListEntries: 100;
11
+ messageCharacters: 2000;
12
+ messageUtf8Bytes: 32768;
13
+ messageTokens: 512;
14
+ configurationBytes: 1048576;
15
+ minimumFuzzyCharacters: 4;
16
+ maximumFuzzyDistance: 3;
17
+ }>;
18
+ export type EnhancedNativeCoverage = "keyword" | "regex" | "spam" | "none";
19
+ export type EnhancedTransformEvidence = "case_fold" | "unicode_nfkc" | "default_ignorable" | "confusable" | "leetspeak" | "separator" | "repeat_compaction" | "edit_distance";
20
+ export interface CompiledEnhancedTerm {
21
+ termId: string;
22
+ kind: AutoModerationScannerTermKind;
23
+ value: string;
24
+ foldedTokens: readonly string[];
25
+ normalisedTokens: readonly string[];
26
+ allowList: readonly CompiledEnhancedAllowance[];
27
+ normalisedMatching: boolean;
28
+ fuzzyMatching: boolean;
29
+ minimumConfidence: number | null;
30
+ nativeCoverage: EnhancedNativeCoverage;
31
+ enhancedAvailable: boolean;
32
+ }
33
+ export interface CompiledEnhancedAllowance {
34
+ foldedTokens: readonly string[];
35
+ normalisedTokens: readonly string[];
36
+ }
37
+ export interface CompiledEnhancedRule extends Omit<AutoModerationScannerRuleV1, "terms"> {
38
+ terms: readonly CompiledEnhancedTerm[];
39
+ }
40
+ export interface CompiledEnhancedConfiguration {
41
+ source: AutoModerationScannerConfigurationV1;
42
+ rules: readonly CompiledEnhancedRule[];
43
+ estimatedBytes: number;
44
+ fuzzyTermCount: number;
45
+ }
46
+ export type EnhancedConfigurationIssueCode = "configuration_too_large" | "unsupported_engine_version" | "unsupported_scoring_version" | "invalid_threshold" | "invalid_rule" | "invalid_term" | "limit_exceeded" | "canonical_term_conflict" | "enhanced_regex_unavailable";
47
+ export interface EnhancedConfigurationIssue {
48
+ code: EnhancedConfigurationIssueCode;
49
+ path: string;
50
+ message: string;
51
+ }
52
+ export declare class EnhancedConfigurationError extends Error {
53
+ readonly issues: readonly EnhancedConfigurationIssue[];
54
+ constructor(issues: readonly EnhancedConfigurationIssue[]);
55
+ }
56
+ export interface EnhancedMessageInput {
57
+ content: string;
58
+ channelId: string;
59
+ parentChannelId: string | null;
60
+ memberRoleIds: readonly string[];
61
+ source: "user" | "bot" | "webhook" | "system" | "unknown";
62
+ }
63
+ export interface EnhancedDetection {
64
+ ruleId: string;
65
+ categoryId: string;
66
+ termId: string;
67
+ severity: AutoModerationSeverity;
68
+ method: AutoModerationDetectionMethod;
69
+ confidenceScore: number;
70
+ confidenceClass: AutoModerationConfidenceClass;
71
+ effectiveThreshold: number;
72
+ nativeCoverage: EnhancedNativeCoverage;
73
+ evidence: readonly EnhancedTransformEvidence[];
74
+ }
75
+ export type EnhancedScanResult = {
76
+ outcome: "detected";
77
+ detection: EnhancedDetection;
78
+ } | {
79
+ outcome: "not_detected" | "not_scannable" | "exempt";
80
+ reason: string;
81
+ };
82
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1,25 @@
1
+ export const ENHANCED_ENGINE_VERSION = "helyx-automod-engine/1";
2
+ export const ENHANCED_SCORING_VERSION = "helyx-automod-score/1";
3
+ export const ENHANCED_ENGINE_LIMITS = Object.freeze({
4
+ rules: 100,
5
+ termsPerRule: 1_000,
6
+ totalTerms: 4_000,
7
+ fuzzyTerms: 250,
8
+ termCharacters: 60,
9
+ allowListEntries: 100,
10
+ messageCharacters: 2_000,
11
+ messageUtf8Bytes: 32_768,
12
+ messageTokens: 512,
13
+ configurationBytes: 1_048_576,
14
+ minimumFuzzyCharacters: 4,
15
+ maximumFuzzyDistance: 3,
16
+ });
17
+ export class EnhancedConfigurationError extends Error {
18
+ issues;
19
+ constructor(issues) {
20
+ super(issues[0]?.message ?? "Enhanced configuration is invalid.");
21
+ this.name = "EnhancedConfigurationError";
22
+ this.issues = issues;
23
+ }
24
+ }
25
+ //# sourceMappingURL=contracts.js.map
@@ -0,0 +1,11 @@
1
+ export * from "./canonicalisation.js";
2
+ export * from "./compile.js";
3
+ export * from "./confidence.js";
4
+ export * from "./configuration-cache.js";
5
+ export * from "./contracts.js";
6
+ export * from "./matcher.js";
7
+ export * from "./observations.js";
8
+ export * from "./operation-id.js";
9
+ export * from "./similar-message-window.js";
10
+ export * from "./term-import.js";
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,11 @@
1
+ export * from "./canonicalisation.js";
2
+ export * from "./compile.js";
3
+ export * from "./confidence.js";
4
+ export * from "./configuration-cache.js";
5
+ export * from "./contracts.js";
6
+ export * from "./matcher.js";
7
+ export * from "./observations.js";
8
+ export * from "./operation-id.js";
9
+ export * from "./similar-message-window.js";
10
+ export * from "./term-import.js";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import { type CompiledEnhancedConfiguration, type EnhancedMessageInput, type EnhancedScanResult } from "./contracts.js";
2
+ export declare function scanEnhancedMessage(configuration: CompiledEnhancedConfiguration, input: EnhancedMessageInput): EnhancedScanResult;
3
+ //# sourceMappingURL=matcher.d.ts.map
@@ -0,0 +1,206 @@
1
+ import { canonicaliseText } from "./canonicalisation.js";
2
+ import { confidenceClass, fuzzyConfidence, transformedConfidence, } from "./confidence.js";
3
+ import { ENHANCED_ENGINE_LIMITS, } from "./contracts.js";
4
+ const SEVERITY_RANK = {
5
+ low: 1,
6
+ medium: 2,
7
+ high: 3,
8
+ critical: 4,
9
+ };
10
+ const METHOD_RANK = {
11
+ exact: 7,
12
+ phrase: 6,
13
+ wildcard: 5,
14
+ safe_regex: 4,
15
+ normalised: 3,
16
+ fuzzy: 2,
17
+ similar_message: 1,
18
+ };
19
+ export function scanEnhancedMessage(configuration, input) {
20
+ if (input.source !== "user")
21
+ return { outcome: "not_scannable", reason: `source_${input.source}` };
22
+ if (input.content.length > ENHANCED_ENGINE_LIMITS.messageCharacters ||
23
+ new TextEncoder().encode(input.content).byteLength >
24
+ ENHANCED_ENGINE_LIMITS.messageUtf8Bytes)
25
+ return { outcome: "not_scannable", reason: "message_size_limit" };
26
+ const message = canonicaliseText(withoutUrls(input.content));
27
+ if (message.foldedTokens.length > ENHANCED_ENGINE_LIMITS.messageTokens ||
28
+ message.normalisedTokens.length > ENHANCED_ENGINE_LIMITS.messageTokens)
29
+ return { outcome: "not_scannable", reason: "message_token_limit" };
30
+ const candidates = [];
31
+ let eligibleRules = 0;
32
+ for (const rule of configuration.rules) {
33
+ if (isExempt(rule, input))
34
+ continue;
35
+ eligibleRules += 1;
36
+ for (const term of rule.terms) {
37
+ const candidate = matchTerm(configuration, rule, term, message);
38
+ if (candidate)
39
+ candidates.push(candidate);
40
+ }
41
+ }
42
+ if (!eligibleRules)
43
+ return { outcome: "exempt", reason: "all_rules_exempt" };
44
+ candidates.sort(compareCandidates);
45
+ const detection = candidates[0];
46
+ return detection
47
+ ? { outcome: "detected", detection }
48
+ : { outcome: "not_detected", reason: "no_eligible_match" };
49
+ }
50
+ function matchTerm(configuration, rule, term, message) {
51
+ if (!term.enhancedAvailable || term.kind === "similar_message")
52
+ return null;
53
+ if (isAllowed(term, message))
54
+ return null;
55
+ const threshold = Math.max(configuration.source.categoryThresholds[rule.severity], term.minimumConfidence ?? 60);
56
+ const exact = directMatch(term, message.foldedTokens, "foldedTokens");
57
+ if (exact)
58
+ return candidate(rule, term, exact, 100, threshold, []);
59
+ if (term.normalisedMatching) {
60
+ const normalised = directMatch(term, message.normalisedTokens, "normalisedTokens");
61
+ if (normalised) {
62
+ const score = transformedConfidence(message.evidence);
63
+ if (score >= 90 || score >= threshold)
64
+ return candidate(rule, term, "normalised", score, threshold, message.evidence);
65
+ }
66
+ }
67
+ if (!term.fuzzyMatching ||
68
+ term.kind === "wildcard" ||
69
+ term.kind === "safe_regex")
70
+ return null;
71
+ const fuzzy = fuzzyMatch(term, message.normalisedTokens, message.evidence, threshold);
72
+ return fuzzy
73
+ ? candidate(rule, term, "fuzzy", fuzzy.score, threshold, [
74
+ ...message.evidence,
75
+ "edit_distance",
76
+ ])
77
+ : null;
78
+ }
79
+ function directMatch(term, messageTokens, termTokensKey) {
80
+ const termTokens = term[termTokensKey];
81
+ if (!termTokens.length)
82
+ return null;
83
+ if (term.kind === "wildcard")
84
+ return wildcardMatch(term.value, termTokens[0], messageTokens)
85
+ ? "wildcard"
86
+ : null;
87
+ if (!containsSequence(messageTokens, termTokens))
88
+ return null;
89
+ return term.kind === "phrase" || termTokens.length > 1 ? "phrase" : "exact";
90
+ }
91
+ function fuzzyMatch(term, messageTokens, evidence, threshold) {
92
+ const expected = term.normalisedTokens;
93
+ const length = expected.join(" ").length;
94
+ if (length < ENHANCED_ENGINE_LIMITS.minimumFuzzyCharacters)
95
+ return null;
96
+ const maximumDistance = fuzzyDistanceLimit(length);
97
+ let best = 0;
98
+ const windowSize = expected.length;
99
+ for (let index = 0; index + windowSize <= messageTokens.length; index += 1) {
100
+ const observed = messageTokens.slice(index, index + windowSize).join(" ");
101
+ if (Math.abs(observed.length - length) > maximumDistance)
102
+ continue;
103
+ const distance = boundedLevenshtein(expected.join(" "), observed, maximumDistance);
104
+ if (distance < 1 || distance > maximumDistance)
105
+ continue;
106
+ if (length <= 5 &&
107
+ !reviewedShortEdit(expected.join(" "), observed, evidence))
108
+ continue;
109
+ best = Math.max(best, fuzzyConfidence(length, distance));
110
+ }
111
+ return best >= threshold ? { score: best } : null;
112
+ }
113
+ function reviewedShortEdit(expected, observed, evidence) {
114
+ if (Math.abs(expected.length - observed.length) === 1 &&
115
+ (evidence.includes("separator") || evidence.includes("repeat_compaction")))
116
+ return true;
117
+ if (expected.length !== observed.length)
118
+ return false;
119
+ const changes = [...expected].flatMap((character, index) => character === observed[index] ? [] : [`${character}/${observed[index]}`]);
120
+ return (changes.length === 1 &&
121
+ ["u/v", "v/u", "i/l", "l/i", "m/n", "n/m"].includes(changes[0]));
122
+ }
123
+ function fuzzyDistanceLimit(length) {
124
+ if (length < 6)
125
+ return 1;
126
+ if (length < 10)
127
+ return 2;
128
+ return ENHANCED_ENGINE_LIMITS.maximumFuzzyDistance;
129
+ }
130
+ function boundedLevenshtein(left, right, maximum) {
131
+ if (Math.abs(left.length - right.length) > maximum)
132
+ return maximum + 1;
133
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
134
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
135
+ const current = [leftIndex];
136
+ let rowMinimum = leftIndex;
137
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
138
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
139
+ const value = Math.min(previous[rightIndex] + 1, current[rightIndex - 1] + 1, previous[rightIndex - 1] + cost);
140
+ current.push(value);
141
+ rowMinimum = Math.min(rowMinimum, value);
142
+ }
143
+ if (rowMinimum > maximum)
144
+ return maximum + 1;
145
+ previous = current;
146
+ }
147
+ return previous[right.length] ?? maximum + 1;
148
+ }
149
+ function isAllowed(term, message) {
150
+ return term.allowList.some((allowance) => containsSequence(message.foldedTokens, allowance.foldedTokens) ||
151
+ (term.normalisedMatching &&
152
+ containsSequence(message.normalisedTokens, allowance.normalisedTokens)));
153
+ }
154
+ function isExempt(rule, input) {
155
+ if (rule.exemptChannelIds.includes(input.channelId) ||
156
+ (input.parentChannelId !== null &&
157
+ rule.exemptChannelIds.includes(input.parentChannelId)))
158
+ return true;
159
+ return input.memberRoleIds.some((roleId) => rule.exemptRoleIds.includes(roleId));
160
+ }
161
+ function containsSequence(source, expected) {
162
+ if (!expected.length || expected.length > source.length)
163
+ return false;
164
+ for (let start = 0; start + expected.length <= source.length; start += 1)
165
+ if (expected.every((token, offset) => source[start + offset] === token))
166
+ return true;
167
+ return false;
168
+ }
169
+ function wildcardMatch(value, expected, tokens) {
170
+ const prefix = value.endsWith("*");
171
+ const suffix = value.startsWith("*");
172
+ return tokens.some((token) => {
173
+ if (prefix && suffix)
174
+ return token.includes(expected);
175
+ if (prefix)
176
+ return token.startsWith(expected);
177
+ return token.endsWith(expected);
178
+ });
179
+ }
180
+ function candidate(rule, term, method, confidenceScore, effectiveThreshold, evidence) {
181
+ return {
182
+ ruleId: rule.ruleId,
183
+ categoryId: rule.categoryId,
184
+ termId: term.termId,
185
+ severity: rule.severity,
186
+ method,
187
+ confidenceScore,
188
+ confidenceClass: confidenceClass(confidenceScore),
189
+ effectiveThreshold,
190
+ nativeCoverage: term.nativeCoverage,
191
+ evidence: [...new Set(evidence)].sort(),
192
+ termLength: term.normalisedTokens.join(" ").length,
193
+ };
194
+ }
195
+ function compareCandidates(left, right) {
196
+ return (SEVERITY_RANK[right.severity] - SEVERITY_RANK[left.severity] ||
197
+ right.confidenceScore - left.confidenceScore ||
198
+ METHOD_RANK[right.method] - METHOD_RANK[left.method] ||
199
+ right.termLength - left.termLength ||
200
+ left.ruleId.localeCompare(right.ruleId) ||
201
+ left.termId.localeCompare(right.termId));
202
+ }
203
+ function withoutUrls(value) {
204
+ return value.replace(/\b(?:https?:\/\/|www\.)\S+/giu, " ");
205
+ }
206
+ //# sourceMappingURL=matcher.js.map
@@ -0,0 +1,45 @@
1
+ export type EnhancedMessageSource = "user" | "bot" | "webhook" | "system" | "unknown";
2
+ export interface EnhancedGatewayMessage {
3
+ guildId: string;
4
+ channelId: string;
5
+ parentChannelId?: string | null;
6
+ messageId: string;
7
+ authorUserId?: string;
8
+ source?: EnhancedMessageSource;
9
+ content?: unknown;
10
+ observedAtMs: number;
11
+ }
12
+ export type EnhancedMessageObservation = {
13
+ outcome: "observed";
14
+ observation: {
15
+ type: "create" | "edit";
16
+ version: string;
17
+ guildId: string;
18
+ channelId: string;
19
+ parentChannelId: string | null;
20
+ messageId: string;
21
+ authorUserId: string;
22
+ source: EnhancedMessageSource;
23
+ content: string;
24
+ observedAtMs: number;
25
+ };
26
+ } | {
27
+ outcome: "not_observed" | "unscannable_edit";
28
+ reason: "missing_content" | "source_unresolved" | "source_ineligible";
29
+ };
30
+ export declare class EnhancedMessageObservationCache {
31
+ #private;
32
+ constructor(options?: {
33
+ maximumEntries?: number;
34
+ ttlMs?: number;
35
+ });
36
+ observeCreate(input: EnhancedGatewayMessage): EnhancedMessageObservation;
37
+ observeEdit(input: EnhancedGatewayMessage): EnhancedMessageObservation;
38
+ metrics(): {
39
+ entries: number;
40
+ evictions: number;
41
+ unscannableEdits: number;
42
+ };
43
+ clear(): void;
44
+ }
45
+ //# sourceMappingURL=observations.d.ts.map
@@ -0,0 +1,105 @@
1
+ export class EnhancedMessageObservationCache {
2
+ #entries = new Map();
3
+ #maximumEntries;
4
+ #ttlMs;
5
+ #sequence = 0;
6
+ #evictions = 0;
7
+ #unscannableEdits = 0;
8
+ constructor(options = {}) {
9
+ this.#maximumEntries = options.maximumEntries ?? 50_000;
10
+ this.#ttlMs = options.ttlMs ?? 10 * 60_000;
11
+ if (!Number.isInteger(this.#maximumEntries) ||
12
+ this.#maximumEntries < 1 ||
13
+ !Number.isInteger(this.#ttlMs) ||
14
+ this.#ttlMs < 1 ||
15
+ this.#ttlMs > 10 * 60_000)
16
+ throw new Error("Message metadata cache bounds are invalid.");
17
+ }
18
+ observeCreate(input) {
19
+ const resolved = resolveMetadata(input);
20
+ if (resolved)
21
+ this.#remember(resolved, input.observedAtMs);
22
+ return observation("create", input, resolved);
23
+ }
24
+ observeEdit(input) {
25
+ this.#pruneExpired(input.observedAtMs);
26
+ const supplied = resolveMetadata(input);
27
+ const cached = this.#entries.get(input.messageId);
28
+ const resolved = supplied ?? cached ?? null;
29
+ if (!resolved && typeof input.content === "string") {
30
+ this.#unscannableEdits += 1;
31
+ return { outcome: "unscannable_edit", reason: "source_unresolved" };
32
+ }
33
+ if (supplied)
34
+ this.#remember(supplied, input.observedAtMs);
35
+ else if (cached) {
36
+ cached.accessed = ++this.#sequence;
37
+ cached.expiresAtMs = input.observedAtMs + this.#ttlMs;
38
+ }
39
+ return observation("edit", input, resolved);
40
+ }
41
+ metrics() {
42
+ return {
43
+ entries: this.#entries.size,
44
+ evictions: this.#evictions,
45
+ unscannableEdits: this.#unscannableEdits,
46
+ };
47
+ }
48
+ clear() {
49
+ this.#entries.clear();
50
+ }
51
+ #remember(input, observedAtMs) {
52
+ this.#entries.set(input.messageId, {
53
+ ...input,
54
+ expiresAtMs: observedAtMs + this.#ttlMs,
55
+ accessed: ++this.#sequence,
56
+ });
57
+ while (this.#entries.size > this.#maximumEntries) {
58
+ const oldest = [...this.#entries.values()].sort((left, right) => left.accessed - right.accessed ||
59
+ left.messageId.localeCompare(right.messageId))[0];
60
+ if (!oldest)
61
+ return;
62
+ this.#entries.delete(oldest.messageId);
63
+ this.#evictions += 1;
64
+ }
65
+ }
66
+ #pruneExpired(observedAtMs) {
67
+ for (const [messageId, entry] of this.#entries)
68
+ if (entry.expiresAtMs <= observedAtMs)
69
+ this.#entries.delete(messageId);
70
+ }
71
+ }
72
+ function resolveMetadata(input) {
73
+ if (!input.authorUserId || !input.source)
74
+ return null;
75
+ return {
76
+ guildId: input.guildId,
77
+ channelId: input.channelId,
78
+ parentChannelId: input.parentChannelId ?? null,
79
+ messageId: input.messageId,
80
+ authorUserId: input.authorUserId,
81
+ source: input.source,
82
+ };
83
+ }
84
+ function observation(type, input, metadata) {
85
+ if (typeof input.content !== "string")
86
+ return { outcome: "not_observed", reason: "missing_content" };
87
+ if (!metadata)
88
+ return {
89
+ outcome: type === "edit" ? "unscannable_edit" : "not_observed",
90
+ reason: "source_unresolved",
91
+ };
92
+ if (metadata.source !== "user")
93
+ return { outcome: "not_observed", reason: "source_ineligible" };
94
+ return {
95
+ outcome: "observed",
96
+ observation: {
97
+ type,
98
+ version: `${input.messageId}:${input.observedAtMs}`,
99
+ ...metadata,
100
+ content: input.content,
101
+ observedAtMs: input.observedAtMs,
102
+ },
103
+ };
104
+ }
105
+ //# sourceMappingURL=observations.js.map
@@ -0,0 +1,18 @@
1
+ export interface EnhancedOperationIdentity {
2
+ environment: string;
3
+ observation: {
4
+ guildId: string;
5
+ channelId: string;
6
+ messageId: string;
7
+ type: "create" | "edit";
8
+ version: string;
9
+ };
10
+ configurationRevision: string;
11
+ detectorVersion: string;
12
+ ruleId: string;
13
+ termId: string;
14
+ resultClass: string;
15
+ }
16
+ /** Stable across Hosted and embedded observations of the same revision. */
17
+ export declare function enhancedOperationId(input: EnhancedOperationIdentity): string;
18
+ //# sourceMappingURL=operation-id.d.ts.map
@@ -0,0 +1,24 @@
1
+ import { AUTOMOD_ENHANCED_RESULT_PROTOCOL } from "@helyx/sdk";
2
+ import { createHash } from "node:crypto";
3
+ /** Stable across Hosted and embedded observations of the same revision. */
4
+ export function enhancedOperationId(input) {
5
+ const values = [
6
+ AUTOMOD_ENHANCED_RESULT_PROTOCOL,
7
+ input.environment,
8
+ input.observation.guildId,
9
+ input.observation.channelId,
10
+ input.observation.messageId,
11
+ input.observation.type,
12
+ input.observation.version,
13
+ input.configurationRevision,
14
+ input.detectorVersion,
15
+ input.ruleId,
16
+ input.termId,
17
+ input.resultClass,
18
+ ];
19
+ const canonical = values
20
+ .map((value) => `${Buffer.byteLength(value, "utf8")}:${value}`)
21
+ .join("");
22
+ return createHash("sha256").update(canonical).digest("hex");
23
+ }
24
+ //# sourceMappingURL=operation-id.js.map