@helyx/bot 1.2.1 → 1.3.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.
@@ -1,6 +1,7 @@
1
1
  import type { ViolationEvidenceReference, ViolationThreadConfigurationInspection, ViolationThreadDeliveryResult, ViolationThreadService } from "@helyx/sdk";
2
2
  import { type Client } from "discord.js";
3
3
  import type { TransientViolationEvidenceStore } from "./transient-violation-evidence.js";
4
+ import { type ViolationEvidenceMessageContext } from "./discord-violation-evidence-message.js";
4
5
  /** Guild-owner-routed adapter for role-gated private violation threads. */
5
6
  export declare class DiscordViolationThreadAdapter implements ViolationThreadService {
6
7
  #private;
@@ -14,13 +15,21 @@ export declare class DiscordViolationThreadAdapter implements ViolationThreadSer
14
15
  removeMember(input: Parameters<ViolationThreadService["removeMember"]>[0]): ReturnType<ViolationThreadService["removeMember"]>;
15
16
  send(input: Parameters<ViolationThreadService["send"]>[0]): ReturnType<ViolationThreadService["send"]>;
16
17
  close(input: Parameters<ViolationThreadService["close"]>[0]): ReturnType<ViolationThreadService["close"]>;
18
+ editControls(input: Parameters<NonNullable<ViolationThreadService["editControls"]>>[0]): Promise<{
19
+ outcome: "updated";
20
+ } | {
21
+ outcome: "failed";
22
+ safeCode: string;
23
+ }>;
17
24
  reconcile(input: Parameters<ViolationThreadService["reconcile"]>[0]): ReturnType<ViolationThreadService["reconcile"]>;
18
25
  }
19
26
  export interface DiscordViolationEvidenceDeliveryInput {
20
27
  guildId: string;
21
28
  threadId: string;
29
+ openingMessageId?: string;
22
30
  evidenceReference: ViolationEvidenceReference;
23
31
  operationKey: string;
32
+ context?: ViolationEvidenceMessageContext;
24
33
  }
25
34
  /** Bot-private capability; never installed in the module service map. */
26
35
  export declare class DiscordViolationEvidenceDeliveryAdapter {
@@ -1,7 +1,11 @@
1
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";
2
+ import { ChannelType, DiscordAPIError, PermissionFlagsBits, } from "discord.js";
3
3
  import { createHash } from "node:crypto";
4
4
  import { toReplyOptions } from "./interactions.js";
5
+ import { closeAndDeleteViolationThread } from "./discord-violation-thread-close.js";
6
+ import { violationEvidenceMessage, } from "./discord-violation-evidence-message.js";
7
+ import { configureViolationNotificationUsers, inspectViolationNotificationUsers, } from "./discord-violation-notification-users.js";
8
+ import { violationLayoutOpening, violationLayoutControls, violationLayoutEvidence, } from "./discord-violation-layout-message.js";
5
9
  const SNOWFLAKE_PATTERN = /^\d{17,20}$/u;
6
10
  const OPERATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
7
11
  const MAX_OPERATION_CACHE_ENTRIES = 10_000;
@@ -51,15 +55,34 @@ export class DiscordViolationThreadAdapter {
51
55
  if (!guild)
52
56
  return deniedInspection("guild_mismatch", "guild_not_owned");
53
57
  const channel = await guild.channels
54
- .fetch(input.parentChannelId)
58
+ .fetch(input.parentChannelId, { force: true })
55
59
  .catch(() => null);
56
60
  if (!channel ||
57
61
  channel.guild.id !== guild.id ||
58
62
  channel.type !== ChannelType.GuildText)
59
63
  return deniedInspection("channel_missing", "parent_channel_missing");
64
+ const notificationUsers = await inspectViolationNotificationUsers(guild, channel, input.moderatorUserIds ?? []);
65
+ if (notificationUsers.outcome !== "allowed")
66
+ return {
67
+ outcome: "denied",
68
+ configurationRevision: null,
69
+ accessRole: null,
70
+ missingPermissions: [],
71
+ requiredChanges: [],
72
+ safeCode: notificationUsers.safeCode,
73
+ };
60
74
  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);
75
+ if (input.moderatorRoleIds.includes(guild.roles.everyone.id))
76
+ return {
77
+ outcome: "denied",
78
+ configurationRevision: configurationRevision(channel, null, input.moderatorRoleIds, input.moderatorUserIds),
79
+ accessRole: null,
80
+ missingPermissions: [],
81
+ requiredChanges: [],
82
+ safeCode: "moderator_role_unsafe",
83
+ };
84
+ const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId, { force: true }).catch(() => null)));
85
+ const revision = configurationRevision(channel, null, input.moderatorRoleIds, input.moderatorUserIds);
63
86
  if (moderatorRoles.some((role) => role === null))
