@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +725 -0
  3. package/README.md +107 -0
  4. package/dist/action-support.d.ts +48 -0
  5. package/dist/action-support.js +201 -0
  6. package/dist/case-actions.d.ts +9 -0
  7. package/dist/case-actions.js +311 -0
  8. package/dist/case-naming.d.ts +4 -0
  9. package/dist/case-naming.js +9 -0
  10. package/dist/case-repository.d.ts +29 -0
  11. package/dist/case-repository.js +199 -0
  12. package/dist/channel-actions.d.ts +3 -0
  13. package/dist/channel-actions.js +267 -0
  14. package/dist/commands.d.ts +4 -0
  15. package/dist/commands.js +309 -0
  16. package/dist/components.d.ts +15 -0
  17. package/dist/components.js +381 -0
  18. package/dist/configuration.d.ts +16 -0
  19. package/dist/configuration.js +94 -0
  20. package/dist/constants.d.ts +53 -0
  21. package/dist/constants.js +53 -0
  22. package/dist/contracts.d.ts +109 -0
  23. package/dist/contracts.js +84 -0
  24. package/dist/domain.d.ts +29 -0
  25. package/dist/domain.js +101 -0
  26. package/dist/events.d.ts +3 -0
  27. package/dist/events.js +60 -0
  28. package/dist/health.d.ts +10 -0
  29. package/dist/health.js +56 -0
  30. package/dist/index.d.ts +9 -0
  31. package/dist/index.js +92 -0
  32. package/dist/moderation-cases-resource.d.ts +14 -0
  33. package/dist/moderation-cases-resource.js +226 -0
  34. package/dist/presentation.d.ts +8 -0
  35. package/dist/presentation.js +69 -0
  36. package/dist/privacy.d.ts +8 -0
  37. package/dist/privacy.js +38 -0
  38. package/dist/provider.d.ts +4 -0
  39. package/dist/provider.js +335 -0
  40. package/dist/receipt-repository.d.ts +9 -0
  41. package/dist/receipt-repository.js +36 -0
  42. package/dist/records.d.ts +399 -0
  43. package/dist/records.js +303 -0
  44. package/dist/repository-model.d.ts +131 -0
  45. package/dist/repository-model.js +318 -0
  46. package/dist/repository.d.ts +21 -0
  47. package/dist/repository.js +41 -0
  48. package/dist/service.d.ts +75 -0
  49. package/dist/service.js +329 -0
  50. package/dist/tasks.d.ts +49 -0
  51. package/dist/tasks.js +391 -0
  52. package/dist/thread-controls.d.ts +23 -0
  53. package/dist/thread-controls.js +376 -0
  54. package/dist/thread-deletion-recovery.d.ts +13 -0
  55. package/dist/thread-deletion-recovery.js +46 -0
  56. package/dist/thread-delivery.d.ts +11 -0
  57. package/dist/thread-delivery.js +427 -0
  58. package/dist/thread-reconciliation.d.ts +24 -0
  59. package/dist/thread-reconciliation.js +181 -0
  60. package/dist/thread-repository.d.ts +16 -0
  61. package/dist/thread-repository.js +70 -0
  62. package/manifest.json +999 -0
  63. package/migrations/0001_moderation_foundation.sql +552 -0
  64. package/migrations/0002_staff_attempt_parameters.sql +27 -0
  65. package/package.json +56 -0
