@helyx/bot 1.0.1 → 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.
- package/CHANGELOG.md +80 -0
- package/README.md +27 -2
- package/dist/auto-moderation-scanner-registry.d.ts +27 -0
- package/dist/auto-moderation-scanner-registry.js +172 -0
- package/dist/cloud-connector.js +3 -0
- package/dist/cloud-control-dispatcher.js +32 -0
- package/dist/command-path-contract.js +1 -0
- package/dist/control-permissions.d.ts +2 -2
- package/dist/control-permissions.js +4 -2
- package/dist/control-server.d.ts +72 -16
- package/dist/control-server.js +150 -250
- package/dist/dashboard-action-control.d.ts +25 -0
- package/dist/dashboard-action-control.js +73 -0
- package/dist/dashboard-action-routes.d.ts +9 -0
- package/dist/dashboard-action-routes.js +36 -0
- package/dist/dashboard-feature-control.d.ts +37 -0
- package/dist/dashboard-feature-control.js +206 -0
- package/dist/dashboard-module-projection.d.ts +9 -0
- package/dist/dashboard-module-projection.js +15 -0
- package/dist/dashboard-preview-import-routes.d.ts +6 -0
- package/dist/dashboard-preview-import-routes.js +121 -0
- package/dist/developer-operations-module.d.ts +2 -0
- package/dist/developer-operations-module.js +136 -2
- package/dist/discord-analytics.d.ts +3 -0
- package/dist/discord-analytics.js +49 -5
- package/dist/discord-auto-moderation-service.d.ts +21 -0
- package/dist/discord-auto-moderation-service.js +425 -0
- package/dist/discord-guild-health.d.ts +2 -2
- package/dist/discord-member-enforcement-service.d.ts +26 -0
- package/dist/discord-member-enforcement-service.js +590 -0
- package/dist/discord-message-enforcement-service.d.ts +9 -0
- package/dist/discord-message-enforcement-service.js +102 -0
- package/dist/discord-violation-thread-service.d.ts +33 -0
- package/dist/discord-violation-thread-service.js +789 -0
- package/dist/ecko-evidence-receiver.d.ts +32 -0
- package/dist/ecko-evidence-receiver.js +173 -0
- package/dist/ecko-transport-auth.d.ts +19 -0
- package/dist/ecko-transport-auth.js +80 -0
- package/dist/ecko-transport-routes.d.ts +19 -0
- package/dist/ecko-transport-routes.js +130 -0
- package/dist/embedded-auto-moderation-pool.d.ts +46 -0
- package/dist/embedded-auto-moderation-pool.js +324 -0
- package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
- package/dist/embedded-auto-moderation-scanner.js +263 -0
- package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
- package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
- package/dist/embedded-auto-moderation-worker.d.ts +2 -0
- package/dist/embedded-auto-moderation-worker.js +13 -0
- package/dist/events.d.ts +5 -1
- package/dist/events.js +275 -9
- package/dist/guilds.d.ts +2 -1
- package/dist/guilds.js +164 -5
- package/dist/hosted-server-moderation.d.ts +100 -0
- package/dist/hosted-server-moderation.js +226 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +101 -12
- package/dist/moderation-action-registry.d.ts +11 -0
- package/dist/moderation-action-registry.js +375 -0
- package/dist/moderation-case-disassociation.d.ts +13 -0
- package/dist/moderation-case-disassociation.js +83 -0
- package/dist/moderation-platform-services.d.ts +27 -0
- package/dist/moderation-platform-services.js +47 -0
- package/dist/modules.d.ts +6 -0
- package/dist/modules.js +65 -3
- package/dist/premium-activation-module.js +2 -0
- package/dist/privileged-message-metadata-cache.d.ts +35 -0
- package/dist/privileged-message-metadata-cache.js +70 -0
- package/dist/scheduled-task-runner.js +24 -1
- package/dist/shard-contracts.d.ts +151 -1
- package/dist/shard-contracts.js +142 -1
- package/dist/shard-coordinator.d.ts +1 -1
- package/dist/shard-ecko-scanner-registry.d.ts +38 -0
- package/dist/shard-ecko-scanner-registry.js +96 -0
- package/dist/shard-router.d.ts +23 -0
- package/dist/shard-router.js +69 -5
- package/dist/shard-supervisor.d.ts +11 -0
- package/dist/shard-supervisor.js +72 -6
- package/dist/shard-worker.d.ts +16 -1
- package/dist/shard-worker.js +108 -10
- package/dist/transient-violation-evidence.d.ts +39 -0
- package/dist/transient-violation-evidence.js +132 -0
- package/documentation/README.md +2 -0
- package/documentation/examples/.env.example +13 -0
- package/documentation/guides/self-hosting.md +17 -1
- package/documentation/modules/auto-moderation.md +51 -0
- package/documentation/modules/moderation.md +42 -0
- package/documentation/reference/environment.md +11 -0
- package/package.json +17 -5
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE } from "@helyx/module-manifest";
|
|
2
|
+
import { AUTOMOD_ENFORCEMENT_POLICY_CONTRACT_VERSION, AUTOMOD_ENFORCEMENT_POLICY_PROVIDER_ID, AUTOMOD_TIMEOUT_SECONDS, HELYX_SERVICE_NAMES, MODERATION_ACTION_CONTRACT_VERSION, MODERATION_ACTION_PROVIDER_ID, MODERATION_CONTRACT_LIMITS, } from "@helyx/sdk";
|
|
3
|
+
const MODERATION_MODULE_ID = "helyx.moderation";
|
|
4
|
+
const AUTOMOD_MODULE_ID = "helyx.automod";
|
|
5
|
+
const ALLOWED_CALLER_MODULE_IDS = new Set([AUTOMOD_MODULE_ID]);
|
|
6
|
+
const ID_PATTERN = /^\d{17,20}$/u;
|
|
7
|
+
const OPERATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/u;
|
|
8
|
+
const SAFE_CODE_PATTERN = /^[a-z0-9][a-z0-9._-]{0,99}$/u;
|
|
9
|
+
/** Local closed registry. In sharded mode it is instantiated in each guild owner. */
|
|
10
|
+
export class BotModerationActionRegistry {
|
|
11
|
+
services;
|
|
12
|
+
evidenceDelivery;
|
|
13
|
+
#actionProvider;
|
|
14
|
+
#policyProvider;
|
|
15
|
+
constructor(modules, services, evidenceDelivery) {
|
|
16
|
+
this.services = services;
|
|
17
|
+
this.evidenceDelivery = evidenceDelivery;
|
|
18
|
+
const actionProviders = modules.flatMap((module) => module.moderationActionProvider
|
|
19
|
+
? [
|
|
20
|
+
{
|
|
21
|
+
moduleId: module.manifest.id,
|
|
22
|
+
provider: module.moderationActionProvider,
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
: []);
|
|
26
|
+
if (actionProviders.length > 1)
|
|
27
|
+
throw new Error("Moderation action provider ownership is ambiguous");
|
|
28
|
+
const actionRegistration = actionProviders[0];
|
|
29
|
+
if (!actionRegistration) {
|
|
30
|
+
this.#actionProvider = null;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
if (actionRegistration.moduleId !== MODERATION_MODULE_ID ||
|
|
34
|
+
actionRegistration.provider.providerId !==
|
|
35
|
+
MODERATION_ACTION_PROVIDER_ID ||
|
|
36
|
+
actionRegistration.provider.contractVersion !==
|
|
37
|
+
MODERATION_ACTION_CONTRACT_VERSION ||
|
|
38
|
+
actionRegistration.provider.allowedCallerModuleIds.length < 1 ||
|
|
39
|
+
new Set(actionRegistration.provider.allowedCallerModuleIds).size !==
|
|
40
|
+
actionRegistration.provider.allowedCallerModuleIds.length ||
|
|
41
|
+
actionRegistration.provider.allowedCallerModuleIds.some((moduleId) => !ALLOWED_CALLER_MODULE_IDS.has(moduleId)))
|
|
42
|
+
throw new Error("Moderation action provider registration is invalid");
|
|
43
|
+
this.#actionProvider = actionRegistration.provider;
|
|
44
|
+
}
|
|
45
|
+
const policyProviders = modules.flatMap((module) => module.autoModerationEnforcementPolicyProvider
|
|
46
|
+
? [
|
|
47
|
+
{
|
|
48
|
+
moduleId: module.manifest.id,
|
|
49
|
+
provider: module.autoModerationEnforcementPolicyProvider,
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
: []);
|
|
53
|
+
if (policyProviders.length > 1)
|
|
54
|
+
throw new Error("Auto Moderation policy provider ownership is ambiguous");
|
|
55
|
+
const policyRegistration = policyProviders[0];
|
|
56
|
+
if (!policyRegistration) {
|
|
57
|
+
this.#policyProvider = null;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
if (policyRegistration.moduleId !== AUTOMOD_MODULE_ID ||
|
|
61
|
+
policyRegistration.provider.providerId !==
|
|
62
|
+
AUTOMOD_ENFORCEMENT_POLICY_PROVIDER_ID ||
|
|
63
|
+
policyRegistration.provider.contractVersion !==
|
|
64
|
+
AUTOMOD_ENFORCEMENT_POLICY_CONTRACT_VERSION)
|
|
65
|
+
throw new Error("Auto Moderation policy provider registration is invalid");
|
|
66
|
+
this.#policyProvider = policyRegistration.provider;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async execute(input) {
|
|
70
|
+
if (!this.#actionProvider ||
|
|
71
|
+
!this.#policyProvider ||
|
|
72
|
+
input.providerId !== MODERATION_ACTION_PROVIDER_ID ||
|
|
73
|
+
input.contractVersion !== MODERATION_ACTION_CONTRACT_VERSION)
|
|
74
|
+
return rejected("module_unavailable");
|
|
75
|
+
if (input.callerModuleId !== input.request.callerModuleId ||
|
|
76
|
+
!ALLOWED_CALLER_MODULE_IDS.has(input.callerModuleId) ||
|
|
77
|
+
!this.#actionProvider.allowedCallerModuleIds.includes(input.callerModuleId))
|
|
78
|
+
return rejected("caller_not_allowed");
|
|
79
|
+
if (!isValidRequest(input.request))
|
|
80
|
+
return rejected("invalid_request");
|
|
81
|
+
const installations = this.services.get(HELYX_SERVICE_NAMES.installations);
|
|
82
|
+
const availability = this.services.has(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
83
|
+
? this.services.get(HELYX_SERVICE_NAMES.moduleAvailability)
|
|
84
|
+
: null;
|
|
85
|
+
try {
|
|
86
|
+
const modulesAvailable = (await installations.isModuleEnabled(input.request.guildId, MODERATION_MODULE_ID)) &&
|
|
87
|
+
(await installations.isModuleEnabled(input.request.guildId, input.callerModuleId)) &&
|
|
88
|
+
(!availability ||
|
|
89
|
+
((await availability.isAvailable(MODERATION_MODULE_ID)) &&
|
|
90
|
+
(await availability.isAvailable(input.callerModuleId))));
|
|
91
|
+
if (!modulesAvailable)
|
|
92
|
+
return rejected("module_unavailable");
|
|
93
|
+
if (input.request.detectionSource === "enhanced" &&
|
|
94
|
+
!(await enhancedFeatureAllowed(this.services, input.request.guildId)))
|
|
95
|
+
return rejected("feature_unavailable");
|
|
96
|
+
const policyContext = { services: this.services };
|
|
97
|
+
const resolution = await this.#policyProvider.resolve(policyContext, input.request);
|
|
98
|
+
if (resolution.outcome === "rejected")
|
|
99
|
+
return rejected(mapPolicyRejection(resolution.code));
|
|
100
|
+
if (!isValidPolicy(resolution))
|
|
101
|
+
return rejected("invalid_request");
|
|
102
|
+
if (resolution.configurationRevision !== input.request.configurationRevision)
|
|
103
|
+
return rejected("stale_revision");
|
|
104
|
+
const policy = copyPolicy(resolution);
|
|
105
|
+
const evidenceClaims = {
|
|
106
|
+
caseIds: new Set(),
|
|
107
|
+
threadIds: new Set(),
|
|
108
|
+
};
|
|
109
|
+
const requestServices = createRequestBoundServices(this.services, evidenceClaims);
|
|
110
|
+
return await this.#actionProvider.execute({
|
|
111
|
+
services: requestServices,
|
|
112
|
+
violationEvidence: {
|
|
113
|
+
deliver: async (delivery) => {
|
|
114
|
+
if (!input.request.evidenceReference ||
|
|
115
|
+
delivery.guildId !== input.request.guildId ||
|
|
116
|
+
!ID_PATTERN.test(delivery.threadId) ||
|
|
117
|
+
!OPERATION_KEY_PATTERN.test(delivery.caseId) ||
|
|
118
|
+
!OPERATION_KEY_PATTERN.test(delivery.operationKey) ||
|
|
119
|
+
!evidenceClaims.caseIds.has(delivery.caseId) ||
|
|
120
|
+
!evidenceClaims.threadIds.has(delivery.threadId))
|
|
121
|
+
return { outcome: "evidence_unavailable" };
|
|
122
|
+
if (!this.evidenceDelivery)
|
|
123
|
+
return { outcome: "evidence_unavailable" };
|
|
124
|
+
return await this.evidenceDelivery.deliverEvidence({
|
|
125
|
+
guildId: delivery.guildId,
|
|
126
|
+
threadId: delivery.threadId,
|
|
127
|
+
evidenceReference: input.request.evidenceReference,
|
|
128
|
+
operationKey: delivery.operationKey,
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}, {
|
|
133
|
+
...input.request,
|
|
134
|
+
policy,
|
|
135
|
+
revalidatePolicy: async () => {
|
|
136
|
+
try {
|
|
137
|
+
const current = await this.#policyProvider?.resolve(policyContext, input.request);
|
|
138
|
+
return Boolean(current &&
|
|
139
|
+
current.outcome === "eligible" &&
|
|
140
|
+
isValidPolicy(current) &&
|
|
141
|
+
samePolicy(policy, current) &&
|
|
142
|
+
(input.request.detectionSource !== "enhanced" ||
|
|
143
|
+
(await enhancedFeatureAllowed(this.services, input.request.guildId))));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return rejected("module_unavailable");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isValidRequest(request) {
|
|
157
|
+
return (OPERATION_KEY_PATTERN.test(request.operationKey) &&
|
|
158
|
+
(request.detectionSource === "native" ||
|
|
159
|
+
request.detectionSource === "enhanced") &&
|
|
160
|
+
ID_PATTERN.test(request.guildId) &&
|
|
161
|
+
ID_PATTERN.test(request.subjectUserId) &&
|
|
162
|
+
OPERATION_KEY_PATTERN.test(request.sourceEventId) &&
|
|
163
|
+
request.occurredAt instanceof Date &&
|
|
164
|
+
!Number.isNaN(request.occurredAt.getTime()) &&
|
|
165
|
+
OPERATION_KEY_PATTERN.test(request.ruleId) &&
|
|
166
|
+
request.configurationRevision.length > 0 &&
|
|
167
|
+
request.configurationRevision.length <= 200 &&
|
|
168
|
+
SAFE_CODE_PATTERN.test(request.reasonCode) &&
|
|
169
|
+
["low", "medium", "high", "critical"].includes(request.severity) &&
|
|
170
|
+
["exact", "native_equivalent", "high", "medium", "low"].includes(request.confidenceClass) &&
|
|
171
|
+
(request.sourceMessage === null ||
|
|
172
|
+
(ID_PATTERN.test(request.sourceMessage.channelId) &&
|
|
173
|
+
(request.sourceMessage.messageId === null ||
|
|
174
|
+
ID_PATTERN.test(request.sourceMessage.messageId)))) &&
|
|
175
|
+
isValidEvidenceReference(request) &&
|
|
176
|
+
((request.requestKind === "detection" &&
|
|
177
|
+
request.observedAction === undefined) ||
|
|
178
|
+
(request.requestKind === "observation" &&
|
|
179
|
+
isValidObservedAction(request.observedAction))));
|
|
180
|
+
}
|
|
181
|
+
function isValidObservedAction(action) {
|
|
182
|
+
if (!action || typeof action !== "object" || !("type" in action))
|
|
183
|
+
return false;
|
|
184
|
+
return (action.type === "discord_native_block_message" ||
|
|
185
|
+
(action.type === "discord_native_timeout" &&
|
|
186
|
+
AUTOMOD_TIMEOUT_SECONDS.includes(action.durationSeconds)));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The Moderation provider gets request-scoped views of the two services that
|
|
190
|
+
* establish the case and thread. Evidence delivery is then limited to IDs
|
|
191
|
+
* returned by those services during this exact action invocation.
|
|
192
|
+
*/
|
|
193
|
+
function createRequestBoundServices(services, claims) {
|
|
194
|
+
return {
|
|
195
|
+
has: (name) => services.has(name),
|
|
196
|
+
get: (name) => {
|
|
197
|
+
if (name === HELYX_SERVICE_NAMES.atomicModerationCases) {
|
|
198
|
+
const service = services.get(name);
|
|
199
|
+
const bound = {
|
|
200
|
+
createPendingCase: async (input) => {
|
|
201
|
+
const result = await service.createPendingCase(input);
|
|
202
|
+
if (result.outcome === "created" || result.outcome === "replayed")
|
|
203
|
+
claims.caseIds.add(result.caseId);
|
|
204
|
+
return result;
|
|
205
|
+
},
|
|
206
|
+
createConfirmedAutoModerationOffenceCase: async (input) => {
|
|
207
|
+
const result = await service.createConfirmedAutoModerationOffenceCase(input);
|
|
208
|
+
if (result.outcome === "created" || result.outcome === "replayed")
|
|
209
|
+
claims.caseIds.add(result.caseId);
|
|
210
|
+
return result;
|
|
211
|
+
},
|
|
212
|
+
createAdditionalPendingAttempt: (input) => service.createAdditionalPendingAttempt(input),
|
|
213
|
+
createCorrectionCase: (input) => service.createCorrectionCase(input),
|
|
214
|
+
settleActionAttempt: (input) => service.settleActionAttempt(input),
|
|
215
|
+
recordObservedAction: (input) => service.recordObservedAction(input),
|
|
216
|
+
listReconciliationCandidates: (input) => service.listReconciliationCandidates(input),
|
|
217
|
+
disassociateCase: (input) => service.disassociateCase(input),
|
|
218
|
+
executeRetention: (input) => service.executeRetention(input),
|
|
219
|
+
};
|
|
220
|
+
return bound;
|
|
221
|
+
}
|
|
222
|
+
if (name === HELYX_SERVICE_NAMES.violationThreads) {
|
|
223
|
+
const service = services.get(name);
|
|
224
|
+
const bound = {
|
|
225
|
+
inspectConfiguration: (input) => service.inspectConfiguration(input),
|
|
226
|
+
createAccessRole: (input) => service.createAccessRole(input),
|
|
227
|
+
configureParent: (input) => service.configureParent(input),
|
|
228
|
+
create: async (input) => {
|
|
229
|
+
const result = await service.create(input);
|
|
230
|
+
if (result.outcome === "created" ||
|
|
231
|
+
result.outcome === "already_exists")
|
|
232
|
+
claims.threadIds.add(result.threadId);
|
|
233
|
+
return result;
|
|
234
|
+
},
|
|
235
|
+
addMember: (input) => service.addMember(input),
|
|
236
|
+
removeMember: (input) => service.removeMember(input),
|
|
237
|
+
send: (input) => service.send(input),
|
|
238
|
+
close: (input) => service.close(input),
|
|
239
|
+
reconcile: (input) => service.reconcile(input),
|
|
240
|
+
};
|
|
241
|
+
return bound;
|
|
242
|
+
}
|
|
243
|
+
return services.get(name);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function isValidPolicy(policy) {
|
|
248
|
+
return (policy.policyFingerprint.length > 0 &&
|
|
249
|
+
policy.policyFingerprint.length <= 200 &&
|
|
250
|
+
Number.isSafeInteger(policy.ruleRevision) &&
|
|
251
|
+
policy.ruleRevision > 0 &&
|
|
252
|
+
policy.configurationRevision.length > 0 &&
|
|
253
|
+
policy.configurationRevision.length <= 200 &&
|
|
254
|
+
policy.windowStartsAt instanceof Date &&
|
|
255
|
+
!Number.isNaN(policy.windowStartsAt.getTime()) &&
|
|
256
|
+
typeof policy.openViolationThread === "boolean" &&
|
|
257
|
+
validRoleIds(policy.globalIgnoredRoleIds, MODERATION_CONTRACT_LIMITS.ignoredRolesPerScope) &&
|
|
258
|
+
validRoleIds(policy.ruleIgnoredRoleIds, MODERATION_CONTRACT_LIMITS.ignoredRolesPerScope) &&
|
|
259
|
+
Boolean(policy.actionPolicy) &&
|
|
260
|
+
isValidAction(policy.actionPolicy.first) &&
|
|
261
|
+
isValidAction(policy.actionPolicy.second) &&
|
|
262
|
+
isValidAction(policy.actionPolicy.thirdAndLater));
|
|
263
|
+
}
|
|
264
|
+
function isValidAction(action) {
|
|
265
|
+
if (!action || typeof action !== "object" || !("type" in action))
|
|
266
|
+
return false;
|
|
267
|
+
switch (action.type) {
|
|
268
|
+
case "case_only":
|
|
269
|
+
case "delete_message":
|
|
270
|
+
case "warn":
|
|
271
|
+
case "kick":
|
|
272
|
+
return true;
|
|
273
|
+
case "timeout":
|
|
274
|
+
return AUTOMOD_TIMEOUT_SECONDS.includes(action.durationSeconds);
|
|
275
|
+
case "ban":
|
|
276
|
+
return [0, 3_600, 21_600, 86_400, 259_200, 604_800].includes(action.deleteMessageSeconds);
|
|
277
|
+
case "demote":
|
|
278
|
+
return (action.roleIds.length > 0 &&
|
|
279
|
+
validRoleIds(action.roleIds, MODERATION_CONTRACT_LIMITS.automaticDemotionRoles));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function isValidEvidenceReference(request) {
|
|
283
|
+
const reference = request.evidenceReference;
|
|
284
|
+
if (!reference)
|
|
285
|
+
return true;
|
|
286
|
+
if (reference.key.trim().length < 1 ||
|
|
287
|
+
reference.key.length > 160 ||
|
|
288
|
+
reference.sourceEventId !== request.sourceEventId ||
|
|
289
|
+
reference.guildId !== request.guildId ||
|
|
290
|
+
reference.authorUserId !== request.subjectUserId ||
|
|
291
|
+
request.sourceMessage === null ||
|
|
292
|
+
reference.channelId !== request.sourceMessage.channelId ||
|
|
293
|
+
reference.messageId !== request.sourceMessage.messageId)
|
|
294
|
+
return false;
|
|
295
|
+
return (reference.source === "discord_auto_moderation" ||
|
|
296
|
+
(reference.source === "enhanced_scanner" &&
|
|
297
|
+
reference.configurationRevision === request.configurationRevision));
|
|
298
|
+
}
|
|
299
|
+
function validRoleIds(roleIds, maximum) {
|
|
300
|
+
return (roleIds.length <= maximum &&
|
|
301
|
+
new Set(roleIds).size === roleIds.length &&
|
|
302
|
+
roleIds.every((roleId) => ID_PATTERN.test(roleId)));
|
|
303
|
+
}
|
|
304
|
+
function copyPolicy(policy) {
|
|
305
|
+
return {
|
|
306
|
+
...policy,
|
|
307
|
+
windowStartsAt: new Date(policy.windowStartsAt),
|
|
308
|
+
globalIgnoredRoleIds: Object.freeze([...policy.globalIgnoredRoleIds]),
|
|
309
|
+
ruleIgnoredRoleIds: Object.freeze([...policy.ruleIgnoredRoleIds]),
|
|
310
|
+
actionPolicy: Object.freeze({
|
|
311
|
+
first: copyAction(policy.actionPolicy.first),
|
|
312
|
+
second: copyAction(policy.actionPolicy.second),
|
|
313
|
+
thirdAndLater: copyAction(policy.actionPolicy.thirdAndLater),
|
|
314
|
+
}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function copyAction(action) {
|
|
318
|
+
return action.type === "demote"
|
|
319
|
+
? { ...action, roleIds: Object.freeze([...action.roleIds]) }
|
|
320
|
+
: { ...action };
|
|
321
|
+
}
|
|
322
|
+
function samePolicy(expected, current) {
|
|
323
|
+
return (expected.policyFingerprint === current.policyFingerprint &&
|
|
324
|
+
expected.ruleRevision === current.ruleRevision &&
|
|
325
|
+
expected.configurationRevision === current.configurationRevision &&
|
|
326
|
+
expected.windowStartsAt.getTime() === current.windowStartsAt.getTime() &&
|
|
327
|
+
expected.openViolationThread === current.openViolationThread &&
|
|
328
|
+
sameStrings(expected.globalIgnoredRoleIds, current.globalIgnoredRoleIds) &&
|
|
329
|
+
sameStrings(expected.ruleIgnoredRoleIds, current.ruleIgnoredRoleIds) &&
|
|
330
|
+
sameAction(expected.actionPolicy.first, current.actionPolicy.first) &&
|
|
331
|
+
sameAction(expected.actionPolicy.second, current.actionPolicy.second) &&
|
|
332
|
+
sameAction(expected.actionPolicy.thirdAndLater, current.actionPolicy.thirdAndLater));
|
|
333
|
+
}
|
|
334
|
+
function sameAction(expected, current) {
|
|
335
|
+
if (expected.type !== current.type)
|
|
336
|
+
return false;
|
|
337
|
+
if (expected.type === "timeout" && current.type === "timeout")
|
|
338
|
+
return expected.durationSeconds === current.durationSeconds;
|
|
339
|
+
if (expected.type === "ban" && current.type === "ban")
|
|
340
|
+
return expected.deleteMessageSeconds === current.deleteMessageSeconds;
|
|
341
|
+
if (expected.type === "demote" && current.type === "demote")
|
|
342
|
+
return sameStrings(expected.roleIds, current.roleIds);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
function sameStrings(expected, current) {
|
|
346
|
+
return (expected.length === current.length &&
|
|
347
|
+
expected.every((value, index) => value === current[index]));
|
|
348
|
+
}
|
|
349
|
+
function mapPolicyRejection(code) {
|
|
350
|
+
switch (code) {
|
|
351
|
+
case "source_unresolved":
|
|
352
|
+
return "invalid_request";
|
|
353
|
+
case "source_exempt":
|
|
354
|
+
return "subject_exempt";
|
|
355
|
+
default:
|
|
356
|
+
return code;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async function enhancedFeatureAllowed(services, guildId) {
|
|
360
|
+
if (!services.has(HELYX_SERVICE_NAMES.featureAccess))
|
|
361
|
+
return false;
|
|
362
|
+
const access = await services
|
|
363
|
+
.get(HELYX_SERVICE_NAMES.featureAccess)
|
|
364
|
+
.check({
|
|
365
|
+
guildId,
|
|
366
|
+
moduleId: AUTOMOD_MODULE_ID,
|
|
367
|
+
featureId: HELYX_AUTOMOD_ENHANCED_SCANNING_FEATURE,
|
|
368
|
+
})
|
|
369
|
+
.catch(() => ({ decision: "dependency_unhealthy" }));
|
|
370
|
+
return access.decision === "granted";
|
|
371
|
+
}
|
|
372
|
+
function rejected(code) {
|
|
373
|
+
return { outcome: "rejected", code };
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=moderation-action-registry.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type AtomicModerationCaseService, type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
/** Adds Auto Moderation unlinking after the authoritative case transition. */
|
|
3
|
+
export declare function decorateModerationCaseDisassociation(input: {
|
|
4
|
+
modules: readonly ModuleDefinition[];
|
|
5
|
+
services: ServiceAccess;
|
|
6
|
+
service: AtomicModerationCaseService;
|
|
7
|
+
}): AtomicModerationCaseService;
|
|
8
|
+
export declare function installModerationCaseDisassociation(input: {
|
|
9
|
+
modules: readonly ModuleDefinition[];
|
|
10
|
+
services: Map<string, unknown>;
|
|
11
|
+
serviceAccess: ServiceAccess;
|
|
12
|
+
}): void;
|
|
13
|
+
//# sourceMappingURL=moderation-case-disassociation.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AUTOMOD_CASE_LINK_CONTRACT_VERSION, AUTOMOD_CASE_LINK_PROVIDER_ID, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
const AUTOMOD_MODULE_ID = "helyx.automod";
|
|
3
|
+
/** Adds Auto Moderation unlinking after the authoritative case transition. */
|
|
4
|
+
export function decorateModerationCaseDisassociation(input) {
|
|
5
|
+
const provider = caseLinkProvider(input.modules);
|
|
6
|
+
if (!provider)
|
|
7
|
+
return input.service;
|
|
8
|
+
const service = input.service;
|
|
9
|
+
return {
|
|
10
|
+
createPendingCase: (request) => service.createPendingCase(request),
|
|
11
|
+
createConfirmedAutoModerationOffenceCase: (request) => service.createConfirmedAutoModerationOffenceCase(request),
|
|
12
|
+
createAdditionalPendingAttempt: (request) => service.createAdditionalPendingAttempt(request),
|
|
13
|
+
createCorrectionCase: (request) => service.createCorrectionCase(request),
|
|
14
|
+
settleActionAttempt: (request) => service.settleActionAttempt(request),
|
|
15
|
+
recordObservedAction: (request) => service.recordObservedAction(request),
|
|
16
|
+
listReconciliationCandidates: (request) => service.listReconciliationCandidates(request),
|
|
17
|
+
async disassociateCase(request) {
|
|
18
|
+
const result = await service.disassociateCase(request);
|
|
19
|
+
if (result.outcome !== "disassociated" &&
|
|
20
|
+
result.outcome !== "already_disassociated")
|
|
21
|
+
return result;
|
|
22
|
+
// Deliberately pass no former subject identity across the module boundary.
|
|
23
|
+
await provider.disassociateCase({ services: input.services }, {
|
|
24
|
+
guildId: request.guildId,
|
|
25
|
+
moderationCaseId: result.caseId,
|
|
26
|
+
occurredAt: request.occurredAt,
|
|
27
|
+
});
|
|
28
|
+
if (input.services.has(HELYX_SERVICE_NAMES.memberRoleGrants)) {
|
|
29
|
+
const grants = input.services.get(HELYX_SERVICE_NAMES.memberRoleGrants);
|
|
30
|
+
let cursor;
|
|
31
|
+
do {
|
|
32
|
+
const released = await grants.releaseOwned({
|
|
33
|
+
sourceModuleId: "helyx.moderation",
|
|
34
|
+
selector: {
|
|
35
|
+
kind: "scope",
|
|
36
|
+
guildId: request.guildId,
|
|
37
|
+
sourceScopeId: result.caseId,
|
|
38
|
+
},
|
|
39
|
+
batchKey: `${request.operationKey}:release-access`,
|
|
40
|
+
occurredAt: request.occurredAt,
|
|
41
|
+
reason: "Moderation case disassociated",
|
|
42
|
+
limit: 100,
|
|
43
|
+
...(cursor ? { cursor } : {}),
|
|
44
|
+
});
|
|
45
|
+
cursor = released.nextCursor;
|
|
46
|
+
} while (cursor);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
},
|
|
50
|
+
executeRetention: (request) => service.executeRetention(request),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function caseLinkProvider(modules) {
|
|
54
|
+
const providers = modules.flatMap((module) => module.autoModerationCaseLinkProvider
|
|
55
|
+
? [
|
|
56
|
+
{
|
|
57
|
+
moduleId: module.manifest.id,
|
|
58
|
+
provider: module.autoModerationCaseLinkProvider,
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
: []);
|
|
62
|
+
if (providers.length > 1)
|
|
63
|
+
throw new Error("Auto Moderation case-link ownership is ambiguous");
|
|
64
|
+
const registered = providers[0];
|
|
65
|
+
if (!registered)
|
|
66
|
+
return null;
|
|
67
|
+
if (registered.moduleId !== AUTOMOD_MODULE_ID ||
|
|
68
|
+
registered.provider.providerId !== AUTOMOD_CASE_LINK_PROVIDER_ID ||
|
|
69
|
+
registered.provider.contractVersion !== AUTOMOD_CASE_LINK_CONTRACT_VERSION)
|
|
70
|
+
throw new Error("Auto Moderation case-link registration is invalid");
|
|
71
|
+
return registered.provider;
|
|
72
|
+
}
|
|
73
|
+
export function installModerationCaseDisassociation(input) {
|
|
74
|
+
const current = input.services.get(HELYX_SERVICE_NAMES.atomicModerationCases);
|
|
75
|
+
if (!current)
|
|
76
|
+
return;
|
|
77
|
+
input.services.set(HELYX_SERVICE_NAMES.atomicModerationCases, decorateModerationCaseDisassociation({
|
|
78
|
+
modules: input.modules,
|
|
79
|
+
services: input.serviceAccess,
|
|
80
|
+
service: current,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=moderation-case-disassociation.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ModuleDefinition, ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import type { Client } from "discord.js";
|
|
3
|
+
import { BotAutoModerationScannerRegistry } from "./auto-moderation-scanner-registry.js";
|
|
4
|
+
import { EmbeddedAutoModerationScanner } from "./embedded-auto-moderation-scanner.js";
|
|
5
|
+
import { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
|
|
6
|
+
export interface ModerationPlatformServiceHandle {
|
|
7
|
+
evidenceStore: TransientViolationEvidenceStore;
|
|
8
|
+
scannerRegistry: BotAutoModerationScannerRegistry;
|
|
9
|
+
embeddedScanner?: EmbeddedAutoModerationScanner;
|
|
10
|
+
}
|
|
11
|
+
/** Installs bounded moderation adapters; no module or Ecko lifecycle starts here. */
|
|
12
|
+
export declare function installModerationPlatformServices(input: {
|
|
13
|
+
services: Map<string, unknown>;
|
|
14
|
+
serviceAccess: ServiceAccess;
|
|
15
|
+
client: Client;
|
|
16
|
+
modules: readonly ModuleDefinition[];
|
|
17
|
+
runtimeProfile: "hosted" | "self-hosted";
|
|
18
|
+
deploymentEnvironment?: string;
|
|
19
|
+
embedded?: {
|
|
20
|
+
workers: number;
|
|
21
|
+
maximumItems: number;
|
|
22
|
+
maximumBytes: number;
|
|
23
|
+
maximumItemsPerGuild: number;
|
|
24
|
+
refreshIntervalMs: number;
|
|
25
|
+
};
|
|
26
|
+
}): ModerationPlatformServiceHandle;
|
|
27
|
+
//# sourceMappingURL=moderation-platform-services.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
2
|
+
import { BotAutoModerationScannerRegistry } from "./auto-moderation-scanner-registry.js";
|
|
3
|
+
import { DiscordAutoModerationAdapter } from "./discord-auto-moderation-service.js";
|
|
4
|
+
import { DiscordMessageEnforcementAdapter } from "./discord-message-enforcement-service.js";
|
|
5
|
+
import { DiscordViolationEvidenceDeliveryAdapter, DiscordViolationThreadAdapter, } from "./discord-violation-thread-service.js";
|
|
6
|
+
import { BotModerationActionRegistry } from "./moderation-action-registry.js";
|
|
7
|
+
import { EmbeddedAutoModerationScanner } from "./embedded-auto-moderation-scanner.js";
|
|
8
|
+
import { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
|
|
9
|
+
import { installModerationCaseDisassociation } from "./moderation-case-disassociation.js";
|
|
10
|
+
/** Installs bounded moderation adapters; no module or Ecko lifecycle starts here. */
|
|
11
|
+
export function installModerationPlatformServices(input) {
|
|
12
|
+
const evidenceStore = new TransientViolationEvidenceStore();
|
|
13
|
+
installModerationCaseDisassociation({
|
|
14
|
+
modules: input.modules,
|
|
15
|
+
services: input.services,
|
|
16
|
+
serviceAccess: input.serviceAccess,
|
|
17
|
+
});
|
|
18
|
+
const scannerRegistry = new BotAutoModerationScannerRegistry(input.modules, input.serviceAccess, input.runtimeProfile, evidenceStore, input.deploymentEnvironment);
|
|
19
|
+
const evidenceDelivery = new DiscordViolationEvidenceDeliveryAdapter(input.client, evidenceStore);
|
|
20
|
+
input.services.set(HELYX_SERVICE_NAMES.discordAutoModeration, new DiscordAutoModerationAdapter(input.client));
|
|
21
|
+
input.services.set(HELYX_SERVICE_NAMES.messageEnforcement, new DiscordMessageEnforcementAdapter(input.client));
|
|
22
|
+
input.services.set(HELYX_SERVICE_NAMES.violationThreads, new DiscordViolationThreadAdapter(input.client));
|
|
23
|
+
input.services.set(HELYX_SERVICE_NAMES.moderationActions, new BotModerationActionRegistry(input.modules, input.serviceAccess, evidenceDelivery));
|
|
24
|
+
input.services.set(HELYX_SERVICE_NAMES.automodScannerProvider, scannerRegistry);
|
|
25
|
+
const embeddedScanner = input.runtimeProfile === "self-hosted"
|
|
26
|
+
? new EmbeddedAutoModerationScanner({
|
|
27
|
+
registry: scannerRegistry,
|
|
28
|
+
evidenceStore,
|
|
29
|
+
environment: input.deploymentEnvironment ?? "self-hosted",
|
|
30
|
+
poolLimits: {
|
|
31
|
+
workers: input.embedded?.workers ?? 1,
|
|
32
|
+
maximumItems: input.embedded?.maximumItems ?? 5_000,
|
|
33
|
+
maximumBytes: input.embedded?.maximumBytes ?? 64 * 1_024 * 1_024,
|
|
34
|
+
maximumItemsPerGuild: input.embedded?.maximumItemsPerGuild ?? 100,
|
|
35
|
+
},
|
|
36
|
+
refreshIntervalMs: input.embedded?.refreshIntervalMs ?? 300_000,
|
|
37
|
+
})
|
|
38
|
+
: undefined;
|
|
39
|
+
if (embeddedScanner)
|
|
40
|
+
input.services.set(HELYX_SERVICE_NAMES.automodScannerObservations, embeddedScanner);
|
|
41
|
+
return {
|
|
42
|
+
evidenceStore,
|
|
43
|
+
scannerRegistry,
|
|
44
|
+
...(embeddedScanner ? { embeddedScanner } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=moderation-platform-services.js.map
|
package/dist/modules.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface ConfiguredModulePackage extends LoadedDefinition {
|
|
|
12
12
|
}
|
|
13
13
|
export declare const secureRandomSelectionWorkerKeyName = "HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY";
|
|
14
14
|
interface PlatformServiceEnvironment extends Pick<BotEnvironment, "HELYX_DATA_ENCRYPTION_KEY"> {
|
|
15
|
+
HELYX_RUNTIME_PROFILE?: BotEnvironment["HELYX_RUNTIME_PROFILE"];
|
|
16
|
+
HELYX_MESSAGE_CONTENT_ENABLED?: boolean;
|
|
17
|
+
HELYX_GUILD_MEMBERS_ENABLED?: boolean;
|
|
18
|
+
HELYX_ECKO_TRANSPORT_SECRET?: string | undefined;
|
|
15
19
|
DISCORD_CLIENT_ID?: string;
|
|
16
20
|
HELYX_DASHBOARD_PUBLIC_URL?: string;
|
|
17
21
|
HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY?: string;
|
|
@@ -22,6 +26,8 @@ export declare function platformServiceOptions(environment: PlatformServiceEnvir
|
|
|
22
26
|
secureRandomSelectionKey?: Uint8Array;
|
|
23
27
|
inviteFingerprintKey?: Uint8Array;
|
|
24
28
|
dashboardBaseUrl?: string;
|
|
29
|
+
runtimeProfile: "hosted" | "self-hosted";
|
|
30
|
+
availableDiscordCapabilities: readonly ("guild-members" | "message-content")[];
|
|
25
31
|
};
|
|
26
32
|
export declare function deriveInviteFingerprintKey(selectionKey: Uint8Array): Uint8Array;
|
|
27
33
|
export declare function loadConfiguredModules(specifiers: readonly string[], importer?: ModuleImporter): Promise<ModuleDefinition[]>;
|