@helyx/bot 1.1.0 → 1.2.0

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 (85) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +16 -1
  3. package/dist/auto-moderation-scanner-registry.d.ts +27 -0
  4. package/dist/auto-moderation-scanner-registry.js +172 -0
  5. package/dist/cloud-connector.js +3 -0
  6. package/dist/cloud-control-dispatcher.js +32 -0
  7. package/dist/command-path-contract.js +1 -0
  8. package/dist/control-permissions.d.ts +2 -2
  9. package/dist/control-permissions.js +4 -2
  10. package/dist/control-server.d.ts +72 -16
  11. package/dist/control-server.js +150 -250
  12. package/dist/dashboard-action-control.d.ts +25 -0
  13. package/dist/dashboard-action-control.js +73 -0
  14. package/dist/dashboard-action-routes.d.ts +9 -0
  15. package/dist/dashboard-action-routes.js +36 -0
  16. package/dist/dashboard-feature-control.d.ts +37 -0
  17. package/dist/dashboard-feature-control.js +206 -0
  18. package/dist/dashboard-module-projection.d.ts +9 -0
  19. package/dist/dashboard-module-projection.js +15 -0
  20. package/dist/dashboard-preview-import-routes.d.ts +6 -0
  21. package/dist/dashboard-preview-import-routes.js +121 -0
  22. package/dist/discord-analytics.d.ts +3 -0
  23. package/dist/discord-analytics.js +49 -5
  24. package/dist/discord-auto-moderation-service.d.ts +21 -0
  25. package/dist/discord-auto-moderation-service.js +425 -0
  26. package/dist/discord-guild-health.d.ts +2 -2
  27. package/dist/discord-member-enforcement-service.d.ts +26 -0
  28. package/dist/discord-member-enforcement-service.js +590 -0
  29. package/dist/discord-message-enforcement-service.d.ts +9 -0
  30. package/dist/discord-message-enforcement-service.js +102 -0
  31. package/dist/discord-violation-thread-service.d.ts +33 -0
  32. package/dist/discord-violation-thread-service.js +789 -0
  33. package/dist/ecko-evidence-receiver.d.ts +32 -0
  34. package/dist/ecko-evidence-receiver.js +173 -0
  35. package/dist/ecko-transport-auth.d.ts +19 -0
  36. package/dist/ecko-transport-auth.js +80 -0
  37. package/dist/ecko-transport-routes.d.ts +19 -0
  38. package/dist/ecko-transport-routes.js +130 -0
  39. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  40. package/dist/embedded-auto-moderation-pool.js +324 -0
  41. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  42. package/dist/embedded-auto-moderation-scanner.js +263 -0
  43. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  44. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  45. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  46. package/dist/embedded-auto-moderation-worker.js +13 -0
  47. package/dist/events.d.ts +5 -1
  48. package/dist/events.js +243 -4
  49. package/dist/guilds.d.ts +2 -2
  50. package/dist/guilds.js +14 -8
  51. package/dist/hosted-server-moderation.d.ts +16 -5
  52. package/dist/hosted-server-moderation.js +32 -4
  53. package/dist/index.d.ts +1 -1
  54. package/dist/index.js +84 -14
  55. package/dist/moderation-action-registry.d.ts +11 -0
  56. package/dist/moderation-action-registry.js +375 -0
  57. package/dist/moderation-case-disassociation.d.ts +13 -0
  58. package/dist/moderation-case-disassociation.js +83 -0
  59. package/dist/moderation-platform-services.d.ts +27 -0
  60. package/dist/moderation-platform-services.js +47 -0
  61. package/dist/modules.d.ts +6 -0
  62. package/dist/modules.js +65 -3
  63. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  64. package/dist/privileged-message-metadata-cache.js +70 -0
  65. package/dist/scheduled-task-runner.js +24 -1
  66. package/dist/shard-contracts.d.ts +151 -1
  67. package/dist/shard-contracts.js +142 -1
  68. package/dist/shard-coordinator.d.ts +1 -1
  69. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  70. package/dist/shard-ecko-scanner-registry.js +96 -0
  71. package/dist/shard-router.d.ts +23 -0
  72. package/dist/shard-router.js +69 -5
  73. package/dist/shard-supervisor.d.ts +11 -0
  74. package/dist/shard-supervisor.js +67 -4
  75. package/dist/shard-worker.d.ts +15 -1
  76. package/dist/shard-worker.js +95 -17
  77. package/dist/transient-violation-evidence.d.ts +39 -0
  78. package/dist/transient-violation-evidence.js +132 -0
  79. package/documentation/README.md +2 -0
  80. package/documentation/examples/.env.example +12 -0
  81. package/documentation/guides/self-hosting.md +17 -1
  82. package/documentation/modules/auto-moderation.md +51 -0
  83. package/documentation/modules/moderation.md +42 -0
  84. package/documentation/reference/environment.md +11 -0
  85. package/package.json +17 -5