@@ -0,0 +1,267 @@
1
+ import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { parseModerationConfiguration } from "./configuration.js";
3
+ import { MODERATION_MODULE_ID } from "./constants.js";
4
+ export function createModerationChannelActions() {
5
+ return [
6
+ {
7
+ id: "check-violation-channel",
8
+ async execute(context) {
9
+ const result = await inspectChannel(context.guildId, context.services);
10
+ return {
11
+ title: result.outcome === "allowed"
12
+ ? "Channel check complete"
13
+ : "Channel needs attention",
14
+ description: changes(result),
15
+ };
16
+ },
17
+ },
18
+ {
19
+ id: "configure-violation-channel",
20
+ async inspect(context) {
21
+ const result = await inspectChannel(context.guildId, context.services);
22
+ return {
23
+ title: result.outcome === "allowed"
24
+ ? "Review channel changes"
25
+ : "Channel cannot be configured yet",
26
+ description: changes(result),
27
+ inspectionRevision: result.configurationRevision ?? "unavailable",
28
+ };
29
+ },
30
+ async execute(context, input) {
31
+ if (!input?.inspectionRevision)
32
+ throw new DashboardActionValidationError("Check the channel again before configuring it.");
33
+ let configuration = await configuredChannel(context.guildId, context.services);
34
+ const checked = await inspectChannel(context.guildId, context.services);
35
+ if (checked.outcome !== "allowed")
36
+ throw new DashboardActionValidationError(changes(checked));
37
+ if (checked.configurationRevision !== input.inspectionRevision)
38
+ throw new DashboardActionValidationError("The channel changed after the preview. Check it again.");
39
+ let inspectionRevision = input.inspectionRevision;
40
+ if (!configuration.accessRoleId) {
41
+ configuration = await createAndPersistAccessRole(context, configuration);
42
+ const withRole = await inspectChannel(context.guildId, context.services);
43
+ if (!withRole.configurationRevision)
44
+ throw new DashboardActionValidationError("The new access role could not be checked safely.");
45
+ if (withRole.outcome !== "allowed")
46
+ throw new DashboardActionValidationError(changes(withRole));
47
+ if (withRole.requiredChanges.some((change) => !checked.requiredChanges.includes(change)))
48
+ throw new DashboardActionValidationError("The required channel changes changed while the access role was being created. Check the channel again before configuring it.");
49
+ inspectionRevision = withRole.configurationRevision;
50
+ }
51
+ if (!configuration.accessRoleId)
52
+ throw new DashboardActionValidationError("The violation access role is unavailable.");
53
+ const result = await context.services
54
+ .get(HELYX_SERVICE_NAMES.violationThreads)
55
+ .configureParent({
56
+ guildId: context.guildId,
57
+ parentChannelId: configuration.channelId,
58
+ accessRoleId: configuration.accessRoleId,
59
+ moderatorRoleIds: configuration.moderatorRoleIds,
60
+ moderatorUserIds: configuration.moderatorUserIds,
61
+ expectedConfigurationRevision: inspectionRevision,
62
+ operationKey: `dashboard:${context.correlationId}:configure-violation-channel`,
63
+ revalidate: async () => {
64
+ if (!(await context.revalidate()))
65
+ return false;
66
+ const current = await configuredChannel(context.guildId, context.services);
67
+ return sameConfiguration(configuration, current);
68
+ },
69
+ });
70
+ if (result.outcome !== "configured" &&
71
+ result.outcome !== "already_configured")
72
+ throw new DashboardActionValidationError(configurationFailure(result));
73
+ await context.services
74
+ .get(HELYX_SERVICE_NAMES.audit)
75
+ .append({
76
+ guildId: context.guildId,
77
+ actorUserId: context.actorUserId,
78
+ action: "moderation.violation-channel.configured",
79
+ source: "dashboard",
80
+ correlationId: context.correlationId,
81
+ idempotencyKey: `dashboard:${context.correlationId}:configure-audit`,
82
+ moduleId: MODERATION_MODULE_ID,
83
+ targetType: "discord_channel",
84
+ targetId: configuration.channelId,
85
+ metadata: {
86
+ accessRoleId: configuration.accessRoleId,
87
+ configurationRevision: result.configurationRevision,
88
+ },
89
+ });
90
+ return {
91
+ title: "Violation channel configured",
92
+ description: result.appliedChanges.join("\n") ||
93
+ "The channel was already configured.",
94
+ };
95
+ },
96
+ },
97
+ ];
98
+ }
99
+ async function inspectChannel(guildId, services) {
100
+ const configuration = await configuredChannel(guildId, services);
101
+ return services
102
+ .get(HELYX_SERVICE_NAMES.violationThreads)
103
+ .inspectConfiguration({
104
+ guildId,
105
+ parentChannelId: configuration.channelId,
106
+ accessRoleId: configuration.accessRoleId,
107
+ moderatorRoleIds: configuration.moderatorRoleIds,
108
+ moderatorUserIds: configuration.moderatorUserIds,
109
+ });
110
+ }
111
+ async function configuredChannel(guildId, services) {
112
+ const stored = await services
113
+ .get(HELYX_SERVICE_NAMES.configuration)
114
+ .get(guildId, MODERATION_MODULE_ID);
115
+ const configuration = parseModerationConfiguration(stored?.value);
116
+ if (!configuration.violationThreadChannelId)
117
+ throw new DashboardActionValidationError("Select a violation thread channel in Moderation settings first.");
118
+ return {
119
+ stored,
120
+ value: configuration,
121
+ channelId: configuration.violationThreadChannelId,
122
+ accessRoleId: configuration.violationAccessRoleId,
123
+ moderatorRoleIds: [...configuration.violationModeratorRoleIds],
124
+ moderatorUserIds: [...configuration.violationModeratorUserIds],
125
+ };
126
+ }
127
+ async function createAndPersistAccessRole(context, checked) {
128
+ const created = await context.services
129
+ .get(HELYX_SERVICE_NAMES.violationThreads)
130
+ .createAccessRole({
131
+ guildId: context.guildId,
132
+ name: "Violation",
133
+ operationKey: `moderation:${context.guildId}:violation-access-role`,
134
+ revalidate: async () => {
135
+ if (!(await context.revalidate()))
136
+ return false;
137
+ return sameConfiguration(checked, await configuredChannel(context.guildId, context.services));
138
+ },
139
+ });
140
+ if (created.outcome !== "created")
141
+ throw new DashboardActionValidationError(created.outcome === "missing_permission"
142
+ ? "Discord denied creation of the Violation role. Check the bot's Manage Roles permission and role hierarchy, then check the channel again."
143
+ : created.outcome === "no_longer_required"
144
+ ? "Your access or Moderation settings changed before the Violation role could be created. Reload the settings, then check the channel again."
145
+ : "Creation of the Violation role could not be confirmed. It may already exist; check the channel again before retrying so Helyx can recover it safely.");
146
+ return persistAccessRole(context, created.roleId, checked);
147
+ }
148
+ async function persistAccessRole(context, roleId, checked) {
149
+ const service = context.services.get(HELYX_SERVICE_NAMES.configuration);
150
+ for (let attempt = 0; attempt < 2; attempt += 1) {
151
+ const current = await configuredChannel(context.guildId, context.services);
152
+ if (current.accessRoleId) {
153
+ if (current.accessRoleId === roleId &&
154
+ sameChannelAndModerators(current, checked))
155
+ return current;
156
+ throw staleChannelConfiguration();
157
+ }
158
+ if (!sameConfiguration(current, checked))
159
+ throw staleChannelConfiguration();
160
+ if (!(await context.revalidate()))
161
+ throw new DashboardActionValidationError("Access changed before the role could be saved.");
162
+ try {
163
+ await service.update({
164
+ guildId: context.guildId,
165
+ moduleId: MODERATION_MODULE_ID,
166
+ value: { ...current.value, violationAccessRoleId: roleId },
167
+ expectedVersion: current.stored?.version ?? null,
168
+ context: {
169
+ actorUserId: context.actorUserId,
170
+ correlationId: context.correlationId,
171
+ source: "dashboard",
172
+ },
173
+ });
174
+ return configuredChannel(context.guildId, context.services);
175
+ }
176
+ catch (error) {
177
+ const afterConflict = await configuredChannel(context.guildId, context.services);
178
+ if (afterConflict.accessRoleId) {
179
+ if (afterConflict.accessRoleId === roleId &&
180
+ sameChannelAndModerators(afterConflict, checked))
181
+ return afterConflict;
182
+ throw staleChannelConfiguration();
183
+ }
184
+ if (!sameConfiguration(afterConflict, checked))
185
+ throw staleChannelConfiguration();
186
+ if (attempt === 1)
187
+ throw error;
188
+ }
189
+ }
190
+ throw new DashboardActionValidationError("The Violation role could not be saved safely.");
191
+ }
192
+ function staleChannelConfiguration() {
193
+ return new DashboardActionValidationError("Moderation settings changed after the preview. Check the channel again.");
194
+ }
195
+ function sameConfiguration(left, right) {
196
+ return (left.channelId === right.channelId &&
197
+ left.accessRoleId === right.accessRoleId &&
198
+ left.moderatorRoleIds.join(",") === right.moderatorRoleIds.join(",") &&
199
+ left.moderatorUserIds.join(",") === right.moderatorUserIds.join(","));
200
+ }
201
+ function sameChannelAndModerators(left, right) {
202
+ return (left.channelId === right.channelId &&
203
+ left.moderatorRoleIds.join(",") === right.moderatorRoleIds.join(",") &&
204
+ left.moderatorUserIds.join(",") === right.moderatorUserIds.join(","));
205
+ }
206
+ function changes(result) {
207
+ if (result.outcome !== "allowed") {
208
+ if (result.outcome === "channel_missing")
209
+ return "The selected channel is unavailable or is not a standard text channel. Select an accessible text channel, then check it again.";
210
+ if (result.missingPermissions.length)
211
+ return `The bot is missing these permissions: ${result.missingPermissions.map(permissionLabel).join(", ")}. Grant them, then check the channel again.`;
212
+ if (result.outcome === "role_missing")
213
+ return "A selected access or moderator role is unavailable. Update the selected roles, then check the channel again.";
214
+ if (result.outcome === "unsafe_access_role")
215
+ return "The access role is not suitable: it must be an unmanaged role below the bot, without moderation permissions or access to other private channels. Select a dedicated safe role, then check the channel again.";
216
+ if (result.safeCode === "moderator_role_unsafe")
217
+ return "Moderator roles cannot include @everyone or the violation access role. Update the moderator roles, then check the channel again.";
218
+ if (result.safeCode === "moderator_user_unavailable")
219
+ return "A notification user is unavailable or is not a human member of this server. Update the selected users, then check the channel again.";
220
+ }
221
+ const items = result.requiredChanges.map((change) => change === "create_access_role"
222
+ ? "Create a dedicated Violation access role."
223
+ : configurationChangeLabel(change));
224
+ if (items.length)
225
+ return items.join("\n");
226
+ return result.outcome === "allowed"
227
+ ? "No changes are required."
228
+ : "The configured channel, access role, or bot permissions are not suitable.";
229
+ }
230
+ function permissionLabel(name) {
231
+ return name.toLowerCase().replaceAll("_", " ");
232
+ }
233
+ function configurationChangeLabel(change) {
234
+ if (change === "deny_everyone_view_channel")
235
+ return "Hide the parent channel from @everyone.";
236
+ if (change === "deny_access_role_parent_messages")
237
+ return "Prevent the violation access role from sending messages in the parent channel.";
238
+ const match = /^allow_(access_role|bot|moderator_user_\d{17,20}|moderator_\d{17,20})_(.+)$/u.exec(change);
239
+ if (!match)
240
+ return "Update the reviewed channel permissions.";
241
+ const target = match[1].startsWith("moderator_user_")
242
+ ? `moderator user ${match[1].slice("moderator_user_".length)}`
243
+ : match[1].startsWith("moderator_")
244
+ ? `moderator role ${match[1].slice("moderator_".length)}`
245
+ : match[1] === "bot"
246
+ ? "the bot"
247
+ : "the violation access role";
248
+ return `Allow ${target} to ${permissionLabel(match[2])}.`;
249
+ }
250
+ function configurationFailure(result) {
251
+ if (result.outcome === "stale_inspection")
252
+ return "The channel or roles changed after the check. Check the channel again before configuring it.";
253
+ if (result.outcome === "channel_missing")
254
+ return "The selected text channel is unavailable. Check the channel and the bot's access, then check it again.";
255
+ if (result.outcome === "role_missing")
256
+ return "A selected access or moderator role is unavailable. Update the selected roles, then check the channel again.";
257
+ if (result.outcome === "unsafe_access_role")
258
+ return "The access role is no longer suitable. Select a dedicated safe role, then check the channel again.";
259
+ if (result.outcome === "missing_permission")
260
+ return "Discord denied the channel changes. Check the bot's Manage Channels and Manage Roles permissions and role hierarchy. Some changes may already have applied; check the channel again before retrying.";
261
+ if (result.safeCode === "revalidation_rejected")
262
+ return "Your access or Moderation settings changed before configuration. Reload the settings, then check the channel again.";
263
+ if (result.safeCode === "configuration_recheck_failed")
264
+ return "Discord accepted the permission requests, but the final channel check did not confirm a usable configuration. Some changes may already have applied; check the channel again before retrying.";
265
+ return "The channel changes could not be fully confirmed. Some changes may already have applied; check the channel again before retrying.";
266
+ }
267
+ //# sourceMappingURL=channel-actions.js.map
@@ -0,0 +1,4 @@
1
+ import { type CommandContribution } from "@helyx/sdk";
2
+ import type { ModerationService } from "./service.js";
3
+ export declare function createModerationCommands(service: ModerationService): readonly CommandContribution[];
4
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1,309 @@
1
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { MODERATION_COMMAND_IDS, MODERATION_MODULE_ID } from "./constants.js";
3
+ import { validateBanDeleteMessageSeconds, validateDemotionRoleIds, validateEvidenceReference, validatePrivateNote, validateReason, validateTimeoutSeconds, } from "./domain.js";
4
+ import { requestModerationConfirmation } from "./components.js";
5
+ import { ModerationRepository } from "./repository.js";
6
+ const commonOptions = [
7
+ {
8
+ type: "user",
9
+ name: "member",
10
+ description: "Member to moderate.",
11
+ required: true,
12
+ },
13
+ {
14
+ type: "string",
15
+ name: "reason",
16
+ description: "Private moderation reason (10-500 characters).",
17
+ required: true,
18
+ minLength: 10,
19
+ maxLength: 500,
20
+ },
21
+ {
22
+ type: "string",
23
+ name: "message-link",
24
+ description: "Optional same-server Discord message link.",
25
+ },
26
+ {
27
+ type: "string",
28
+ name: "private-note",
29
+ description: "Optional staff-only note.",
30
+ maxLength: 1_000,
31
+ },
32
+ ];
33
+ export function createModerationCommands(service) {
34
+ return [
35
+ {
36
+ name: "mod",
37
+ description: "Apply accountable moderation actions and review cases.",
38
+ registrationPolicy: "enabled-guild",
39
+ contexts: ["guild"],
40
+ integrationTypes: ["guild_install"],
41
+ subcommands: [
42
+ actionCommand(service, "warn", "Warn a member and create a case."),
43
+ actionCommand(service, "timeout", "Temporarily stop a member from speaking.", [
44
+ ...commonOptions,
45
+ {
46
+ type: "integer",
47
+ name: "duration-minutes",
48
+ description: "Timeout duration in minutes (1-40320).",
49
+ required: true,
50
+ minimum: 1,
51
+ maximum: 40_320,
52
+ },
53
+ ]),
54
+ actionCommand(service, "timeout-remove", "Remove a member timeout."),
55
+ actionCommand(service, "kick", "Remove a member from this server."),
56
+ actionCommand(service, "ban", "Ban a user from this server.", [
57
+ ...commonOptions,
58
+ {
59
+ type: "string",
60
+ name: "delete-messages",
61
+ description: "How much recent message history Discord should delete.",
62
+ choices: [
63
+ ["Do not delete", "0"],
64
+ ["1 hour", "3600"],
65
+ ["6 hours", "21600"],
66
+ ["24 hours", "86400"],
67
+ ["3 days", "259200"],
68
+ ["7 days", "604800"],
69
+ ].map(([name, value]) => ({ name: name, value: value })),
70
+ },
71
+ ]),
72
+ actionCommand(service, "demote", "Remove configured roles from a member.", [
73
+ ...commonOptions,
74
+ ...Array.from({ length: 10 }, (_, index) => ({
75
+ type: "role",
76
+ name: `role-${index + 1}`,
77
+ description: index === 0
78
+ ? "Role to remove."
79
+ : `Additional role ${index + 1} to remove.`,
80
+ required: index === 0,
81
+ })),
82
+ ]),
83
+ actionCommand(service, "unban", "Remove a user ban."),
84
+ {
85
+ name: "history",
86
+ description: "Show recent moderation cases for a member.",
87
+ acknowledgement: "deferred-ephemeral",
88
+ authorization: "resource-scoped",
89
+ permissionId: MODERATION_COMMAND_IDS.history,
90
+ rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
91
+ options: [commonOptions[0]],
92
+ execute: (context) => executeHistory(context),
93
+ },
94
+ ],
95
+ },
96
+ ];
97
+ }
98
+ function actionCommand(service, name, description, options = commonOptions) {
99
+ const permissionId = commandPermission(name);
100
+ return {
101
+ name,
102
+ description,
103
+ acknowledgement: "deferred-ephemeral",
104
+ authorization: "resource-scoped",
105
+ permissionId,
106
+ rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
107
+ options,
108
+ execute: (context) => executeAction(service, context, name, permissionId),
109
+ };
110
+ }
111
+ async function executeAction(service, context, name, permissionId) {
112
+ try {
113
+ const guildId = requireGuild(context.serverId);
114
+ if (!(await consumeGuildLimit(context, permissionId, 30)))
115
+ return;
116
+ const target = context.getUserOption("member");
117
+ if (!target)
118
+ throw new Error("Choose a Discord user.");
119
+ const privateNote = validatePrivateNote(context.getStringOption("private-note"));
120
+ const evidenceMessage = sameGuildEvidence(context, guildId);
121
+ const draft = {
122
+ operationKey: `discord:${context.interactionId}`,
123
+ guildId,
124
+ actor: permissionActor(context),
125
+ permissionId,
126
+ action: caseAction(name),
127
+ subjectUserId: target.id,
128
+ subjectDisplayName: target.displayName,
129
+ reason: validateReason(context.getStringOption("reason")),
130
+ ...(privateNote ? { privateNote } : {}),
131
+ ...(evidenceMessage ? { evidenceMessage } : {}),
132
+ ...(name === "timeout"
133
+ ? {
134
+ requestedDurationSeconds: validateTimeoutSeconds((context.getIntegerOption?.("duration-minutes") ?? 0) * 60),
135
+ }
136
+ : {}),
137
+ ...(name === "ban"
138
+ ? {
139
+ deleteMessageSeconds: validateBanDeleteMessageSeconds(Number(context.getStringOption("delete-messages") ?? "0")),
140
+ }
141
+ : {}),
142
+ ...(name === "demote" ? { demoteRoleIds: demotionRoleIds(context) } : {}),
143
+ };
144
+ if (!(await service.canExecuteStaffAction(context.services, draft)))
145
+ throw new Error("The action is no longer allowed. Check access, membership and role hierarchy.");
146
+ if (requiresConfirmation(draft)) {
147
+ await requestModerationConfirmation(context, draft, service);
148
+ return;
149
+ }
150
+ await context.reply(resultMessage(await service.executeStaffAction(context.services, draft)));
151
+ }
152
+ catch (error) {
153
+ await context.reply(errorMessage(error));
154
+ }
155
+ }
156
+ async function executeHistory(context) {
157
+ const guildId = requireGuild(context.serverId);
158
+ if (!(await consumeGuildLimit(context, MODERATION_COMMAND_IDS.history, 60)))
159
+ return;
160
+ const target = context.getUserOption("member");
161
+ if (!target)
162
+ throw new Error("Choose a Discord user.");
163
+ const page = await new ModerationRepository(context.services).listCases({
164
+ guildId,
165
+ search: target.id,
166
+ limit: 10,
167
+ });
168
+ await context.reply({
169
+ ephemeral: true,
170
+ componentsV2: {
171
+ text: [
172
+ {
173
+ content: page.items.length === 0
174
+ ? `No moderation cases were found for ${target.displayName}.`
175
+ : [
176
+ `Recent moderation cases for ${target.displayName}:`,
177
+ ...page.items.map((item) => `#${item.caseNumber} · ${item.action.replace("_", " ")} · ${item.state} · ${item.createdAt.toISOString().slice(0, 10)}`),
178
+ "Private notes are available only through the separately permissioned dashboard reveal.",
179
+ ].join("\n"),
180
+ markdown: true,
181
+ },
182
+ ],
183
+ },
184
+ });
185
+ void guildId;
186
+ }
187
+ function commandPermission(name) {
188
+ return name === "timeout-remove"
189
+ ? MODERATION_COMMAND_IDS.timeoutRemove
190
+ : MODERATION_COMMAND_IDS[name];
191
+ }
192
+ function caseAction(name) {
193
+ return name === "timeout-remove"
194
+ ? "timeout_remove"
195
+ : name;
196
+ }
197
+ function requiresConfirmation(draft) {
198
+ return (draft.action === "kick" ||
199
+ draft.action === "ban" ||
200
+ draft.action === "demote" ||
201
+ draft.action === "unban" ||
202
+ (draft.action === "timeout" &&
203
+ (draft.requestedDurationSeconds ?? 0) > 86_400));
204
+ }
205
+ function sameGuildEvidence(context, guildId) {
206
+ const link = context.getStringOption("message-link");
207
+ if (!link)
208
+ return undefined;
209
+ const guildMatch = /^https:\/\/discord(?:app)?\.com\/channels\/(\d{17,20})\//u.exec(link.trim());
210
+ if (guildMatch?.[1] !== guildId)
211
+ throw new Error("The message link must belong to this server.");
212
+ return validateEvidenceReference(link);
213
+ }
214
+ function demotionRoleIds(context) {
215
+ return validateDemotionRoleIds(Array.from({ length: 10 }, (_, index) => context.getRoleOption(`role-${index + 1}`)?.id).filter((roleId) => Boolean(roleId)));
216
+ }
217
+ function permissionActor(context) {
218
+ return {
219
+ userId: context.userId,
220
+ roleIds: context.userRoleIds,
221
+ permissions: context.userPermissions ?? [],
222
+ isServerOwner: context.isServerOwner ?? false,
223
+ isAdministrator: context.isAdministrator,
224
+ ...(context.userHighestRolePosition !== undefined
225
+ ? { highestRolePosition: context.userHighestRolePosition }
226
+ : {}),
227
+ };
228
+ }
229
+ async function consumeGuildLimit(context, permissionId, limit) {
230
+ const guildId = requireGuild(context.serverId);
231
+ if (!context.services.has(HELYX_SERVICE_NAMES.rateLimits)) {
232
+ await context.reply(errorText("Moderation rate limits are unavailable."));
233
+ return false;
234
+ }
235
+ const result = await context.services
236
+ .get(HELYX_SERVICE_NAMES.rateLimits)
237
+ .consume({
238
+ scope: "guild",
239
+ subjectId: guildId,
240
+ action: `${MODERATION_MODULE_ID}:${permissionId}`,
241
+ limit,
242
+ windowSeconds: 60,
243
+ });
244
+ if (result.allowed)
245
+ return true;
246
+ await context.reply(errorText("This server is sending moderation actions too quickly. Try again shortly."));
247
+ return false;
248
+ }
249
+ function resultMessage(result) {
250
+ const caseLabel = result.caseNumber ? ` Case ${result.caseNumber}.` : "";
251
+ return {
252
+ ephemeral: true,
253
+ componentsV2: {
254
+ accentColor: result.outcome === "applied"
255
+ ? 0x16a34a
256
+ : result.outcome === "review_required"
257
+ ? 0xf59e0b
258
+ : 0xdc2626,
259
+ text: [
260
+ {
261
+ content: result.outcome === "applied"
262
+ ? `Moderation action applied.${caseLabel}`
263
+ : result.outcome === "review_required"
264
+ ? `The case needs staff review.${caseLabel}`
265
+ : `The action was not applied.${caseLabel}`,
266
+ markdown: false,
267
+ },
268
+ ],
269
+ },
270
+ };
271
+ }
272
+ function errorMessage(error) {
273
+ return errorText(error instanceof Error && safeCommandError(error.message)
274
+ ? error.message
275
+ : "Moderation is temporarily unavailable. Nothing was changed.");
276
+ }
277
+ function safeCommandError(message) {
278
+ return SAFE_COMMAND_ERRORS.has(message);
279
+ }
280
+ const SAFE_COMMAND_ERRORS = new Set([
281
+ "A reason is required.",
282
+ "The reason must be between 10 and 500 characters.",
283
+ "The private note is invalid.",
284
+ "The private note must be no more than 1,000 characters.",
285
+ "Timeout duration must be between 1 minute and 28 days.",
286
+ "The ban message deletion duration is invalid.",
287
+ "Choose between 1 and 10 valid roles to remove.",
288
+ "Demotion roles must be unique.",
289
+ "The message link is invalid.",
290
+ "The message link must belong to this server.",
291
+ "Choose a Discord user.",
292
+ "The action is no longer allowed. Check access, membership and role hierarchy.",
293
+ "Moderation commands can be used only in a server.",
294
+ ]);
295
+ function errorText(content) {
296
+ return {
297
+ ephemeral: true,
298
+ componentsV2: {
299
+ accentColor: 0xdc2626,
300
+ text: [{ content, markdown: false }],
301
+ },
302
+ };
303
+ }
304
+ function requireGuild(guildId) {
305
+ if (!guildId)
306
+ throw new Error("Moderation commands can be used only in a server.");
307
+ return guildId;
308
+ }
309
+ //# sourceMappingURL=commands.js.map
@@ -0,0 +1,15 @@
1
+ import { type InteractionContributions, type MessageResponse, type ModalCapableInteractionContext, type PermissionActor } from "@helyx/sdk";
2
+ import type { ModerationService, ModerationStaffDraft } from "./service.js";
3
+ export interface ModerationThreadControlRuntime {
4
+ executeControl(context: ModalCapableInteractionContext, action: "kick" | "ban" | "unmute" | "no_further_action" | "close" | "close_delete", controlToken: string): Promise<void>;
5
+ }
6
+ export declare function createModerationInteractions(service: ModerationService, threadControls: ModerationThreadControlRuntime): InteractionContributions;
7
+ export declare function requestModerationConfirmation(context: ModalCapableInteractionContext, draft: ModerationStaffDraft, _service: ModerationService): Promise<void>;
8
+ export declare function currentThreadActor(context: Pick<ModalCapableInteractionContext, "serverId" | "userId" | "services">, permissionId: string): Promise<PermissionActor | null>;
9
+ export declare function initialThreadControls(token: string, timedOut: boolean): MessageResponse;
10
+ export declare function closeThreadControl(token: string): MessageResponse;
11
+ export declare function notificationThreadMessage(roleIds: readonly string[], subjectUserId?: string, staffUserIds?: readonly string[]): MessageResponse;
12
+ export declare function safeViolationThreadMessage(content: string): MessageResponse;
13
+ export declare function violationOpeningMessage(roleIds: readonly string[], subjectUserId: string, opening: string, controls: MessageResponse, staffUserIds?: readonly string[]): MessageResponse;
14
+ export declare function privateThreadControlMessage(content: string): MessageResponse;
15
+ //# sourceMappingURL=components.d.ts.map