@helyx/bot 1.1.0 → 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.
Files changed (85) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +16 -1
  3. package/dist/auto-moderation-scanner-registry.d.ts +27 -0
  4. package/dist/auto-moderation-scanner-registry.js +172 -0
  5. package/dist/cloud-connector.js +3 -0
  6. package/dist/cloud-control-dispatcher.js +32 -0
  7. package/dist/command-path-contract.js +1 -0
  8. package/dist/control-permissions.d.ts +2 -2
  9. package/dist/control-permissions.js +4 -2
  10. package/dist/control-server.d.ts +72 -16
  11. package/dist/control-server.js +150 -250
  12. package/dist/dashboard-action-control.d.ts +25 -0
  13. package/dist/dashboard-action-control.js +73 -0
  14. package/dist/dashboard-action-routes.d.ts +9 -0
  15. package/dist/dashboard-action-routes.js +36 -0
  16. package/dist/dashboard-feature-control.d.ts +37 -0
  17. package/dist/dashboard-feature-control.js +206 -0
  18. package/dist/dashboard-module-projection.d.ts +9 -0
  19. package/dist/dashboard-module-projection.js +15 -0
  20. package/dist/dashboard-preview-import-routes.d.ts +6 -0
  21. package/dist/dashboard-preview-import-routes.js +121 -0
  22. package/dist/discord-analytics.d.ts +3 -0
  23. package/dist/discord-analytics.js +49 -5
  24. package/dist/discord-auto-moderation-service.d.ts +21 -0
  25. package/dist/discord-auto-moderation-service.js +425 -0
  26. package/dist/discord-guild-health.d.ts +2 -2
  27. package/dist/discord-member-enforcement-service.d.ts +26 -0
  28. package/dist/discord-member-enforcement-service.js +590 -0
  29. package/dist/discord-message-enforcement-service.d.ts +9 -0
  30. package/dist/discord-message-enforcement-service.js +102 -0
  31. package/dist/discord-violation-thread-service.d.ts +33 -0
  32. package/dist/discord-violation-thread-service.js +789 -0
  33. package/dist/ecko-evidence-receiver.d.ts +32 -0
  34. package/dist/ecko-evidence-receiver.js +173 -0
  35. package/dist/ecko-transport-auth.d.ts +19 -0
  36. package/dist/ecko-transport-auth.js +80 -0
  37. package/dist/ecko-transport-routes.d.ts +19 -0
  38. package/dist/ecko-transport-routes.js +130 -0
  39. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  40. package/dist/embedded-auto-moderation-pool.js +324 -0
  41. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  42. package/dist/embedded-auto-moderation-scanner.js +263 -0
  43. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  44. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  45. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  46. package/dist/embedded-auto-moderation-worker.js +13 -0
  47. package/dist/events.d.ts +5 -1
  48. package/dist/events.js +243 -4
  49. package/dist/guilds.d.ts +2 -2
  50. package/dist/guilds.js +14 -8
  51. package/dist/hosted-server-moderation.d.ts +16 -5
  52. package/dist/hosted-server-moderation.js +32 -4
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.js +84 -14
  55. package/dist/moderation-action-registry.d.ts +11 -0
  56. package/dist/moderation-action-registry.js +375 -0
  57. package/dist/moderation-case-disassociation.d.ts +13 -0
  58. package/dist/moderation-case-disassociation.js +83 -0
  59. package/dist/moderation-platform-services.d.ts +27 -0
  60. package/dist/moderation-platform-services.js +47 -0
  61. package/dist/modules.d.ts +6 -0
  62. package/dist/modules.js +65 -3
  63. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  64. package/dist/privileged-message-metadata-cache.js +70 -0
  65. package/dist/scheduled-task-runner.js +24 -1
  66. package/dist/shard-contracts.d.ts +151 -1
  67. package/dist/shard-contracts.js +142 -1
  68. package/dist/shard-coordinator.d.ts +1 -1
  69. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  70. package/dist/shard-ecko-scanner-registry.js +96 -0
  71. package/dist/shard-router.d.ts +23 -0
  72. package/dist/shard-router.js +69 -5
  73. package/dist/shard-supervisor.d.ts +11 -0
  74. package/dist/shard-supervisor.js +67 -4
  75. package/dist/shard-worker.d.ts +15 -1
  76. package/dist/shard-worker.js +95 -17
  77. package/dist/transient-violation-evidence.d.ts +39 -0
  78. package/dist/transient-violation-evidence.js +132 -0
  79. package/documentation/README.md +2 -0
  80. package/documentation/examples/.env.example +12 -0
  81. package/documentation/guides/self-hosting.md +17 -1
  82. package/documentation/modules/auto-moderation.md +51 -0
  83. package/documentation/modules/moderation.md +42 -0
  84. package/documentation/reference/environment.md +11 -0
  85. package/package.json +17 -5