64
87
  return {
65
88
  outcome: "role_missing",
@@ -74,7 +97,8 @@ export class DiscordViolationThreadAdapter {
74
97
  const missingPermissions = VIOLATION_THREAD_REQUIRED_PERMISSIONS.filter((name) => name === "MANAGE_ROLES"
75
98
  ? !botMember?.permissions.has(PermissionFlagsBits.ManageRoles)
76
99
  : !permissions?.has(PERMISSION_FLAGS[name]));
77
- if (missingPermissions.length > 0)
100
+ if (missingPermissions.includes("MANAGE_ROLES") ||
101
+ missingPermissions.includes("MANAGE_CHANNELS"))
78
102
  return {
79
103
  outcome: "denied",
80
104
  configurationRevision: revision,
@@ -87,24 +111,28 @@ export class DiscordViolationThreadAdapter {
87
111
  outcome: "allowed",
88
112
  configurationRevision: revision,
89
113
  accessRole: null,
90
- missingPermissions: [],
91
- requiredChanges: ["create_access_role"],
114
+ missingPermissions,
115
+ requiredChanges: [
116
+ "create_access_role",
117
+ ...requiredConfigurationChanges(channel, guild, null, moderatorRoles),
118
+ ...notificationUsers.requiredChanges,
119
+ ],
92
120
  };
93
121
  }
94
122
  const accessRole = await guild.roles
95
- .fetch(input.accessRoleId)
123
+ .fetch(input.accessRoleId, { force: true })
96
124
  .catch(() => null);
97
125
  if (!accessRole)
98
126
  return {
99
127
  outcome: "role_missing",
100
- configurationRevision: configurationRevision(channel, null, input.moderatorRoleIds),
128
+ configurationRevision: configurationRevision(channel, null, input.moderatorRoleIds, input.moderatorUserIds),
101
129
  accessRole: null,
102
130
  missingPermissions: [],
103
131
  requiredChanges: [],
104
132
  safeCode: "access_role_missing",
105
133
  };
106
134
  const accessInspection = await inspectAccessRole(guild, channel.id, accessRole);
107
- const revision = configurationRevision(channel, accessRole, input.moderatorRoleIds);
135
+ const revision = configurationRevision(channel, accessRole, input.moderatorRoleIds, input.moderatorUserIds);
108
136
  if (accessInspection.outcome !== "safe")
109
137
  return {
110
138
  outcome: "unsafe_access_role",
@@ -124,7 +152,7 @@ export class DiscordViolationThreadAdapter {
124
152
  requiredChanges: [],
125
153
  safeCode: "moderator_role_unsafe",
126
154
  };
127
- const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId).catch(() => null)));
155
+ const moderatorRoles = await Promise.all(input.moderatorRoleIds.map((roleId) => guild.roles.fetch(roleId, { force: true }).catch(() => null)));
128
156
  if (moderatorRoles.some((role) => role === null))
129
157
  return {
130
158
  outcome: "role_missing",
@@ -153,7 +181,10 @@ export class DiscordViolationThreadAdapter {
153
181
  configurationRevision: revision,
154
182
  accessRole: accessInspection,
155
183
  missingPermissions,
156
- requiredChanges: requiredConfigurationChanges(channel, guild, accessRole, moderatorRoles),
184
+ requiredChanges: [
185
+ ...requiredConfigurationChanges(channel, guild, accessRole, moderatorRoles),
186
+ ...notificationUsers.requiredChanges,
187
+ ],
157
188
  };
158
189
  }
