@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,376 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { closeThreadControl, currentThreadActor, privateThreadControlMessage, } from "./components.js";
|
|
3
|
+
import { MODERATION_MODULE_ID } from "./constants.js";
|
|
4
|
+
import { appendModerationAudit, moderationConfiguration, moderationRetentionExpiresAt, scheduleActionReconciliation, } from "./action-support.js";
|
|
5
|
+
import { ModerationRepository, } from "./repository.js";
|
|
6
|
+
import { markAmbiguousThreadControl } from "./thread-reconciliation.js";
|
|
7
|
+
export class ModerationThreadControls {
|
|
8
|
+
moderation;
|
|
9
|
+
constructor(moderation) {
|
|
10
|
+
this.moderation = moderation;
|
|
11
|
+
}
|
|
12
|
+
async executeControl(context, action, token) {
|
|
13
|
+
try {
|
|
14
|
+
const repository = new ModerationRepository(context.services);
|
|
15
|
+
const delivery = await repository.findThreadDeliveryByControlToken(token);
|
|
16
|
+
if (!delivery ||
|
|
17
|
+
!isActiveDelivery(delivery) ||
|
|
18
|
+
!context.serverId ||
|
|
19
|
+
delivery.guildId !== context.serverId ||
|
|
20
|
+
delivery.threadId !== context.channelId ||
|
|
21
|
+
delivery.controlMessageId !== context.messageId)
|
|
22
|
+
throw new Error("This moderation control is no longer valid.");
|
|
23
|
+
const moderationCase = await repository.findCase(delivery.guildId, delivery.caseId);
|
|
24
|
+
if (!moderationCase ||
|
|
25
|
+
moderationCase.revision !== delivery.caseRevision ||
|
|
26
|
+
moderationCase.state !== "applied" ||
|
|
27
|
+
!moderationCase.subjectUserId)
|
|
28
|
+
throw new Error("This moderation case changed. Refresh the case before acting.");
|
|
29
|
+
const permissionId = isClosingAction(action)
|
|
30
|
+
? action === "close_delete"
|
|
31
|
+
? "moderation.threads.close-delete"
|
|
32
|
+
: "moderation.threads.close"
|
|
33
|
+
: "moderation.threads.act";
|
|
34
|
+
const actor = await currentThreadActor(context, permissionId);
|
|
35
|
+
if (!actor)
|
|
36
|
+
throw new Error("You no longer have access to this control.");
|
|
37
|
+
if (delivery.resolutionState === "closed" && action !== "close_delete")
|
|
38
|
+
throw new Error("This violation thread is already closed.");
|
|
39
|
+
if (isClosingAction(action) &&
|
|
40
|
+
delivery.resolutionState === "awaiting_staff")
|
|
41
|
+
throw new Error("Resolve the case before closing this thread.");
|
|
42
|
+
if (!isClosingAction(action) && delivery.resolutionState === "resolved") {
|
|
43
|
+
await showCloseControl(context, delivery);
|
|
44
|
+
await context.reply(privateThreadControlMessage("This case already has a staff resolution. Close is now available."));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!isClosingAction(action) &&
|
|
48
|
+
delivery.resolutionState !== "awaiting_staff")
|
|
49
|
+
throw new Error("This case already has a staff resolution.");
|
|
50
|
+
await this.#apply(context, repository, delivery, moderationCase, actor.userId, action);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
await context.reply(privateThreadControlMessage(error instanceof Error && safeControlError(error.message)
|
|
54
|
+
? error.message
|
|
55
|
+
: "The moderation control is temporarily unavailable."));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async #apply(context, repository, delivery, moderationCase, actorUserId, action) {
|
|
59
|
+
const subjectUserId = moderationCase.subjectUserId;
|
|
60
|
+
if (!subjectUserId)
|
|
61
|
+
throw new Error("This case is disassociated.");
|
|
62
|
+
const caseAction = action === "unmute"
|
|
63
|
+
? "timeout_remove"
|
|
64
|
+
: action === "no_further_action" || isClosingAction(action)
|
|
65
|
+
? "case_only"
|
|
66
|
+
: action;
|
|
67
|
+
const configuration = await moderationConfiguration(context.services, delivery.guildId);
|
|
68
|
+
const retentionDays = configuration.caseRetentionDays;
|
|
69
|
+
const attempt = await atomic(context.services).createAdditionalPendingAttempt({
|
|
70
|
+
operationKey: `thread:${delivery.controlToken}:${action}`,
|
|
71
|
+
guildId: delivery.guildId,
|
|
72
|
+
caseId: delivery.caseId,
|
|
73
|
+
expectedRevision: moderationCase.revision,
|
|
74
|
+
purpose: "thread_control",
|
|
75
|
+
action: caseAction,
|
|
76
|
+
actorUserId,
|
|
77
|
+
reason: `Staff selected ${action.replaceAll("_", " ")} in the violation thread.`,
|
|
78
|
+
occurredAt: new Date(),
|
|
79
|
+
});
|
|
80
|
+
if (attempt.outcome === "rejected")
|
|
81
|
+
throw new Error("This moderation case changed.");
|
|
82
|
+
if (attempt.outcome === "replayed") {
|
|
83
|
+
await context.reply(privateThreadControlMessage("This moderation control was already processed."));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await appendModerationAudit(context.services, {
|
|
88
|
+
guildId: delivery.guildId,
|
|
89
|
+
actorUserId,
|
|
90
|
+
action: "moderation.thread-control",
|
|
91
|
+
source: "discord",
|
|
92
|
+
correlationId: context.interactionId,
|
|
93
|
+
idempotencyKey: `thread:${delivery.controlToken}:${action}:audit`,
|
|
94
|
+
targetType: "moderation_case",
|
|
95
|
+
targetId: delivery.caseId,
|
|
96
|
+
metadata: { action, caseNumber: moderationCase.caseNumber },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
await settle(context.services, delivery, attempt, "failed", "audit_unavailable", retentionDays);
|
|
101
|
+
throw new Error("Audit is unavailable. No Discord action was taken.");
|
|
102
|
+
}
|
|
103
|
+
await markAmbiguousThreadControl(repository, delivery, action === "no_further_action" ? "resolve" : action, attempt.attemptId);
|
|
104
|
+
const currentDelivery = await repository.findThreadDeliveryByControlToken(delivery.controlToken);
|
|
105
|
+
if (!currentDelivery || !isActiveDelivery(currentDelivery))
|
|
106
|
+
throw new Error("This moderation control is no longer valid.");
|
|
107
|
+
delivery = currentDelivery;
|
|
108
|
+
const revalidate = async () => {
|
|
109
|
+
if (!(await currentThreadActor(context, isClosingAction(action)
|
|
110
|
+
? action === "close_delete"
|
|
111
|
+
? "moderation.threads.close-delete"
|
|
112
|
+
: "moderation.threads.close"
|
|
113
|
+
: "moderation.threads.act")))
|
|
114
|
+
return false;
|
|
115
|
+
const current = await repository.findCase(delivery.guildId, delivery.caseId);
|
|
116
|
+
const binding = action === "close_delete"
|
|
117
|
+
? await repository.findThreadDeliveryByControlToken(delivery.controlToken)
|
|
118
|
+
: delivery;
|
|
119
|
+
return (current?.state === "pending" &&
|
|
120
|
+
current.revision === attempt.revision &&
|
|
121
|
+
current.subjectUserId === subjectUserId &&
|
|
122
|
+
!current.disassociatedAt &&
|
|
123
|
+
binding?.guildId === delivery.guildId &&
|
|
124
|
+
binding.threadId === delivery.threadId &&
|
|
125
|
+
binding.parentChannelId === delivery.parentChannelId &&
|
|
126
|
+
binding.accessRoleId === delivery.accessRoleId &&
|
|
127
|
+
(action !== "close_delete" ||
|
|
128
|
+
JSON.stringify(await moderationConfiguration(context.services, delivery.guildId)) === JSON.stringify(configuration)));
|
|
129
|
+
};
|
|
130
|
+
let effect = await (!(await revalidate())
|
|
131
|
+
? Promise.resolve({
|
|
132
|
+
outcome: "missing_permission",
|
|
133
|
+
safeCode: "actor_or_case_changed",
|
|
134
|
+
})
|
|
135
|
+
: isClosingAction(action)
|
|
136
|
+
? closeViolationThread(context.services, delivery, subjectUserId, revalidate, action === "close_delete")
|
|
137
|
+
: this.moderation.dispatchAction(context.services, {
|
|
138
|
+
guildId: delivery.guildId,
|
|
139
|
+
subjectUserId,
|
|
140
|
+
actorUserId,
|
|
141
|
+
action: action === "kick"
|
|
142
|
+
? { type: "kick" }
|
|
143
|
+
: action === "ban"
|
|
144
|
+
? { type: "ban", deleteMessageSeconds: 0 }
|
|
145
|
+
: action === "unmute"
|
|
146
|
+
? { type: "timeout_remove" }
|
|
147
|
+
: { type: "case_only" },
|
|
148
|
+
reason: `[Helyx case ${moderationCase.caseNumber}] Violation thread staff action`,
|
|
149
|
+
operationKey: `thread:${delivery.controlToken}:${action}:discord`,
|
|
150
|
+
protectedRoleIds: [delivery.accessRoleId],
|
|
151
|
+
revalidate,
|
|
152
|
+
})).catch(() => ({
|
|
153
|
+
outcome: "ambiguous",
|
|
154
|
+
safeCode: "enforcement_unavailable",
|
|
155
|
+
}));
|
|
156
|
+
if (action === "close_delete" &&
|
|
157
|
+
!["succeeded", "already_applied"].includes(effect.outcome))
|
|
158
|
+
effect = { outcome: "ambiguous", safeCode: "thread_delete_unconfirmed" };
|
|
159
|
+
const result = await settle(context.services, delivery, attempt, effect.outcome, effect.safeCode, retentionDays).catch(() => null);
|
|
160
|
+
if (!result || effect.outcome === "ambiguous")
|
|
161
|
+
await scheduleActionReconciliation(context.services, {
|
|
162
|
+
guildId: delivery.guildId,
|
|
163
|
+
caseId: delivery.caseId,
|
|
164
|
+
attemptId: attempt.attemptId,
|
|
165
|
+
operationKey: `thread:${delivery.controlToken}:${action}`,
|
|
166
|
+
}).catch(() => undefined);
|
|
167
|
+
if (!result ||
|
|
168
|
+
(effect.outcome !== "succeeded" && effect.outcome !== "already_applied"))
|
|
169
|
+
throw new Error("The action was not confirmed and needs dashboard review.");
|
|
170
|
+
await scheduleActionReconciliation(context.services, {
|
|
171
|
+
guildId: delivery.guildId,
|
|
172
|
+
caseId: delivery.caseId,
|
|
173
|
+
attemptId: attempt.attemptId,
|
|
174
|
+
operationKey: `thread:${delivery.controlToken}:${action}`,
|
|
175
|
+
}).catch(() => undefined);
|
|
176
|
+
if ((action === "kick" || action === "ban") &&
|
|
177
|
+
(await releaseViolationThreadAccess(context.services, delivery, subjectUserId, action)) === "review_required") {
|
|
178
|
+
await repository.updateThreadDelivery({
|
|
179
|
+
deliveryId: delivery.deliveryId,
|
|
180
|
+
expectedFenceToken: delivery.fenceToken,
|
|
181
|
+
values: {
|
|
182
|
+
retryAt: new Date(),
|
|
183
|
+
updatedAt: new Date(),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
throw new Error("The action was applied, but thread access needs dashboard review.");
|
|
187
|
+
}
|
|
188
|
+
const changed = await repository.updateThreadDelivery({
|
|
189
|
+
deliveryId: delivery.deliveryId,
|
|
190
|
+
expectedFenceToken: delivery.fenceToken,
|
|
191
|
+
values: {
|
|
192
|
+
caseRevision: result.revision,
|
|
193
|
+
resolutionState: isClosingAction(action) ? "closed" : "resolved",
|
|
194
|
+
closeState: isClosingAction(action) ? "succeeded" : delivery.closeState,
|
|
195
|
+
...(isClosingAction(action) ? { safeCode: null, retryAt: null } : {}),
|
|
196
|
+
updatedAt: new Date(),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
if (!changed)
|
|
200
|
+
throw new Error("The thread state changed. Refresh it before acting.");
|
|
201
|
+
if (!isClosingAction(action))
|
|
202
|
+
await showCloseControl(context, delivery);
|
|
203
|
+
if (!isClosingAction(action)) {
|
|
204
|
+
const refreshed = await repository.findThreadDeliveryByControlToken(delivery.controlToken);
|
|
205
|
+
if (refreshed)
|
|
206
|
+
await repository.updateThreadDelivery({
|
|
207
|
+
deliveryId: refreshed.deliveryId,
|
|
208
|
+
expectedFenceToken: refreshed.fenceToken,
|
|
209
|
+
values: { safeCode: null, retryAt: null, updatedAt: new Date() },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
await context.reply(privateThreadControlMessage(isClosingAction(action)
|
|
213
|
+
? action === "close_delete"
|
|
214
|
+
? "Violation thread closed and deleted. Case records retained."
|
|
215
|
+
: "Violation thread closed."
|
|
216
|
+
: "Staff resolution recorded. Close is now available."));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function safeControlError(message) {
|
|
220
|
+
return SAFE_CONTROL_ERRORS.has(message);
|
|
221
|
+
}
|
|
222
|
+
const SAFE_CONTROL_ERRORS = new Set([
|
|
223
|
+
"This moderation control is no longer valid.",
|
|
224
|
+
"This moderation case changed. Refresh the case before acting.",
|
|
225
|
+
"You no longer have access to this control.",
|
|
226
|
+
"This violation thread is already closed.",
|
|
227
|
+
"Resolve the case before closing this thread.",
|
|
228
|
+
"This case already has a staff resolution.",
|
|
229
|
+
"This case is disassociated.",
|
|
230
|
+
"This moderation case changed.",
|
|
231
|
+
"Audit is unavailable. No Discord action was taken.",
|
|
232
|
+
"The action was not confirmed and needs dashboard review.",
|
|
233
|
+
"The action was applied, but thread access needs dashboard review.",
|
|
234
|
+
"The thread state changed. Refresh it before acting.",
|
|
235
|
+
"Staff resolution recorded, but the controls could not be refreshed. Click a staff button again to show Close.",
|
|
236
|
+
]);
|
|
237
|
+
async function showCloseControl(context, delivery) {
|
|
238
|
+
try {
|
|
239
|
+
const threadService = threads(context.services);
|
|
240
|
+
if (threadService.editControls &&
|
|
241
|
+
delivery.threadId &&
|
|
242
|
+
delivery.controlMessageId) {
|
|
243
|
+
const result = await threadService.editControls({
|
|
244
|
+
guildId: delivery.guildId,
|
|
245
|
+
threadId: delivery.threadId,
|
|
246
|
+
messageId: delivery.controlMessageId,
|
|
247
|
+
buttons: closeThreadControl(delivery.controlToken).buttons,
|
|
248
|
+
operationKey: `thread:${delivery.controlToken}:show-close`,
|
|
249
|
+
revalidate: () => currentThreadActor(context, "moderation.threads.act").then(Boolean),
|
|
250
|
+
});
|
|
251
|
+
if (result.outcome !== "updated")
|
|
252
|
+
throw new Error("control_edit_failed");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!context.editSourceMessage)
|
|
256
|
+
throw new Error("source_edit_unavailable");
|
|
257
|
+
await context.editSourceMessage(closeThreadControl(delivery.controlToken));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
throw new Error("Staff resolution recorded, but the controls could not be refreshed. Click a staff button again to show Close.");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function settle(services, delivery, attempt, outcome, safeCode, retentionDays) {
|
|
264
|
+
const occurredAt = new Date();
|
|
265
|
+
const result = await atomic(services).settleActionAttempt({
|
|
266
|
+
operationKey: `thread:${delivery.controlToken}:${attempt.attemptId}:settle`,
|
|
267
|
+
guildId: delivery.guildId,
|
|
268
|
+
caseId: delivery.caseId,
|
|
269
|
+
attemptId: attempt.attemptId,
|
|
270
|
+
expectedRevision: attempt.revision,
|
|
271
|
+
outcome,
|
|
272
|
+
...(safeCode ? { safeCode } : {}),
|
|
273
|
+
...(retentionDays
|
|
274
|
+
? {
|
|
275
|
+
retentionExpiresAt: moderationRetentionExpiresAt(occurredAt, retentionDays),
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
278
|
+
occurredAt,
|
|
279
|
+
});
|
|
280
|
+
return result.outcome === "rejected" ? null : result;
|
|
281
|
+
}
|
|
282
|
+
export async function closeViolationThread(services, delivery, subjectUserId, revalidate, deleteThread = false) {
|
|
283
|
+
if (!delivery.threadId)
|
|
284
|
+
return { outcome: "review_required", safeCode: "thread_missing" };
|
|
285
|
+
if (!(await revalidate()))
|
|
286
|
+
return { outcome: "missing_permission", safeCode: "actor_access_changed" };
|
|
287
|
+
const inspection = deleteThread
|
|
288
|
+
? await threads(services).reconcile({
|
|
289
|
+
guildId: delivery.guildId,
|
|
290
|
+
parentChannelId: delivery.parentChannelId,
|
|
291
|
+
threadId: delivery.threadId,
|
|
292
|
+
operationKey: `thread:${delivery.controlToken}:delete-inspect`,
|
|
293
|
+
requireBotOwner: true,
|
|
294
|
+
})
|
|
295
|
+
: null;
|
|
296
|
+
if (inspection?.outcome === "unknown")
|
|
297
|
+
return { outcome: "ambiguous", safeCode: "delete_inspection_unknown" };
|
|
298
|
+
const removed = inspection?.outcome === "absent"
|
|
299
|
+
? { outcome: "already_exists" }
|
|
300
|
+
: await threads(services).removeMember({
|
|
301
|
+
guildId: delivery.guildId,
|
|
302
|
+
threadId: delivery.threadId,
|
|
303
|
+
memberUserId: subjectUserId,
|
|
304
|
+
operationKey: `thread:${delivery.controlToken}:remove-member`,
|
|
305
|
+
});
|
|
306
|
+
if (!["created", "already_exists", "member_missing"].includes(removed.outcome))
|
|
307
|
+
return {
|
|
308
|
+
outcome: "review_required",
|
|
309
|
+
safeCode: `member_${removed.outcome}`,
|
|
310
|
+
};
|
|
311
|
+
if (!(await revalidate()))
|
|
312
|
+
return { outcome: "missing_permission", safeCode: "actor_access_changed" };
|
|
313
|
+
if ((await releaseViolationThreadAccess(services, delivery, subjectUserId, "close")) === "review_required")
|
|
314
|
+
return { outcome: "review_required", safeCode: "role_release_review" };
|
|
315
|
+
const closed = await threads(services).close({
|
|
316
|
+
guildId: delivery.guildId,
|
|
317
|
+
threadId: delivery.threadId,
|
|
318
|
+
operationKey: `thread:${delivery.controlToken}:${deleteThread ? "close-delete" : "close"}`,
|
|
319
|
+
...(deleteThread
|
|
320
|
+
? { deleteThread: true, parentChannelId: delivery.parentChannelId }
|
|
321
|
+
: {}),
|
|
322
|
+
revalidate,
|
|
323
|
+
});
|
|
324
|
+
if (deleteThread &&
|
|
325
|
+
(closed.outcome === "created" || closed.outcome === "already_exists")) {
|
|
326
|
+
const verified = await threads(services).reconcile({
|
|
327
|
+
guildId: delivery.guildId,
|
|
328
|
+
parentChannelId: delivery.parentChannelId,
|
|
329
|
+
threadId: delivery.threadId,
|
|
330
|
+
requireBotOwner: true,
|
|
331
|
+
operationKey: `thread:${delivery.controlToken}:delete-verify`,
|
|
332
|
+
});
|
|
333
|
+
return verified.outcome === "absent"
|
|
334
|
+
? { outcome: "succeeded" }
|
|
335
|
+
: { outcome: "ambiguous", safeCode: "thread_delete_unconfirmed" };
|
|
336
|
+
}
|
|
337
|
+
return closed.outcome === "created" || closed.outcome === "already_exists"
|
|
338
|
+
? { outcome: "succeeded" }
|
|
339
|
+
: {
|
|
340
|
+
outcome: deleteThread || closed.outcome === "ambiguous"
|
|
341
|
+
? "ambiguous"
|
|
342
|
+
: "review_required",
|
|
343
|
+
safeCode: closed.outcome,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export async function releaseViolationThreadAccess(services, delivery, subjectUserId, reason) {
|
|
347
|
+
const result = await services
|
|
348
|
+
.get(HELYX_SERVICE_NAMES.memberRoleGrants)
|
|
349
|
+
.release({
|
|
350
|
+
guildId: delivery.guildId,
|
|
351
|
+
memberUserId: subjectUserId,
|
|
352
|
+
roleId: delivery.accessRoleId,
|
|
353
|
+
sourceModuleId: MODERATION_MODULE_ID,
|
|
354
|
+
sourceId: delivery.caseId,
|
|
355
|
+
sourceScopeId: delivery.caseId,
|
|
356
|
+
operationKey: `thread:${delivery.controlToken}:release:${reason}`,
|
|
357
|
+
occurredAt: delivery.createdAt,
|
|
358
|
+
reason: `Violation thread access release after ${reason}`,
|
|
359
|
+
});
|
|
360
|
+
return result.outcome === "committed" && result.deliveryState === "delivered"
|
|
361
|
+
? "released"
|
|
362
|
+
: "review_required";
|
|
363
|
+
}
|
|
364
|
+
function atomic(services) {
|
|
365
|
+
return services.get(HELYX_SERVICE_NAMES.atomicModerationCases);
|
|
366
|
+
}
|
|
367
|
+
function threads(services) {
|
|
368
|
+
return services.get(HELYX_SERVICE_NAMES.violationThreads);
|
|
369
|
+
}
|
|
370
|
+
function isActiveDelivery(delivery) {
|
|
371
|
+
return Boolean(delivery.parentChannelId && delivery.accessRoleId && delivery.controlToken);
|
|
372
|
+
}
|
|
373
|
+
function isClosingAction(action) {
|
|
374
|
+
return action === "close" || action === "close_delete";
|
|
375
|
+
}
|
|
376
|
+
//# sourceMappingURL=thread-controls.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import { ModerationRepository } from "./repository.js";
|
|
3
|
+
import { type RecoverableThreadAction } from "./thread-reconciliation.js";
|
|
4
|
+
export declare function retryRequestedThreadDeletion(services: ServiceAccess, input: {
|
|
5
|
+
guildId: string;
|
|
6
|
+
caseId: string;
|
|
7
|
+
attemptId: string;
|
|
8
|
+
actorUserId?: string | null;
|
|
9
|
+
}, recovery: {
|
|
10
|
+
action: RecoverableThreadAction;
|
|
11
|
+
attemptId: string;
|
|
12
|
+
}, item: Awaited<ReturnType<ModerationRepository["findCase"]>>, delivery: Awaited<ReturnType<ModerationRepository["findThreadDelivery"]>>): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=thread-deletion-recovery.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { moderationConfiguration } from "./action-support.js";
|
|
2
|
+
import { currentThreadActor } from "./components.js";
|
|
3
|
+
import { ModerationRepository } from "./repository.js";
|
|
4
|
+
import { closeViolationThread } from "./thread-controls.js";
|
|
5
|
+
import { recoveryMarker, } from "./thread-reconciliation.js";
|
|
6
|
+
export async function retryRequestedThreadDeletion(services, input, recovery, item, delivery) {
|
|
7
|
+
if (recovery.action !== "close_delete" ||
|
|
8
|
+
!input.actorUserId ||
|
|
9
|
+
!item?.subjectUserId ||
|
|
10
|
+
!delivery?.parentChannelId ||
|
|
11
|
+
!delivery.accessRoleId ||
|
|
12
|
+
!delivery.controlToken ||
|
|
13
|
+
recovery.attemptId !== input.attemptId.toLowerCase())
|
|
14
|
+
return;
|
|
15
|
+
const configuration = JSON.stringify(await moderationConfiguration(services, input.guildId));
|
|
16
|
+
const revalidate = async () => {
|
|
17
|
+
if (!(await currentThreadActor({ serverId: input.guildId, userId: input.actorUserId, services }, "moderation.cases.close-and-delete-thread")))
|
|
18
|
+
return false;
|
|
19
|
+
const repository = new ModerationRepository(services);
|
|
20
|
+
const [current, binding] = await Promise.all([
|
|
21
|
+
repository.findCase(input.guildId, input.caseId),
|
|
22
|
+
repository.findThreadDelivery(input.guildId, input.caseId),
|
|
23
|
+
]);
|
|
24
|
+
return (current?.revision === item.revision &&
|
|
25
|
+
current.subjectUserId === item.subjectUserId &&
|
|
26
|
+
!current.disassociatedAt &&
|
|
27
|
+
binding?.threadId === delivery.threadId &&
|
|
28
|
+
binding.parentChannelId === delivery.parentChannelId &&
|
|
29
|
+
binding.accessRoleId === delivery.accessRoleId &&
|
|
30
|
+
binding.controlToken === delivery.controlToken &&
|
|
31
|
+
current.state === item.state &&
|
|
32
|
+
recoveryMarker(binding)?.action === "close_delete" &&
|
|
33
|
+
recoveryMarker(binding)?.attemptId === recovery.attemptId &&
|
|
34
|
+
JSON.stringify(await moderationConfiguration(services, input.guildId)) ===
|
|
35
|
+
configuration);
|
|
36
|
+
};
|
|
37
|
+
if (!(await revalidate()))
|
|
38
|
+
return;
|
|
39
|
+
await closeViolationThread(services, {
|
|
40
|
+
...delivery,
|
|
41
|
+
parentChannelId: delivery.parentChannelId,
|
|
42
|
+
accessRoleId: delivery.accessRoleId,
|
|
43
|
+
controlToken: delivery.controlToken,
|
|
44
|
+
}, item.subjectUserId, revalidate, true);
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=thread-deletion-recovery.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ScheduledTaskExecutionResult, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
import type { ModerationThreadRuntime } from "./service.js";
|
|
3
|
+
export declare class ModerationThreadDelivery implements ModerationThreadRuntime {
|
|
4
|
+
deliver(input: Parameters<ModerationThreadRuntime["deliver"]>[0]): ReturnType<ModerationThreadRuntime["deliver"]>;
|
|
5
|
+
deliverPending(services: ServiceAccess, input: {
|
|
6
|
+
guildId: string;
|
|
7
|
+
caseId: string;
|
|
8
|
+
}): Promise<ScheduledTaskExecutionResult>;
|
|
9
|
+
reconcileAccess(services: ServiceAccess, guildId: string): Promise<ScheduledTaskExecutionResult>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=thread-delivery.d.ts.map
|