@@ -0,0 +1,789 @@
1
+ import { VIOLATION_THREAD_LIMITS, VIOLATION_THREAD_REQUIRED_PERMISSIONS, VIOLATION_THREAD_RUNTIME_PERMISSIONS, } from "@helyx/sdk";
2
+ import { ChannelType, DiscordAPIError, MessageFlags, PermissionFlagsBits, } from "discord.js";
3
+ import { createHash } from "node:crypto";
4
+ import { toReplyOptions } from "./interactions.js";
5
+ const SNOWFLAKE_PATTERN = /^\d{17,20}$/u;
6
+ const OPERATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
7
+ const MAX_OPERATION_CACHE_ENTRIES = 10_000;
8
+ const MAX_ARCHIVED_THREAD_PAGES = 20;
9
+ const UNKNOWN_THREAD = Symbol("unknown-thread");
10
+ const PERMISSION_FLAGS = Object.freeze({
11
+ VIEW_CHANNEL: PermissionFlagsBits.ViewChannel,
12
+ READ_MESSAGE_HISTORY: PermissionFlagsBits.ReadMessageHistory,
13
+ CREATE_PRIVATE_THREADS: PermissionFlagsBits.CreatePrivateThreads,
14
+ SEND_MESSAGES_IN_THREADS: PermissionFlagsBits.SendMessagesInThreads,
15
+ MANAGE_THREADS: PermissionFlagsBits.ManageThreads,
16
+ MANAGE_ROLES: PermissionFlagsBits.ManageRoles,
17
+ MANAGE_CHANNELS: PermissionFlagsBits.ManageChannels,
18
+ });
19
+ const ACCESS_PERMISSION_FLAGS = Object.freeze({
20
+ VIEW_CHANNEL: PermissionFlagsBits.ViewChannel,
21
+ READ_MESSAGE_HISTORY: PermissionFlagsBits.ReadMessageHistory,
22
+ SEND_MESSAGES_IN_THREADS: PermissionFlagsBits.SendMessagesInThreads,
23
+ });
24
+ const DANGEROUS_ROLE_PERMISSIONS = Object.freeze({
25
+ ADMINISTRATOR: PermissionFlagsBits.Administrator,
26
+ BAN_MEMBERS: PermissionFlagsBits.BanMembers,
27
+ KICK_MEMBERS: PermissionFlagsBits.KickMembers,
28
+ MANAGE_CHANNELS: PermissionFlagsBits.ManageChannels,
29
+ MANAGE_GUILD: PermissionFlagsBits.ManageGuild,
30
+ MANAGE_MESSAGES: PermissionFlagsBits.ManageMessages,
31
+ MANAGE_ROLES: PermissionFlagsBits.ManageRoles,
32
+ MANAGE_THREADS: PermissionFlagsBits.ManageThreads,
33
+ MANAGE_WEBHOOKS: PermissionFlagsBits.ManageWebhooks,
34
+ MODERATE_MEMBERS: PermissionFlagsBits.ModerateMembers,
35
+ });
36
+ /** Guild-owner-routed adapter for role-gated private violation threads. */
37
+ export class DiscordViolationThreadAdapter {
38
+ client;
39
+ #operationResources = new Map();
40
+ #operationMessages = new Map();
41
+ constructor(client) {
42
+ this.client = client;
43
+ }
44
+ async inspectConfiguration(input) {
45
+ validateSnowflake(input.guildId);
46
+ validateSnowflake(input.parentChannelId);
47
+ if (input.accessRoleId !== null)
48
+ validateSnowflake(input.accessRoleId);
49
+ validateRoleList(input.moderatorRoleIds);
50
+ const guild = this.client.guilds.cache.get(input.guildId);
51
+ if (!guild)
52
+ return deniedInspection("guild_mismatch", "guild_not_owned");
53
+ const channel = await guild.channels
54
+ .fetch(input.parentChannelId)
55
+ .catch(() => null);
56
+ if (!channel ||
57
+ channel.guild.id !== guild.id ||
58
+ channel.type !== ChannelType.GuildText)
59
+ return deniedInspection("channel_missing", "parent_channel_missing");
60
+ if (input.accessRoleId === null) {
61
+ const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId).catch(() => null)));
62
+ const revision = configurationRevision(channel, null, input.moderatorRoleIds);
63
+ if (moderatorRoles.some((role) => role === null))
64
+ return {
65
+ outcome: "role_missing",
66
+ configurationRevision: revision,
67
+ accessRole: null,
68
+ missingPermissions: [],
69
+ requiredChanges: [],
70
+ safeCode: "moderator_role_missing",
71
+ };
72
+ const botMember = guild.members.me;
73
+ const permissions = botMember ? channel.permissionsFor(botMember) : null;
74
+ const missingPermissions = VIOLATION_THREAD_REQUIRED_PERMISSIONS.filter((name) => name === "MANAGE_ROLES"
75
+ ? !botMember?.permissions.has(PermissionFlagsBits.ManageRoles)
76
+ : !permissions?.has(PERMISSION_FLAGS[name]));
77
+ if (missingPermissions.length > 0)
78
+ return {
79
+ outcome: "denied",
80
+ configurationRevision: revision,
81
+ accessRole: null,
82
+ missingPermissions,
83
+ requiredChanges: [],
84
+ safeCode: "bot_permissions_missing",
85
+ };
86
+ return {
87
+ outcome: "allowed",
88
+ configurationRevision: revision,
89
+ accessRole: null,
90
+ missingPermissions: [],
91
+ requiredChanges: ["create_access_role"],
92
+ };
93
+ }
94
+ const accessRole = await guild.roles
95
+ .fetch(input.accessRoleId)
96
+ .catch(() => null);
97
+ if (!accessRole)
98
+ return {
99
+ outcome: "role_missing",
100
+ configurationRevision: configurationRevision(channel, null, input.moderatorRoleIds),
101
+ accessRole: null,
102
+ missingPermissions: [],
103
+ requiredChanges: [],
104
+ safeCode: "access_role_missing",
105
+ };
106
+ const accessInspection = await inspectAccessRole(guild, channel.id, accessRole);
107
+ const revision = configurationRevision(channel, accessRole, input.moderatorRoleIds);
108
+ if (accessInspection.outcome !== "safe")
109
+ return {
110
+ outcome: "unsafe_access_role",
111
+ configurationRevision: revision,
112
+ accessRole: accessInspection,
113
+ missingPermissions: [],
114
+ requiredChanges: [],
115
+ safeCode: `access_role_${accessInspection.outcome}`,
116
+ };
117
+ if (input.moderatorRoleIds.includes(guild.roles.everyone.id) ||
118
+ input.moderatorRoleIds.includes(accessRole.id))
119
+ return {
120
+ outcome: "denied",
121
+ configurationRevision: revision,
122
+ accessRole: accessInspection,
123
+ missingPermissions: [],
124
+ requiredChanges: [],
125
+ safeCode: "moderator_role_unsafe",
126
+ };
127
+ const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId).catch(() => null)));
128
+ if (moderatorRoles.some((role) => role === null))
129
+ return {
130
+ outcome: "role_missing",
131
+ configurationRevision: revision,
132
+ accessRole: accessInspection,
133
+ missingPermissions: [],
134
+ requiredChanges: [],
135
+ safeCode: "moderator_role_missing",
136
+ };
137
+ const botMember = guild.members.me;
138
+ const permissions = botMember ? channel.permissionsFor(botMember) : null;
139
+ const missingPermissions = VIOLATION_THREAD_REQUIRED_PERMISSIONS.filter((name) => name === "MANAGE_ROLES"
140
+ ? !botMember?.permissions.has(PermissionFlagsBits.ManageRoles)
141
+ : !permissions?.has(PERMISSION_FLAGS[name]));
142
+ if (missingPermissions.includes("MANAGE_ROLES"))
143
+ return {
144
+ outcome: "denied",
145
+ configurationRevision: revision,
146
+ accessRole: accessInspection,
147
+ missingPermissions,
148
+ requiredChanges: [],
149
+ safeCode: "bot_permissions_missing",
150
+ };
151
+ return {
152
+ outcome: "allowed",
153
+ configurationRevision: revision,
154
+ accessRole: accessInspection,
155
+ missingPermissions,
156
+ requiredChanges: requiredConfigurationChanges(channel, guild, accessRole, moderatorRoles),
157
+ };
158
+ }
159
+ async createAccessRole(input) {
160
+ validateSnowflake(input.guildId);
161
+ validateOperationKey(input.operationKey);
162
+ validateName(input.name, "Violation access role");
163
+ const guild = this.client.guilds.cache.get(input.guildId);
164
+ if (!guild)
165
+ return { outcome: "failed", safeCode: "guild_not_owned" };
166
+ if (!guild.members.me?.permissions.has(PermissionFlagsBits.ManageRoles))
167
+ return { outcome: "missing_permission", safeCode: "manage_roles" };
168
+ const operationIdentity = `${input.guildId}:role:${input.operationKey}`;
169
+ const knownRoleId = this.#operationResources.get(operationIdentity);
170
+ if (knownRoleId) {
171
+ const known = await guild.roles.fetch(knownRoleId).catch(() => null);
172
+ if (known && isSafeCreatedAccessRole(known, guild))
173
+ return { outcome: "created", roleId: knownRoleId };
174
+ this.#operationResources.delete(operationIdentity);
175
+ }
176
+ const marker = operationMarker(operationIdentity);
177
+ const roles = await guild.roles.fetch().catch(() => null);
178
+ if (!roles)
179
+ return { outcome: "ambiguous", safeCode: "role_lookup_failed" };
180
+ const existing = roles.find((role) => role.name.endsWith(`[${marker}]`));
181
+ if (existing) {
182
+ if (!isSafeCreatedAccessRole(existing, guild))
183
+ return { outcome: "failed", safeCode: "role_marker_collision" };
184
+ this.#remember(this.#operationResources, operationIdentity, existing.id);
185
+ return { outcome: "created", roleId: existing.id };
186
+ }
187
+ if (!(await safelyRevalidate(() => input.revalidate())))
188
+ return { outcome: "no_longer_required" };
189
+ try {
190
+ const role = await guild.roles.create({
191
+ name: markedName(input.name, marker),
192
+ permissions: 0n,
193
+ mentionable: false,
194
+ hoist: false,
195
+ reason: auditReason(input.operationKey),
196
+ });
197
+ this.#remember(this.#operationResources, operationIdentity, role.id);
198
+ return { outcome: "created", roleId: role.id };
199
+ }
200
+ catch (error) {
201
+ return configurationMutationFailure(error);
202
+ }
203
+ }
204
+ async configureParent(input) {
205
+ validateSnowflake(input.guildId);
206
+ validateSnowflake(input.parentChannelId);
207
+ validateSnowflake(input.accessRoleId);
208
+ validateRoleList(input.moderatorRoleIds);
209
+ validateOperationKey(input.operationKey);
210
+ const initial = await this.inspectConfiguration(input);
211
+ if (initial.outcome !== "allowed")
212
+ return configurationInspectionFailure(initial);
213
+ if (initial.missingPermissions.includes("MANAGE_CHANNELS"))
214
+ return { outcome: "missing_permission", safeCode: "manage_channels" };
215
+ if (initial.configurationRevision !== input.expectedConfigurationRevision)
216
+ return { outcome: "stale_inspection" };
217
+ if (initial.requiredChanges.length === 0)
218
+ return {
219
+ outcome: "already_configured",
220
+ configurationRevision: initial.configurationRevision,
221
+ appliedChanges: [],
222
+ };
223
+ if (!(await safelyRevalidate(() => input.revalidate())))
224
+ return { outcome: "failed", safeCode: "revalidation_rejected" };
225
+ const current = await this.inspectConfiguration(input);
226
+ if (current.outcome !== "allowed" ||
227
+ current.configurationRevision !== input.expectedConfigurationRevision)
228
+ return { outcome: "stale_inspection" };
229
+ const guild = this.client.guilds.cache.get(input.guildId);
230
+ const channel = (await guild.channels.fetch(input.parentChannelId));
231
+ const botMember = guild.members.me;
232
+ try {
233
+ await channel.permissionOverwrites.edit(guild.roles.everyone, { ViewChannel: false }, { reason: auditReason(input.operationKey) });
234
+ await channel.permissionOverwrites.edit(input.accessRoleId, {
235
+ ViewChannel: true,
236
+ ReadMessageHistory: true,
237
+ SendMessages: false,
238
+ SendMessagesInThreads: true,
239
+ }, { reason: auditReason(input.operationKey) });
240
+ await channel.permissionOverwrites.edit(botMember, {
241
+ ViewChannel: true,
242
+ ReadMessageHistory: true,
243
+ CreatePrivateThreads: true,
244
+ SendMessagesInThreads: true,
245
+ ManageThreads: true,
246
+ ManageChannels: true,
247
+ }, { reason: auditReason(input.operationKey) });
248
+ for (const roleId of input.moderatorRoleIds)
249
+ await channel.permissionOverwrites.edit(roleId, {
250
+ ViewChannel: true,
251
+ ReadMessageHistory: true,
252
+ SendMessagesInThreads: true,
253
+ ManageThreads: true,
254
+ }, { reason: auditReason(input.operationKey) });
255
+ }
256
+ catch (error) {
257
+ return isDiscordStatus(error, 403)
258
+ ? { outcome: "missing_permission", safeCode: "discord_forbidden" }
259
+ : { outcome: "ambiguous", safeCode: "configuration_partially_applied" };
260
+ }
261
+ const completed = await this.inspectConfiguration(input);
262
+ if (completed.outcome !== "allowed" ||
263
+ completed.missingPermissions.length > 0 ||
264
+ completed.requiredChanges.length > 0)
265
+ return { outcome: "ambiguous", safeCode: "configuration_recheck_failed" };
266
+ return {
267
+ outcome: "configured",
268
+ configurationRevision: completed.configurationRevision,
269
+ appliedChanges: initial.requiredChanges,
270
+ };
271
+ }
272
+ async create(input) {
273
+ validateMutation(input);
274
+ const guild = this.client.guilds.cache.get(input.guildId);
275
+ if (!guild)
276
+ return { outcome: "channel_missing", safeCode: "guild_not_owned" };
277
+ const parent = await guild.channels
278
+ .fetch(input.parentChannelId)
279
+ .catch(() => null);
280
+ if (!parent || parent.type !== ChannelType.GuildText)
281
+ return { outcome: "channel_missing" };
282
+ const me = guild.members.me;
283
+ const permissions = me ? parent.permissionsFor(me) : null;
284
+ const missingPermission = VIOLATION_THREAD_RUNTIME_PERMISSIONS.find((name) => !permissions?.has(PERMISSION_FLAGS[name]));
285
+ if (missingPermission)
286
+ return {
287
+ outcome: "missing_permission",
288
+ safeCode: missingPermission.toLowerCase(),
289
+ };
290
+ if (parent
291
+ .permissionsFor(guild.roles.everyone)
292
+ .has(PermissionFlagsBits.ViewChannel))
293
+ return {
294
+ outcome: "missing_permission",
295
+ safeCode: "parent_not_restricted",
296
+ };
297
+ const operationIdentity = `${input.guildId}:${input.parentChannelId}:${input.operationKey}`;
298
+ const marker = operationMarker(operationIdentity);
299
+ const knownThreadId = this.#operationResources.get(operationIdentity);
300
+ if (knownThreadId) {
301
+ const known = await guild.channels.fetch(knownThreadId).catch(() => null);
302
+ if (isOwnedPrivateThread(known, input.guildId, input.parentChannelId))
303
+ return { outcome: "already_exists", threadId: known.id };
304
+ this.#operationResources.delete(operationIdentity);
305
+ }
306
+ const existing = await findPrivateThreadByMarker(parent, marker);
307
+ if (existing === UNKNOWN_THREAD)
308
+ return { outcome: "ambiguous", safeCode: "idempotency_lookup_failed" };
309
+ if (existing) {
310
+ this.#remember(this.#operationResources, operationIdentity, existing);
311
+ return { outcome: "already_exists", threadId: existing };
312
+ }
313
+ if (!(await safelyRevalidate(() => input.revalidate())))
314
+ return { outcome: "no_longer_required" };
315
+ try {
316
+ const thread = await parent.threads.create({
317
+ name: markedName(input.name, marker),
318
+ type: ChannelType.PrivateThread,
319
+ invitable: false,
320
+ reason: auditReason(input.operationKey),
321
+ });
322
+ this.#remember(this.#operationResources, operationIdentity, thread.id);
323
+ return { outcome: "created", threadId: thread.id };
324
+ }
325
+ catch (error) {
326
+ return threadMutationFailure(error);
327
+ }
328
+ }
329
+ async addMember(input) {
330
+ return await this.#changeThreadMember(input, "add");
331
+ }
332
+ async removeMember(input) {
333
+ return await this.#changeThreadMember(input, "remove");
334
+ }
335
+ async send(input) {
336
+ validateThreadInput(input);
337
+ const operationIdentity = `${input.guildId}:${input.threadId}:message:${input.operationKey}`;
338
+ const known = this.#operationMessages.get(operationIdentity);
339
+ if (known)
340
+ return { outcome: "already_sent", messageId: known };
341
+ const thread = await this.#privateThread(input.guildId, input.threadId);
342
+ if (!thread)
343
+ return { outcome: "thread_missing" };
344
+ try {
345
+ const message = await thread.send(toReplyOptions({
346
+ ...input.response,
347
+ ephemeral: false,
348
+ }));
349
+ this.#remember(this.#operationMessages, operationIdentity, message.id);
350
+ return { outcome: "sent", messageId: message.id };
351
+ }
352
+ catch (error) {
353
+ return deliveryFailure(error);
354
+ }
355
+ }
356
+ async close(input) {
357
+ validateThreadInput(input);
358
+ const thread = await this.#privateThread(input.guildId, input.threadId);
359
+ if (!thread)
360
+ return { outcome: "thread_missing" };
361
+ if (thread.archived && thread.locked)
362
+ return { outcome: "already_exists", threadId: thread.id };
363
+ if (!(await safelyRevalidate(() => input.revalidate())))
364
+ return { outcome: "no_longer_required" };
365
+ try {
366
+ await thread.edit({
367
+ archived: true,
368
+ locked: true,
369
+ reason: auditReason(input.operationKey),
370
+ });
371
+ return { outcome: "created", threadId: thread.id };
372
+ }
373
+ catch (error) {
374
+ return threadMutationFailure(error);
375
+ }
376
+ }
377
+ async reconcile(input) {
378
+ validateSnowflake(input.guildId);
379
+ validateSnowflake(input.parentChannelId);
380
+ validateOperationKey(input.operationKey);
381
+ if (!input.threadId)
382
+ return { outcome: "unknown", safeCode: "thread_id_unavailable" };
383
+ validateSnowflake(input.threadId);
384
+ const guild = this.client.guilds.cache.get(input.guildId);
385
+ if (!guild)
386
+ return { outcome: "unknown", safeCode: "guild_not_owned" };
387
+ try {
388
+ const channel = await guild.channels.fetch(input.threadId);
389
+ if (!isOwnedPrivateThread(channel, input.guildId) ||
390
+ channel.parentId !== input.parentChannelId)
391
+ return { outcome: "absent" };
392
+ return {
393
+ outcome: "exists",
394
+ threadId: channel.id,
395
+ archived: channel.archived ?? false,
396
+ locked: channel.locked ?? false,
397
+ };
398
+ }
399
+ catch (error) {
400
+ return isDiscordStatus(error, 404)
401
+ ? { outcome: "absent" }
402
+ : { outcome: "unknown", safeCode: "discord_inspection_failed" };
403
+ }
404
+ }
405
+ async #changeThreadMember(input, operation) {
406
+ validateThreadInput(input);
407
+ validateSnowflake(input.memberUserId);
408
+ const guild = this.client.guilds.cache.get(input.guildId);
409
+ const thread = await this.#privateThread(input.guildId, input.threadId);
410
+ if (!guild || !thread)
411
+ return { outcome: "thread_missing" };
412
+ const member = await guild.members
413
+ .fetch(input.memberUserId)
414
+ .catch(() => null);
415
+ if (!member)
416
+ return { outcome: "member_missing" };
417
+ const parent = thread.parent;
418
+ const memberPermissions = parent?.permissionsFor(member);
419
+ if (!memberPermissions?.has(PermissionFlagsBits.ViewChannel) ||
420
+ !memberPermissions.has(PermissionFlagsBits.SendMessagesInThreads))
421
+ return {
422
+ outcome: "missing_permission",
423
+ safeCode: "member_parent_access",
424
+ };
425
+ try {
426
+ const present = await thread.members
427
+ .fetch(input.memberUserId)
428
+ .catch(() => null);
429
+ if ((operation === "add" && present) ||
430
+ (operation === "remove" && !present))
431
+ return { outcome: "already_exists", threadId: thread.id };
432
+ if (operation === "add")
433
+ await thread.members.add(input.memberUserId);
434
+ else
435
+ await thread.members.remove(input.memberUserId);
436
+ return { outcome: "created", threadId: thread.id };
437
+ }
438
+ catch (error) {
439
+ return threadMutationFailure(error);
440
+ }
441
+ }
442
+ async #privateThread(guildId, threadId) {
443
+ const guild = this.client.guilds.cache.get(guildId);
444
+ if (!guild)
445
+ return null;
446
+ const channel = await guild.channels.fetch(threadId).catch(() => null);
447
+ return isOwnedPrivateThread(channel, guildId) ? channel : null;
448
+ }
449
+ #remember(cache, key, value) {
450
+ cache.delete(key);
451
+ cache.set(key, value);
452
+ while (cache.size > MAX_OPERATION_CACHE_ENTRIES) {
453
+ const oldest = cache.keys().next().value;
454
+ if (!oldest)
455
+ break;
456
+ cache.delete(oldest);
457
+ }
458
+ }
459
+ }
460
+ /** Bot-private capability; never installed in the module service map. */
461
+ export class DiscordViolationEvidenceDeliveryAdapter {
462
+ client;
463
+ evidence;
464
+ #operationMessages = new Map();
465
+ constructor(client, evidence) {
466
+ this.client = client;
467
+ this.evidence = evidence;
468
+ }
469
+ async deliverEvidence(input) {
470
+ validateThreadInput(input);
471
+ const operationIdentity = `${input.guildId}:${input.threadId}:evidence:${input.operationKey}`;
472
+ const known = this.#operationMessages.get(operationIdentity);
473
+ if (known)
474
+ return { outcome: "already_sent", messageId: known };
475
+ if (input.evidenceReference.guildId !== input.guildId)
476
+ return { outcome: "evidence_mismatch" };
477
+ const guild = this.client.guilds.cache.get(input.guildId);
478
+ const channel = guild
479
+ ? await guild.channels.fetch(input.threadId).catch(() => null)
480
+ : null;
481
+ if (!isOwnedPrivateThread(channel, input.guildId))
482
+ return { outcome: "thread_missing" };
483
+ const evidence = this.evidence.read(input.evidenceReference);
484
+ if (evidence.outcome !== "available")
485
+ return {
486
+ outcome: evidence.outcome === "mismatch"
487
+ ? "evidence_mismatch"
488
+ : "evidence_unavailable",
489
+ };
490
+ try {
491
+ const message = await channel.send({
492
+ content: evidence.content,
493
+ allowedMentions: { parse: [] },
494
+ flags: MessageFlags.SuppressEmbeds,
495
+ });
496
+ rememberOperation(this.#operationMessages, operationIdentity, message.id);
497
+ this.evidence.discard(input.evidenceReference);
498
+ return { outcome: "sent", messageId: message.id };
499
+ }
500
+ catch (error) {
501
+ return deliveryFailure(error);
502
+ }
503
+ }
504
+ }
505
+ async function inspectAccessRole(guild, parentChannelId, role) {
506
+ const base = {
507
+ roleId: role.id,
508
+ dangerousPermissionNames: [],
509
+ unintendedChannelIds: [],
510
+ unintendedChannelCount: 0,
511
+ truncated: false,
512
+ };
513
+ if (role.id === guild.id)
514
+ return { outcome: "everyone", ...base };
515
+ if (role.managed)
516
+ return { outcome: "managed", ...base };
517
+ const me = guild.members.me;
518
+ if (!me || !role.editable || role.comparePositionTo(me.roles.highest) >= 0)
519
+ return { outcome: "hierarchy_blocked", ...base };
520
+ const dangerousPermissionNames = Object.entries(DANGEROUS_ROLE_PERMISSIONS)
521
+ .filter(([, permission]) => role.permissions.has(permission))
522
+ .map(([name]) => name);
523
+ if (dangerousPermissionNames.length > 0)
524
+ return {
525
+ outcome: "dangerous_permissions",
526
+ ...base,
527
+ dangerousPermissionNames,
528
+ };
529
+ let channels;
530
+ try {
531
+ channels = await guild.channels.fetch();
532
+ }
533
+ catch {
534
+ return { outcome: "unintended_channel_access", ...base, truncated: true };
535
+ }
536
+ const unintendedChannelIds = [];
537
+ let unintendedChannelCount = 0;
538
+ for (const channel of channels.values()) {
539
+ if (!channel ||
540
+ channel.id === parentChannelId ||
541
+ channel.isThread() ||
542
+ !channel.permissionsFor(role).has(PermissionFlagsBits.ViewChannel) ||
543
+ channel
544
+ .permissionsFor(guild.roles.everyone)
545
+ .has(PermissionFlagsBits.ViewChannel))
546
+ continue;
547
+ unintendedChannelCount += 1;
548
+ if (unintendedChannelIds.length <
549
+ VIOLATION_THREAD_LIMITS.unsafeChannelExamples)
550
+ unintendedChannelIds.push(channel.id);
551
+ }
552
+ if (unintendedChannelCount > 0)
553
+ return {
554
+ outcome: "unintended_channel_access",
555
+ ...base,
556
+ unintendedChannelIds,
557
+ unintendedChannelCount,
558
+ truncated: unintendedChannelCount > unintendedChannelIds.length,
559
+ };
560
+ return { outcome: "safe", roleId: role.id };
561
+ }
562
+ function isSafeCreatedAccessRole(role, guild) {
563
+ const me = guild.members.me;
564
+ return Boolean(me &&
565
+ role.id !== guild.id &&
566
+ !role.managed &&
567
+ role.editable &&
568
+ role.permissions.bitfield === 0n &&
569
+ role.comparePositionTo(me.roles.highest) < 0);
570
+ }
571
+ function requiredConfigurationChanges(channel, guild, accessRole, moderatorRoles) {
572
+ const changes = [];
573
+ if (!overwriteDenies(channel, guild.roles.everyone.id, PermissionFlagsBits.ViewChannel))
574
+ changes.push("deny_everyone_view_channel");
575
+ for (const [name, permission] of Object.entries(ACCESS_PERMISSION_FLAGS))
576
+ if (!overwriteAllows(channel, accessRole.id, permission))
577
+ changes.push(`allow_access_role_${name.toLowerCase()}`);
578
+ if (!overwriteDenies(channel, accessRole.id, PermissionFlagsBits.SendMessages))
579
+ changes.push("deny_access_role_parent_messages");
580
+ const botMemberId = guild.members.me.id;
581
+ for (const name of [
582
+ ...VIOLATION_THREAD_RUNTIME_PERMISSIONS,
583
+ "MANAGE_CHANNELS",
584
+ ]) {
585
+ const permission = PERMISSION_FLAGS[name];
586
+ if (!overwriteAllows(channel, botMemberId, permission))
587
+ changes.push(`allow_bot_${name.toLowerCase()}`);
588
+ }
589
+ for (const role of moderatorRoles)
590
+ for (const [name, permission] of Object.entries({
591
+ VIEW_CHANNEL: PermissionFlagsBits.ViewChannel,
592
+ READ_MESSAGE_HISTORY: PermissionFlagsBits.ReadMessageHistory,
593
+ SEND_MESSAGES_IN_THREADS: PermissionFlagsBits.SendMessagesInThreads,
594
+ MANAGE_THREADS: PermissionFlagsBits.ManageThreads,
595
+ }))
596
+ if (!overwriteAllows(channel, role.id, permission))
597
+ changes.push(`allow_moderator_${role.id}_${name.toLowerCase()}`);
598
+ return changes;
599
+ }
600
+ function overwriteAllows(channel, id, permission) {
601
+ return (channel.permissionOverwrites.cache.get(id)?.allow.has(permission) ?? false);
602
+ }
603
+ function overwriteDenies(channel, id, permission) {
604
+ return (channel.permissionOverwrites.cache.get(id)?.deny.has(permission) ?? false);
605
+ }
606
+ function rememberOperation(cache, key, value) {
607
+ cache.delete(key);
608
+ cache.set(key, value);
609
+ while (cache.size > MAX_OPERATION_CACHE_ENTRIES) {
610
+ const oldest = cache.keys().next().value;
611
+ if (!oldest)
612
+ break;
613
+ cache.delete(oldest);
614
+ }
615
+ }
616
+ function configurationRevision(channel, role, moderatorRoleIds) {
617
+ const overwrites = [...channel.permissionOverwrites.cache.values()]
618
+ .map((overwrite) => ({
619
+ id: overwrite.id,
620
+ type: overwrite.type,
621
+ allow: overwrite.allow.bitfield.toString(),
622
+ deny: overwrite.deny.bitfield.toString(),
623
+ }))
624
+ .sort((left, right) => left.id.localeCompare(right.id));
625
+ return createHash("sha256")
626
+ .update(JSON.stringify({
627
+ channelId: channel.id,
628
+ role: role
629
+ ? {
630
+ id: role.id,
631
+ permissions: role.permissions.bitfield.toString(),
632
+ position: role.position,
633
+ managed: role.managed,
634
+ }
635
+ : null,
636
+ moderatorRoleIds: [...moderatorRoleIds].sort(),
637
+ overwrites,
638
+ }))
639
+ .digest("hex");
640
+ }
641
+ function deniedInspection(outcome, safeCode) {
642
+ return {
643
+ outcome,
644
+ configurationRevision: null,
645
+ accessRole: null,
646
+ missingPermissions: [...VIOLATION_THREAD_REQUIRED_PERMISSIONS],
647
+ requiredChanges: [],
648
+ safeCode,
649
+ };
650
+ }
651
+ function configurationInspectionFailure(inspection) {
652
+ if (inspection.outcome === "channel_missing")
653
+ return { outcome: "channel_missing", safeCode: inspection.safeCode };
654
+ if (inspection.outcome === "role_missing")
655
+ return { outcome: "role_missing", safeCode: inspection.safeCode };
656
+ if (inspection.outcome === "unsafe_access_role")
657
+ return { outcome: "unsafe_access_role", safeCode: inspection.safeCode };
658
+ if (inspection.missingPermissions.length > 0)
659
+ return { outcome: "missing_permission", safeCode: inspection.safeCode };
660
+ return { outcome: "failed", safeCode: inspection.safeCode };
661
+ }
662
+ function configurationMutationFailure(error) {
663
+ if (isDiscordStatus(error, 403))
664
+ return { outcome: "missing_permission", safeCode: "discord_forbidden" };
665
+ if (error instanceof DiscordAPIError)
666
+ return {
667
+ outcome: "failed",
668
+ safeCode: `discord_${error.code}`.slice(0, 80),
669
+ };
670
+ return { outcome: "ambiguous", safeCode: "discord_transport_unknown" };
671
+ }
672
+ function threadMutationFailure(error) {
673
+ if (isDiscordStatus(error, 403))
674
+ return { outcome: "missing_permission", safeCode: "discord_forbidden" };
675
+ if (isDiscordStatus(error, 404))
676
+ return { outcome: "thread_missing" };
677
+ if (error instanceof DiscordAPIError)
678
+ return {
679
+ outcome: "failed",
680
+ safeCode: `discord_${error.code}`.slice(0, 80),
681
+ };
682
+ return { outcome: "ambiguous", safeCode: "discord_transport_unknown" };
683
+ }
684
+ function deliveryFailure(error) {
685
+ if (isDiscordStatus(error, 403))
686
+ return { outcome: "missing_permission", safeCode: "discord_forbidden" };
687
+ if (isDiscordStatus(error, 404))
688
+ return { outcome: "thread_missing" };
689
+ if (error instanceof DiscordAPIError)
690
+ return {
691
+ outcome: "failed",
692
+ safeCode: `discord_${error.code}`.slice(0, 80),
693
+ };
694
+ return { outcome: "ambiguous", safeCode: "discord_transport_unknown" };
695
+ }
696
+ function isOwnedPrivateThread(channel, guildId, parentChannelId) {
697
+ return Boolean(channel?.isThread() &&
698
+ channel.type === ChannelType.PrivateThread &&
699
+ channel.guild.id === guildId &&
700
+ (!parentChannelId || channel.parentId === parentChannelId));
701
+ }
702
+ async function findPrivateThreadByMarker(parent, marker) {
703
+ const matches = (name) => name?.endsWith(`[${marker}]`) ?? false;
704
+ const cached = parent.threads.cache.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread.name));
705
+ if (cached)
706
+ return cached.id;
707
+ try {
708
+ const active = await parent.threads.fetchActive();
709
+ const activeMatch = active.threads.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread.name));
710
+ if (activeMatch)
711
+ return activeMatch.id;
712
+ let before;
713
+ for (let page = 0; page < MAX_ARCHIVED_THREAD_PAGES; page += 1) {
714
+ const archived = await parent.threads.fetchArchived({
715
+ type: "private",
716
+ limit: 100,
717
+ ...(before ? { before } : {}),
718
+ });
719
+ const match = archived.threads.find((thread) => matches(thread.name));
720
+ if (match)
721
+ return match.id;
722
+ if (!archived.hasMore)
723
+ return null;
724
+ const oldest = archived.threads.last();
725
+ if (!oldest || oldest.id === before)
726
+ return UNKNOWN_THREAD;
727
+ before = oldest.id;
728
+ }
729
+ return UNKNOWN_THREAD;
730
+ }
731
+ catch {
732
+ return UNKNOWN_THREAD;
733
+ }
734
+ }
735
+ function validateMutation(input) {
736
+ validateSnowflake(input.guildId);
737
+ validateSnowflake(input.parentChannelId);
738
+ validateOperationKey(input.operationKey);
739
+ validateName(input.name, "Violation thread");
740
+ }
741
+ function validateThreadInput(input) {
742
+ validateSnowflake(input.guildId);
743
+ validateSnowflake(input.threadId);
744
+ validateOperationKey(input.operationKey);
745
+ }
746
+ function validateRoleList(roleIds) {
747
+ if (roleIds.length > VIOLATION_THREAD_LIMITS.moderatorRoles ||
748
+ new Set(roleIds).size !== roleIds.length)
749
+ throw new Error("Violation thread moderator roles are invalid");
750
+ for (const roleId of roleIds)
751
+ validateSnowflake(roleId);
752
+ }
753
+ function validateSnowflake(value) {
754
+ if (!SNOWFLAKE_PATTERN.test(value))
755
+ throw new Error("Violation thread Discord identity is invalid");
756
+ }
757
+ function validateOperationKey(value) {
758
+ if (!OPERATION_KEY_PATTERN.test(value))
759
+ throw new Error("Violation thread operation key is invalid");
760
+ }
761
+ function validateName(value, subject) {
762
+ if (value.trim().length < 1 || value.length > 100)
763
+ throw new Error(`${subject} name is invalid`);
764
+ }
765
+ function operationMarker(operationIdentity) {
766
+ return `hx-${createHash("sha256").update(operationIdentity).digest("hex").slice(0, 12)}`;
767
+ }
768
+ function markedName(name, marker) {
769
+ const suffix = ` [${marker}]`;
770
+ return `${name
771
+ .trim()
772
+ .slice(0, 100 - suffix.length)
773
+ .trimEnd()}${suffix}`;
774
+ }
775
+ function auditReason(operationKey) {
776
+ return `Helyx moderation ${operationKey}`.slice(0, 512);
777
+ }
778
+ async function safelyRevalidate(revalidate) {
779
+ try {
780
+ return await revalidate();
781
+ }
782
+ catch {
783
+ return false;
784
+ }
785
+ }
786
+ function isDiscordStatus(error, status) {
787
+ return error instanceof DiscordAPIError && error.status === status;
788
+ }
789
+ //# sourceMappingURL=discord-violation-thread-service.js.map