159
190
  async createAccessRole(input) {
@@ -226,17 +257,27 @@ export class DiscordViolationThreadAdapter {
226
257
  if (current.outcome !== "allowed" ||
227
258
  current.configurationRevision !== input.expectedConfigurationRevision)
228
259
  return { outcome: "stale_inspection" };
260
+ if (current.missingPermissions.includes("MANAGE_CHANNELS"))
261
+ return { outcome: "missing_permission", safeCode: "manage_channels" };
229
262
  const guild = this.client.guilds.cache.get(input.guildId);
230
- const channel = (await guild.channels.fetch(input.parentChannelId));
263
+ const channel = await guild.channels
264
+ .fetch(input.parentChannelId, { force: true })
265
+ .catch(() => null);
266
+ if (!channel || channel.type !== ChannelType.GuildText)
267
+ return { outcome: "channel_missing" };
268
+ const accessRole = await guild.roles
269
+ .fetch(input.accessRoleId, { force: true })
270
+ .catch(() => null);
271
+ if (!accessRole)
272
+ return { outcome: "role_missing" };
273
+ const notificationUsers = await inspectViolationNotificationUsers(guild, channel, input.moderatorUserIds ?? []);
274
+ if (notificationUsers.outcome !== "allowed")
275
+ return { outcome: "failed", safeCode: notificationUsers.safeCode };
276
+ if (configurationRevision(channel, accessRole, input.moderatorRoleIds, input.moderatorUserIds) !== input.expectedConfigurationRevision)
277
+ return { outcome: "stale_inspection" };
231
278
  const botMember = guild.members.me;
232
279
  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) });
