@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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +725 -0
- package/README.md +107 -0
- package/dist/action-support.d.ts +48 -0
- package/dist/action-support.js +201 -0
- package/dist/case-actions.d.ts +9 -0
- package/dist/case-actions.js +311 -0
- package/dist/case-naming.d.ts +4 -0
- package/dist/case-naming.js +9 -0
- package/dist/case-repository.d.ts +29 -0
- package/dist/case-repository.js +199 -0
- package/dist/channel-actions.d.ts +3 -0
- package/dist/channel-actions.js +267 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +309 -0
- package/dist/components.d.ts +15 -0
- package/dist/components.js +381 -0
- package/dist/configuration.d.ts +16 -0
- package/dist/configuration.js +94 -0
- package/dist/constants.d.ts +53 -0
- package/dist/constants.js +53 -0
- package/dist/contracts.d.ts +109 -0
- package/dist/contracts.js +84 -0
- package/dist/domain.d.ts +29 -0
- package/dist/domain.js +101 -0
- package/dist/events.d.ts +3 -0
- package/dist/events.js +60 -0
- package/dist/health.d.ts +10 -0
- package/dist/health.js +56 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +92 -0
- package/dist/moderation-cases-resource.d.ts +14 -0
- package/dist/moderation-cases-resource.js +226 -0
- package/dist/presentation.d.ts +8 -0
- package/dist/presentation.js +69 -0
- package/dist/privacy.d.ts +8 -0
- package/dist/privacy.js +38 -0
- package/dist/provider.d.ts +4 -0
- package/dist/provider.js +335 -0
- package/dist/receipt-repository.d.ts +9 -0
- package/dist/receipt-repository.js +36 -0
- package/dist/records.d.ts +399 -0
- package/dist/records.js +303 -0
- package/dist/repository-model.d.ts +131 -0
- package/dist/repository-model.js +318 -0
- package/dist/repository.d.ts +21 -0
- package/dist/repository.js +41 -0
- package/dist/service.d.ts +75 -0
- package/dist/service.js +329 -0
- package/dist/tasks.d.ts +49 -0
- package/dist/tasks.js +391 -0
- package/dist/thread-controls.d.ts +23 -0
- package/dist/thread-controls.js +376 -0
- package/dist/thread-deletion-recovery.d.ts +13 -0
- package/dist/thread-deletion-recovery.js +46 -0
- package/dist/thread-delivery.d.ts +11 -0
- package/dist/thread-delivery.js +427 -0
- package/dist/thread-reconciliation.d.ts +24 -0
- package/dist/thread-reconciliation.js +181 -0
- package/dist/thread-repository.d.ts +16 -0
- package/dist/thread-repository.js +70 -0
- package/manifest.json +999 -0
- package/migrations/0001_moderation_foundation.sql +552 -0
- package/migrations/0002_staff_attempt_parameters.sql +27 -0
- package/package.json +56 -0
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { MODERATION_MODULE_ID, MODERATION_TASK_KINDS } from "./constants.js";
|
|
3
|
+
import { ModerationRepository } from "./repository.js";
|
|
4
|
+
import { retryRequestedThreadDeletion } from "./thread-deletion-recovery.js";
|
|
5
|
+
import { moderationConfiguration, moderationRetentionExpiresAt, } from "./action-support.js";
|
|
6
|
+
import { finalizeThreadControlRecovery, inspectThreadControlEffect, recoveryMarker, } from "./thread-reconciliation.js";
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
8
|
+
export function createModerationTasks(runtime, health) {
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
kind: MODERATION_TASK_KINDS.threadDelivery,
|
|
12
|
+
version: 1,
|
|
13
|
+
delivery: "one-shot",
|
|
14
|
+
attemptLimit: 6,
|
|
15
|
+
validatePayload: validateThreadDeliveryPayload,
|
|
16
|
+
execute(context, task) {
|
|
17
|
+
const payload = validateThreadDeliveryPayload(task.payload);
|
|
18
|
+
return trackTask(health, MODERATION_TASK_KINDS.threadDelivery, () => runtime.deliverPendingThread(context.services, {
|
|
19
|
+
guildId: requiredGuild(task.guildId),
|
|
20
|
+
caseId: payload.caseId,
|
|
21
|
+
}));
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
kind: MODERATION_TASK_KINDS.actionReconcile,
|
|
26
|
+
version: 1,
|
|
27
|
+
delivery: "one-shot",
|
|
28
|
+
attemptLimit: 4,
|
|
29
|
+
validatePayload: validateActionReconcilePayload,
|
|
30
|
+
execute(context, task) {
|
|
31
|
+
const payload = validateActionReconcilePayload(task.payload);
|
|
32
|
+
return trackTask(health, MODERATION_TASK_KINDS.actionReconcile, () => runtime.reconcileAction(context.services, {
|
|
33
|
+
guildId: requiredGuild(task.guildId),
|
|
34
|
+
caseId: payload.caseId,
|
|
35
|
+
attemptId: payload.attemptId,
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kind: MODERATION_TASK_KINDS.threadAccessReconcile,
|
|
41
|
+
version: 1,
|
|
42
|
+
delivery: "coalesced",
|
|
43
|
+
minimumIntervalMs: 5_000,
|
|
44
|
+
attemptLimit: 6,
|
|
45
|
+
validatePayload: validateEmptyPayload,
|
|
46
|
+
execute(context, task) {
|
|
47
|
+
validateEmptyPayload(task.payload);
|
|
48
|
+
return trackTask(health, MODERATION_TASK_KINDS.threadAccessReconcile, () => runtime.reconcileThreadAccess(context.services, {
|
|
49
|
+
guildId: requiredGuild(task.guildId),
|
|
50
|
+
}));
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
export function validateThreadDeliveryPayload(input) {
|
|
56
|
+
const value = exactRecord(input, ["caseId"]);
|
|
57
|
+
return { caseId: uuid(value.caseId, "caseId") };
|
|
58
|
+
}
|
|
59
|
+
export function validateActionReconcilePayload(input) {
|
|
60
|
+
const value = exactRecord(input, ["caseId", "attemptId"]);
|
|
61
|
+
return {
|
|
62
|
+
caseId: uuid(value.caseId, "caseId"),
|
|
63
|
+
attemptId: uuid(value.attemptId, "attemptId"),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function validateEmptyPayload(input) {
|
|
67
|
+
exactRecord(input, []);
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
export async function reconcileModerationAction(services, input) {
|
|
71
|
+
const atomic = services.get(HELYX_SERVICE_NAMES.atomicModerationCases);
|
|
72
|
+
const repository = new ModerationRepository(services);
|
|
73
|
+
const page = await atomic.listReconciliationCandidates({
|
|
74
|
+
guildId: input.guildId,
|
|
75
|
+
updatedBefore: new Date(Date.now() - 1_000),
|
|
76
|
+
limit: 50,
|
|
77
|
+
});
|
|
78
|
+
const candidate = page.items.find((item) => item.caseId === input.caseId && item.latestAttemptId === input.attemptId);
|
|
79
|
+
if (!candidate) {
|
|
80
|
+
const [delivery, storedCase] = await Promise.all([
|
|
81
|
+
repository.findThreadDelivery(input.guildId, input.caseId),
|
|
82
|
+
repository.findCase(input.guildId, input.caseId),
|
|
83
|
+
]);
|
|
84
|
+
const recovery = recoveryMarker(delivery);
|
|
85
|
+
if (!recovery)
|
|
86
|
+
return storedCase?.state === "applied"
|
|
87
|
+
? synchronizeAwaitingThreadRevision(services, input, storedCase.revision)
|
|
88
|
+
: { outcome: "completed" };
|
|
89
|
+
if (recovery.attemptId !== input.attemptId.toLowerCase())
|
|
90
|
+
return {
|
|
91
|
+
outcome: "requires_review",
|
|
92
|
+
safeCode: "recovery_attempt_mismatch",
|
|
93
|
+
};
|
|
94
|
+
if (!storedCase?.subjectUserId || storedCase.state !== "applied")
|
|
95
|
+
return { outcome: "requires_review", safeCode: "case_not_applied" };
|
|
96
|
+
await retryRequestedThreadDeletion(services, input, recovery, storedCase, delivery);
|
|
97
|
+
const inspection = recovery.action === "resolve"
|
|
98
|
+
? "applied"
|
|
99
|
+
: await inspectThreadControlEffect(services, {
|
|
100
|
+
guildId: input.guildId,
|
|
101
|
+
caseId: input.caseId,
|
|
102
|
+
subjectUserId: storedCase.subjectUserId,
|
|
103
|
+
action: recovery.action,
|
|
104
|
+
});
|
|
105
|
+
if (inspection === "unknown")
|
|
106
|
+
return { outcome: "retry", delayMs: 2_000, safeCode: "effect_unknown" };
|
|
107
|
+
if (inspection !== "applied")
|
|
108
|
+
return { outcome: "requires_review", safeCode: "effect_not_provable" };
|
|
109
|
+
return finalizeThreadControlRecovery(services, {
|
|
110
|
+
guildId: input.guildId,
|
|
111
|
+
caseId: input.caseId,
|
|
112
|
+
caseRevision: storedCase.revision,
|
|
113
|
+
subjectUserId: storedCase.subjectUserId,
|
|
114
|
+
action: recovery.action,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const [storedCase, delivery] = await Promise.all([
|
|
118
|
+
repository.findCase(input.guildId, input.caseId),
|
|
119
|
+
repository.findThreadDelivery(input.guildId, input.caseId),
|
|
120
|
+
]);
|
|
121
|
+
if (!storedCase)
|
|
122
|
+
return { outcome: "requires_review", safeCode: "case_missing" };
|
|
123
|
+
const retentionDays = (await moderationConfiguration(services, input.guildId))
|
|
124
|
+
.caseRetentionDays;
|
|
125
|
+
if (!candidate.subjectUserId)
|
|
126
|
+
return { outcome: "requires_review", safeCode: "case_disassociated" };
|
|
127
|
+
const recovery = recoveryMarker(delivery);
|
|
128
|
+
if (recovery &&
|
|
129
|
+
recovery.attemptId !== candidate.latestAttemptId.toLowerCase())
|
|
130
|
+
return {
|
|
131
|
+
outcome: "requires_review",
|
|
132
|
+
safeCode: "recovery_attempt_mismatch",
|
|
133
|
+
};
|
|
134
|
+
const threadAction = recovery?.action ?? null;
|
|
135
|
+
if (recovery)
|
|
136
|
+
await retryRequestedThreadDeletion(services, input, recovery, storedCase, delivery);
|
|
137
|
+
const threadInspection = threadAction && threadAction !== "resolve"
|
|
138
|
+
? await inspectThreadControlEffect(services, {
|
|
139
|
+
guildId: input.guildId,
|
|
140
|
+
caseId: input.caseId,
|
|
141
|
+
subjectUserId: candidate.subjectUserId,
|
|
142
|
+
action: threadAction,
|
|
143
|
+
})
|
|
144
|
+
: null;
|
|
145
|
+
if (threadInspection === "unknown")
|
|
146
|
+
return { outcome: "retry", delayMs: 2_000, safeCode: "effect_unknown" };
|
|
147
|
+
const outcome = threadAction && threadAction !== "resolve"
|
|
148
|
+
? threadInspection === "applied"
|
|
149
|
+
? "already_applied"
|
|
150
|
+
: "review_required"
|
|
151
|
+
: await inspectEffect(services, input.guildId, {
|
|
152
|
+
...candidate,
|
|
153
|
+
subjectUserId: candidate.subjectUserId,
|
|
154
|
+
});
|
|
155
|
+
if (candidate.latestAttemptOutcome === "pending" &&
|
|
156
|
+
storedCase.state === "pending") {
|
|
157
|
+
const occurredAt = new Date();
|
|
158
|
+
const settled = await atomic.settleActionAttempt({
|
|
159
|
+
operationKey: `recover-pending:${candidate.latestAttemptId}`,
|
|
160
|
+
guildId: input.guildId,
|
|
161
|
+
caseId: input.caseId,
|
|
162
|
+
attemptId: candidate.latestAttemptId,
|
|
163
|
+
expectedRevision: candidate.revision,
|
|
164
|
+
outcome,
|
|
165
|
+
safeCode: outcome === "already_applied"
|
|
166
|
+
? "state_inspected"
|
|
167
|
+
: "pending_effect_not_provable",
|
|
168
|
+
retentionExpiresAt: moderationRetentionExpiresAt(occurredAt, retentionDays),
|
|
169
|
+
occurredAt,
|
|
170
|
+
});
|
|
171
|
+
if (settled.outcome === "rejected")
|
|
172
|
+
return settled.code === "revision_conflict" ||
|
|
173
|
+
settled.code === "attempt_not_pending"
|
|
174
|
+
? { outcome: "completed" }
|
|
175
|
+
: { outcome: "requires_review", safeCode: settled.code };
|
|
176
|
+
return outcome === "already_applied"
|
|
177
|
+
? threadAction
|
|
178
|
+
? finalizeThreadRecovery(services, input, candidate.subjectUserId, threadAction, settled.revision)
|
|
179
|
+
: synchronizeAwaitingThreadRevision(services, input, settled.revision)
|
|
180
|
+
: {
|
|
181
|
+
outcome: "requires_review",
|
|
182
|
+
safeCode: "pending_effect_not_provable",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (outcome === "review_required")
|
|
186
|
+
return { outcome: "requires_review", safeCode: "effect_not_provable" };
|
|
187
|
+
const operationKey = input.operationKey ?? `reconcile:${input.attemptId}`;
|
|
188
|
+
const additional = await atomic.createAdditionalPendingAttempt({
|
|
189
|
+
operationKey,
|
|
190
|
+
guildId: input.guildId,
|
|
191
|
+
caseId: input.caseId,
|
|
192
|
+
expectedRevision: candidate.revision,
|
|
193
|
+
purpose: "reconciliation",
|
|
194
|
+
action: candidate.latestAttemptAction,
|
|
195
|
+
...(candidate.latestAttemptAction === "timeout" &&
|
|
196
|
+
candidate.requestedDurationSeconds !== null
|
|
197
|
+
? { requestedDurationSeconds: candidate.requestedDurationSeconds }
|
|
198
|
+
: {}),
|
|
199
|
+
...(candidate.latestAttemptAction === "demote"
|
|
200
|
+
? { demoteRoleIds: candidate.demoteRoleIds }
|
|
201
|
+
: {}),
|
|
202
|
+
actorUserId: input.actorUserId ?? null,
|
|
203
|
+
reason: "System reconciliation of an ambiguous Discord result.",
|
|
204
|
+
occurredAt: new Date(),
|
|
205
|
+
});
|
|
206
|
+
if (additional.outcome === "rejected")
|
|
207
|
+
return additional.code === "revision_conflict"
|
|
208
|
+
? { outcome: "deferred", delayMs: 2_000, safeCode: "revision_changed" }
|
|
209
|
+
: { outcome: "requires_review", safeCode: additional.code };
|
|
210
|
+
if (additional.outcome === "replayed" &&
|
|
211
|
+
additional.attemptOutcome !== "pending")
|
|
212
|
+
return additional.attemptOutcome === "succeeded" ||
|
|
213
|
+
additional.attemptOutcome === "already_applied"
|
|
214
|
+
? threadAction
|
|
215
|
+
? finalizeThreadRecovery(services, input, candidate.subjectUserId, threadAction, additional.revision)
|
|
216
|
+
: synchronizeAwaitingThreadRevision(services, input, additional.revision)
|
|
217
|
+
: { outcome: "requires_review", safeCode: "reconciliation_replayed" };
|
|
218
|
+
const occurredAt = new Date();
|
|
219
|
+
const settled = await atomic.settleActionAttempt({
|
|
220
|
+
operationKey: `${operationKey}:settle`,
|
|
221
|
+
guildId: input.guildId,
|
|
222
|
+
caseId: input.caseId,
|
|
223
|
+
attemptId: additional.attemptId,
|
|
224
|
+
expectedRevision: additional.revision,
|
|
225
|
+
outcome,
|
|
226
|
+
safeCode: "state_inspected",
|
|
227
|
+
retentionExpiresAt: moderationRetentionExpiresAt(occurredAt, retentionDays),
|
|
228
|
+
occurredAt,
|
|
229
|
+
});
|
|
230
|
+
if (settled.outcome === "rejected")
|
|
231
|
+
return { outcome: "requires_review", safeCode: settled.code };
|
|
232
|
+
return threadAction
|
|
233
|
+
? finalizeThreadRecovery(services, input, candidate.subjectUserId, threadAction, settled.revision)
|
|
234
|
+
: synchronizeAwaitingThreadRevision(services, input, settled.revision);
|
|
235
|
+
}
|
|
236
|
+
async function synchronizeAwaitingThreadRevision(services, input, revision) {
|
|
237
|
+
const repository = new ModerationRepository(services);
|
|
238
|
+
const [item, delivery] = await Promise.all([
|
|
239
|
+
repository.findCase(input.guildId, input.caseId),
|
|
240
|
+
repository.findThreadDelivery(input.guildId, input.caseId),
|
|
241
|
+
]);
|
|
242
|
+
if (!delivery ||
|
|
243
|
+
delivery.resolutionState !== "awaiting_staff" ||
|
|
244
|
+
delivery.caseRevision === revision)
|
|
245
|
+
return { outcome: "completed" };
|
|
246
|
+
if (!item ||
|
|
247
|
+
item.state !== "applied" ||
|
|
248
|
+
item.revision !== revision ||
|
|
249
|
+
!item.subjectUserId ||
|
|
250
|
+
item.disassociatedAt ||
|
|
251
|
+
recoveryMarker(delivery))
|
|
252
|
+
return {
|
|
253
|
+
outcome: "requires_review",
|
|
254
|
+
safeCode: "thread_case_binding_changed",
|
|
255
|
+
};
|
|
256
|
+
return (await repository.updateThreadDelivery({
|
|
257
|
+
deliveryId: delivery.deliveryId,
|
|
258
|
+
expectedFenceToken: delivery.fenceToken,
|
|
259
|
+
values: { caseRevision: revision, updatedAt: new Date() },
|
|
260
|
+
}))
|
|
261
|
+
? { outcome: "completed" }
|
|
262
|
+
: {
|
|
263
|
+
outcome: "retry",
|
|
264
|
+
delayMs: 2_000,
|
|
265
|
+
safeCode: "thread_delivery_revision_changed",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function finalizeThreadRecovery(services, input, subjectUserId, action, caseRevision) {
|
|
269
|
+
return finalizeThreadControlRecovery(services, {
|
|
270
|
+
guildId: input.guildId,
|
|
271
|
+
caseId: input.caseId,
|
|
272
|
+
caseRevision,
|
|
273
|
+
subjectUserId,
|
|
274
|
+
action,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async function trackTask(health, taskKind, execute) {
|
|
278
|
+
try {
|
|
279
|
+
const result = await execute();
|
|
280
|
+
if (result.outcome === "completed")
|
|
281
|
+
health?.recordTaskSuccess(taskKind);
|
|
282
|
+
else
|
|
283
|
+
health?.recordTaskFailure(taskKind, "safeCode" in result && result.safeCode
|
|
284
|
+
? result.safeCode
|
|
285
|
+
: `task_${result.outcome}`);
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
health?.recordTaskFailure(taskKind, "task_exception");
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function inspectEffect(services, guildId, candidate) {
|
|
294
|
+
const enforcement = services.get(HELYX_SERVICE_NAMES.memberEnforcement);
|
|
295
|
+
if (candidate.latestAttemptAction === "ban") {
|
|
296
|
+
const ban = await enforcement.inspectBan({
|
|
297
|
+
guildId,
|
|
298
|
+
userId: candidate.subjectUserId,
|
|
299
|
+
});
|
|
300
|
+
return ban.outcome === "banned" ? "already_applied" : "review_required";
|
|
301
|
+
}
|
|
302
|
+
if (candidate.latestAttemptAction === "unban") {
|
|
303
|
+
const ban = await enforcement.inspectBan({
|
|
304
|
+
guildId,
|
|
305
|
+
userId: candidate.subjectUserId,
|
|
306
|
+
});
|
|
307
|
+
return ban.outcome === "not_banned" ? "already_applied" : "review_required";
|
|
308
|
+
}
|
|
309
|
+
const member = await enforcement.inspectMember({
|
|
310
|
+
guildId,
|
|
311
|
+
memberUserId: candidate.subjectUserId,
|
|
312
|
+
});
|
|
313
|
+
if (candidate.latestAttemptAction === "timeout")
|
|
314
|
+
return member.outcome === "present" &&
|
|
315
|
+
candidate.requestedDurationSeconds !== null &&
|
|
316
|
+
member.timeoutEndsAt &&
|
|
317
|
+
member.timeoutEndsAt.getTime() >=
|
|
318
|
+
candidate.latestAttemptCreatedAt.getTime() +
|
|
319
|
+
candidate.requestedDurationSeconds * 1_000 -
|
|
320
|
+
5_000
|
|
321
|
+
? "already_applied"
|
|
322
|
+
: "review_required";
|
|
323
|
+
if (candidate.latestAttemptAction === "timeout_remove")
|
|
324
|
+
return member.outcome === "present" && !member.timeoutEndsAt
|
|
325
|
+
? "already_applied"
|
|
326
|
+
: "review_required";
|
|
327
|
+
if (candidate.latestAttemptAction === "demote")
|
|
328
|
+
return member.outcome === "present" &&
|
|
329
|
+
candidate.demoteRoleIds.every((roleId) => !member.roleIds.includes(roleId))
|
|
330
|
+
? "already_applied"
|
|
331
|
+
: "review_required";
|
|
332
|
+
if (candidate.latestAttemptAction === "warn")
|
|
333
|
+
return "already_applied";
|
|
334
|
+
if (candidate.latestAttemptAction === "case_only")
|
|
335
|
+
return "already_applied";
|
|
336
|
+
return "review_required";
|
|
337
|
+
}
|
|
338
|
+
function exactRecord(input, keys) {
|
|
339
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
340
|
+
throw new Error("Moderation scheduled-task payload is invalid.");
|
|
341
|
+
const value = input;
|
|
342
|
+
const actual = Object.keys(value).sort();
|
|
343
|
+
const expected = [...keys].sort();
|
|
344
|
+
if (actual.length !== expected.length ||
|
|
345
|
+
actual.some((key, index) => key !== expected[index]))
|
|
346
|
+
throw new Error("Moderation scheduled-task payload is invalid.");
|
|
347
|
+
return value;
|
|
348
|
+
}
|
|
349
|
+
function uuid(input, field) {
|
|
350
|
+
if (typeof input !== "string" || !UUID.test(input))
|
|
351
|
+
throw new Error(`Moderation scheduled-task ${field} is invalid.`);
|
|
352
|
+
return input;
|
|
353
|
+
}
|
|
354
|
+
function requiredGuild(guildId) {
|
|
355
|
+
if (!guildId)
|
|
356
|
+
throw new Error("Moderation tasks require a Discord server.");
|
|
357
|
+
return guildId;
|
|
358
|
+
}
|
|
359
|
+
export async function scheduleModerationThreadRetry(services, input) {
|
|
360
|
+
if (!services.has(HELYX_SERVICE_NAMES.scheduledTasks))
|
|
361
|
+
return;
|
|
362
|
+
await services
|
|
363
|
+
.get(HELYX_SERVICE_NAMES.scheduledTasks)
|
|
364
|
+
.schedule({
|
|
365
|
+
moduleId: MODERATION_MODULE_ID,
|
|
366
|
+
taskKind: MODERATION_TASK_KINDS.threadDelivery,
|
|
367
|
+
guildId: input.guildId,
|
|
368
|
+
idempotencyKey: `${input.operationKey}:thread-retry`,
|
|
369
|
+
payload: { caseId: input.caseId },
|
|
370
|
+
scheduledFor: new Date(Date.now() + 5_000),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
export async function reconcileDashboardCase(input) {
|
|
374
|
+
const repository = new ModerationRepository(input.services);
|
|
375
|
+
const item = await repository.findCase(input.guildId, input.caseId);
|
|
376
|
+
if (!item || item.revision !== input.expectedRevision)
|
|
377
|
+
throw new DashboardActionValidationError("This moderation case changed.");
|
|
378
|
+
const latest = (await repository.listAttempts(input.guildId, input.caseId)).at(-1);
|
|
379
|
+
if (!latest)
|
|
380
|
+
throw new DashboardActionValidationError("This case has no action attempt.");
|
|
381
|
+
const result = await reconcileModerationAction(input.services, {
|
|
382
|
+
guildId: input.guildId,
|
|
383
|
+
caseId: input.caseId,
|
|
384
|
+
attemptId: latest.attemptId,
|
|
385
|
+
actorUserId: input.actorUserId,
|
|
386
|
+
operationKey: input.operationKey,
|
|
387
|
+
});
|
|
388
|
+
if (result.outcome !== "completed")
|
|
389
|
+
throw new DashboardActionValidationError("This case still needs manual review.");
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=tasks.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ModalCapableInteractionContext, type ModerationActionAttemptTerminalOutcome, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import { type ModerationThreadControlRuntime } from "./components.js";
|
|
3
|
+
import { type StoredModerationThreadDelivery } from "./repository.js";
|
|
4
|
+
import type { ModerationService } from "./service.js";
|
|
5
|
+
type ThreadAction = "kick" | "ban" | "unmute" | "no_further_action" | "close" | "close_delete";
|
|
6
|
+
type ActiveThreadDelivery = StoredModerationThreadDelivery & {
|
|
7
|
+
parentChannelId: string;
|
|
8
|
+
accessRoleId: string;
|
|
9
|
+
controlToken: string;
|
|
10
|
+
};
|
|
11
|
+
export declare class ModerationThreadControls implements ModerationThreadControlRuntime {
|
|
12
|
+
#private;
|
|
13
|
+
private readonly moderation;
|
|
14
|
+
constructor(moderation: ModerationService);
|
|
15
|
+
executeControl(context: ModalCapableInteractionContext, action: ThreadAction, token: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function closeViolationThread(services: ServiceAccess, delivery: ActiveThreadDelivery, subjectUserId: string, revalidate: () => Promise<boolean>, deleteThread?: boolean): Promise<{
|
|
18
|
+
outcome: ModerationActionAttemptTerminalOutcome;
|
|
19
|
+
safeCode?: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function releaseViolationThreadAccess(services: ServiceAccess, delivery: ActiveThreadDelivery, subjectUserId: string, reason: string): Promise<"released" | "review_required">;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=thread-controls.d.ts.map
|