@helyx/module-moderation 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 (65) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +725 -0
  3. package/README.md +107 -0
  4. package/dist/action-support.d.ts +48 -0
  5. package/dist/action-support.js +201 -0
  6. package/dist/case-actions.d.ts +9 -0
  7. package/dist/case-actions.js +311 -0
  8. package/dist/case-naming.d.ts +4 -0
  9. package/dist/case-naming.js +9 -0
  10. package/dist/case-repository.d.ts +29 -0
  11. package/dist/case-repository.js +199 -0
  12. package/dist/channel-actions.d.ts +3 -0
  13. package/dist/channel-actions.js +267 -0
  14. package/dist/commands.d.ts +4 -0
  15. package/dist/commands.js +309 -0
  16. package/dist/components.d.ts +15 -0
  17. package/dist/components.js +381 -0
  18. package/dist/configuration.d.ts +16 -0
  19. package/dist/configuration.js +94 -0
  20. package/dist/constants.d.ts +53 -0
  21. package/dist/constants.js +53 -0
  22. package/dist/contracts.d.ts +109 -0
  23. package/dist/contracts.js +84 -0
  24. package/dist/domain.d.ts +29 -0
  25. package/dist/domain.js +101 -0
  26. package/dist/events.d.ts +3 -0
  27. package/dist/events.js +60 -0
  28. package/dist/health.d.ts +10 -0
  29. package/dist/health.js +56 -0
  30. package/dist/index.d.ts +9 -0
  31. package/dist/index.js +92 -0
  32. package/dist/moderation-cases-resource.d.ts +14 -0
  33. package/dist/moderation-cases-resource.js +226 -0
  34. package/dist/presentation.d.ts +8 -0
  35. package/dist/presentation.js +69 -0
  36. package/dist/privacy.d.ts +8 -0
  37. package/dist/privacy.js +38 -0
  38. package/dist/provider.d.ts +4 -0
  39. package/dist/provider.js +335 -0
  40. package/dist/receipt-repository.d.ts +9 -0
  41. package/dist/receipt-repository.js +36 -0
  42. package/dist/records.d.ts +399 -0
  43. package/dist/records.js +303 -0
  44. package/dist/repository-model.d.ts +131 -0
  45. package/dist/repository-model.js +318 -0
  46. package/dist/repository.d.ts +21 -0
  47. package/dist/repository.js +41 -0
  48. package/dist/service.d.ts +75 -0
  49. package/dist/service.js +329 -0
  50. package/dist/tasks.d.ts +49 -0
  51. package/dist/tasks.js +391 -0
  52. package/dist/thread-controls.d.ts +23 -0
  53. package/dist/thread-controls.js +376 -0
  54. package/dist/thread-deletion-recovery.d.ts +13 -0
  55. package/dist/thread-deletion-recovery.js +46 -0
  56. package/dist/thread-delivery.d.ts +11 -0
  57. package/dist/thread-delivery.js +427 -0
  58. package/dist/thread-reconciliation.d.ts +24 -0
  59. package/dist/thread-reconciliation.js +181 -0
  60. package/dist/thread-repository.d.ts +16 -0
  61. package/dist/thread-repository.js +70 -0
  62. package/manifest.json +999 -0
  63. package/migrations/0001_moderation_foundation.sql +552 -0
  64. package/migrations/0002_staff_attempt_parameters.sql +27 -0
  65. package/package.json +56 -0