@@ -0,0 +1,324 @@
1
+ import { Worker } from "node:worker_threads";
2
+ const MAXIMUM_WORKERS = 4;
3
+ const MAXIMUM_RESTARTS = 5;
4
+ const MAXIMUM_RESTART_BACKOFF_MS = 5_000;
5
+ export class EmbeddedAutoModerationPool {
6
+ limits;
7
+ #queues = new Map();
8
+ #activeGuilds = new Set();
9
+ #workers = new Set();
10
+ #workerFactory;
11
+ #configurations = [];
12
+ #configuredGuilds = new Set();
13
+ #requestSequence = 0;
14
+ #queuedItems = 0;
15
+ #queuedBytes = 0;
16
+ #overflow = 0;
17
+ #expired = 0;
18
+ #workerFailures = 0;
19
+ #consecutiveWorkerFailures = 0;
20
+ #restartTimer = null;
21
+ #stopping = false;
22
+ #stopped = false;
23
+ #circuitOpen = false;
24
+ #drainWaiters = [];
25
+ constructor(limits, options = {}) {
26
+ this.limits = limits;
27
+ validateLimits(limits);
28
+ const workerUrl = options.workerUrl ?? defaultWorkerUrl();
29
+ this.#workerFactory =
30
+ options.workerFactory ?? (() => new Worker(workerUrl));
31
+ this.now = options.now ?? Date.now;
32
+ }
33
+ now;
34
+ replaceConfigurations(configurations) {
35
+ if (this.#stopping || this.#stopped)
36
+ return;
37
+ const guildIds = new Set();
38
+ for (const configuration of configurations) {
39
+ const guildId = configuration.source.guildId;
40
+ if (!guildId || guildIds.has(guildId))
41
+ throw new Error("Embedded scanner configuration snapshot is invalid");
42
+ guildIds.add(guildId);
43
+ }
44
+ this.#configurations = Object.freeze([...configurations]);
45
+ this.#configuredGuilds = guildIds;
46
+ if (configurations.length === 0) {
47
+ this.#discardQueued();
48
+ void this.#terminateWorkers();
49
+ return;
50
+ }
51
+ if (this.#circuitOpen)
52
+ return;
53
+ this.#ensureWorkers();
54
+ for (const record of this.#workers)
55
+ record.worker.postMessage({
56
+ type: "replace-configurations",
57
+ configurations: this.#configurations,
58
+ });
59
+ this.#pump();
60
+ }
61
+ submit(input) {
62
+ const bytes = Buffer.byteLength(input.observation.content, "utf8");
63
+ const guildQueue = this.#queues.get(input.observation.guildId) ?? [];
64
+ if (this.#stopping ||
65
+ this.#stopped ||
66
+ this.#circuitOpen ||
67
+ !this.#configuredGuilds.has(input.observation.guildId) ||
68
+ input.deadlineAt <= this.now() ||
69
+ this.#queuedItems >= this.limits.maximumItems ||
70
+ this.#queuedBytes + bytes > this.limits.maximumBytes ||
71
+ guildQueue.length >= this.limits.maximumItemsPerGuild) {
72
+ this.#overflow += 1;
73
+ return false;
74
+ }
75
+ guildQueue.push({
76
+ requestId: ++this.#requestSequence,
77
+ guildId: input.observation.guildId,
78
+ bytes,
79
+ deadlineAt: input.deadlineAt,
80
+ observation: input.observation,
81
+ complete: input.complete,
82
+ });
83
+ this.#queues.set(input.observation.guildId, guildQueue);
84
+ this.#queuedItems += 1;
85
+ this.#queuedBytes += bytes;
86
+ this.#pump();
87
+ return true;
88
+ }
89
+ snapshot() {
90
+ return {
91
+ status: this.#stopped
92
+ ? "stopped"
93
+ : this.#circuitOpen
94
+ ? "circuit_open"
95
+ : this.#configurations.length === 0
96
+ ? "idle"
97
+ : this.#workers.size === this.limits.workers
98
+ ? "ready"
99
+ : "degraded",
100
+ workers: this.#workers.size,
101
+ configuredGuilds: this.#configuredGuilds.size,
102
+ queuedItems: this.#queuedItems,
103
+ queuedBytes: this.#queuedBytes,
104
+ activeItems: [...this.#workers].filter(({ active }) => active).length,
105
+ overflow: this.#overflow,
106
+ expired: this.#expired,
107
+ workerFailures: this.#workerFailures,
108
+ };
109
+ }
110
+ async stop(timeoutMs = 5_000) {
111
+ if (this.#stopped)
112
+ return true;
113
+ this.#stopping = true;
114
+ if (this.#restartTimer)
115
+ clearTimeout(this.#restartTimer);
116
+ const drained = await this.#waitForDrain(timeoutMs);
117
+ this.#discardQueued();
118
+ this.#configurations = [];
119
+ this.#configuredGuilds.clear();
120
+ await this.#terminateWorkers();
121
+ this.#stopped = true;
122
+ this.#notifyDrain();
123
+ return drained;
124
+ }
125
+ #ensureWorkers() {
126
+ while (!this.#stopping &&
127
+ !this.#circuitOpen &&
128
+ this.#configurations.length > 0 &&
129
+ this.#workers.size < this.limits.workers) {
130
+ let worker;
131
+ try {
132
+ worker = this.#workerFactory();
133
+ }
134
+ catch {
135
+ this.#recordWorkerFailure();
136
+ return;
137
+ }
138
+ const record = {
139
+ worker,
140
+ ready: false,
141
+ active: null,
142
+ failed: false,
143
+ };
144
+ this.#workers.add(record);
145
+ worker.on("message", (value) => this.#workerMessage(record, value));
146
+ worker.on("error", () => this.#workerLost(record));
147
+ worker.on("exit", () => this.#workerLost(record));
148
+ worker.postMessage({
149
+ type: "replace-configurations",
150
+ configurations: this.#configurations,
151
+ });
152
+ }
153
+ }
154
+ #workerMessage(record, value) {
155
+ const response = value;
156
+ if (response.type === "ready") {
157
+ record.ready = true;
158
+ this.#pump();
159
+ return;
160
+ }
161
+ if (response.type === "configurations-replaced")
162
+ return;
163
+ const item = record.active;
164
+ if (!item ||
165
+ (response.type !== "result" && response.type !== "failed") ||
166
+ response.requestId !== item.requestId) {
167
+ this.#workerLost(record);
168
+ void record.worker.terminate();
169
+ return;
170
+ }
171
+ record.active = null;
172
+ this.#consecutiveWorkerFailures = 0;
173
+ this.#pump();
174
+ void item
175
+ .complete(response.type === "result" ? response.result : null)
176
+ .catch(() => undefined)
177
+ .finally(() => {
178
+ this.#activeGuilds.delete(item.guildId);
179
+ this.#pump();
180
+ this.#notifyDrain();
181
+ });
182
+ }
183
+ #workerLost(record) {
184
+ if (record.failed || !this.#workers.delete(record))
185
+ return;
186
+ record.failed = true;
187
+ if (record.active) {
188
+ const item = record.active;
189
+ record.active = null;
190
+ this.#activeGuilds.delete(item.guildId);
191
+ void item.complete(null).catch(() => undefined);
192
+ }
193
+ if (this.#stopping) {
194
+ this.#notifyDrain();
195
+ return;
196
+ }
197
+ this.#recordWorkerFailure();
198
+ this.#pump();
199
+ }
200
+ #recordWorkerFailure() {
201
+ this.#workerFailures += 1;
202
+ this.#consecutiveWorkerFailures += 1;
203
+ if (this.#consecutiveWorkerFailures >= MAXIMUM_RESTARTS) {
204
+ this.#circuitOpen = true;
205
+ this.#discardQueued();
206
+ void this.#terminateWorkers();
207
+ return;
208
+ }
209
+ if (this.#restartTimer || this.#configurations.length === 0)
210
+ return;
211
+ const delay = Math.min(100 * 2 ** (this.#consecutiveWorkerFailures - 1), MAXIMUM_RESTART_BACKOFF_MS);
212
+ this.#restartTimer = setTimeout(() => {
213
+ this.#restartTimer = null;
214
+ this.#ensureWorkers();
215
+ this.#pump();
216
+ }, delay);
217
+ this.#restartTimer.unref();
218
+ }
219
+ #pump() {
220
+ if (this.#stopped || this.#circuitOpen)
221
+ return;
222
+ for (const record of this.#workers) {
223
+ if (!record.ready || record.active)
224
+ continue;
225
+ const item = this.#nextItem();
226
+ if (!item)
227
+ break;
228
+ record.active = item;
229
+ this.#activeGuilds.add(item.guildId);
230
+ record.worker.postMessage({
231
+ type: "scan",
232
+ requestId: item.requestId,
233
+ observation: item.observation,
234
+ });
235
+ }
236
+ this.#notifyDrain();
237
+ }
238
+ #nextItem() {
239
+ while (this.#queuedItems > 0) {
240
+ let removedExpired = false;
241
+ for (const [guildId, queue] of this.#queues) {
242
+ if (this.#activeGuilds.has(guildId))
243
+ continue;
244
+ const item = queue.shift();
245
+ if (!item)
246
+ continue;
247
+ if (queue.length === 0)
248
+ this.#queues.delete(guildId);
249
+ this.#queuedItems -= 1;
250
+ this.#queuedBytes -= item.bytes;
251
+ if (item.deadlineAt <= this.now()) {
252
+ this.#expired += 1;
253
+ removedExpired = true;
254
+ break;
255
+ }
256
+ return item;
257
+ }
258
+ if (!removedExpired)
259
+ break;
260
+ }
261
+ return null;
262
+ }
263
+ #discardQueued() {
264
+ this.#queues.clear();
265
+ this.#queuedItems = 0;
266
+ this.#queuedBytes = 0;
267
+ this.#notifyDrain();
268
+ }
269
+ async #terminateWorkers() {
270
+ const workers = [...this.#workers];
271
+ this.#workers.clear();
272
+ for (const record of workers) {
273
+ if (!record.active)
274
+ continue;
275
+ this.#activeGuilds.delete(record.active.guildId);
276
+ record.active = null;
277
+ }
278
+ await Promise.all(workers.map(async ({ worker }) => {
279
+ try {
280
+ worker.postMessage({ type: "clear" });
281
+ await worker.terminate();
282
+ }
283
+ catch {
284
+ // Termination is best effort after intake has stopped and memory cleared.
285
+ }
286
+ }));
287
+ this.#notifyDrain();
288
+ }
289
+ #waitForDrain(timeoutMs) {
290
+ if (this.#queuedItems === 0 && this.#activeGuilds.size === 0)
291
+ return Promise.resolve(true);
292
+ return new Promise((resolve) => {
293
+ const timer = setTimeout(() => resolve(false), timeoutMs);
294
+ this.#drainWaiters.push(() => {
295
+ clearTimeout(timer);
296
+ resolve(true);
297
+ });
298
+ });
299
+ }
300
+ #notifyDrain() {
301
+ if (this.#queuedItems !== 0 || this.#activeGuilds.size !== 0)
302
+ return;
303
+ for (const resolve of this.#drainWaiters.splice(0))
304
+ resolve();
305
+ }
306
+ }
307
+ function validateLimits(limits) {
308
+ if (!Number.isSafeInteger(limits.workers) ||
309
+ limits.workers < 1 ||
310
+ limits.workers > MAXIMUM_WORKERS ||
311
+ !Number.isSafeInteger(limits.maximumItems) ||
312
+ limits.maximumItems < 1 ||
313
+ !Number.isSafeInteger(limits.maximumBytes) ||
314
+ limits.maximumBytes < 1 ||
315
+ !Number.isSafeInteger(limits.maximumItemsPerGuild) ||
316
+ limits.maximumItemsPerGuild < 1 ||
317
+ limits.maximumItemsPerGuild > limits.maximumItems)
318
+ throw new Error("Embedded scanner pool limits are invalid");
319
+ }
320
+ function defaultWorkerUrl() {
321
+ const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
322
+ return new URL(`./embedded-auto-moderation-worker.${extension}`, import.meta.url);
323
+ }
324
+ //# sourceMappingURL=embedded-auto-moderation-pool.js.map
@@ -0,0 +1,34 @@
1
+ import { type AutoModerationScannerObservation, type AutoModerationScannerObservationService } from "@helyx/sdk";
2
+ import type { BotAutoModerationScannerRegistry } from "./auto-moderation-scanner-registry.js";
3
+ import { EmbeddedAutoModerationPool, type EmbeddedScannerPoolLimits, type EmbeddedScannerPoolSnapshot } from "./embedded-auto-moderation-pool.js";
4
+ import type { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
5
+ export interface EmbeddedScannerSnapshot {
6
+ status: "idle" | "ready" | "degraded" | "stopped";
7
+ configurationFingerprint: string;
8
+ lastSynchronizedAt: string | null;
9
+ synchronizationFailures: number;
10
+ scanned: number;
11
+ matched: number;
12
+ rejected: number;
13
+ pool: EmbeddedScannerPoolSnapshot;
14
+ }
15
+ export declare class EmbeddedAutoModerationScanner implements AutoModerationScannerObservationService {
16
+ #private;
17
+ private readonly input;
18
+ constructor(input: {
19
+ registry: BotAutoModerationScannerRegistry;
20
+ evidenceStore: TransientViolationEvidenceStore;
21
+ environment: string;
22
+ poolLimits: EmbeddedScannerPoolLimits;
23
+ refreshIntervalMs: number;
24
+ pool?: EmbeddedAutoModerationPool;
25
+ now?: () => number;
26
+ });
27
+ synchronize(): Promise<void>;
28
+ observe(observation: AutoModerationScannerObservation): boolean;
29
+ isConfigured(guildId: string): boolean;
30
+ snapshot(): EmbeddedScannerSnapshot;
31
+ stop(): Promise<boolean>;
32
+ private readonly now;
33
+ }
34
+ //# sourceMappingURL=embedded-auto-moderation-scanner.d.ts.map
@@ -0,0 +1,263 @@
1
+ import { scanSimilarMessage, SimilarMessageWindow, } from "@helyx/module-automod/engine";
2
+ import { AUTOMOD_ENHANCED_CONTRACT_VERSION, AUTOMOD_ENHANCED_PROVIDER_ID, AUTOMOD_ENHANCED_RESULT_PROTOCOL, AUTOMOD_SCANNER_LIMITS, } from "@helyx/sdk";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import { EmbeddedAutoModerationPool, } from "./embedded-auto-moderation-pool.js";
5
+ const MAXIMUM_CONFIGURATION_ENTRIES = 5_000;
6
+ const MAXIMUM_CONFIGURATION_BYTES = 512 * 1_024 * 1_024;
7
+ const MAXIMUM_CONFIGURATION_PAGES = 50;
8
+ export class EmbeddedAutoModerationScanner {
9
+ input;
10
+ #pool;
11
+ #similarMessages = new SimilarMessageWindow(randomBytes(32));
12
+ #configurations = new Map();
13
+ #configurationFingerprint = "";
14
+ #lastSynchronizedAt = null;
15
+ #synchronizationFailures = 0;
16
+ #scanned = 0;
17
+ #matched = 0;
18
+ #rejected = 0;
19
+ #stopped = false;
20
+ #synchronizing = null;
21
+ #refreshTimer = null;
22
+ constructor(input) {
23
+ this.input = input;
24
+ if (!Number.isSafeInteger(input.refreshIntervalMs) ||
25
+ input.refreshIntervalMs < 30_000 ||
26
+ input.refreshIntervalMs > 300_000)
27
+ throw new Error("Embedded scanner refresh interval is invalid");
28
+ this.#pool = input.pool ?? new EmbeddedAutoModerationPool(input.poolLimits);
29
+ }
30
+ synchronize() {
31
+ if (this.#stopped)
32
+ return Promise.resolve();
33
+ if (this.#synchronizing)
34
+ return this.#synchronizing;
35
+ this.#synchronizing = this.#replaceSnapshot().finally(() => {
36
+ this.#synchronizing = null;
37
+ });
38
+ return this.#synchronizing;
39
+ }
40
+ observe(observation) {
41
+ const now = this.now();
42
+ const configuration = this.#configurations.get(observation.guildId);
43
+ if (this.#stopped ||
44
+ !configuration ||
45
+ Date.parse(configuration.source.expiresAt) <= now ||
46
+ observation.source !== "user" ||
47
+ observation.content.length < 1 ||
48
+ observation.content.length > AUTOMOD_SCANNER_LIMITS.messageCharacters ||
49
+ Buffer.byteLength(observation.content, "utf8") >
50
+ AUTOMOD_SCANNER_LIMITS.messageUtf8Bytes) {
51
+ this.#rejected += 1;
52
+ return false;
53
+ }
54
+ const accepted = this.#pool.submit({
55
+ observation: {
56
+ guildId: observation.guildId,
57
+ channelId: observation.channelId,
58
+ parentChannelId: observation.parentChannelId,
59
+ memberRoleIds: [...observation.memberRoleIds],
60
+ content: observation.content,
61
+ source: observation.source,
62
+ },
63
+ deadlineAt: now + AUTOMOD_SCANNER_LIMITS.actionDeadlineSeconds * 1_000,
64
+ complete: (scan) => {
65
+ const contextual = scanSimilarMessage(configuration, this.#similarMessages, {
66
+ guildId: observation.guildId,
67
+ memberId: observation.authorUserId,
68
+ channelId: observation.channelId,
69
+ parentChannelId: observation.parentChannelId,
70
+ memberRoleIds: observation.memberRoleIds,
71
+ content: observation.content,
72
+ observedAtMs: Date.parse(observation.observedAt),
73
+ source: observation.source,
74
+ });
75
+ return this.#completeObservation(observation, configuration, scan?.outcome === "detected" ? scan : contextual);
76
+ },
77
+ });
78
+ if (!accepted)
79
+ this.#rejected += 1;
80
+ else
81
+ this.#scanned += 1;
82
+ return accepted;
83
+ }
84
+ isConfigured(guildId) {
85
+ const configuration = this.#configurations.get(guildId);
86
+ return Boolean(!this.#stopped &&
87
+ configuration &&
88
+ Date.parse(configuration.source.expiresAt) > this.now());
89
+ }
90
+ snapshot() {
91
+ const pool = this.#pool.snapshot();
92
+ return {
93
+ status: this.#stopped
94
+ ? "stopped"
95
+ : pool.status === "idle"
96
+ ? "idle"
97
+ : pool.status === "ready" && this.#synchronizationFailures === 0
98
+ ? "ready"
99
+ : "degraded",
100
+ configurationFingerprint: this.#configurationFingerprint,
101
+ lastSynchronizedAt: this.#lastSynchronizedAt,
102
+ synchronizationFailures: this.#synchronizationFailures,
103
+ scanned: this.#scanned,
104
+ matched: this.#matched,
105
+ rejected: this.#rejected,
106
+ pool,
107
+ };
108
+ }
109
+ async stop() {
110
+ if (this.#stopped)
111
+ return true;
112
+ this.#stopped = true;
113
+ if (this.#refreshTimer)
114
+ clearInterval(this.#refreshTimer);
115
+ this.#refreshTimer = null;
116
+ this.#configurations.clear();
117
+ this.#configurationFingerprint = "";
118
+ return this.#pool.stop(AUTOMOD_SCANNER_LIMITS.actionDeadlineSeconds * 1_000);
119
+ }
120
+ now = () => (this.input.now ?? Date.now)();
121
+ async #replaceSnapshot() {
122
+ try {
123
+ const configurations = [];
124
+ const cursors = new Set();
125
+ let cursor;
126
+ let snapshotFingerprint = null;
127
+ for (let pageIndex = 0; pageIndex < MAXIMUM_CONFIGURATION_PAGES; pageIndex += 1) {
128
+ const page = await this.input.registry.getConfigurationPage({
129
+ adapter: "embedded",
130
+ providerId: AUTOMOD_ENHANCED_PROVIDER_ID,
131
+ contractVersion: AUTOMOD_ENHANCED_CONTRACT_VERSION,
132
+ limit: AUTOMOD_SCANNER_LIMITS.configurationPageServers,
133
+ ...(cursor ? { cursor } : {}),
134
+ });
135
+ if (snapshotFingerprint !== null &&
136
+ snapshotFingerprint !== page.snapshotFingerprint)
137
+ throw new Error("Embedded scanner snapshot changed between pages");
138
+ snapshotFingerprint = page.snapshotFingerprint;
139
+ configurations.push(...page.items);
140
+ if (!page.nextCursor)
141
+ break;
142
+ if (cursors.has(page.nextCursor))
143
+ throw new Error("Embedded scanner snapshot cursor repeated");
144
+ cursors.add(page.nextCursor);
145
+ cursor = page.nextCursor;
146
+ if (pageIndex === MAXIMUM_CONFIGURATION_PAGES - 1)
147
+ throw new Error("Embedded scanner snapshot exceeded page bounds");
148
+ }
149
+ if (configurations.length > MAXIMUM_CONFIGURATION_ENTRIES)
150
+ throw new Error("Embedded scanner snapshot exceeded entry bounds");
151
+ const next = new Map();
152
+ const { compileEnhancedConfiguration } = await import("@helyx/module-automod/engine");
153
+ let bytes = 0;
154
+ for (const source of configurations) {
155
+ if (next.has(source.guildId))
156
+ throw new Error("Embedded scanner snapshot contains a duplicate guild");
157
+ const refreshAfter = Date.parse(source.refreshAfter);
158
+ const expiresAt = Date.parse(source.expiresAt);
159
+ if (!Number.isFinite(refreshAfter) ||
160
+ !Number.isFinite(expiresAt) ||
161
+ refreshAfter >= expiresAt ||
162
+ expiresAt <= this.now())
163
+ throw new Error("Embedded scanner configuration freshness is invalid");
164
+ const compiled = compileEnhancedConfiguration(source);
165
+ bytes += compiled.estimatedBytes;
166
+ if (bytes > MAXIMUM_CONFIGURATION_BYTES)
167
+ throw new Error("Embedded scanner snapshot exceeded memory bounds");
168
+ next.set(source.guildId, compiled);
169
+ }
170
+ this.#configurations = next;
171
+ this.#similarMessages.clear();
172
+ this.#configurationFingerprint = snapshotFingerprint ?? "";
173
+ this.#lastSynchronizedAt = new Date(this.now()).toISOString();
174
+ this.#synchronizationFailures = 0;
175
+ this.#pool.replaceConfigurations([...next.values()]);
176
+ this.#updateRefreshTimer(true);
177
+ }
178
+ catch (error) {
179
+ this.#synchronizationFailures += 1;
180
+ this.#updateRefreshTimer(true);
181
+ throw error;
182
+ }
183
+ }
184
+ #updateRefreshTimer(active) {
185
+ if (!active)
186
+ return;
187
+ if (this.#refreshTimer)
188
+ return;
189
+ this.#refreshTimer = setInterval(() => {
190
+ void this.synchronize().catch(() => undefined);
191
+ }, this.input.refreshIntervalMs);
192
+ this.#refreshTimer.unref();
193
+ }
194
+ async #completeObservation(observation, configuration, scan) {
195
+ if (!scan || scan.outcome !== "detected")
196
+ return;
197
+ this.#matched += 1;
198
+ const now = this.now();
199
+ const { enhancedOperationId } = await import("@helyx/module-automod/engine");
200
+ const operationId = enhancedOperationId({
201
+ environment: this.input.environment,
202
+ observation,
203
+ configurationRevision: configuration.source.configurationRevision,
204
+ detectorVersion: configuration.source.engineVersion,
205
+ ruleId: scan.detection.ruleId,
206
+ termId: scan.detection.termId,
207
+ resultClass: scan.detection.confidenceClass,
208
+ });
209
+ const result = {
210
+ protocol: AUTOMOD_ENHANCED_RESULT_PROTOCOL,
211
+ providerId: AUTOMOD_ENHANCED_PROVIDER_ID,
212
+ contractVersion: AUTOMOD_ENHANCED_CONTRACT_VERSION,
213
+ detectorVersion: configuration.source.engineVersion,
214
+ scoringVersion: configuration.source.scoringVersion,
215
+ operationId,
216
+ nonce: randomBytes(24).toString("base64url"),
217
+ issuedAt: new Date(now).toISOString(),
218
+ deadlineAt: new Date(now + AUTOMOD_SCANNER_LIMITS.actionDeadlineSeconds * 1_000).toISOString(),
219
+ environment: this.input.environment,
220
+ guildId: observation.guildId,
221
+ channelId: observation.channelId,
222
+ parentChannelId: observation.parentChannelId,
223
+ messageId: observation.messageId,
224
+ authorUserId: observation.authorUserId,
225
+ observation: {
226
+ type: observation.type,
227
+ version: observation.version,
228
+ observedAt: observation.observedAt,
229
+ },
230
+ ruleId: scan.detection.ruleId,
231
+ categoryId: scan.detection.categoryId,
232
+ termId: scan.detection.termId,
233
+ configurationRevision: configuration.source.configurationRevision,
234
+ severity: scan.detection.severity,
235
+ method: scan.detection.method,
236
+ confidenceScore: scan.detection.confidenceScore,
237
+ confidenceClass: scan.detection.confidenceClass,
238
+ effectiveThreshold: scan.detection.effectiveThreshold,
239
+ correlationId: randomUUID(),
240
+ };
241
+ const rule = configuration.source.rules.find(({ ruleId }) => ruleId === result.ruleId);
242
+ const evidenceReference = rule?.openViolationThread
243
+ ? (this.input.evidenceStore.capture({
244
+ source: "enhanced_scanner",
245
+ sourceEventId: operationId,
246
+ configurationRevision: result.configurationRevision,
247
+ guildId: result.guildId,
248
+ channelId: result.channelId,
249
+ messageId: result.messageId,
250
+ authorUserId: result.authorUserId,
251
+ content: observation.content,
252
+ }) ?? undefined)
253
+ : undefined;
254
+ await this.input.registry.acceptResult({
255
+ adapter: "embedded",
256
+ providerId: AUTOMOD_ENHANCED_PROVIDER_ID,
257
+ contractVersion: AUTOMOD_ENHANCED_CONTRACT_VERSION,
258
+ result,
259
+ ...(evidenceReference ? { evidenceReference } : {}),
260
+ });
261
+ }
262
+ }
263
+ //# sourceMappingURL=embedded-auto-moderation-scanner.js.map
@@ -0,0 +1,37 @@
1
+ import { type CompiledEnhancedConfiguration, type EnhancedScanResult } from "@helyx/module-automod/engine";
2
+ export interface EmbeddedWorkerObservation {
3
+ guildId: string;
4
+ channelId: string;
5
+ parentChannelId: string | null;
6
+ memberRoleIds: readonly string[];
7
+ content: string;
8
+ source: "user" | "bot" | "webhook" | "system" | "unknown";
9
+ }
10
+ export type EmbeddedWorkerRequest = {
11
+ type: "replace-configurations";
12
+ configurations: readonly CompiledEnhancedConfiguration[];
13
+ } | {
14
+ type: "scan";
15
+ requestId: number;
16
+ observation: EmbeddedWorkerObservation;
17
+ } | {
18
+ type: "clear";
19
+ };
20
+ export type EmbeddedWorkerResponse = {
21
+ type: "ready";
22
+ } | {
23
+ type: "configurations-replaced";
24
+ count: number;
25
+ } | {
26
+ type: "result";
27
+ requestId: number;
28
+ result: EnhancedScanResult;
29
+ } | {
30
+ type: "failed";
31
+ requestId: number;
32
+ };
33
+ export declare class EmbeddedWorkerRuntime {
34
+ #private;
35
+ handle(request: EmbeddedWorkerRequest): EmbeddedWorkerResponse | null;
36
+ }
37
+ //# sourceMappingURL=embedded-auto-moderation-worker-runtime.d.ts.map
@@ -0,0 +1,37 @@
1
+ import { scanEnhancedMessage, } from "@helyx/module-automod/engine";
2
+ export class EmbeddedWorkerRuntime {
3
+ #configurations = new Map();
4
+ handle(request) {
5
+ if (request.type === "clear") {
6
+ this.#configurations.clear();
7
+ return null;
8
+ }
9
+ if (request.type === "replace-configurations") {
10
+ const next = new Map();
11
+ for (const configuration of request.configurations) {
12
+ const guildId = configuration.source.guildId;
13
+ if (!guildId || next.has(guildId))
14
+ throw new Error("Embedded scanner configuration set is invalid");
15
+ next.set(guildId, configuration);
16
+ }
17
+ this.#configurations.clear();
18
+ for (const [guildId, configuration] of next)
19
+ this.#configurations.set(guildId, configuration);
20
+ return { type: "configurations-replaced", count: next.size };
21
+ }
22
+ const configuration = this.#configurations.get(request.observation.guildId);
23
+ if (!configuration)
24
+ return { type: "failed", requestId: request.requestId };
25
+ try {
26
+ return {
27
+ type: "result",
28
+ requestId: request.requestId,
29
+ result: scanEnhancedMessage(configuration, request.observation),
30
+ };
31
+ }
32
+ catch {
33
+ return { type: "failed", requestId: request.requestId };
34
+ }
35
+ }
36
+ }
37
+ //# sourceMappingURL=embedded-auto-moderation-worker-runtime.js.map