@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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +725 -0
- package/README.md +80 -0
- package/dist/activity-resource.d.ts +3 -0
- package/dist/activity-resource.js +114 -0
- package/dist/case-link-provider.d.ts +3 -0
- package/dist/case-link-provider.js +13 -0
- package/dist/configuration.d.ts +22 -0
- package/dist/configuration.js +85 -0
- package/dist/constants.d.ts +30 -0
- package/dist/constants.js +37 -0
- package/dist/contracts.d.ts +30 -0
- package/dist/contracts.js +51 -0
- package/dist/domain.d.ts +61 -0
- package/dist/domain.js +274 -0
- package/dist/engine/canonicalisation.d.ts +9 -0
- package/dist/engine/canonicalisation.js +150 -0
- package/dist/engine/compile.d.ts +4 -0
- package/dist/engine/compile.js +169 -0
- package/dist/engine/confidence.d.ts +13 -0
- package/dist/engine/confidence.js +57 -0
- package/dist/engine/configuration-cache.d.ts +29 -0
- package/dist/engine/configuration-cache.js +115 -0
- package/dist/engine/contracts.d.ts +82 -0
- package/dist/engine/contracts.js +25 -0
- package/dist/engine/index.d.ts +11 -0
- package/dist/engine/index.js +11 -0
- package/dist/engine/matcher.d.ts +3 -0
- package/dist/engine/matcher.js +206 -0
- package/dist/engine/observations.d.ts +45 -0
- package/dist/engine/observations.js +105 -0
- package/dist/engine/operation-id.d.ts +18 -0
- package/dist/engine/operation-id.js +24 -0
- package/dist/engine/similar-message-window.d.ts +68 -0
- package/dist/engine/similar-message-window.js +259 -0
- package/dist/engine/term-import.d.ts +15 -0
- package/dist/engine/term-import.js +43 -0
- package/dist/enhanced-configuration.d.ts +16 -0
- package/dist/enhanced-configuration.js +91 -0
- package/dist/events.d.ts +4 -0
- package/dist/events.js +371 -0
- package/dist/health.d.ts +9 -0
- package/dist/health.js +45 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +141 -0
- package/dist/policy-provider.d.ts +3 -0
- package/dist/policy-provider.js +66 -0
- package/dist/publication-support.d.ts +14 -0
- package/dist/publication-support.js +69 -0
- package/dist/publication.d.ts +32 -0
- package/dist/publication.js +333 -0
- package/dist/receipt-statistics.d.ts +11 -0
- package/dist/receipt-statistics.js +58 -0
- package/dist/records.d.ts +299 -0
- package/dist/records.js +288 -0
- package/dist/repository-model.d.ts +54 -0
- package/dist/repository-model.js +132 -0
- package/dist/repository.d.ts +133 -0
- package/dist/repository.js +523 -0
- package/dist/resource-cursor.d.ts +5 -0
- package/dist/resource-cursor.js +45 -0
- package/dist/resource-presentation.d.ts +21 -0
- package/dist/resource-presentation.js +111 -0
- package/dist/resource-validation.d.ts +6 -0
- package/dist/resource-validation.js +80 -0
- package/dist/resources.d.ts +4 -0
- package/dist/resources.js +319 -0
- package/dist/scanner-configuration.d.ts +10 -0
- package/dist/scanner-configuration.js +146 -0
- package/dist/scanner-provider.d.ts +5 -0
- package/dist/scanner-provider.js +310 -0
- package/dist/scanner-result-validation.d.ts +3 -0
- package/dist/scanner-result-validation.js +61 -0
- package/dist/settings-preview.d.ts +6 -0
- package/dist/settings-preview.js +84 -0
- package/manifest.json +1601 -0
- package/migrations/0001_automod_standard.sql +114 -0
- package/migrations/0002_automod_enhanced_receipts.sql +40 -0
- 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
|
package/dist/health.d.ts
ADDED
|
@@ -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
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|