@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/events.js ADDED
@@ -0,0 +1,371 @@
1
+ import { AUTOMOD_SCANNER_LIMITS, AUTOMOD_TIMEOUT_SECONDS, HELYX_SERVICE_NAMES, MODERATION_ACTION_CONTRACT_VERSION, MODERATION_ACTION_PROVIDER_ID, } from "@helyx/sdk";
2
+ import { HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE } from "@helyx/module-manifest";
3
+ import { AUTOMOD_MODULE_ID } from "./constants.js";
4
+ import { countAutoModerationReceipt } from "./receipt-statistics.js";
5
+ import { enforcementActionFamily, enforcementClaimFingerprint, enforcementClaimKey, } from "./domain.js";
6
+ import { AutoModerationRepository, } from "./repository.js";
7
+ export function createAutoModerationEvents(publication) {
8
+ return {
9
+ privilegedMessageCreate: [
10
+ {
11
+ type: "discord.privileged-message-create",
12
+ featureId: HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE,
13
+ isEligible: async (context) => eligibleObservation(context),
14
+ execute: (context) => {
15
+ const source = context.source;
16
+ if (!source?.resolved || !source.member.present)
17
+ return Promise.resolve();
18
+ context.services
19
+ .get(HELYX_SERVICE_NAMES.automodScannerObservations)
20
+ .observe({
21
+ guildId: context.serverId,
22
+ channelId: context.channelId,
23
+ parentChannelId: source.parentChannelId,
24
+ messageId: context.messageId,
25
+ authorUserId: context.userId,
26
+ memberRoleIds: source.member.roleIds,
27
+ content: context.content,
28
+ source: "user",
29
+ type: "create",
30
+ version: context.messageId,
31
+ observedAt: snowflakeTime(context.messageId).toISOString(),
32
+ });
33
+ return Promise.resolve();
34
+ },
35
+ },
36
+ ],
37
+ privilegedMessageUpdate: [
38
+ {
39
+ type: "discord.privileged-message-update",
40
+ featureId: HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE,
41
+ execute: (context) => {
42
+ if (!context.content.present ||
43
+ !eligibleSource(context.source) ||
44
+ !context.services.has(HELYX_SERVICE_NAMES.automodScannerObservations))
45
+ return Promise.resolve();
46
+ const scanner = context.services.get(HELYX_SERVICE_NAMES.automodScannerObservations);
47
+ if (!scanner.isConfigured(context.serverId))
48
+ return Promise.resolve();
49
+ scanner.observe({
50
+ guildId: context.serverId,
51
+ channelId: context.channelId,
52
+ parentChannelId: context.source.parentChannelId,
53
+ messageId: context.messageId,
54
+ authorUserId: context.source.userId,
55
+ memberRoleIds: context.source.member.roleIds,
56
+ content: context.content.value,
57
+ source: "user",
58
+ type: "edit",
59
+ version: context.observationVersion,
60
+ observedAt: context.observedAt.toISOString(),
61
+ });
62
+ return Promise.resolve();
63
+ },
64
+ },
65
+ ],
66
+ discordAutoModerationRuleCreate: [
67
+ {
68
+ type: "discord.auto-moderation-rule-create",
69
+ execute: (context) => publication.observeNativeRule(context.services, context.rule, context.serverId, context.rule.ruleId),
70
+ },
71
+ ],
72
+ discordAutoModerationRuleUpdate: [
73
+ {
74
+ type: "discord.auto-moderation-rule-update",
75
+ execute: (context) => publication.observeNativeRule(context.services, context.rule, context.serverId, context.rule.ruleId),
76
+ },
77
+ ],
78
+ discordAutoModerationRuleDelete: [
79
+ {
80
+ type: "discord.auto-moderation-rule-delete",
81
+ execute: (context) => publication.observeNativeRule(context.services, null, context.serverId, context.rule.ruleId),
82
+ },
83
+ ],
84
+ discordAutoModerationActionExecution: [
85
+ {
86
+ type: "discord.auto-moderation-action-execution",
87
+ execute: handleNativeAction,
88
+ },
89
+ ],
90
+ };
91
+ }
92
+ function eligibleObservation(context) {
93
+ if (!eligibleSource(context.source) ||
94
+ context.content.length < 1 ||
95
+ context.content.length > AUTOMOD_SCANNER_LIMITS.messageCharacters ||
96
+ Buffer.byteLength(context.content, "utf8") >
97
+ AUTOMOD_SCANNER_LIMITS.messageUtf8Bytes ||
98
+ !context.services.has(HELYX_SERVICE_NAMES.automodScannerObservations))
99
+ return Promise.resolve(false);
100
+ return Promise.resolve(context.services
101
+ .get(HELYX_SERVICE_NAMES.automodScannerObservations)
102
+ .isConfigured(context.serverId));
103
+ }
104
+ function eligibleSource(source) {
105
+ return Boolean(source?.resolved &&
106
+ source.member.present &&
107
+ !source.userIsBot &&
108
+ !source.userIsSystem &&
109
+ !source.webhookId);
110
+ }
111
+ function snowflakeTime(messageId) {
112
+ try {
113
+ return new Date(Number(BigInt(messageId) >> 22n) + 1_420_070_400_000);
114
+ }
115
+ catch {
116
+ return new Date();
117
+ }
118
+ }
119
+ async function handleNativeAction(context) {
120
+ const repository = new AutoModerationRepository(context.services);
121
+ const rule = await repository.findByNativeRuleId(context.serverId, context.ruleId);
122
+ if (!rule || rule.status !== "published")
123
+ return;
124
+ const claimKey = enforcementClaimKey({
125
+ guildId: context.serverId,
126
+ configurationRevision: rule.configurationRevision,
127
+ subjectUserId: context.userId,
128
+ messageId: context.messageId,
129
+ channelId: context.channelId,
130
+ sourceEventId: context.executionId,
131
+ });
132
+ const claimActionFamily = enforcementActionFamily(rule.draft.actionPolicy);
133
+ const eventKey = `${context.executionId}:${context.action.type}`;
134
+ const request = nativeRequest(context, rule, claimKey);
135
+ const claim = await repository.claimEnforcement({
136
+ guildId: context.serverId,
137
+ claimKey,
138
+ rule,
139
+ subjectUserId: context.userId,
140
+ channelId: context.channelId,
141
+ messageId: context.messageId,
142
+ actionFamily: claimActionFamily,
143
+ source: "native",
144
+ requestFingerprint: enforcementClaimFingerprint({
145
+ claimKey,
146
+ logicalRuleId: rule.ruleId,
147
+ ruleRevision: rule.revision,
148
+ actionFamily: claimActionFamily,
149
+ }),
150
+ occurredAt: request.occurredAt,
151
+ });
152
+ const receipt = await repository.appendReceipt({
153
+ guildId: context.serverId,
154
+ eventKey,
155
+ rule,
156
+ nativeRuleId: context.ruleId,
157
+ subjectUserId: context.userId,
158
+ channelId: context.channelId,
159
+ messageId: context.messageId,
160
+ alertSystemMessageId: context.alertSystemMessageId,
161
+ actionType: context.action.type,
162
+ claimKey,
163
+ occurredAt: request.occurredAt,
164
+ });
165
+ const resumingReceipt = !receipt.created;
166
+ if (resumingReceipt) {
167
+ const existingReceipt = await repository.findReceiptByEventKey({
168
+ guildId: context.serverId,
169
+ eventKey,
170
+ });
171
+ if (!existingReceipt || existingReceipt.outcome !== "observed")
172
+ return;
173
+ }
174
+ const resumableClaim = resumingReceipt &&
175
+ !claim.created &&
176
+ claim.source === "native" &&
177
+ claim.outcome === "pending";
178
+ if (claim.conflict || (!claim.created && !resumableClaim)) {
179
+ await repository.settleReceipt({
180
+ guildId: context.serverId,
181
+ eventKey,
182
+ outcome: claim.conflict || claim.outcome === "pending"
183
+ ? "review_required"
184
+ : claim.outcome,
185
+ ...(claim.moderationCaseId
186
+ ? { moderationCaseId: claim.moderationCaseId }
187
+ : {}),
188
+ safeCode: claim.conflict
189
+ ? "claim_conflict"
190
+ : (claim.safeCode ?? "first_source_won"),
191
+ });
192
+ return;
193
+ }
194
+ await countReceipt(context, rule.ruleId, eventKey);
195
+ await emitTriggerLog(context, rule.ruleId, eventKey);
196
+ if (!context.services.has(HELYX_SERVICE_NAMES.moderationActions)) {
197
+ await settleUnavailable(repository, context.serverId, eventKey, claimKey, context.occurredAt);
198
+ return;
199
+ }
200
+ let result;
201
+ try {
202
+ result = await context.services
203
+ .get(HELYX_SERVICE_NAMES.moderationActions)
204
+ .execute({
205
+ providerId: MODERATION_ACTION_PROVIDER_ID,
206
+ contractVersion: MODERATION_ACTION_CONTRACT_VERSION,
207
+ callerModuleId: AUTOMOD_MODULE_ID,
208
+ request,
209
+ });
210
+ }
211
+ catch {
212
+ await settleDispatchUnconfirmed(repository, context.serverId, eventKey, claimKey, context.occurredAt);
213
+ await emitEnforcementFailure(context, rule.ruleId, eventKey);
214
+ return;
215
+ }
216
+ const caseId = "caseId" in result ? result.caseId : undefined;
217
+ const outcome = result.outcome;
218
+ const safeCode = result.outcome === "rejected"
219
+ ? result.code
220
+ : "safeCode" in result
221
+ ? result.safeCode
222
+ : undefined;
223
+ await repository.settleReceipt({
224
+ guildId: context.serverId,
225
+ eventKey,
226
+ outcome,
227
+ ...(caseId ? { moderationCaseId: caseId } : {}),
228
+ ...(safeCode ? { safeCode } : {}),
229
+ });
230
+ await repository.settleClaim({
231
+ guildId: context.serverId,
232
+ claimKey,
233
+ outcome,
234
+ ...(caseId ? { moderationCaseId: caseId } : {}),
235
+ ...(result.outcome === "applied" || result.outcome === "already_applied"
236
+ ? {
237
+ repeatOrdinal: result.offenceOrdinal,
238
+ selectedAction: result.selectedAction.type,
239
+ }
240
+ : {}),
241
+ ...(safeCode ? { safeCode } : {}),
242
+ occurredAt: new Date(),
243
+ });
244
+ }
245
+ function nativeRequest(context, rule, claimKey) {
246
+ const observedDuration = context.action.type === "timeout" ? context.action.durationSeconds : null;
247
+ const timeoutSeconds = AUTOMOD_TIMEOUT_SECONDS.find((seconds) => seconds === observedDuration);
248
+ const observedAction = timeoutSeconds !== undefined
249
+ ? {
250
+ type: "discord_native_timeout",
251
+ durationSeconds: timeoutSeconds,
252
+ }
253
+ : context.action.type === "block_message"
254
+ ? { type: "discord_native_block_message" }
255
+ : null;
256
+ const base = {
257
+ operationKey: `automod:${claimKey}`,
258
+ detectionSource: "native",
259
+ guildId: context.serverId,
260
+ subjectUserId: context.userId,
261
+ sourceMessage: context.channelId
262
+ ? { channelId: context.channelId, messageId: context.messageId }
263
+ : null,
264
+ sourceEventId: context.executionId,
265
+ occurredAt: observationTime(context),
266
+ callerModuleId: AUTOMOD_MODULE_ID,
267
+ ruleId: rule.ruleId,
268
+ configurationRevision: rule.configurationRevision,
269
+ reasonCode: "native_rule_triggered",
270
+ severity: rule.category,
271
+ confidenceClass: "native_equivalent",
272
+ ...(context.evidenceReference
273
+ ? { evidenceReference: context.evidenceReference }
274
+ : {}),
275
+ };
276
+ return observedAction
277
+ ? { ...base, requestKind: "observation", observedAction }
278
+ : { ...base, requestKind: "detection" };
279
+ }
280
+ function observationTime(context) {
281
+ if (context.messageId) {
282
+ try {
283
+ const milliseconds = Number(BigInt(context.messageId) >> 22n) + 1_420_070_400_000;
284
+ if (Number.isSafeInteger(milliseconds))
285
+ return new Date(milliseconds);
286
+ }
287
+ catch {
288
+ // The platform validates Discord identifiers; fall through defensively.
289
+ }
290
+ }
291
+ return new Date(Math.floor(context.occurredAt.getTime() / 1_000) * 1_000);
292
+ }
293
+ async function settleUnavailable(repository, guildId, eventKey, claimKey, occurredAt) {
294
+ await repository.settleReceipt({
295
+ guildId,
296
+ eventKey,
297
+ outcome: "native_only",
298
+ safeCode: "moderation_unavailable",
299
+ });
300
+ await repository.settleClaim({
301
+ guildId,
302
+ claimKey,
303
+ outcome: "native_only",
304
+ safeCode: "moderation_unavailable",
305
+ occurredAt,
306
+ });
307
+ }
308
+ async function settleDispatchUnconfirmed(repository, guildId, eventKey, claimKey, occurredAt) {
309
+ const safeCode = "moderation_dispatch_unconfirmed";
310
+ await repository.settleReceipt({
311
+ guildId,
312
+ eventKey,
313
+ outcome: "review_required",
314
+ safeCode,
315
+ });
316
+ await repository.settleClaim({
317
+ guildId,
318
+ claimKey,
319
+ outcome: "review_required",
320
+ safeCode,
321
+ occurredAt,
322
+ });
323
+ }
324
+ async function countReceipt(context, ruleId, eventKey) {
325
+ await countAutoModerationReceipt(context.services, {
326
+ guildId: context.serverId,
327
+ ruleId,
328
+ eventKey,
329
+ source: "native",
330
+ actionType: context.action.type,
331
+ occurredAt: context.occurredAt,
332
+ }).catch(() => undefined);
333
+ }
334
+ async function emitTriggerLog(context, ruleId, eventKey) {
335
+ if (!context.services.has(HELYX_SERVICE_NAMES.moduleLogging))
336
+ return;
337
+ await context.services
338
+ .get(HELYX_SERVICE_NAMES.moduleLogging)
339
+ .emit({
340
+ guildId: context.serverId,
341
+ moduleId: AUTOMOD_MODULE_ID,
342
+ eventId: "automod-rule-triggered",
343
+ summary: "A Discord Auto Moderation rule triggered.",
344
+ details: [
345
+ { label: "Rule", value: ruleId },
346
+ { label: "Action", value: context.action.type },
347
+ { label: "Outcome", value: "Observed by Discord" },
348
+ ],
349
+ idempotencyKey: eventKey,
350
+ })
351
+ .catch(() => undefined);
352
+ }
353
+ async function emitEnforcementFailure(context, ruleId, eventKey) {
354
+ if (!context.services.has(HELYX_SERVICE_NAMES.moduleLogging))
355
+ return;
356
+ await context.services
357
+ .get(HELYX_SERVICE_NAMES.moduleLogging)
358
+ .emit({
359
+ guildId: context.serverId,
360
+ moduleId: AUTOMOD_MODULE_ID,
361
+ eventId: "automod-enforcement-failed",
362
+ summary: "An Auto Moderation follow-up requires review.",
363
+ details: [
364
+ { label: "Rule", value: ruleId },
365
+ { label: "Outcome", value: "Moderation dispatch unconfirmed" },
366
+ ],
367
+ idempotencyKey: `${eventKey}:enforcement-failed`,
368
+ })
369
+ .catch(() => undefined);
370
+ }
371
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1,9 @@
1
+ import { type HealthResult, type RuntimeContext } from "@helyx/sdk";
2
+ export declare class AutoModerationHealth {
3
+ #private;
4
+ start(context: RuntimeContext): void;
5
+ stop(): void;
6
+ recordConfigurationReconciliation(succeeded: boolean): void;
7
+ snapshot(): HealthResult;
8
+ }
9
+ //# sourceMappingURL=health.d.ts.map
package/dist/health.js ADDED
@@ -0,0 +1,45 @@
1
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ const REQUIRED_SERVICES = [
3
+ HELYX_SERVICE_NAMES.records,
4
+ HELYX_SERVICE_NAMES.configuration,
5
+ HELYX_SERVICE_NAMES.installations,
6
+ HELYX_SERVICE_NAMES.discordAutoModeration,
7
+ HELYX_SERVICE_NAMES.discordResources,
8
+ HELYX_SERVICE_NAMES.resourceAuthorization,
9
+ ];
10
+ export class AutoModerationHealth {
11
+ #running = false;
12
+ #missing = [];
13
+ #configurationReconciliationFailed = false;
14
+ start(context) {
15
+ this.#running = true;
16
+ this.#missing = REQUIRED_SERVICES.filter((name) => !context.services.has(name));
17
+ }
18
+ stop() {
19
+ this.#running = false;
20
+ this.#missing = [];
21
+ this.#configurationReconciliationFailed = false;
22
+ }
23
+ recordConfigurationReconciliation(succeeded) {
24
+ this.#configurationReconciliationFailed = !succeeded;
25
+ }
26
+ snapshot() {
27
+ if (!this.#running)
28
+ return { status: "unhealthy", message: "Auto Moderation is stopped" };
29
+ if (this.#missing.length)
30
+ return {
31
+ status: "unhealthy",
32
+ message: "Auto Moderation runtime services are unavailable",
33
+ };
34
+ if (this.#configurationReconciliationFailed)
35
+ return {
36
+ status: "degraded",
37
+ message: "Auto Moderation configuration was saved but Discord rule reconciliation needs review",
38
+ };
39
+ return {
40
+ status: "healthy",
41
+ message: "Discord-native Auto Moderation rules and receipts are active",
42
+ };
43
+ }
44
+ }
45
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,10 @@
1
+ export declare const automodModule: import("@helyx/sdk").ModuleDefinition;
2
+ export default automodModule;
3
+ export * from "./configuration.js";
4
+ export * from "./constants.js";
5
+ export * from "./contracts.js";
6
+ export * from "./domain.js";
7
+ export * from "./enhanced-configuration.js";
8
+ export * from "./records.js";
9
+ export * from "./repository.js";
10
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,141 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE, parseModuleManifest, } from "@helyx/module-manifest";
3
+ import { HELYX_SERVICE_NAMES, createPrivacyProvider, defineModule, getModulePrivacyDeclaration, } from "@helyx/sdk";
4
+ import { createAutoModerationActivityResource } from "./activity-resource.js";
5
+ import { createAutoModerationCaseLinkProvider } from "./case-link-provider.js";
6
+ import { parseAutoModerationConfiguration } from "./configuration.js";
7
+ import { AUTOMOD_PERMISSION_IDS } from "./constants.js";
8
+ import { automodDefaultPermissions, automodLogging } from "./contracts.js";
9
+ import { createAutoModerationEvents } from "./events.js";
10
+ import { AutoModerationHealth } from "./health.js";
11
+ import { createAutoModerationPolicyProvider } from "./policy-provider.js";
12
+ import { createAutoModerationScannerProvider } from "./scanner-provider.js";
13
+ import { AutoModerationPublicationService } from "./publication.js";
14
+ import { automodRecords } from "./records.js";
15
+ import { createAutoModerationRulesResource } from "./resources.js";
16
+ import { previewEnhancedSettings } from "./settings-preview.js";
17
+ const manifest = parseModuleManifest(JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8")));
18
+ const scannerProvider = createAutoModerationScannerProvider();
19
+ const publication = new AutoModerationPublicationService(async (services) => {
20
+ scannerProvider.invalidate();
21
+ try {
22
+ await synchronizeEmbeddedScanner(services);
23
+ health.recordConfigurationReconciliation(true);
24
+ }
25
+ catch {
26
+ // The rule and Discord mapping are already committed. A scanner refresh
27
+ // failure degrades delivery rather than inviting a duplicate Save.
28
+ health.recordConfigurationReconciliation(false);
29
+ }
30
+ });
31
+ const health = new AutoModerationHealth();
32
+ export const automodModule = defineModule({
33
+ manifest,
34
+ configuration: {
35
+ schemaVersion: 1,
36
+ validate: (input) => ({ ...parseAutoModerationConfiguration(input) }),
37
+ validateUpdate: async ({ guildId, value, services }) => {
38
+ const configuration = parseAutoModerationConfiguration(value);
39
+ if (services.has(HELYX_SERVICE_NAMES.configuration)) {
40
+ const moderation = await services
41
+ .get(HELYX_SERVICE_NAMES.configuration)
42
+ .get(guildId, "helyx.moderation");
43
+ const violationAccessRoleId = typeof moderation?.value.violationAccessRoleId === "string"
44
+ ? moderation.value.violationAccessRoleId
45
+ : null;
46
+ if (violationAccessRoleId &&
47
+ configuration.defaultExemptRoleIds.includes(violationAccessRoleId))
48
+ throw new Error("The violation-access role cannot be globally ignored by Auto Moderation.");
49
+ }
50
+ if (configuration.configuredMode !== "enhanced")
51
+ return;
52
+ const access = await services
53
+ .get(HELYX_SERVICE_NAMES.featureAccess)
54
+ .check({
55
+ guildId,
56
+ moduleId: manifest.id,
57
+ featureId: HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE,
58
+ });
59
+ if (access.decision !== "granted")
60
+ throw new Error("Enhanced Auto Moderation is unavailable for this server.");
61
+ },
62
+ settingsPreview: previewEnhancedSettings,
63
+ },
64
+ defaultPermissions: automodDefaultPermissions,
65
+ permissionOperations: Object.values(AUTOMOD_PERMISSION_IDS).map((id) => ({
66
+ id,
67
+ name: id,
68
+ description: `Authorises ${id}.`,
69
+ })),
70
+ records: automodRecords,
71
+ events: createAutoModerationEvents(publication),
72
+ logging: automodLogging,
73
+ privacy: {
74
+ providerVersion: 1,
75
+ create: (executor) => createPrivacyProvider({
76
+ id: manifest.id,
77
+ declaration: getModulePrivacyDeclaration(manifest),
78
+ executor,
79
+ }),
80
+ },
81
+ managedResources: [
82
+ createAutoModerationRulesResource(publication),
83
+ createAutoModerationActivityResource(),
84
+ ],
85
+ autoModerationEnforcementPolicyProvider: createAutoModerationPolicyProvider(),
86
+ autoModerationScannerProvider: scannerProvider,
87
+ autoModerationCaseLinkProvider: createAutoModerationCaseLinkProvider(),
88
+ onConfigurationChanged: async (context) => {
89
+ scannerProvider.invalidate();
90
+ try {
91
+ const enabled = await context.services
92
+ .get(HELYX_SERVICE_NAMES.installations)
93
+ .isModuleEnabled(context.guildId, manifest.id);
94
+ await publication.synchronizeOwnedRules(context.services, context.guildId, enabled);
95
+ await synchronizeEmbeddedScanner(context.services);
96
+ health.recordConfigurationReconciliation(true);
97
+ }
98
+ catch {
99
+ // The configuration transaction is already authoritative. Surface a
100
+ // degraded module instead of returning a false save failure to the
101
+ // dashboard and inviting a conflicting retry.
102
+ health.recordConfigurationReconciliation(false);
103
+ }
104
+ },
105
+ onActivated: async (context) => {
106
+ await publication.synchronizeOwnedRules(context.services, context.guildId, true);
107
+ await synchronizeEmbeddedScanner(context.services);
108
+ },
109
+ onDeactivated: async (context) => {
110
+ await publication.synchronizeOwnedRules(context.services, context.guildId, false);
111
+ await synchronizeEmbeddedScanner(context.services);
112
+ },
113
+ start(context) {
114
+ health.start(context);
115
+ context.logger.info({ event: "automod.started" }, "Auto Moderation module started");
116
+ return Promise.resolve();
117
+ },
118
+ stop(context) {
119
+ health.stop();
120
+ context.logger.info({ event: "automod.stopped" }, "Auto Moderation module stopped");
121
+ return Promise.resolve();
122
+ },
123
+ health: () => health.snapshot(),
124
+ });
125
+ async function synchronizeEmbeddedScanner(services) {
126
+ if (!services.has(HELYX_SERVICE_NAMES.automodScannerObservations))
127
+ return;
128
+ await services
129
+ .get(HELYX_SERVICE_NAMES.automodScannerObservations)
130
+ .synchronize()
131
+ .catch(() => undefined);
132
+ }
133
+ export default automodModule;
134
+ export * from "./configuration.js";
135
+ export * from "./constants.js";
136
+ export * from "./contracts.js";
137
+ export * from "./domain.js";
138
+ export * from "./enhanced-configuration.js";
139
+ export * from "./records.js";
140
+ export * from "./repository.js";
141
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import { type AutoModerationEnforcementPolicyProviderContributionV1 } from "@helyx/sdk";
2
+ export declare function createAutoModerationPolicyProvider(): AutoModerationEnforcementPolicyProviderContributionV1;
3
+ //# sourceMappingURL=policy-provider.d.ts.map
@@ -0,0 +1,66 @@
1
+ import { AUTOMOD_ENFORCEMENT_POLICY_CONTRACT_VERSION, AUTOMOD_ENFORCEMENT_POLICY_PROVIDER_ID, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE } from "@helyx/module-manifest";
3
+ import { AUTOMOD_MODULE_ID } from "./constants.js";
4
+ import { policyFingerprint } from "./domain.js";
5
+ import { currentConfiguration } from "./publication.js";
6
+ import { AutoModerationRepository } from "./repository.js";
7
+ export function createAutoModerationPolicyProvider() {
8
+ return {
9
+ providerId: AUTOMOD_ENFORCEMENT_POLICY_PROVIDER_ID,
10
+ contractVersion: AUTOMOD_ENFORCEMENT_POLICY_CONTRACT_VERSION,
11
+ async resolve(context, request) {
12
+ if (request.callerModuleId !== AUTOMOD_MODULE_ID)
13
+ return { outcome: "rejected", code: "action_not_allowed" };
14
+ const services = context.services;
15
+ if (!services.has(HELYX_SERVICE_NAMES.installations) ||
16
+ !(await services
17
+ .get(HELYX_SERVICE_NAMES.installations)
18
+ .isModuleEnabled(request.guildId, AUTOMOD_MODULE_ID)))
19
+ return { outcome: "rejected", code: "module_unavailable" };
20
+ if (request.detectionSource === "enhanced") {
21
+ if (!services.has(HELYX_SERVICE_NAMES.featureAccess))
22
+ return { outcome: "rejected", code: "feature_unavailable" };
23
+ const access = await services
24
+ .get(HELYX_SERVICE_NAMES.featureAccess)
25
+ .check({
26
+ guildId: request.guildId,
27
+ moduleId: AUTOMOD_MODULE_ID,
28
+ featureId: HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE,
29
+ })
30
+ .catch(() => ({ decision: "dependency_unhealthy" }));
31
+ if (access.decision !== "granted")
32
+ return { outcome: "rejected", code: "feature_unavailable" };
33
+ }
34
+ if (services.has(HELYX_SERVICE_NAMES.moduleAvailability) &&
35
+ !(await services
36
+ .get(HELYX_SERVICE_NAMES.moduleAvailability)
37
+ .isAvailable(AUTOMOD_MODULE_ID)))
38
+ return { outcome: "rejected", code: "module_unavailable" };
39
+ const rule = await new AutoModerationRepository(services).findRule(request.guildId, request.ruleId);
40
+ if (!rule || rule.status !== "published")
41
+ return { outcome: "rejected", code: "stale_operation" };
42
+ const configuration = await currentConfiguration(services, request.guildId);
43
+ if (request.configurationRevision !== configuration.revision ||
44
+ rule.configurationRevision !== configuration.revision)
45
+ return { outcome: "rejected", code: "stale_revision" };
46
+ const resolved = {
47
+ ruleRevision: rule.revision,
48
+ configurationRevision: configuration.revision,
49
+ windowStartsAt: new Date(request.occurredAt.getTime() -
50
+ configuration.value.repeatOffenderWindowDays * 86_400_000),
51
+ globalIgnoredRoleIds: configuration.value.defaultExemptRoleIds,
52
+ ruleIgnoredRoleIds: rule.draft.ignoredRoleIds,
53
+ actionPolicy: rule.draft.actionPolicy,
54
+ openViolationThread: rule.draft.openViolationThread,
55
+ deleteSourceMessage: request.detectionSource === "enhanced" && rule.draft.blockMessage,
56
+ };
57
+ return {
58
+ outcome: "eligible",
59
+ ruleName: rule.draft.name,
60
+ ...resolved,
61
+ policyFingerprint: policyFingerprint(resolved),
62
+ };
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=policy-provider.js.map
@@ -0,0 +1,14 @@
1
+ import { type DiscordAutoModerationRuleDefinition, type DiscordAutoModerationRuleSnapshot, type ServiceAccess } from "@helyx/sdk";
2
+ import { type AutoModerationConfiguration } from "./configuration.js";
3
+ import { type AutoModerationRuleDraft } from "./domain.js";
4
+ export declare function isKindEnabled(draft: AutoModerationRuleDraft, configuration: AutoModerationConfiguration): boolean;
5
+ export declare function validateKindEnabled(draft: AutoModerationRuleDraft, configuration: AutoModerationConfiguration): void;
6
+ export declare function currentConfiguration(services: ServiceAccess, guildId: string): Promise<{
7
+ value: AutoModerationConfiguration;
8
+ revision: string;
9
+ }>;
10
+ export declare function definitionFor(ruleId: string, draft: AutoModerationRuleDraft, configuration: AutoModerationConfiguration, enabled: boolean): DiscordAutoModerationRuleDefinition;
11
+ export declare function snapshotDefinition(snapshot: DiscordAutoModerationRuleSnapshot): DiscordAutoModerationRuleDefinition;
12
+ export declare function publicationError(outcome: string, safeCode?: string): Error;
13
+ export declare function mutationSafeCode(result: object): string | undefined;
14
+ //# sourceMappingURL=publication-support.d.ts.map