@@ -0,0 +1,335 @@
1
+ import { HELYX_SERVICE_NAMES, MODERATION_ACTION_CONTRACT_VERSION, MODERATION_ACTION_PROVIDER_ID, } from "@helyx/sdk";
2
+ import { DEFAULT_MODERATION_CONFIGURATION, parseModerationConfiguration, } from "./configuration.js";
3
+ import { MODERATION_MODULE_ID } from "./constants.js";
4
+ import { appendModerationAudit, emitAutomaticModerationActionLog, moderationRetentionExpiresAt, sendWarningDirectMessage, } from "./action-support.js";
5
+ const ALLOWED_CALLERS = Object.freeze(["helyx.automod"]);
6
+ export function createModerationActionProvider(runtime) {
7
+ return {
8
+ providerId: MODERATION_ACTION_PROVIDER_ID,
9
+ contractVersion: MODERATION_ACTION_CONTRACT_VERSION,
10
+ allowedCallerModuleIds: ALLOWED_CALLERS,
11
+ execute: (context, request) => execute(runtime, context, request),
12
+ };
13
+ }
14
+ async function execute(runtime, context, request) {
15
+ const services = context.services;
16
+ if (!ALLOWED_CALLERS.includes(request.callerModuleId))
17
+ return { outcome: "rejected", code: "caller_not_allowed" };
18
+ if (!(await automaticRequestStillAllowed(services, request)))
19
+ return { outcome: "rejected", code: "subject_exempt" };
20
+ const configuration = await moderationConfiguration(services, request.guildId).catch(() => null);
21
+ if (!configuration)
22
+ return { outcome: "rejected", code: "module_unavailable" };
23
+ const pending = await atomicCases(services).createConfirmedAutoModerationOffenceCase({
24
+ operationKey: request.operationKey,
25
+ guildId: request.guildId,
26
+ subjectUserId: request.subjectUserId,
27
+ sourceModuleId: request.callerModuleId,
28
+ sourceEventId: request.sourceEventId,
29
+ sourceMessage: request.sourceMessage,
30
+ ruleId: request.ruleId,
31
+ ruleRevision: request.policy.ruleRevision,
32
+ configurationRevision: request.configurationRevision,
33
+ policyFingerprint: request.policy.policyFingerprint,
34
+ actionPolicy: request.policy.actionPolicy,
35
+ repeatWindowStartsAt: request.policy.windowStartsAt,
36
+ reasonCode: request.reasonCode,
37
+ ...(request.evidenceReference
38
+ ? { evidenceReference: request.evidenceReference }
39
+ : {}),
40
+ openViolationThread: request.policy.openViolationThread,
41
+ occurredAt: request.occurredAt,
42
+ ...(request.requestKind === "observation"
43
+ ? { observedAction: request.observedAction }
44
+ : {}),
45
+ });
46
+ if (pending.outcome === "rejected")
47
+ return rejectedCreate(pending.code);
48
+ if (pending.outcome === "replayed") {
49
+ if (pending.attemptOutcome === "pending" ||
50
+ pending.attemptOutcome === "ambiguous")
51
+ await scheduleReconciliation(services, request, pending.caseId, pending.attemptId);
52
+ const replayThread = request.policy.openViolationThread && applied(pending.attemptOutcome)
53
+ ? await runtime
54
+ .deliverViolationThread({
55
+ services,
56
+ guildId: request.guildId,
57
+ caseId: pending.caseId,
58
+ caseNumber: pending.caseNumber,
59
+ caseRevision: pending.revision,
60
+ subjectUserId: request.subjectUserId,
61
+ severity: request.severity,
62
+ operationKey: request.operationKey,
63
+ evidence: context.violationEvidence,
64
+ })
65
+ .catch(() => "review_required")
66
+ : "not_requested";
67
+ if (!applied(pending.attemptOutcome))
68
+ return {
69
+ outcome: pending.attemptOutcome === "pending" ||
70
+ pending.attemptOutcome === "ambiguous"
71
+ ? "ambiguous"
72
+ : pending.attemptOutcome === "review_required"
73
+ ? "review_required"
74
+ : "failed",
75
+ caseId: pending.caseId,
76
+ caseNumber: pending.caseNumber,
77
+ safeCode: `replayed_${pending.attemptOutcome}`,
78
+ replayed: true,
79
+ };
80
+ return {
81
+ outcome: "already_applied",
82
+ caseId: pending.caseId,
83
+ caseNumber: pending.caseNumber,
84
+ offenceOrdinal: pending.offenceOrdinal,
85
+ selectedAction: pending.selectedAction,
86
+ actionOutcome: "already_applied",
87
+ threadOutcome: replayThread,
88
+ ...(replayThread === "retry_pending" || replayThread === "review_required"
89
+ ? { safeCode: `thread_${replayThread}` }
90
+ : {}),
91
+ replayed: true,
92
+ };
93
+ }
94
+ try {
95
+ await appendModerationAudit(services, {
96
+ guildId: request.guildId,
97
+ action: "moderation.action-attempt",
98
+ source: "system",
99
+ idempotencyKey: `${request.operationKey}:audit`,
100
+ targetType: "moderation_case",
101
+ targetId: pending.caseId,
102
+ metadata: {
103
+ action: pending.selectedAction.type,
104
+ caseNumber: pending.caseNumber,
105
+ deleteSourceMessage: request.policy.deleteSourceMessage === true,
106
+ },
107
+ });
108
+ }
109
+ catch {
110
+ const settledAt = new Date();
111
+ await atomicCases(services).settleActionAttempt({
112
+ operationKey: `${request.operationKey}:audit-failed`,
113
+ guildId: request.guildId,
114
+ caseId: pending.caseId,
115
+ attemptId: pending.attemptId,
116
+ expectedRevision: pending.revision,
117
+ outcome: "failed",
118
+ safeCode: "audit_unavailable",
119
+ retentionExpiresAt: moderationRetentionExpiresAt(settledAt, configuration.caseRetentionDays),
120
+ occurredAt: settledAt,
121
+ });
122
+ return {
123
+ outcome: "failed",
124
+ caseId: pending.caseId,
125
+ caseNumber: pending.caseNumber,
126
+ safeCode: "audit_unavailable",
127
+ replayed: false,
128
+ };
129
+ }
130
+ const messageRemoval = request.detectionSource === "enhanced" &&
131
+ request.policy.deleteSourceMessage === true &&
132
+ pending.selectedAction.type !== "delete_message"
133
+ ? await runtime
134
+ .dispatchAction(services, {
135
+ guildId: request.guildId,
136
+ subjectUserId: request.subjectUserId,
137
+ action: { type: "delete_message" },
138
+ reason: boundedAuditReason(pending.caseNumber, "Automatic message removal"),
139
+ operationKey: `${request.operationKey}:block-message`,
140
+ sourceMessage: request.sourceMessage,
141
+ protectedRoleIds: [],
142
+ revalidate: () => automaticRequestStillAllowed(services, request),
143
+ })
144
+ .catch(() => ({ outcome: "ambiguous" }))
145
+ : null;
146
+ const effect = await runtime
147
+ .dispatchAction(services, {
148
+ guildId: request.guildId,
149
+ subjectUserId: request.subjectUserId,
150
+ action: pending.selectedAction,
151
+ reason: boundedAuditReason(pending.caseNumber, `Automatic moderation: ${request.reasonCode}`),
152
+ operationKey: `${request.operationKey}:discord`,
153
+ sourceMessage: request.sourceMessage,
154
+ protectedRoleIds: [
155
+ ...request.policy.globalIgnoredRoleIds,
156
+ ...request.policy.ruleIgnoredRoleIds,
157
+ ...(configuration.violationAccessRoleId
158
+ ? [configuration.violationAccessRoleId]
159
+ : []),
160
+ ],
161
+ revalidate: () => automaticRequestStillAllowed(services, request),
162
+ })
163
+ .catch(() => ({
164
+ outcome: "ambiguous",
165
+ safeCode: "enforcement_unavailable",
166
+ }));
167
+ const settledAt = new Date();
168
+ const settlement = await atomicCases(services)
169
+ .settleActionAttempt({
170
+ operationKey: `${request.operationKey}:settle`,
171
+ guildId: request.guildId,
172
+ caseId: pending.caseId,
173
+ attemptId: pending.attemptId,
174
+ expectedRevision: pending.revision,
175
+ outcome: effect.outcome,
176
+ ...(effect.safeCode ? { safeCode: effect.safeCode } : {}),
177
+ ...("removedRoleIds" in effect && effect.removedRoleIds
178
+ ? { removedRoleIds: effect.removedRoleIds }
179
+ : {}),
180
+ retentionExpiresAt: moderationRetentionExpiresAt(settledAt, configuration.caseRetentionDays),
181
+ occurredAt: settledAt,
182
+ })
183
+ .catch(() => null);
184
+ if (!settlement) {
185
+ await scheduleReconciliation(services, request, pending.caseId, pending.attemptId).catch(() => undefined);
186
+ return {
187
+ outcome: "review_required",
188
+ caseId: pending.caseId,
189
+ caseNumber: pending.caseNumber,
190
+ safeCode: "settlement_unavailable",
191
+ replayed: false,
192
+ };
193
+ }
194
+ if (settlement.outcome === "rejected")
195
+ return {
196
+ outcome: "review_required",
197
+ caseId: pending.caseId,
198
+ caseNumber: pending.caseNumber,
199
+ safeCode: `settlement_${settlement.code}`,
200
+ replayed: false,
201
+ };
202
+ if (effect.outcome === "ambiguous")
203
+ await scheduleReconciliation(services, request, pending.caseId, pending.attemptId);
204
+ if (applied(effect.outcome))
205
+ await emitAutomaticModerationActionLog(services, {
206
+ guildId: request.guildId,
207
+ action: pending.selectedAction,
208
+ caseId: pending.caseId,
209
+ caseNumber: pending.caseNumber,
210
+ subjectUserId: request.subjectUserId,
211
+ operationKey: request.operationKey,
212
+ });
213
+ if (applied(effect.outcome) && pending.selectedAction.type === "warn")
214
+ await sendWarningDirectMessage(services, {
215
+ guildId: request.guildId,
216
+ caseId: pending.caseId,
217
+ caseNumber: pending.caseNumber,
218
+ subjectUserId: request.subjectUserId,
219
+ }).catch(() => undefined);
220
+ const threadOutcome = request.policy.openViolationThread && applied(effect.outcome)
221
+ ? await runtime
222
+ .deliverViolationThread({
223
+ services,
224
+ guildId: request.guildId,
225
+ caseId: pending.caseId,
226
+ caseNumber: pending.caseNumber,
227
+ caseRevision: settlement.revision,
228
+ subjectUserId: request.subjectUserId,
229
+ severity: request.severity,
230
+ operationKey: request.operationKey,
231
+ evidence: context.violationEvidence,
232
+ })
233
+ .catch(() => "review_required")
234
+ : "not_requested";
235
+ if (!applied(effect.outcome))
236
+ return {
237
+ outcome: effect.outcome === "ambiguous"
238
+ ? "ambiguous"
239
+ : effect.outcome === "review_required"
240
+ ? "review_required"
241
+ : "failed",
242
+ caseId: pending.caseId,
243
+ caseNumber: pending.caseNumber,
244
+ ...(effect.safeCode ? { safeCode: effect.safeCode } : {}),
245
+ replayed: false,
246
+ };
247
+ return {
248
+ outcome: "applied",
249
+ caseId: pending.caseId,
250
+ caseNumber: pending.caseNumber,
251
+ offenceOrdinal: pending.offenceOrdinal,
252
+ selectedAction: pending.selectedAction,
253
+ actionOutcome: effect.outcome === "succeeded" ? "succeeded" : "already_applied",
254
+ threadOutcome,
255
+ ...(!messageRemoval || applied(messageRemoval.outcome)
256
+ ? threadOutcome === "retry_pending" || threadOutcome === "review_required"
257
+ ? { safeCode: `thread_${threadOutcome}` }
258
+ : {}
259
+ : { safeCode: "message_removal_unconfirmed" }),
260
+ replayed: false,
261
+ };
262
+ }
263
+ async function automaticRequestStillAllowed(services, request) {
264
+ if (!services.has(HELYX_SERVICE_NAMES.installations) ||
265
+ !(await services
266
+ .get(HELYX_SERVICE_NAMES.installations)
267
+ .isModuleEnabled(request.guildId, MODERATION_MODULE_ID)) ||
268
+ (services.has(HELYX_SERVICE_NAMES.moduleAvailability) &&
269
+ !(await services
270
+ .get(HELYX_SERVICE_NAMES.moduleAvailability)
271
+ .isAvailable(MODERATION_MODULE_ID))) ||
272
+ !(await request.revalidatePolicy()))
273
+ return false;
274
+ const member = await services
275
+ .get(HELYX_SERVICE_NAMES.memberEnforcement)
276
+ .inspectMember({
277
+ guildId: request.guildId,
278
+ memberUserId: request.subjectUserId,
279
+ });
280
+ if (member.outcome !== "present" ||
281
+ member.isGuildOwner ||
282
+ member.isAdministrator ||
283
+ member.isBot ||
284
+ member.helyxHierarchy !== "above")
285
+ return false;
286
+ const ignored = new Set([
287
+ ...request.policy.globalIgnoredRoleIds,
288
+ ...request.policy.ruleIgnoredRoleIds,
289
+ ]);
290
+ return !member.roleIds.some((roleId) => ignored.has(roleId));
291
+ }
292
+ function rejectedCreate(code) {
293
+ return {
294
+ outcome: "rejected",
295
+ code: code === "operation_key_conflict"
296
+ ? "operation_key_conflict"
297
+ : code === "stale_policy"
298
+ ? "stale_operation"
299
+ : "invalid_request",
300
+ };
301
+ }
302
+ function atomicCases(services) {
303
+ return services.get(HELYX_SERVICE_NAMES.atomicModerationCases);
304
+ }
305
+ async function moderationConfiguration(services, guildId) {
306
+ if (!services.has(HELYX_SERVICE_NAMES.configuration))
307
+ return DEFAULT_MODERATION_CONFIGURATION;
308
+ const stored = await services
309
+ .get(HELYX_SERVICE_NAMES.configuration)
310
+ .get(guildId, MODERATION_MODULE_ID);
311
+ return stored
312
+ ? parseModerationConfiguration(stored.value)
313
+ : DEFAULT_MODERATION_CONFIGURATION;
314
+ }
315
+ async function scheduleReconciliation(services, request, caseId, attemptId) {
316
+ if (!services.has(HELYX_SERVICE_NAMES.scheduledTasks))
317
+ return;
318
+ await services
319
+ .get(HELYX_SERVICE_NAMES.scheduledTasks)
320
+ .schedule({
321
+ moduleId: MODERATION_MODULE_ID,
322
+ taskKind: "moderation.action-reconcile",
323
+ guildId: request.guildId,
324
+ idempotencyKey: `${request.operationKey}:reconcile`,
325
+ payload: { caseId, attemptId },
326
+ scheduledFor: new Date(Date.now() + 2_000),
327
+ });
328
+ }
329
+ function applied(outcome) {
330
+ return outcome === "succeeded" || outcome === "already_applied";
331
+ }
332
+ function boundedAuditReason(caseNumber, reason) {
333
+ return `[Helyx case ${caseNumber}] ${reason}`.slice(0, 512);
334
+ }
335
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1,9 @@
1
+ import type { ModuleRecordService } from "@helyx/sdk";
2
+ import { type StoredModerationDmReceipt } from "./repository-model.js";
3
+ export declare class ModerationReceiptRepository {
4
+ private readonly records;
5
+ constructor(records: ModuleRecordService);
6
+ findDmReceipt(guildId: string, caseId: string): Promise<StoredModerationDmReceipt | null>;
7
+ upsertDmReceipt(input: StoredModerationDmReceipt): Promise<void>;
8
+ }
9
+ //# sourceMappingURL=receipt-repository.d.ts.map
@@ -0,0 +1,36 @@
1
+ import { MODERATION_MODULE_ID } from "./constants.js";
2
+ import { DM_RECEIPT_FIELDS, dmReceiptWrite, mapDmReceipt, } from "./repository-model.js";
3
+ export class ModerationReceiptRepository {
4
+ records;
5
+ constructor(records) {
6
+ this.records = records;
7
+ }
8
+ async findDmReceipt(guildId, caseId) {
9
+ const row = await this.records.findOne({
10
+ moduleId: MODERATION_MODULE_ID,
11
+ collection: "dm_receipts",
12
+ select: DM_RECEIPT_FIELDS,
13
+ where: { guild_id: guildId, case_id: caseId },
14
+ });
15
+ return row ? mapDmReceipt(row) : null;
16
+ }
17
+ async upsertDmReceipt(input) {
18
+ const write = dmReceiptWrite(input);
19
+ await this.records.upsert({
20
+ moduleId: MODERATION_MODULE_ID,
21
+ collection: "dm_receipts",
22
+ values: write.values,
23
+ uniqueBy: ["guild_id", "case_id"],
24
+ update: [
25
+ "template_revision",
26
+ "delivery_state",
27
+ "safe_code",
28
+ "message_id",
29
+ "attempted_at",
30
+ "updated_at",
31
+ ].filter((field) => Object.hasOwn(write.values, field)),
32
+ clear: write.clear,
33
+ });
34
+ }
35
+ }
36
+ //# sourceMappingURL=receipt-repository.js.map