@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
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
3
|
+
import { DEFAULT_MODERATION_CONFIGURATION, parseModerationConfiguration, } from "./configuration.js";
|
|
4
|
+
import { MODERATION_MODULE_ID } from "./constants.js";
|
|
5
|
+
import { moderationThreadName } from "./case-naming.js";
|
|
6
|
+
import { initialThreadControls, violationOpeningMessage, } from "./components.js";
|
|
7
|
+
import { ModerationRepository, } from "./repository.js";
|
|
8
|
+
import { scheduleModerationThreadRetry } from "./tasks.js";
|
|
9
|
+
export class ModerationThreadDelivery {
|
|
10
|
+
async deliver(input) {
|
|
11
|
+
const configuration = await settings(input.services, input.guildId);
|
|
12
|
+
if (!configuration.violationThreadChannelId ||
|
|
13
|
+
!configuration.violationAccessRoleId ||
|
|
14
|
+
!configuration.violationOpeningMessageTemplate.trim())
|
|
15
|
+
return "review_required";
|
|
16
|
+
const repository = new ModerationRepository(input.services);
|
|
17
|
+
let delivery = await repository.findThreadDelivery(input.guildId, input.caseId);
|
|
18
|
+
if (delivery?.resolutionState === "closed")
|
|
19
|
+
return "already_exists";
|
|
20
|
+
if (delivery && !isActiveDelivery(delivery))
|
|
21
|
+
return "review_required";
|
|
22
|
+
if (delivery && deliveryComplete(delivery))
|
|
23
|
+
return delivery.evidenceState === "failed"
|
|
24
|
+
? "review_required"
|
|
25
|
+
: "already_exists";
|
|
26
|
+
if (delivery && delivery.attemptCount >= 6)
|
|
27
|
+
return "review_required";
|
|
28
|
+
if (!delivery) {
|
|
29
|
+
const token = controlToken(input.services);
|
|
30
|
+
if (!token)
|
|
31
|
+
return "review_required";
|
|
32
|
+
const now = new Date();
|
|
33
|
+
delivery = {
|
|
34
|
+
deliveryId: randomUUID(),
|
|
35
|
+
guildId: input.guildId,
|
|
36
|
+
caseId: input.caseId,
|
|
37
|
+
caseRevision: input.caseRevision,
|
|
38
|
+
operationKey: input.operationKey,
|
|
39
|
+
parentChannelId: configuration.violationThreadChannelId,
|
|
40
|
+
accessRoleId: configuration.violationAccessRoleId,
|
|
41
|
+
threadId: null,
|
|
42
|
+
controlToken: token,
|
|
43
|
+
controlMessageId: null,
|
|
44
|
+
memberState: "pending",
|
|
45
|
+
evidenceState: input.evidence ? "pending" : "not_requested",
|
|
46
|
+
openingState: "pending",
|
|
47
|
+
notificationState: "pending",
|
|
48
|
+
closeState: "not_requested",
|
|
49
|
+
resolutionState: "awaiting_staff",
|
|
50
|
+
attemptCount: 0,
|
|
51
|
+
fenceToken: 0,
|
|
52
|
+
safeCode: null,
|
|
53
|
+
retryAt: null,
|
|
54
|
+
createdAt: now,
|
|
55
|
+
updatedAt: now,
|
|
56
|
+
};
|
|
57
|
+
delivery = await repository.createThreadDelivery(delivery);
|
|
58
|
+
if (delivery.resolutionState === "closed")
|
|
59
|
+
return "already_exists";
|
|
60
|
+
if (!isActiveDelivery(delivery))
|
|
61
|
+
return "review_required";
|
|
62
|
+
if (deliveryComplete(delivery))
|
|
63
|
+
return delivery.evidenceState === "failed"
|
|
64
|
+
? "review_required"
|
|
65
|
+
: "already_exists";
|
|
66
|
+
if (delivery.attemptCount >= 6)
|
|
67
|
+
return "review_required";
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
delivery = await continueDelivery(repository, delivery, configuration, {
|
|
71
|
+
subjectUserId: input.subjectUserId,
|
|
72
|
+
caseNumber: input.caseNumber,
|
|
73
|
+
evidence: input.evidence,
|
|
74
|
+
services: input.services,
|
|
75
|
+
});
|
|
76
|
+
return delivery.evidenceState === "failed"
|
|
77
|
+
? "review_required"
|
|
78
|
+
: delivery.attemptCount > 1
|
|
79
|
+
? "already_exists"
|
|
80
|
+
: "created";
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (threadFailureCode(error) === "thread_delivery_fence_changed")
|
|
84
|
+
return "retry_pending";
|
|
85
|
+
const failed = await markRetry(repository, input.services, input.guildId, input.caseId, input.subjectUserId, error);
|
|
86
|
+
if (failed && failed.attemptCount < 6)
|
|
87
|
+
await scheduleModerationThreadRetry(input.services, {
|
|
88
|
+
guildId: input.guildId,
|
|
89
|
+
caseId: input.caseId,
|
|
90
|
+
operationKey: input.operationKey,
|
|
91
|
+
}).catch(() => undefined);
|
|
92
|
+
return failed && failed.attemptCount < 6
|
|
93
|
+
? "retry_pending"
|
|
94
|
+
: "review_required";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async deliverPending(services, input) {
|
|
98
|
+
const repository = new ModerationRepository(services);
|
|
99
|
+
const [moderationCase, delivery] = await Promise.all([
|
|
100
|
+
repository.findCase(input.guildId, input.caseId),
|
|
101
|
+
repository.findThreadDelivery(input.guildId, input.caseId),
|
|
102
|
+
]);
|
|
103
|
+
if (!moderationCase || !delivery || delivery.resolutionState === "closed")
|
|
104
|
+
return { outcome: "completed" };
|
|
105
|
+
if (!isActiveDelivery(delivery))
|
|
106
|
+
return { outcome: "requires_review", safeCode: "thread_redacted" };
|
|
107
|
+
if (deliveryComplete(delivery))
|
|
108
|
+
return delivery.evidenceState === "failed"
|
|
109
|
+
? {
|
|
110
|
+
outcome: "requires_review",
|
|
111
|
+
safeCode: "thread_evidence_unavailable",
|
|
112
|
+
}
|
|
113
|
+
: { outcome: "completed" };
|
|
114
|
+
if (delivery.attemptCount >= 6)
|
|
115
|
+
return {
|
|
116
|
+
outcome: "requires_review",
|
|
117
|
+
safeCode: "thread_delivery_exhausted",
|
|
118
|
+
};
|
|
119
|
+
if (!moderationCase.subjectUserId)
|
|
120
|
+
return { outcome: "requires_review", safeCode: "case_disassociated" };
|
|
121
|
+
try {
|
|
122
|
+
const completed = await continueDelivery(repository, delivery, await settings(services, input.guildId), {
|
|
123
|
+
subjectUserId: moderationCase.subjectUserId,
|
|
124
|
+
caseNumber: moderationCase.caseNumber,
|
|
125
|
+
evidence: undefined,
|
|
126
|
+
services,
|
|
127
|
+
});
|
|
128
|
+
return completed.evidenceState === "failed"
|
|
129
|
+
? {
|
|
130
|
+
outcome: "requires_review",
|
|
131
|
+
safeCode: "thread_evidence_unavailable",
|
|
132
|
+
}
|
|
133
|
+
: { outcome: "completed" };
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (threadFailureCode(error) === "thread_delivery_fence_changed")
|
|
137
|
+
return {
|
|
138
|
+
outcome: "retry",
|
|
139
|
+
delayMs: 2_000,
|
|
140
|
+
safeCode: "thread_delivery_fence_changed",
|
|
141
|
+
};
|
|
142
|
+
const failed = await markRetry(repository, services, input.guildId, input.caseId, moderationCase.subjectUserId, error);
|
|
143
|
+
return failed && failed.attemptCount < 6
|
|
144
|
+
? {
|
|
145
|
+
outcome: "retry",
|
|
146
|
+
delayMs: 5_000,
|
|
147
|
+
safeCode: "thread_delivery_failed",
|
|
148
|
+
}
|
|
149
|
+
: { outcome: "requires_review", safeCode: "thread_delivery_exhausted" };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async reconcileAccess(services, guildId) {
|
|
153
|
+
const configuration = await settings(services, guildId);
|
|
154
|
+
if (!configuration.violationThreadChannelId ||
|
|
155
|
+
!configuration.violationAccessRoleId)
|
|
156
|
+
return { outcome: "completed" };
|
|
157
|
+
const inspection = await threads(services).inspectConfiguration({
|
|
158
|
+
guildId,
|
|
159
|
+
parentChannelId: configuration.violationThreadChannelId,
|
|
160
|
+
accessRoleId: configuration.violationAccessRoleId,
|
|
161
|
+
moderatorRoleIds: configuration.violationModeratorRoleIds,
|
|
162
|
+
moderatorUserIds: configuration.violationModeratorUserIds,
|
|
163
|
+
});
|
|
164
|
+
if (inspection.outcome !== "allowed")
|
|
165
|
+
return {
|
|
166
|
+
outcome: "requires_review",
|
|
167
|
+
safeCode: `thread_${inspection.outcome}`,
|
|
168
|
+
};
|
|
169
|
+
const result = await grants(services).reconcile({
|
|
170
|
+
maximum: 100,
|
|
171
|
+
now: new Date(),
|
|
172
|
+
});
|
|
173
|
+
if (result.reviewRequired)
|
|
174
|
+
return { outcome: "requires_review", safeCode: "role_grant_review" };
|
|
175
|
+
if (result.retryPending)
|
|
176
|
+
return { outcome: "retry", delayMs: 5_000, safeCode: "role_grant_retry" };
|
|
177
|
+
return { outcome: "completed" };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function continueDelivery(repository, current, configuration, input) {
|
|
181
|
+
if (!isActiveDelivery(current))
|
|
182
|
+
throw new Error("thread_delivery_redacted");
|
|
183
|
+
let delivery = await update(repository, current, {
|
|
184
|
+
attemptCount: current.attemptCount + 1,
|
|
185
|
+
safeCode: null,
|
|
186
|
+
retryAt: null,
|
|
187
|
+
updatedAt: new Date(),
|
|
188
|
+
});
|
|
189
|
+
if (!delivery.threadId) {
|
|
190
|
+
const inspected = await threads(input.services).inspectConfiguration({
|
|
191
|
+
guildId: delivery.guildId,
|
|
192
|
+
parentChannelId: delivery.parentChannelId,
|
|
193
|
+
accessRoleId: delivery.accessRoleId,
|
|
194
|
+
moderatorRoleIds: configuration.violationModeratorRoleIds,
|
|
195
|
+
moderatorUserIds: configuration.violationModeratorUserIds,
|
|
196
|
+
});
|
|
197
|
+
if (inspected.outcome !== "allowed" ||
|
|
198
|
+
inspected.missingPermissions.length > 0 ||
|
|
199
|
+
inspected.requiredChanges.length > 0)
|
|
200
|
+
throw new Error("thread_configuration_invalid");
|
|
201
|
+
const claimed = await grants(input.services).claim({
|
|
202
|
+
guildId: delivery.guildId,
|
|
203
|
+
memberUserId: input.subjectUserId,
|
|
204
|
+
roleId: delivery.accessRoleId,
|
|
205
|
+
sourceModuleId: MODERATION_MODULE_ID,
|
|
206
|
+
sourceId: delivery.caseId,
|
|
207
|
+
sourceScopeId: delivery.caseId,
|
|
208
|
+
operationKey: `${delivery.operationKey}:access-claim`,
|
|
209
|
+
occurredAt: delivery.createdAt,
|
|
210
|
+
reason: `Violation thread access for case ${input.caseNumber}`,
|
|
211
|
+
});
|
|
212
|
+
if (claimed.outcome === "rejected" || claimed.deliveryState !== "delivered")
|
|
213
|
+
throw new Error("thread_access_failed");
|
|
214
|
+
const created = await threads(input.services).create({
|
|
215
|
+
guildId: delivery.guildId,
|
|
216
|
+
parentChannelId: delivery.parentChannelId,
|
|
217
|
+
name: moderationThreadName(configuration, input.caseNumber),
|
|
218
|
+
operationKey: `${delivery.operationKey}:create-thread`,
|
|
219
|
+
revalidate: () => configurationStillMatches(input.services, delivery, configuration),
|
|
220
|
+
});
|
|
221
|
+
if (created.outcome !== "created" && created.outcome !== "already_exists")
|
|
222
|
+
throw new Error("thread_create_failed");
|
|
223
|
+
delivery = await update(repository, delivery, {
|
|
224
|
+
threadId: created.threadId,
|
|
225
|
+
updatedAt: new Date(),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const threadId = delivery.threadId;
|
|
229
|
+
if (!threadId)
|
|
230
|
+
throw new Error("thread_missing");
|
|
231
|
+
if (delivery.memberState !== "succeeded") {
|
|
232
|
+
const member = await threads(input.services).addMember({
|
|
233
|
+
guildId: delivery.guildId,
|
|
234
|
+
threadId,
|
|
235
|
+
memberUserId: input.subjectUserId,
|
|
236
|
+
operationKey: `${delivery.operationKey}:add-member`,
|
|
237
|
+
});
|
|
238
|
+
if (member.outcome !== "created" && member.outcome !== "already_exists")
|
|
239
|
+
throw new Error("thread_member_failed");
|
|
240
|
+
delivery = await update(repository, delivery, {
|
|
241
|
+
memberState: "succeeded",
|
|
242
|
+
updatedAt: new Date(),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (!delivery.controlMessageId) {
|
|
246
|
+
// Each staff user is invited independently; retries converge through the
|
|
247
|
+
// adapter's membership check without granting any moderation authority.
|
|
248
|
+
for (const staffUserId of configuration.violationModeratorUserIds) {
|
|
249
|
+
if (staffUserId === input.subjectUserId)
|
|
250
|
+
continue;
|
|
251
|
+
if (!(await configurationStillMatches(input.services, delivery, configuration)))
|
|
252
|
+
throw new Error("thread_configuration_changed");
|
|
253
|
+
const staff = await threads(input.services).addMember({
|
|
254
|
+
guildId: delivery.guildId,
|
|
255
|
+
threadId,
|
|
256
|
+
memberUserId: staffUserId,
|
|
257
|
+
operationKey: `${delivery.operationKey}:staff:${staffUserId}`,
|
|
258
|
+
});
|
|
259
|
+
if (staff.outcome !== "created" && staff.outcome !== "already_exists")
|
|
260
|
+
throw new Error("thread_staff_notification_access_failed");
|
|
261
|
+
}
|
|
262
|
+
const member = await input.services
|
|
263
|
+
.get(HELYX_SERVICE_NAMES.memberEnforcement)
|
|
264
|
+
.inspectMember({
|
|
265
|
+
guildId: delivery.guildId,
|
|
266
|
+
memberUserId: input.subjectUserId,
|
|
267
|
+
});
|
|
268
|
+
if (!(await configurationStillMatches(input.services, delivery, configuration)))
|
|
269
|
+
throw new Error("thread_configuration_changed");
|
|
270
|
+
const opening = await threads(input.services).send({
|
|
271
|
+
guildId: delivery.guildId,
|
|
272
|
+
threadId,
|
|
273
|
+
presentation: {
|
|
274
|
+
layout: configuration.violationMessageLayout,
|
|
275
|
+
caseNumber: input.caseNumber,
|
|
276
|
+
},
|
|
277
|
+
response: violationOpeningMessage(configuration.violationModeratorRoleIds, input.subjectUserId, configuration.violationOpeningMessageTemplate, initialThreadControls(delivery.controlToken, member.outcome === "present" && Boolean(member.timeoutEndsAt)), configuration.violationModeratorUserIds.filter((id) => id !== input.subjectUserId)),
|
|
278
|
+
operationKey: `${delivery.operationKey}:opening-controls`,
|
|
279
|
+
});
|
|
280
|
+
if (opening.outcome !== "sent" && opening.outcome !== "already_sent")
|
|
281
|
+
throw new Error("thread_opening_failed");
|
|
282
|
+
delivery = await update(repository, delivery, {
|
|
283
|
+
controlMessageId: opening.messageId,
|
|
284
|
+
notificationState: "succeeded",
|
|
285
|
+
openingState: "succeeded",
|
|
286
|
+
updatedAt: new Date(),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (delivery.evidenceState === "pending") {
|
|
290
|
+
const result = input.evidence
|
|
291
|
+
? await input.evidence
|
|
292
|
+
.deliver({
|
|
293
|
+
guildId: delivery.guildId,
|
|
294
|
+
caseId: delivery.caseId,
|
|
295
|
+
threadId,
|
|
296
|
+
...(delivery.controlMessageId
|
|
297
|
+
? { openingMessageId: delivery.controlMessageId }
|
|
298
|
+
: {}),
|
|
299
|
+
operationKey: `${delivery.operationKey}:evidence`,
|
|
300
|
+
})
|
|
301
|
+
.catch(() => {
|
|
302
|
+
throw new Error("thread_evidence_failed");
|
|
303
|
+
})
|
|
304
|
+
: { outcome: "evidence_unavailable" };
|
|
305
|
+
delivery = await update(repository, delivery, {
|
|
306
|
+
evidenceState: result.outcome === "sent" || result.outcome === "already_sent"
|
|
307
|
+
? "succeeded"
|
|
308
|
+
: "failed",
|
|
309
|
+
safeCode: result.outcome === "sent" || result.outcome === "already_sent"
|
|
310
|
+
? null
|
|
311
|
+
: "thread_evidence_unavailable",
|
|
312
|
+
updatedAt: new Date(),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return delivery;
|
|
316
|
+
}
|
|
317
|
+
async function update(repository, current, values) {
|
|
318
|
+
if (!(await repository.updateThreadDelivery({
|
|
319
|
+
deliveryId: current.deliveryId,
|
|
320
|
+
expectedFenceToken: current.fenceToken,
|
|
321
|
+
values,
|
|
322
|
+
})))
|
|
323
|
+
throw new Error("thread_delivery_fence_changed");
|
|
324
|
+
return { ...current, ...values, fenceToken: current.fenceToken + 1 };
|
|
325
|
+
}
|
|
326
|
+
async function markRetry(repository, services, guildId, caseId, subjectUserId, error) {
|
|
327
|
+
const delivery = await repository.findThreadDelivery(guildId, caseId);
|
|
328
|
+
if (!delivery)
|
|
329
|
+
return null;
|
|
330
|
+
const exhausted = delivery.attemptCount >= 6;
|
|
331
|
+
const safeCode = threadFailureCode(error);
|
|
332
|
+
const state = exhausted ? "review_required" : "retry_pending";
|
|
333
|
+
const values = {
|
|
334
|
+
safeCode,
|
|
335
|
+
retryAt: exhausted ? null : new Date(Date.now() + 5_000),
|
|
336
|
+
updatedAt: new Date(),
|
|
337
|
+
...(safeCode === "thread_member_failed" ? { memberState: state } : {}),
|
|
338
|
+
...(safeCode === "thread_opening_failed" ? { openingState: state } : {}),
|
|
339
|
+
...(safeCode === "thread_evidence_failed"
|
|
340
|
+
? { evidenceState: "failed" }
|
|
341
|
+
: {}),
|
|
342
|
+
...(safeCode === "thread_notification_failed"
|
|
343
|
+
? { notificationState: state }
|
|
344
|
+
: {}),
|
|
345
|
+
};
|
|
346
|
+
const updated = await repository
|
|
347
|
+
.updateThreadDelivery({
|
|
348
|
+
deliveryId: delivery.deliveryId,
|
|
349
|
+
expectedFenceToken: delivery.fenceToken,
|
|
350
|
+
values,
|
|
351
|
+
})
|
|
352
|
+
.catch(() => false);
|
|
353
|
+
if (exhausted && delivery.accessRoleId)
|
|
354
|
+
await grants(services)
|
|
355
|
+
.release({
|
|
356
|
+
guildId: delivery.guildId,
|
|
357
|
+
memberUserId: subjectUserId,
|
|
358
|
+
roleId: delivery.accessRoleId,
|
|
359
|
+
sourceModuleId: MODERATION_MODULE_ID,
|
|
360
|
+
sourceId: delivery.caseId,
|
|
361
|
+
sourceScopeId: delivery.caseId,
|
|
362
|
+
operationKey: `${delivery.operationKey}:terminal-access-release`,
|
|
363
|
+
occurredAt: delivery.createdAt,
|
|
364
|
+
reason: "Release violation access after exhausted thread delivery",
|
|
365
|
+
})
|
|
366
|
+
.catch(() => undefined);
|
|
367
|
+
if (exhausted && delivery.threadId)
|
|
368
|
+
await threads(services)
|
|
369
|
+
.close({
|
|
370
|
+
guildId: delivery.guildId,
|
|
371
|
+
threadId: delivery.threadId,
|
|
372
|
+
operationKey: `${delivery.operationKey}:terminal-thread-close`,
|
|
373
|
+
revalidate: () => Promise.resolve(true),
|
|
374
|
+
})
|
|
375
|
+
.catch(() => undefined);
|
|
376
|
+
return updated
|
|
377
|
+
? { ...delivery, ...values, fenceToken: delivery.fenceToken + 1 }
|
|
378
|
+
: null;
|
|
379
|
+
}
|
|
380
|
+
function threadFailureCode(error) {
|
|
381
|
+
const code = error instanceof Error ? error.message : "";
|
|
382
|
+
return THREAD_FAILURE_CODES.has(code) ? code : "thread_delivery_failed";
|
|
383
|
+
}
|
|
384
|
+
const THREAD_FAILURE_CODES = new Set("thread_configuration_invalid thread_access_failed thread_create_failed thread_missing thread_member_failed thread_opening_failed thread_evidence_failed thread_notification_failed thread_controls_failed thread_delivery_fence_changed".split(" "));
|
|
385
|
+
function controlToken(services) {
|
|
386
|
+
return (services
|
|
387
|
+
.get(HELYX_SERVICE_NAMES.secureRandom)
|
|
388
|
+
.opaqueToken?.({ bytes: 24, purpose: "moderation.thread-control" }) ??
|
|
389
|
+
null);
|
|
390
|
+
}
|
|
391
|
+
async function settings(services, guildId) {
|
|
392
|
+
if (!services.has(HELYX_SERVICE_NAMES.configuration))
|
|
393
|
+
return DEFAULT_MODERATION_CONFIGURATION;
|
|
394
|
+
const stored = await services
|
|
395
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
396
|
+
.get(guildId, MODERATION_MODULE_ID);
|
|
397
|
+
return stored
|
|
398
|
+
? parseModerationConfiguration(stored.value)
|
|
399
|
+
: DEFAULT_MODERATION_CONFIGURATION;
|
|
400
|
+
}
|
|
401
|
+
async function configurationStillMatches(services, delivery, expected) {
|
|
402
|
+
const current = await settings(services, delivery.guildId);
|
|
403
|
+
return (current.violationThreadChannelId === delivery.parentChannelId &&
|
|
404
|
+
current.violationAccessRoleId === delivery.accessRoleId &&
|
|
405
|
+
current.violationModeratorRoleIds.join(",") ===
|
|
406
|
+
expected.violationModeratorRoleIds.join(",") &&
|
|
407
|
+
current.violationModeratorUserIds.join(",") ===
|
|
408
|
+
expected.violationModeratorUserIds.join(","));
|
|
409
|
+
}
|
|
410
|
+
function threads(services) {
|
|
411
|
+
return services.get(HELYX_SERVICE_NAMES.violationThreads);
|
|
412
|
+
}
|
|
413
|
+
function grants(services) {
|
|
414
|
+
return services.get(HELYX_SERVICE_NAMES.memberRoleGrants);
|
|
415
|
+
}
|
|
416
|
+
function isActiveDelivery(delivery) {
|
|
417
|
+
return Boolean(delivery.parentChannelId && delivery.accessRoleId && delivery.controlToken);
|
|
418
|
+
}
|
|
419
|
+
function deliveryComplete(delivery) {
|
|
420
|
+
return Boolean(delivery.threadId &&
|
|
421
|
+
delivery.controlMessageId &&
|
|
422
|
+
delivery.memberState === "succeeded" &&
|
|
423
|
+
delivery.openingState === "succeeded" &&
|
|
424
|
+
delivery.notificationState === "succeeded" &&
|
|
425
|
+
delivery.evidenceState !== "pending");
|
|
426
|
+
}
|
|
427
|
+
//# sourceMappingURL=thread-delivery.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ScheduledTaskExecutionResult, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import { ModerationRepository, type StoredModerationThreadDelivery } from "./repository.js";
|
|
3
|
+
export type RecoverableThreadAction = "kick" | "ban" | "unmute" | "close" | "close_delete" | "resolve";
|
|
4
|
+
export type ThreadControlEffectInspection = "applied" | "not_applied" | "unknown";
|
|
5
|
+
export interface ThreadControlRecoveryMarker {
|
|
6
|
+
action: RecoverableThreadAction;
|
|
7
|
+
attemptId: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function markAmbiguousThreadControl(repository: ModerationRepository, delivery: StoredModerationThreadDelivery, action: RecoverableThreadAction, attemptId: string): Promise<void>;
|
|
10
|
+
export declare function recoveryMarker(delivery: StoredModerationThreadDelivery | null): ThreadControlRecoveryMarker | null;
|
|
11
|
+
export declare function inspectThreadControlEffect(services: ServiceAccess, input: {
|
|
12
|
+
guildId: string;
|
|
13
|
+
caseId: string;
|
|
14
|
+
subjectUserId: string;
|
|
15
|
+
action: RecoverableThreadAction;
|
|
16
|
+
}): Promise<ThreadControlEffectInspection>;
|
|
17
|
+
export declare function finalizeThreadControlRecovery(services: ServiceAccess, input: {
|
|
18
|
+
guildId: string;
|
|
19
|
+
caseId: string;
|
|
20
|
+
caseRevision: number;
|
|
21
|
+
subjectUserId: string;
|
|
22
|
+
action: RecoverableThreadAction;
|
|
23
|
+
}): Promise<ScheduledTaskExecutionResult>;
|
|
24
|
+
//# sourceMappingURL=thread-reconciliation.d.ts.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { MODERATION_MODULE_ID } from "./constants.js";
|
|
3
|
+
import { closeThreadControl } from "./components.js";
|
|
4
|
+
import { ModerationRepository, } from "./repository.js";
|
|
5
|
+
export async function markAmbiguousThreadControl(repository, delivery, action, attemptId) {
|
|
6
|
+
let current = delivery;
|
|
7
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
8
|
+
const updated = await repository.updateThreadDelivery({
|
|
9
|
+
deliveryId: current.deliveryId,
|
|
10
|
+
expectedFenceToken: current.fenceToken,
|
|
11
|
+
values: {
|
|
12
|
+
safeCode: recoveryCode(action, attemptId),
|
|
13
|
+
retryAt: new Date(),
|
|
14
|
+
updatedAt: new Date(),
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
if (updated)
|
|
18
|
+
return;
|
|
19
|
+
const refreshed = await repository.findThreadDelivery(current.guildId, current.caseId);
|
|
20
|
+
if (!refreshed)
|
|
21
|
+
throw new Error("thread_delivery_missing");
|
|
22
|
+
if (refreshed.safeCode === recoveryCode(action, attemptId))
|
|
23
|
+
return;
|
|
24
|
+
current = refreshed;
|
|
25
|
+
}
|
|
26
|
+
throw new Error("thread_delivery_fence_changed");
|
|
27
|
+
}
|
|
28
|
+
export function recoveryMarker(delivery) {
|
|
29
|
+
const match = /^thread_control_ambiguous_(kick|ban|unmute|close|close_delete|resolve)_([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/iu.exec(delivery?.safeCode ?? "");
|
|
30
|
+
if (!match?.[1] || !match[2])
|
|
31
|
+
return null;
|
|
32
|
+
return {
|
|
33
|
+
action: match[1],
|
|
34
|
+
attemptId: match[2].toLowerCase(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function inspectThreadControlEffect(services, input) {
|
|
38
|
+
if (input.action === "resolve")
|
|
39
|
+
return "unknown";
|
|
40
|
+
if (input.action === "close" || input.action === "close_delete") {
|
|
41
|
+
const delivery = await new ModerationRepository(services).findThreadDelivery(input.guildId, input.caseId);
|
|
42
|
+
if (!delivery?.parentChannelId || !delivery.threadId)
|
|
43
|
+
return "unknown";
|
|
44
|
+
const thread = await services
|
|
45
|
+
.get(HELYX_SERVICE_NAMES.violationThreads)
|
|
46
|
+
.reconcile({
|
|
47
|
+
guildId: input.guildId,
|
|
48
|
+
parentChannelId: delivery.parentChannelId,
|
|
49
|
+
threadId: delivery.threadId,
|
|
50
|
+
...(input.action === "close_delete" ? { requireBotOwner: true } : {}),
|
|
51
|
+
operationKey: `reconcile:${input.caseId}:thread-close`,
|
|
52
|
+
});
|
|
53
|
+
if (input.action === "close_delete")
|
|
54
|
+
return thread.outcome === "absent" ? "applied" : "unknown";
|
|
55
|
+
if (thread.outcome !== "exists")
|
|
56
|
+
return "unknown";
|
|
57
|
+
return thread.archived && thread.locked ? "applied" : "not_applied";
|
|
58
|
+
}
|
|
59
|
+
const enforcement = services.get(HELYX_SERVICE_NAMES.memberEnforcement);
|
|
60
|
+
if (input.action === "ban") {
|
|
61
|
+
const ban = await enforcement.inspectBan({
|
|
62
|
+
guildId: input.guildId,
|
|
63
|
+
userId: input.subjectUserId,
|
|
64
|
+
});
|
|
65
|
+
return ban.outcome === "banned"
|
|
66
|
+
? "applied"
|
|
67
|
+
: ban.outcome === "not_banned"
|
|
68
|
+
? "not_applied"
|
|
69
|
+
: "unknown";
|
|
70
|
+
}
|
|
71
|
+
const member = await enforcement.inspectMember({
|
|
72
|
+
guildId: input.guildId,
|
|
73
|
+
memberUserId: input.subjectUserId,
|
|
74
|
+
});
|
|
75
|
+
if (member.outcome === "failed")
|
|
76
|
+
return "unknown";
|
|
77
|
+
if (input.action === "kick")
|
|
78
|
+
return member.outcome === "member_missing" ? "applied" : "not_applied";
|
|
79
|
+
return member.outcome === "present" && !member.timeoutEndsAt
|
|
80
|
+
? "applied"
|
|
81
|
+
: "not_applied";
|
|
82
|
+
}
|
|
83
|
+
export async function finalizeThreadControlRecovery(services, input) {
|
|
84
|
+
const repository = new ModerationRepository(services);
|
|
85
|
+
const delivery = await repository.findThreadDelivery(input.guildId, input.caseId);
|
|
86
|
+
if (!delivery)
|
|
87
|
+
return { outcome: "completed" };
|
|
88
|
+
const closing = input.action === "close" || input.action === "close_delete";
|
|
89
|
+
const desiredResolution = closing ? "closed" : "resolved";
|
|
90
|
+
if (delivery.caseRevision === input.caseRevision &&
|
|
91
|
+
delivery.resolutionState === desiredResolution &&
|
|
92
|
+
!recoveryMarker(delivery))
|
|
93
|
+
return { outcome: "completed" };
|
|
94
|
+
if ((input.action === "kick" || input.action === "ban" || closing) &&
|
|
95
|
+
!(await releaseAccess(services, delivery, input.subjectUserId, input.action)))
|
|
96
|
+
return {
|
|
97
|
+
outcome: "retry",
|
|
98
|
+
delayMs: 5_000,
|
|
99
|
+
safeCode: "thread_access_release_pending",
|
|
100
|
+
};
|
|
101
|
+
const updated = await repository.updateThreadDelivery({
|
|
102
|
+
deliveryId: delivery.deliveryId,
|
|
103
|
+
expectedFenceToken: delivery.fenceToken,
|
|
104
|
+
values: {
|
|
105
|
+
caseRevision: input.caseRevision,
|
|
106
|
+
resolutionState: desiredResolution,
|
|
107
|
+
...(closing ? { closeState: "succeeded" } : {}),
|
|
108
|
+
...(closing ? { safeCode: null, retryAt: null } : {}),
|
|
109
|
+
updatedAt: new Date(),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (!updated)
|
|
113
|
+
return {
|
|
114
|
+
outcome: "retry",
|
|
115
|
+
delayMs: 2_000,
|
|
116
|
+
safeCode: "thread_delivery_revision_changed",
|
|
117
|
+
};
|
|
118
|
+
if (closing)
|
|
119
|
+
return { outcome: "completed" };
|
|
120
|
+
const threads = services.get(HELYX_SERVICE_NAMES.violationThreads);
|
|
121
|
+
if (threads.editControls &&
|
|
122
|
+
delivery.threadId &&
|
|
123
|
+
delivery.controlMessageId &&
|
|
124
|
+
delivery.controlToken) {
|
|
125
|
+
const edited = await threads.editControls({
|
|
126
|
+
guildId: input.guildId,
|
|
127
|
+
threadId: delivery.threadId,
|
|
128
|
+
messageId: delivery.controlMessageId,
|
|
129
|
+
buttons: closeThreadControl(delivery.controlToken).buttons,
|
|
130
|
+
operationKey: `recover:${input.caseId}:${input.caseRevision}:controls`,
|
|
131
|
+
revalidate: async () => {
|
|
132
|
+
const current = await repository.findCase(input.guildId, input.caseId);
|
|
133
|
+
return (current?.state === "applied" &&
|
|
134
|
+
current.revision === input.caseRevision &&
|
|
135
|
+
Boolean(current.subjectUserId) &&
|
|
136
|
+
!current.disassociatedAt);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
if (edited.outcome !== "updated")
|
|
140
|
+
return {
|
|
141
|
+
outcome: "retry",
|
|
142
|
+
delayMs: 5_000,
|
|
143
|
+
safeCode: "thread_controls_refresh_pending",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const current = await repository.findThreadDelivery(input.guildId, input.caseId);
|
|
147
|
+
if (!current ||
|
|
148
|
+
!(await repository.updateThreadDelivery({
|
|
149
|
+
deliveryId: current.deliveryId,
|
|
150
|
+
expectedFenceToken: current.fenceToken,
|
|
151
|
+
values: { safeCode: null, retryAt: null, updatedAt: new Date() },
|
|
152
|
+
})))
|
|
153
|
+
return {
|
|
154
|
+
outcome: "retry",
|
|
155
|
+
delayMs: 2_000,
|
|
156
|
+
safeCode: "thread_delivery_revision_changed",
|
|
157
|
+
};
|
|
158
|
+
return { outcome: "completed" };
|
|
159
|
+
}
|
|
160
|
+
async function releaseAccess(services, delivery, subjectUserId, action) {
|
|
161
|
+
if (!delivery.accessRoleId || !delivery.controlToken)
|
|
162
|
+
return false;
|
|
163
|
+
const result = await services
|
|
164
|
+
.get(HELYX_SERVICE_NAMES.memberRoleGrants)
|
|
165
|
+
.release({
|
|
166
|
+
guildId: delivery.guildId,
|
|
167
|
+
memberUserId: subjectUserId,
|
|
168
|
+
roleId: delivery.accessRoleId,
|
|
169
|
+
sourceModuleId: MODERATION_MODULE_ID,
|
|
170
|
+
sourceId: delivery.caseId,
|
|
171
|
+
sourceScopeId: delivery.caseId,
|
|
172
|
+
operationKey: `thread:${delivery.controlToken}:release:${action}`,
|
|
173
|
+
occurredAt: delivery.createdAt,
|
|
174
|
+
reason: `Violation thread access release after ${action}`,
|
|
175
|
+
});
|
|
176
|
+
return result.outcome === "committed" && result.deliveryState === "delivered";
|
|
177
|
+
}
|
|
178
|
+
function recoveryCode(action, attemptId) {
|
|
179
|
+
return `thread_control_ambiguous_${action}_${attemptId}`;
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=thread-reconciliation.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ModuleRecordService } from "@helyx/sdk";
|
|
2
|
+
import { type StoredModerationThreadDelivery } from "./repository-model.js";
|
|
3
|
+
export declare class ModerationThreadRepository {
|
|
4
|
+
private readonly records;
|
|
5
|
+
constructor(records: ModuleRecordService);
|
|
6
|
+
findThreadDelivery(guildId: string, caseId: string): Promise<StoredModerationThreadDelivery | null>;
|
|
7
|
+
findThreadDeliveryByControlToken(controlToken: string): Promise<StoredModerationThreadDelivery | null>;
|
|
8
|
+
upsertThreadDelivery(input: StoredModerationThreadDelivery): Promise<void>;
|
|
9
|
+
createThreadDelivery(input: StoredModerationThreadDelivery): Promise<StoredModerationThreadDelivery>;
|
|
10
|
+
updateThreadDelivery(input: {
|
|
11
|
+
deliveryId: string;
|
|
12
|
+
expectedFenceToken: number;
|
|
13
|
+
values: Partial<Omit<StoredModerationThreadDelivery, "deliveryId" | "guildId" | "caseId" | "createdAt">>;
|
|
14
|
+
}): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=thread-repository.d.ts.map
|