@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,226 @@
|
|
|
1
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { MODERATION_CASE_ACTIONS, MODERATION_MODULE_ID } from "./constants.js";
|
|
3
|
+
import { validateReason } from "./domain.js";
|
|
4
|
+
import { moderationCaseDetail, moderationCaseSummary } from "./presentation.js";
|
|
5
|
+
import { ModerationRepository, } from "./repository.js";
|
|
6
|
+
export function createModerationCasesResource(reconciler, caseActions) {
|
|
7
|
+
return {
|
|
8
|
+
id: "moderation-cases",
|
|
9
|
+
list: async (context, input) => {
|
|
10
|
+
const repository = new ModerationRepository(context.services);
|
|
11
|
+
const page = await repository.listCases(caseQuery(context.guildId, input));
|
|
12
|
+
return {
|
|
13
|
+
items: page.items.map(moderationCaseSummary),
|
|
14
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
read: async (context, resourceId) => {
|
|
18
|
+
const item = await new ModerationRepository(context.services).findCase(context.guildId, resourceId);
|
|
19
|
+
return item
|
|
20
|
+
? caseDetail(new ModerationRepository(context.services), item, false)
|
|
21
|
+
: null;
|
|
22
|
+
},
|
|
23
|
+
preview: () => unsupported("Cases cannot be previewed."),
|
|
24
|
+
create: () => unsupported("Cases are created by moderation actions."),
|
|
25
|
+
update: () => unsupported("Applied cases cannot be edited."),
|
|
26
|
+
delete: () => unsupported("Moderation cases cannot be deleted."),
|
|
27
|
+
executeAction: async (context, input) => {
|
|
28
|
+
const repository = new ModerationRepository(context.services);
|
|
29
|
+
const current = await repository.findCase(context.guildId, input.resourceId);
|
|
30
|
+
if (!current)
|
|
31
|
+
return null;
|
|
32
|
+
if (current.revision !== input.expectedRevision)
|
|
33
|
+
throw validation("This case changed in another session.");
|
|
34
|
+
if (Object.hasOwn(MODERATION_CASE_ACTIONS, input.actionId)) {
|
|
35
|
+
if (!caseActions)
|
|
36
|
+
throw validation("Moderation case actions are temporarily unavailable.");
|
|
37
|
+
await caseActions.execute(context, current, input.actionId, input.value);
|
|
38
|
+
return refreshedCase(repository, current, false);
|
|
39
|
+
}
|
|
40
|
+
const operationKey = `dashboard:${input.actionId}:${context.correlationId}:${current.caseId}`;
|
|
41
|
+
if (input.actionId === "reconcile") {
|
|
42
|
+
await reconciler.reconcileCase({
|
|
43
|
+
guildId: context.guildId,
|
|
44
|
+
caseId: current.caseId,
|
|
45
|
+
expectedRevision: current.revision,
|
|
46
|
+
actorUserId: context.actor.userId,
|
|
47
|
+
operationKey,
|
|
48
|
+
services: context.services,
|
|
49
|
+
});
|
|
50
|
+
return refreshedCase(repository, current, false);
|
|
51
|
+
}
|
|
52
|
+
if (input.actionId === "correct") {
|
|
53
|
+
const result = await atomic(context.services).createCorrectionCase({
|
|
54
|
+
operationKey,
|
|
55
|
+
guildId: context.guildId,
|
|
56
|
+
caseId: current.caseId,
|
|
57
|
+
expectedRevision: current.revision,
|
|
58
|
+
actorUserId: context.actor.userId,
|
|
59
|
+
reason: validateReason(input.value.correctionReason),
|
|
60
|
+
occurredAt: new Date(),
|
|
61
|
+
});
|
|
62
|
+
if (result.outcome === "rejected")
|
|
63
|
+
throw validation(actionFailure(result.code));
|
|
64
|
+
return refreshedCase(repository, current, false);
|
|
65
|
+
}
|
|
66
|
+
if (input.actionId === "disassociate") {
|
|
67
|
+
const result = await atomic(context.services).disassociateCase({
|
|
68
|
+
operationKey,
|
|
69
|
+
guildId: context.guildId,
|
|
70
|
+
caseId: current.caseId,
|
|
71
|
+
expectedRevision: current.revision,
|
|
72
|
+
actorUserId: context.actor.userId,
|
|
73
|
+
occurredAt: new Date(),
|
|
74
|
+
});
|
|
75
|
+
if (result.outcome === "rejected")
|
|
76
|
+
throw validation(actionFailure(result.code));
|
|
77
|
+
return refreshedCase(repository, current, false);
|
|
78
|
+
}
|
|
79
|
+
if (input.actionId === "reveal-private-note") {
|
|
80
|
+
await audit(context.services, {
|
|
81
|
+
guildId: context.guildId,
|
|
82
|
+
actorUserId: context.actor.userId,
|
|
83
|
+
action: "moderation.case.private-note-revealed",
|
|
84
|
+
correlationId: context.correlationId,
|
|
85
|
+
idempotencyKey: operationKey,
|
|
86
|
+
targetId: current.caseId,
|
|
87
|
+
});
|
|
88
|
+
return caseDetail(repository, current, true);
|
|
89
|
+
}
|
|
90
|
+
throw validation("Unknown moderation case action.");
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function refreshedCase(repository, previous, revealPrivateNote) {
|
|
95
|
+
const refreshed = await repository.findCase(previous.guildId, previous.caseId);
|
|
96
|
+
if (!refreshed)
|
|
97
|
+
throw validation("The updated case is unavailable.");
|
|
98
|
+
return caseDetail(repository, refreshed, revealPrivateNote);
|
|
99
|
+
}
|
|
100
|
+
async function caseDetail(repository, item, revealPrivateNote) {
|
|
101
|
+
const delivery = await repository.findThreadDelivery(item.guildId, item.caseId);
|
|
102
|
+
const availableActionIds = ["correct", "disassociate", "reveal-private-note"];
|
|
103
|
+
if (item.state === "failed" ||
|
|
104
|
+
item.state === "review" ||
|
|
105
|
+
(delivery &&
|
|
106
|
+
(delivery.caseRevision !== item.revision ||
|
|
107
|
+
delivery.safeCode?.startsWith("thread_control_ambiguous_"))))
|
|
108
|
+
availableActionIds.push("reconcile");
|
|
109
|
+
if (item.state === "applied" &&
|
|
110
|
+
item.subjectUserId &&
|
|
111
|
+
!item.disassociatedAt &&
|
|
112
|
+
(!delivery || delivery.caseRevision === item.revision) &&
|
|
113
|
+
(delivery?.resolutionState !== "closed" ||
|
|
114
|
+
delivery.closeState === "succeeded")) {
|
|
115
|
+
if (delivery?.resolutionState === "closed")
|
|
116
|
+
availableActionIds.push("close-and-delete-thread");
|
|
117
|
+
else if (delivery?.resolutionState === "resolved")
|
|
118
|
+
availableActionIds.push("close-thread", "close-and-delete-thread");
|
|
119
|
+
else {
|
|
120
|
+
availableActionIds.push("mute", "unmute", "kick", "ban", "demote");
|
|
121
|
+
if (delivery)
|
|
122
|
+
availableActionIds.push("no-further-action");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const detail = moderationCaseDetail(item, { revealPrivateNote });
|
|
126
|
+
return {
|
|
127
|
+
...detail,
|
|
128
|
+
availableActionIds,
|
|
129
|
+
value: {
|
|
130
|
+
...detail.value,
|
|
131
|
+
privateNoteState: item.privateNote
|
|
132
|
+
? revealPrivateNote
|
|
133
|
+
? "Revealed"
|
|
134
|
+
: "Hidden — use Reveal private note"
|
|
135
|
+
: "No private note recorded",
|
|
136
|
+
threadState: delivery?.resolutionState ?? "No violation thread",
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function caseQuery(guildId, input) {
|
|
141
|
+
const filters = input.filters ?? {};
|
|
142
|
+
const repeatOrdinal = optionalOrdinal(filters.repeatOrdinal);
|
|
143
|
+
const from = optionalDate(filters.from, "From");
|
|
144
|
+
const to = optionalDate(filters.to, "To");
|
|
145
|
+
return {
|
|
146
|
+
guildId,
|
|
147
|
+
limit: input.limit,
|
|
148
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
149
|
+
...(input.search?.trim() ? { search: input.search.trim() } : {}),
|
|
150
|
+
...(filters.actorUserId?.trim()
|
|
151
|
+
? { actorUserId: filters.actorUserId.trim() }
|
|
152
|
+
: {}),
|
|
153
|
+
...(filters.ruleId?.trim() ? { ruleId: filters.ruleId.trim() } : {}),
|
|
154
|
+
...(repeatOrdinal ? { repeatOrdinal } : {}),
|
|
155
|
+
...(isAction(filters.action) ? { action: filters.action } : {}),
|
|
156
|
+
...(isState(filters.state) ? { state: filters.state } : {}),
|
|
157
|
+
...(from ? { from } : {}),
|
|
158
|
+
...(to ? { to } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function atomic(services) {
|
|
162
|
+
return services.get(HELYX_SERVICE_NAMES.atomicModerationCases);
|
|
163
|
+
}
|
|
164
|
+
async function audit(services, input) {
|
|
165
|
+
await services.get(HELYX_SERVICE_NAMES.audit).append({
|
|
166
|
+
...input,
|
|
167
|
+
source: "dashboard",
|
|
168
|
+
moduleId: MODERATION_MODULE_ID,
|
|
169
|
+
targetType: "moderation-case",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function actionFailure(code) {
|
|
173
|
+
if (code === "not_found")
|
|
174
|
+
return "This case no longer exists.";
|
|
175
|
+
if (code === "revision_conflict")
|
|
176
|
+
return "This case changed in another session.";
|
|
177
|
+
if (code === "case_not_correctable")
|
|
178
|
+
return "This case cannot be corrected in its current state.";
|
|
179
|
+
return "The case action could not be completed safely.";
|
|
180
|
+
}
|
|
181
|
+
function optionalDate(value, label) {
|
|
182
|
+
if (!value)
|
|
183
|
+
return undefined;
|
|
184
|
+
const parsed = new Date(value);
|
|
185
|
+
if (!Number.isFinite(parsed.getTime()))
|
|
186
|
+
throw validation(`${label} must be a valid date.`);
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
function optionalOrdinal(value) {
|
|
190
|
+
if (!value)
|
|
191
|
+
return undefined;
|
|
192
|
+
if (value === "1" || value === "2" || value === "3")
|
|
193
|
+
return Number(value);
|
|
194
|
+
throw validation("Offence must be first, second or third and later.");
|
|
195
|
+
}
|
|
196
|
+
function isAction(value) {
|
|
197
|
+
return [
|
|
198
|
+
"warn",
|
|
199
|
+
"timeout",
|
|
200
|
+
"timeout_remove",
|
|
201
|
+
"kick",
|
|
202
|
+
"ban",
|
|
203
|
+
"demote",
|
|
204
|
+
"unban",
|
|
205
|
+
"delete_message",
|
|
206
|
+
"case_only",
|
|
207
|
+
].includes(value ?? "");
|
|
208
|
+
}
|
|
209
|
+
function isState(value) {
|
|
210
|
+
return [
|
|
211
|
+
"pending",
|
|
212
|
+
"applied",
|
|
213
|
+
"failed",
|
|
214
|
+
"review",
|
|
215
|
+
"rejected",
|
|
216
|
+
"corrected",
|
|
217
|
+
"void",
|
|
218
|
+
].includes(value ?? "");
|
|
219
|
+
}
|
|
220
|
+
function unsupported(message) {
|
|
221
|
+
throw validation(message);
|
|
222
|
+
}
|
|
223
|
+
function validation(message) {
|
|
224
|
+
return new DashboardActionValidationError(message);
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=moderation-cases-resource.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ManagedResourceDetail, ManagedResourceSummary, MessageResponse } from "@helyx/sdk";
|
|
2
|
+
import type { StoredModerationCase } from "./repository.js";
|
|
3
|
+
export declare function moderationCaseSummary(item: StoredModerationCase): ManagedResourceSummary;
|
|
4
|
+
export declare function moderationCaseDetail(item: StoredModerationCase, options?: {
|
|
5
|
+
revealPrivateNote?: boolean;
|
|
6
|
+
}): ManagedResourceDetail;
|
|
7
|
+
export declare function moderationEphemeral(title: string, description: string): MessageResponse;
|
|
8
|
+
//# sourceMappingURL=presentation.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { formatModerationCaseNumber } from "./case-naming.js";
|
|
2
|
+
export function moderationCaseSummary(item) {
|
|
3
|
+
return {
|
|
4
|
+
id: item.caseId,
|
|
5
|
+
revision: item.revision,
|
|
6
|
+
status: item.state,
|
|
7
|
+
title: `Case #${formatModerationCaseNumber(item.caseNumber)}`,
|
|
8
|
+
description: `${actionLabel(item.action)} · ${sourceLabel(item.source)}`,
|
|
9
|
+
attributes: {
|
|
10
|
+
subjectUserId: item.subjectUserId ?? "Disassociated",
|
|
11
|
+
actorUserId: item.actorUserId ?? "Unavailable",
|
|
12
|
+
action: actionLabel(item.action),
|
|
13
|
+
repeatOrdinal: item.repeatOffenceOrdinal
|
|
14
|
+
? ordinalLabel(item.repeatOffenceOrdinal)
|
|
15
|
+
: "Not a repeat ladder",
|
|
16
|
+
},
|
|
17
|
+
updatedAt: item.updatedAt.toISOString(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function moderationCaseDetail(item, options = {}) {
|
|
21
|
+
const summary = moderationCaseSummary(item);
|
|
22
|
+
return {
|
|
23
|
+
...summary,
|
|
24
|
+
value: {
|
|
25
|
+
caseNumber: item.caseNumber,
|
|
26
|
+
subjectUserId: item.subjectUserId,
|
|
27
|
+
actorUserId: item.actorUserId,
|
|
28
|
+
action: actionLabel(item.action),
|
|
29
|
+
reason: item.reason,
|
|
30
|
+
privateNote: options.revealPrivateNote ? item.privateNote : null,
|
|
31
|
+
source: sourceLabel(item.source),
|
|
32
|
+
state: stateLabel(item.state),
|
|
33
|
+
safeOutcome: item.safeOutcome,
|
|
34
|
+
repeatOrdinal: item.repeatOffenceOrdinal,
|
|
35
|
+
relatedCaseId: item.relatedCaseId,
|
|
36
|
+
createdAt: item.createdAt.toISOString(),
|
|
37
|
+
settledAt: item.settledAt?.toISOString() ?? null,
|
|
38
|
+
disassociatedAt: item.disassociatedAt?.toISOString() ?? null,
|
|
39
|
+
retentionExpiresAt: item.retentionExpiresAt?.toISOString() ?? null,
|
|
40
|
+
retentionProcessedAt: item.retentionProcessedAt?.toISOString() ?? null,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function moderationEphemeral(title, description) {
|
|
45
|
+
return {
|
|
46
|
+
ephemeral: true,
|
|
47
|
+
embed: { title, description, color: 0x7c3aed },
|
|
48
|
+
allowedUserMentionIds: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function actionLabel(action) {
|
|
52
|
+
if (action === "timeout_remove")
|
|
53
|
+
return "Timeout removed";
|
|
54
|
+
if (action === "delete_message")
|
|
55
|
+
return "Message deleted";
|
|
56
|
+
if (action === "case_only")
|
|
57
|
+
return "Case only";
|
|
58
|
+
return action.charAt(0).toUpperCase() + action.slice(1);
|
|
59
|
+
}
|
|
60
|
+
function sourceLabel(source) {
|
|
61
|
+
return source === "staff_command" ? "Staff command" : "Auto Moderation";
|
|
62
|
+
}
|
|
63
|
+
function stateLabel(state) {
|
|
64
|
+
return state.charAt(0).toUpperCase() + state.slice(1).replaceAll("_", " ");
|
|
65
|
+
}
|
|
66
|
+
function ordinalLabel(ordinal) {
|
|
67
|
+
return ordinal === 1 ? "First" : ordinal === 2 ? "Second" : "Third or later";
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=presentation.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type PrivacyProvider, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
export declare function createModerationPrivacyProvider(input: {
|
|
3
|
+
id: string;
|
|
4
|
+
declaration: ReturnType<PrivacyProvider["describe"]>;
|
|
5
|
+
executor: PrivacyProviderExecutor;
|
|
6
|
+
services?: ServiceAccess;
|
|
7
|
+
}): PrivacyProvider;
|
|
8
|
+
//# sourceMappingURL=privacy.d.ts.map
|
package/dist/privacy.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES, createPrivacyProvider, } from "@helyx/sdk";
|
|
2
|
+
export function createModerationPrivacyProvider(input) {
|
|
3
|
+
const generic = createPrivacyProvider({
|
|
4
|
+
id: input.id,
|
|
5
|
+
declaration: input.declaration,
|
|
6
|
+
executor: input.executor,
|
|
7
|
+
});
|
|
8
|
+
const indirect = getIndirectService(input.services);
|
|
9
|
+
return {
|
|
10
|
+
id: generic.id,
|
|
11
|
+
version: generic.version,
|
|
12
|
+
describe: () => generic.describe(),
|
|
13
|
+
captureAccess: (request) => indirect
|
|
14
|
+
? indirect.captureAccess(request)
|
|
15
|
+
: Promise.resolve({
|
|
16
|
+
outcome: "manual_review",
|
|
17
|
+
items: [],
|
|
18
|
+
}),
|
|
19
|
+
erase: (request) => indirect
|
|
20
|
+
? indirect.erase(request)
|
|
21
|
+
: Promise.resolve({
|
|
22
|
+
outcome: "manual_review",
|
|
23
|
+
results: input.declaration.collections.map(({ name }) => ({
|
|
24
|
+
collection: name,
|
|
25
|
+
outcome: "manual_review",
|
|
26
|
+
affectedRecords: 0,
|
|
27
|
+
reasonCode: "moderation_privacy_service_unavailable",
|
|
28
|
+
})),
|
|
29
|
+
}),
|
|
30
|
+
assessCorrection: (request) => generic.assessCorrection(request),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function getIndirectService(services) {
|
|
34
|
+
if (!services?.has(HELYX_SERVICE_NAMES.moderationCasePrivacy))
|
|
35
|
+
return null;
|
|
36
|
+
return services.get(HELYX_SERVICE_NAMES.moderationCasePrivacy);
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=privacy.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ModerationActionProviderContributionV2 } from "@helyx/sdk";
|
|
2
|
+
import type { ModerationService } from "./service.js";
|
|
3
|
+
export declare function createModerationActionProvider(runtime: Pick<ModerationService, "dispatchAction" | "deliverViolationThread">): ModerationActionProviderContributionV2;
|
|
4
|
+
//# sourceMappingURL=provider.d.ts.map
|