280
+ // Retain the bot's access before hiding a previously public parent.
240
281
  await channel.permissionOverwrites.edit(botMember, {
241
282
  ViewChannel: true,
242
283
  ReadMessageHistory: true,
@@ -245,6 +286,12 @@ export class DiscordViolationThreadAdapter {
245
286
  ManageThreads: true,
246
287
  ManageChannels: true,
247
288
  }, { reason: auditReason(input.operationKey) });
289
+ await channel.permissionOverwrites.edit(input.accessRoleId, {
290
+ ViewChannel: true,
291
+ ReadMessageHistory: true,
292
+ SendMessages: false,
293
+ SendMessagesInThreads: true,
294
+ }, { reason: auditReason(input.operationKey) });
248
295
  for (const roleId of input.moderatorRoleIds)
249
296
  await channel.permissionOverwrites.edit(roleId, {
250
297
  ViewChannel: true,
@@ -252,6 +299,12 @@ export class DiscordViolationThreadAdapter {
252
299
  SendMessagesInThreads: true,
253
300
  ManageThreads: true,
254
301
  }, { reason: auditReason(input.operationKey) });
302
+ if (!(await configureViolationNotificationUsers(channel, notificationUsers.members, () => input.revalidate(), auditReason(input.operationKey))))
303
+ return {
304
+ outcome: "ambiguous",
305
+ safeCode: "configuration_partially_applied",
306
+ };
307
+ await channel.permissionOverwrites.edit(guild.roles.everyone, { ViewChannel: false }, { reason: auditReason(input.operationKey) });
255
308
  }
256
309
  catch (error) {
257
310
  return isDiscordStatus(error, 403)
@@ -296,6 +349,7 @@ export class DiscordViolationThreadAdapter {
296
349
  };
297
350
  const operationIdentity = `${input.guildId}:${input.parentChannelId}:${input.operationKey}`;
298
351
  const marker = operationMarker(operationIdentity);
352
+ const caseSuffix = /-\d{3,16}$/u.exec(input.name.trim())?.[0];
299
353
  const knownThreadId = this.#operationResources.get(operationIdentity);
300
354
  if (knownThreadId) {
301
355
  const known = await guild.channels.fetch(knownThreadId).catch(() => null);
@@ -303,7 +357,7 @@ export class DiscordViolationThreadAdapter {
303
357
  return { outcome: "already_exists", threadId: known.id };
304
358
  this.#operationResources.delete(operationIdentity);
305
359
  }
306
- const existing = await findPrivateThreadByMarker(parent, marker);
360
+ const existing = await findPrivateThreadByMarker(parent, marker, caseSuffix, this.client.user?.id);
307
361
  if (existing === UNKNOWN_THREAD)
308
362
  return { outcome: "ambiguous", safeCode: "idempotency_lookup_failed" };
309
363
  if (existing) {
@@ -314,7 +368,7 @@ export class DiscordViolationThreadAdapter {
314
368
  return { outcome: "no_longer_required" };
315
369
  try {
316
370
  const thread = await parent.threads.create({
317
- name: markedName(input.name, marker),
371
+ name: caseSuffix ? input.name.trim() : markedName(input.name, marker),
318
372
  type: ChannelType.PrivateThread,
319
373
  invitable: false,
320
374
  reason: auditReason(input.operationKey),
@@ -342,10 +396,12 @@ export class DiscordViolationThreadAdapter {
342
396
  if (!thread)
343
397
  return { outcome: "thread_missing" };
344
398
  try {
345
- const message = await thread.send(toReplyOptions({
346
- ...input.response,
347
- ephemeral: false,
348
- }));
399
+ const message = await thread.send(input.presentation
400
+ ? violationLayoutOpening(input.response, input.presentation)
401
+ : toReplyOptions({
402
+ ...input.response,
403
+ ephemeral: false,
404
+ }));
349
405
  this.#remember(this.#operationMessages, operationIdentity, message.id);
350
406
  return { outcome: "sent", messageId: message.id };
351
407
  }
@@ -355,6 +411,8 @@ export class DiscordViolationThreadAdapter {
355
411
  }
356
412
  async close(input) {
357
413
  validateThreadInput(input);
414
+ if (input.deleteThread)
415
+ return this.#closeAndDelete(input);
358
416
  const thread = await this.#privateThread(input.guildId, input.threadId);
359
417
  if (!thread)
360
418
  return { outcome: "thread_missing" };
@@ -374,6 +432,42 @@ export class DiscordViolationThreadAdapter {
374
432
  return threadMutationFailure(error);
375
433
  }
376
434
  }
435
+ async #closeAndDelete(input) {
436
+ if (!input.parentChannelId)
437
+ return {
438
+ outcome: "missing_permission",
439
+ safeCode: "thread_binding_missing",
440
+ };
441
+ validateSnowflake(input.parentChannelId);
442
+ try {
443
+ return await closeAndDeleteViolationThread(this.client, input, auditReason(input.operationKey), () => safelyRevalidate(() => input.revalidate()));
444
+ }
445
+ catch (error) {
446
+ return threadMutationFailure(error);
447
+ }
448
+ }
449
+ async editControls(input) {
450
+ validateThreadInput(input);
451
+ validateSnowflake(input.messageId);
452
+ const thread = await this.#privateThread(input.guildId, input.threadId);
453
+ if (!thread)
454
+ return { outcome: "failed", safeCode: "thread_missing" };
455
+ try {
456
+ const message = await thread.messages.fetch(input.messageId);
457
+ if (message.author.id !== this.client.user?.id)
458
+ return { outcome: "failed", safeCode: "control_owner_mismatch" };
459
+ if (!(await safelyRevalidate(() => input.revalidate())))
460
+ return { outcome: "failed", safeCode: "control_access_changed" };
461
+ await message.edit({
462
+ allowedMentions: { parse: [] },
463
+ components: violationLayoutControls(message.components.map((component) => component.toJSON()), input.buttons),
464
+ });
465
+ return { outcome: "updated" };
466
+ }
467
+ catch {
468
+ return { outcome: "failed", safeCode: "control_edit_failed" };
469
+ }
470
+ }
377
471
  async reconcile(input) {
378
472
  validateSnowflake(input.guildId);
379
473
  validateSnowflake(input.parentChannelId);
@@ -387,8 +481,12 @@ export class DiscordViolationThreadAdapter {
387
481
  try {
388
482
  const channel = await guild.channels.fetch(input.threadId);
389
483
  if (!isOwnedPrivateThread(channel, input.guildId) ||
390
- channel.parentId !== input.parentChannelId)
391
- return { outcome: "absent" };
484
+ channel.parentId !== input.parentChannelId ||
485
+ (input.requireBotOwner &&
486
+ (!this.client.user?.id || channel.ownerId !== this.client.user.id)))
487
+ return channel
488
+ ? { outcome: "unknown", safeCode: "thread_binding_mismatch" }
489
+ : { outcome: "absent" };
392
490
  return {
393
491
  outcome: "exists",
394
492
  threadId: channel.id,
@@ -409,23 +507,37 @@ export class DiscordViolationThreadAdapter {
409
507
  const thread = await this.#privateThread(input.guildId, input.threadId);
410
508
  if (!guild || !thread)
411
509
  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" };
510
+ let member;
511
+ try {
512
+ // Singular role grants return a clone; without GuildMembers updates the
513
+ // cached member can still lack the just-granted violation access role.
514
+ member = await guild.members.fetch({
515
+ user: input.memberUserId,
516
+ force: true,
517
+ });
518
+ }
519
+ catch (error) {
520
+ return isDiscordStatus(error, 404)
521
+ ? { outcome: "member_missing" }
522
+ : threadMutationFailure(error);
523
+ }
417
524
  const parent = thread.parent;
418
525
  const memberPermissions = parent?.permissionsFor(member);
419
- if (!memberPermissions?.has(PermissionFlagsBits.ViewChannel) ||
420
- !memberPermissions.has(PermissionFlagsBits.SendMessagesInThreads))
526
+ if (operation === "add" &&
527
+ (!memberPermissions?.has(PermissionFlagsBits.ViewChannel) ||
528
+ !memberPermissions.has(PermissionFlagsBits.SendMessagesInThreads)))
421
529
  return {
422
530
  outcome: "missing_permission",
423
531
  safeCode: "member_parent_access",
424
532
  };
425
533
  try {
426
534
  const present = await thread.members
427
- .fetch(input.memberUserId)
428
- .catch(() => null);
535
+ .fetch({ member: input.memberUserId, force: true, withMember: false })
536
+ .catch((error) => {
537
+ if (isDiscordStatus(error, 404))
538
+ return null;
539
+ throw error;
540
+ });
429
541
  if ((operation === "add" && present) ||
430
542
  (operation === "remove" && !present))
431
543
  return { outcome: "already_exists", threadId: thread.id };
@@ -468,6 +580,8 @@ export class DiscordViolationEvidenceDeliveryAdapter {
468
580
  }
469
581
  async deliverEvidence(input) {
470
582
  validateThreadInput(input);
583
+ if (input.openingMessageId)
584
+ validateSnowflake(input.openingMessageId);
471
585
  const operationIdentity = `${input.guildId}:${input.threadId}:evidence:${input.operationKey}`;
472
586
  const known = this.#operationMessages.get(operationIdentity);
473
587
  if (known)
@@ -488,11 +602,25 @@ export class DiscordViolationEvidenceDeliveryAdapter {
488
602
  : "evidence_unavailable",
489
603
  };
490
604
  try {
491
- const message = await channel.send({
492
- content: evidence.content,
493
- allowedMentions: { parse: [] },
494
- flags: MessageFlags.SuppressEmbeds,
495
- });
605
+ if (input.openingMessageId) {
606
+ const opening = await channel.messages.fetch(input.openingMessageId);
607
+ if (opening.author.id !== this.client.user?.id)
608
+ return { outcome: "evidence_mismatch" };
609
+ const components = violationLayoutEvidence(opening.components.map((component) => component.toJSON()), input.evidenceReference, evidence.content, input.context);
610
+ if (components) {
611
+ await opening.edit({ components, allowedMentions: { parse: [] } });
612
+ rememberOperation(this.#operationMessages, operationIdentity, opening.id);
613
+ this.evidence.discard(input.evidenceReference);
614
+ return { outcome: "sent", messageId: opening.id };
615
+ }
616
+ const summary = violationLayoutEvidence(opening.components.map((component) => component.toJSON()), input.evidenceReference, evidence.content, input.context, true);
617
+ if (summary)
618
+ await opening.edit({
619
+ components: summary,
620
+ allowedMentions: { parse: [] },
621
+ });
622
+ }
623
+ const message = await channel.send(violationEvidenceMessage(input.evidenceReference, evidence.content, input.context));
496
624
  rememberOperation(this.#operationMessages, operationIdentity, message.id);
497
625
  this.evidence.discard(input.evidenceReference);
498
626
  return { outcome: "sent", messageId: message.id };
@@ -573,9 +701,10 @@ function requiredConfigurationChanges(channel, guild, accessRole, moderatorRoles
573
701
  if (!overwriteDenies(channel, guild.roles.everyone.id, PermissionFlagsBits.ViewChannel))
574
702
  changes.push("deny_everyone_view_channel");
575
703
  for (const [name, permission] of Object.entries(ACCESS_PERMISSION_FLAGS))
576
- if (!overwriteAllows(channel, accessRole.id, permission))
704
+ if (!accessRole || !overwriteAllows(channel, accessRole.id, permission))
577
705
  changes.push(`allow_access_role_${name.toLowerCase()}`);
578
- if (!overwriteDenies(channel, accessRole.id, PermissionFlagsBits.SendMessages))
706
+ if (!accessRole ||
707
+ !overwriteDenies(channel, accessRole.id, PermissionFlagsBits.SendMessages))
579
708
  changes.push("deny_access_role_parent_messages");
580
709
  const botMemberId = guild.members.me.id;
581
710
  for (const name of [
@@ -613,7 +742,7 @@ function rememberOperation(cache, key, value) {
613
742
  cache.delete(oldest);
614
743
  }
615
744
  }
616
- function configurationRevision(channel, role, moderatorRoleIds) {
745
+ function configurationRevision(channel, role, moderatorRoleIds, moderatorUserIds = []) {
617
746
  const overwrites = [...channel.permissionOverwrites.cache.values()]
618
747
  .map((overwrite) => ({
619
748
  id: overwrite.id,
@@ -634,6 +763,7 @@ function configurationRevision(channel, role, moderatorRoleIds) {
634
763
  }
635
764
  : null,
636
765
  moderatorRoleIds: [...moderatorRoleIds].sort(),
766
+ moderatorUserIds: [...moderatorUserIds].sort(),
637
767
  overwrites,
638
768
  }))
639
769
  .digest("hex");
@@ -699,14 +829,22 @@ function isOwnedPrivateThread(channel, guildId, parentChannelId) {
699
829
  channel.guild.id === guildId &&
700
830
  (!parentChannelId || channel.parentId === parentChannelId));
701
831
  }
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));
832
+ async function findPrivateThreadByMarker(parent, marker, caseSuffix, botUserId) {
833
+ // Guild case numbers never repeat. The fixed, padded numeric suffix survives
834
+ // prefix changes without exposing the operation hash in the thread name.
835
+ // Restrict the new-name recovery path to private threads created by this bot.
836
+ // Legacy operation markers remain readable for deliveries created before this change.
837
+ const matches = (thread) => thread.name.endsWith(`[${marker}]`) ||
838
+ Boolean(caseSuffix &&
839
+ botUserId &&
840
+ thread.ownerId === botUserId &&
841
+ thread.name.endsWith(caseSuffix));
842
+ const cached = parent.threads.cache.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread));
705
843
  if (cached)
706
844
  return cached.id;
707
845
  try {
708
846
  const active = await parent.threads.fetchActive();
709
- const activeMatch = active.threads.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread.name));
847
+ const activeMatch = active.threads.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread));
710
848
  if (activeMatch)
711
849
  return activeMatch.id;
712
850
  let before;
@@ -716,7 +854,7 @@ async function findPrivateThreadByMarker(parent, marker) {
716
854
  limit: 100,
717
855
  ...(before ? { before } : {}),
718
856
  });
719
- const match = archived.threads.find((thread) => matches(thread.name));
857
+ const match = archived.threads.find((thread) => thread.type === ChannelType.PrivateThread && matches(thread));
720
858
  if (match)
721
859
  return match.id;
722
860
  if (!archived.hasMore)
package/dist/events.js CHANGED
@@ -7,6 +7,7 @@ import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./
7
7
  import { canAccessModule } from "./premium-access.js";
8
8
  import { fromDiscordAction, toRuleSnapshot, } from "./discord-auto-moderation-service.js";
9
9
  import { PrivilegedMessageMetadataCache } from "./privileged-message-metadata-cache.js";
10
+ import { DiscordAutoModerationExecutionIds } from "./discord-automod-execution-ids.js";
10
11
  export function availabilityAwareInstallations(services, modulesById) {
11
12
  const installations = services.get(HELYX_SERVICE_NAMES.installations);
12
13
  const candidate = typeof services.has === "function" &&
@@ -65,6 +66,7 @@ function privilegedSource(message) {
65
66
  }
66
67
  export function installModuleEvents(client, modules, logger, services, options) {
67
68
  const runtimeProfile = options?.runtimeProfile ?? "hosted";
69
+ const autoModerationExecutionIds = new DiscordAutoModerationExecutionIds();
68
70
  const modulesById = new Map(modules.map((module) => [module.manifest.id, module]));
69
71
  const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? [])
70
72
  .filter((contribution) => !contribution.featureId || runtimeProfile === "self-hosted")
@@ -342,7 +344,11 @@ export function installModuleEvents(client, modules, logger, services, options)
342
344
  }
343
345
  if (autoModerationActionContributions.length > 0) {
344
346
  client.on(Events.AutoModerationActionExecution, (execution) => {
345
- queue.enqueue(execution.guild.id, () => handleAutoModerationAction(execution));
347
+ // Correlate at Gateway receipt, not after earlier actions/thread setup
348
+ // have held the per-guild queue longer than the correlation window.
349
+ const executionId = autoModerationExecutionIds.identify(execution);
350
+ const occurredAt = new Date();
351
+ queue.enqueue(execution.guild.id, () => handleAutoModerationAction(execution, executionId, occurredAt));
346
352
  });
347
353
  }
348
354
  if (messageDeleteContributions.length > 0) {
@@ -542,18 +548,9 @@ export function installModuleEvents(client, modules, logger, services, options)
542
548
  }
543
549
  }
544
550
  }
545
- async function handleAutoModerationAction(execution) {
551
+ async function handleAutoModerationAction(execution, executionId, occurredAt) {
546
552
  const installations = availabilityAwareInstallations(services, modulesById);
547
553
  const action = fromDiscordAction(execution.action);
548
- const executionId = createHash("sha256")
549
- .update(JSON.stringify([
550
- execution.guild.id,
551
- execution.ruleId,
552
- execution.userId,
553
- execution.channelId,
554
- execution.messageId,
555
- ]))
556
- .digest("hex");
557
554
  const evidenceReference = options?.evidenceStore && execution.channelId && execution.content
558
555
  ? options.evidenceStore.capture({
559
556
  source: "discord_auto_moderation",
@@ -580,7 +577,7 @@ export function installModuleEvents(client, modules, logger, services, options)
580
577
  action,
581
578
  executionId,
582
579
  evidenceReference,
583
- occurredAt: new Date(),
580
+ occurredAt,
584
581
  services,
585
582
  });
586
583
  }
@@ -1685,7 +1682,10 @@ export class DiscordMemberRoleService {
1685
1682
  const guild = await this.client.guilds.fetch(input.guildId);
1686
1683
  if (guild.id !== input.guildId)
1687
1684
  throw new Error("Discord guild validation failed");
1688
- const member = await guild.members.fetch(input.memberUserId);
1685
+ const member = await guild.members.fetch({
1686
+ user: input.memberUserId,
1687
+ force: true,
1688
+ });
1689
1689
  if (member.id !== input.memberUserId || member.guild.id !== guild.id)
1690
1690
  throw new Error("Discord member validation failed");
1691
1691
  return { guild, member };
@@ -1,6 +1,7 @@
1
1
  import { isCustomerSelectableModule, } from "@helyx/module-manifest";
2
2
  import { defineModule, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
3
  import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
4
+ import { validateDiscordUserSettingSelections } from "./dashboard-discord-settings.js";
4
5
  const MODULE_SELECT_ID = "helyx.management.module";
5
6
  const SETTING_SELECT_ID = "helyx.management.setting";
6
7
  const VALUE_SELECT_ID = "helyx.management.value";
@@ -129,7 +130,9 @@ export function createManagementModule(configurableModules, resources) {
129
130
  const setting = selectedSetting(module, context.selectedValues[0]);
130
131
  const state = { ...session.state, selectedSettingKey: setting.key };
131
132
  await updateSession(context, session, state);
132
- if (setting.type === "string" || setting.type === "integer") {
133
+ if (setting.type === "string" ||
134
+ setting.type === "integer" ||
135
+ setting.type === "discord-user") {
133
136
  const now = Date.now();
134
137
  for (const [key, prepared] of preparedValueModals) {
135
138
  if (prepared.expiresAt <= now)
@@ -222,6 +225,7 @@ export function createManagementModule(configurableModules, resources) {
222
225
  return;
223
226
  }
224
227
  try {
228
+ await validateDiscordUserSettingSelections(context.services, context.serverId, module.manifest.dashboard?.settings ?? [], value);
225
229
  const validated = module.configuration?.validate(value);
226
230
  if (validated && module.configuration?.validateUpdate) {
227
231
  await module.configuration.validateUpdate({
@@ -353,6 +357,15 @@ export function createManagementModule(configurableModules, resources) {
353
357
  return;
354
358
  const module = selectedModule(session.state, byId);
355
359
  const setting = selectedSetting(module, stringState(session.state, "selectedSettingKey"));
360
+ if (setting.type === "discord-user") {
361
+ const userIds = [...context.getSelectedUserIds(VALUE_FIELD_ID)];
362
+ const updated = await updateSession(context, session, {
363
+ ...session.state,
364
+ draft: { ...draft(session.state), [setting.key]: userIds },
365
+ });
366
+ await respond(context, configurationResponse(module, updated.state));
367
+ return;
368
+ }
356
369
  if (setting.type !== "string" && setting.type !== "integer") {
357
370
  throw new Error("Selected setting does not use text input");
358
371
  }
@@ -593,7 +606,7 @@ function textSettingResponse(setting) {
593
606
  text: [{ content: `## ${setting.label}\n${setting.description}` }],
594
607
  },
595
608
  buttons: [
596
- button(OPEN_VALUE_MODAL_ID, "Enter value", "primary"),
609
+ button(OPEN_VALUE_MODAL_ID, setting.type === "discord-user" ? "Choose users" : "Enter value", "primary"),
597
610
  button(CONFIGURE_ID, "Back to settings", "secondary"),
598
611
  button(CANCEL_ID, "Cancel", "secondary"),
599
612
  ],
@@ -680,6 +693,25 @@ function preparedModalKey(serverId, userId) {
680
693
  }
681
694
  function settingModal(setting, values) {
682
695
  const current = values[setting.key];
696
+ if (setting.type === "discord-user")
697
+ return {
698
+ customId: VALUE_MODAL_ID,
699
+ title: truncate(setting.label, 45),
700
+ text: [{ content: setting.description, markdown: false }],
701
+ fields: [
702
+ {
703
+ type: "discord-user",
704
+ customId: VALUE_FIELD_ID,
705
+ label: setting.label,
706
+ required: setting.required,
707
+ minValues: Math.max(setting.required ? 1 : 0, setting.minItems ?? 0),
708
+ maxValues: setting.maxItems ?? 25,
709
+ defaultValues: Array.isArray(current)
710
+ ? current.filter((id) => typeof id === "string")
711
+ : [],
712
+ },
713
+ ],
714
+ };
683
715
  return {
684
716
  customId: VALUE_MODAL_ID,
685
717
  title: truncate(setting.label, 45),
@@ -724,6 +756,8 @@ function initialDraft(module, existing) {
724
756
  return result;
725
757
  }
726
758
  function emptyValue(setting) {
759
+ if (setting.type === "discord-user")
760
+ return [];
727
761
  if (setting.type === "discord-channel" && setting.multiple)
728
762
  return [];
729
763
  if (setting.type === "discord-role" && setting.multiple)