@helyx/bot 0.4.9 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/events.js CHANGED
@@ -1,7 +1,23 @@
1
1
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
- import { ChannelType, Events, PermissionFlagsBits, RESTJSONErrorCodes, } from "discord.js";
2
+ import { VerificationSetupLeaseLostError } from "@helyx/database";
3
+ import { ChannelType, Events, GatewayIntentBits, GuildMemberFlags, GuildOnboardingMode, PermissionFlagsBits, RESTJSONErrorCodes, UserFlags, } from "discord.js";
3
4
  import { toReplyOptions } from "./interactions.js";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
4
7
  export function installModuleEvents(client, modules, logger, services) {
8
+ const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? []).map((contribution) => ({
9
+ moduleId: module.manifest.id,
10
+ contribution,
11
+ })));
12
+ const guildMessageContributions = modules.flatMap((module) => (module.events?.guildMessageCreate ?? []).map((contribution) => ({
13
+ moduleId: module.manifest.id,
14
+ classificationRequested: module.requiredDiscordCapabilities?.includes("message-content") ??
15
+ false,
16
+ contribution,
17
+ })));
18
+ const messageClassificationRequested = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
19
+ const messageClassificationAvailable = messageClassificationRequested &&
20
+ client.options.intents.has(GatewayIntentBits.MessageContent);
5
21
  const reactionContributions = modules.flatMap((module) => (module.events?.messageReactionAdd ?? []).map((contribution) => ({
6
22
  moduleId: module.manifest.id,
7
23
  contribution,
@@ -18,6 +34,10 @@ export function installModuleEvents(client, modules, logger, services) {
18
34
  moduleId: module.manifest.id,
19
35
  contribution,
20
36
  })));
37
+ const memberRemoveContributions = modules.flatMap((module) => (module.events?.guildMemberRemove ?? []).map((contribution) => ({
38
+ moduleId: module.manifest.id,
39
+ contribution,
40
+ })));
21
41
  const guildRoleDeleteContributions = modules.flatMap((module) => (module.events?.guildRoleDelete ?? []).map((contribution) => ({
22
42
  moduleId: module.manifest.id,
23
43
  contribution,
@@ -42,10 +62,13 @@ export function installModuleEvents(client, modules, logger, services) {
42
62
  moduleId: module.manifest.id,
43
63
  contribution,
44
64
  })));
45
- if (reactionContributions.length === 0 &&
65
+ if (privilegedMessageContributions.length === 0 &&
66
+ guildMessageContributions.length === 0 &&
67
+ reactionContributions.length === 0 &&
46
68
  reactionRemoveContributions.length === 0 &&
47
69
  messageDeleteContributions.length === 0 &&
48
70
  memberAddContributions.length === 0 &&
71
+ memberRemoveContributions.length === 0 &&
49
72
  guildRoleDeleteContributions.length === 0 &&
50
73
  guildRoleUpdateContributions.length === 0 &&
51
74
  channelDeleteContributions.length === 0 &&
@@ -54,6 +77,59 @@ export function installModuleEvents(client, modules, logger, services) {
54
77
  guildEmojiUpdateContributions.length === 0)
55
78
  return { stop: () => Promise.resolve(), queueDepth: () => 0 };
56
79
  const queue = new BoundedGuildEventQueue(logger);
80
+ const privilegedEligibilityQueue = privilegedMessageContributions.length > 0
81
+ ? new BoundedGuildEventQueue(logger)
82
+ : null;
83
+ const guildMessageQueue = guildMessageContributions.length > 0
84
+ ? new BoundedGuildEventQueue(logger)
85
+ : null;
86
+ if (privilegedMessageContributions.length > 0) {
87
+ client.on(Events.MessageCreate, (message) => {
88
+ if (!message.guildId || !privilegedEligibilityQueue)
89
+ return;
90
+ privilegedEligibilityQueue.enqueue(message.guildId, async () => {
91
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
92
+ const eligible = [];
93
+ for (const item of privilegedMessageContributions) {
94
+ try {
95
+ if ((await installations.isModuleEnabled(message.guildId, item.moduleId)) &&
96
+ (await item.contribution.isEligible({
97
+ serverId: message.guildId,
98
+ channelId: message.channelId,
99
+ services,
100
+ }))) {
101
+ eligible.push(item);
102
+ }
103
+ }
104
+ catch (error) {
105
+ logger.warn({
106
+ event: "module.privileged_message_eligibility_failed",
107
+ moduleId: item.moduleId,
108
+ error: safeError(error),
109
+ guildId: message.guildId,
110
+ channelId: message.channelId,
111
+ }, "Module privileged message eligibility failed");
112
+ }
113
+ }
114
+ if (eligible.length > 0) {
115
+ queue.enqueue(message.guildId, () => handlePrivilegedMessageCreate(message, eligible));
116
+ }
117
+ });
118
+ });
119
+ }
120
+ if (guildMessageContributions.length > 0) {
121
+ client.on(Events.MessageCreate, (message) => {
122
+ if (!message.guildId ||
123
+ !guildMessageQueue ||
124
+ message.author.bot ||
125
+ message.author.system ||
126
+ Boolean(message.webhookId) ||
127
+ message.system) {
128
+ return;
129
+ }
130
+ guildMessageQueue.enqueue(message.guildId, () => handleGuildMessageCreate(message));
131
+ });
132
+ }
57
133
  if (reactionContributions.length > 0) {
58
134
  client.on(Events.MessageReactionAdd, (reaction, user, details) => {
59
135
  const guildId = reaction.message.guildId;
@@ -79,6 +155,11 @@ export function installModuleEvents(client, modules, logger, services) {
79
155
  queue.enqueue(member.guild.id, () => handleMemberAdd(member));
80
156
  });
81
157
  }
158
+ if (memberRemoveContributions.length > 0) {
159
+ client.on(Events.GuildMemberRemove, (member) => {
160
+ queue.enqueue(member.guild.id, () => handleMemberRemove(member));
161
+ });
162
+ }
82
163
  if (guildRoleDeleteContributions.length > 0) {
83
164
  client.on(Events.GuildRoleDelete, (role) => {
84
165
  queue.enqueue(role.guild.id, () => handleResourceLifecycle(guildRoleDeleteContributions, role.guild.id, "roleId", role.id, "guild_role_delete"));
@@ -137,6 +218,116 @@ export function installModuleEvents(client, modules, logger, services) {
137
218
  }
138
219
  }
139
220
  }
221
+ async function handlePrivilegedMessageCreate(message, contributions) {
222
+ if (!message.guildId || !message.channelId)
223
+ return;
224
+ for (const { moduleId, contribution } of contributions) {
225
+ try {
226
+ await contribution.execute({
227
+ serverId: message.guildId,
228
+ channelId: message.channelId,
229
+ messageId: message.id,
230
+ userId: message.author.id,
231
+ userIsBot: message.author.bot,
232
+ webhookId: message.webhookId,
233
+ content: message.content,
234
+ services,
235
+ deleteSource: async () => {
236
+ await message.delete();
237
+ },
238
+ });
239
+ }
240
+ catch (error) {
241
+ logger.warn({
242
+ event: "module.privileged_message_create_failed",
243
+ moduleId,
244
+ error: safeError(error),
245
+ messageId: message.id,
246
+ guildId: message.guildId,
247
+ }, "Module privileged message-create event failed");
248
+ }
249
+ }
250
+ }
251
+ async function handleGuildMessageCreate(message) {
252
+ if (!message.guildId || !message.channelId)
253
+ return;
254
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
255
+ const parentChannelId = message.channel.isThread()
256
+ ? (message.channel.parentId ?? undefined)
257
+ : undefined;
258
+ const authorRoleIds = message.member
259
+ ? [...message.member.roles.cache.keys()].sort((left, right) => left.localeCompare(right, "en"))
260
+ : [];
261
+ const authorDisplayName = message.member?.displayName ??
262
+ message.author.globalName ??
263
+ message.author.username;
264
+ let classifiedMessage;
265
+ for (const { moduleId, classificationRequested, contribution, } of guildMessageContributions) {
266
+ try {
267
+ if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
268
+ continue;
269
+ }
270
+ await contribution.execute({
271
+ serverId: message.guildId,
272
+ channelId: message.channelId,
273
+ ...(parentChannelId ? { parentChannelId } : {}),
274
+ messageId: message.id,
275
+ authorId: message.author.id,
276
+ authorDisplayName,
277
+ authorRoleIds,
278
+ authorIsBot: false,
279
+ authorIsWebhook: false,
280
+ authorIsSystem: false,
281
+ get classification() {
282
+ if (!classificationRequested || !messageClassificationAvailable)
283
+ return unavailableGuildMessageClassification;
284
+ classifiedMessage ??= classifyGuildMessage(message);
285
+ return classifiedMessage;
286
+ },
287
+ createdAt: message.createdAt,
288
+ services,
289
+ addAuthorRole: async (roleId) => {
290
+ await services
291
+ .get(HELYX_SERVICE_NAMES.memberRoles)
292
+ .addApprovedRole({
293
+ guildId: message.guildId,
294
+ memberUserId: message.author.id,
295
+ roleId,
296
+ moduleId,
297
+ reason: "Guild message contribution author role addition",
298
+ });
299
+ },
300
+ removeAuthorRole: async (roleId) => {
301
+ await services
302
+ .get(HELYX_SERVICE_NAMES.memberRoles)
303
+ .removeApprovedRole({
304
+ guildId: message.guildId,
305
+ memberUserId: message.author.id,
306
+ roleId,
307
+ moduleId,
308
+ reason: "Guild message contribution author role removal",
309
+ });
310
+ },
311
+ sendToChannel: (channelId, response) => services
312
+ .get(HELYX_SERVICE_NAMES.discordMessages)
313
+ .send({
314
+ guildId: message.guildId,
315
+ channelId,
316
+ response,
317
+ }),
318
+ });
319
+ }
320
+ catch (error) {
321
+ logger.warn({
322
+ event: "module.guild_message_create_failed",
323
+ moduleId,
324
+ error: safeError(error),
325
+ messageId: message.id,
326
+ guildId: message.guildId,
327
+ }, "Module guild message-create event failed");
328
+ }
329
+ }
330
+ }
140
331
  async function handleReaction(receivedReaction, receivedUser, burst) {
141
332
  try {
142
333
  const user = receivedUser.partial
@@ -197,6 +388,8 @@ export function installModuleEvents(client, modules, logger, services) {
197
388
  }
198
389
  }
199
390
  async function handleMemberAdd(member) {
391
+ if (!member.joinedAt)
392
+ throw new Error("Discord member join timestamp is unavailable");
200
393
  const installations = services.get(HELYX_SERVICE_NAMES.installations);
201
394
  for (const { moduleId, contribution } of memberAddContributions) {
202
395
  try {
@@ -209,6 +402,8 @@ export function installModuleEvents(client, modules, logger, services) {
209
402
  memberId: member.id,
210
403
  memberDisplayName: member.displayName,
211
404
  memberIsBot: member.user.bot,
405
+ accountCreatedAt: member.user.createdAt,
406
+ joinedAt: member.joinedAt,
212
407
  services,
213
408
  assignRole: (roleId) => assignMemberRole(member, roleId),
214
409
  sendToChannel: (channelId, response) => sendToGuildChannel(member, channelId, response),
@@ -225,6 +420,33 @@ export function installModuleEvents(client, modules, logger, services) {
225
420
  }
226
421
  }
227
422
  }
423
+ async function handleMemberRemove(member) {
424
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
425
+ for (const { moduleId, contribution } of memberRemoveContributions) {
426
+ try {
427
+ if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
428
+ continue;
429
+ }
430
+ await contribution.execute({
431
+ serverId: member.guild.id,
432
+ memberId: member.id,
433
+ memberIsBot: member.user.bot,
434
+ accountCreatedAt: member.user.createdAt,
435
+ joinedAt: member.joinedAt,
436
+ services,
437
+ });
438
+ }
439
+ catch (error) {
440
+ logger.warn({
441
+ event: "module.member_remove_failed",
442
+ moduleId,
443
+ error: safeError(error),
444
+ memberId: member.id,
445
+ guildId: member.guild.id,
446
+ }, "Module member-remove event failed");
447
+ }
448
+ }
449
+ }
228
450
  async function handleReactionRemove(receivedReaction, receivedUser, burst) {
229
451
  try {
230
452
  if (receivedUser.bot)
@@ -293,7 +515,16 @@ export function installModuleEvents(client, modules, logger, services) {
293
515
  }
294
516
  }
295
517
  }
296
- return { stop: () => queue.stop(), queueDepth: () => queue.queueDepth() };
518
+ return {
519
+ stop: async () => {
520
+ await privilegedEligibilityQueue?.stop();
521
+ await guildMessageQueue?.stop();
522
+ await queue.stop();
523
+ },
524
+ queueDepth: () => queue.queueDepth() +
525
+ (privilegedEligibilityQueue?.queueDepth() ?? 0) +
526
+ (guildMessageQueue?.queueDepth() ?? 0),
527
+ };
297
528
  }
298
529
  class BoundedGuildEventQueue {
299
530
  logger;
@@ -405,11 +636,966 @@ const dangerousRolePermissionNames = new Set([
405
636
  "MUTE_MEMBERS",
406
637
  "VIEW_AUDIT_LOG",
407
638
  ]);
639
+ const directMessageAttemptTimeoutMs = 5_000;
640
+ const guildChannelAudienceInspectionLimit = 500;
641
+ export class DiscordMessageDeliveryService {
642
+ client;
643
+ constructor(client) {
644
+ this.client = client;
645
+ }
646
+ async send(input) {
647
+ validateSnowflake(input.guildId, "guild");
648
+ validateSnowflake(input.channelId, "channel");
649
+ const guild = await this.client.guilds.fetch(input.guildId);
650
+ if (guild.id !== input.guildId)
651
+ throw new Error("Discord guild validation failed");
652
+ const channel = await guild.channels.fetch(input.channelId);
653
+ if (!channel ||
654
+ channel.guild.id !== guild.id ||
655
+ !channel.isSendable() ||
656
+ channel.isDMBased()) {
657
+ throw new Error("Discord message channel is unavailable");
658
+ }
659
+ const me = guild.members.me ?? (await guild.members.fetchMe());
660
+ const effective = channel.permissionsFor(me);
661
+ if (!effective?.has(PermissionFlagsBits.ViewChannel) ||
662
+ !effective.has(PermissionFlagsBits.SendMessages)) {
663
+ throw new Error("Discord message channel permissions are unavailable");
664
+ }
665
+ const options = {
666
+ ...toReplyOptions({ ...input.response, ephemeral: false }),
667
+ };
668
+ if (input.idempotencyKey) {
669
+ const nonce = createHash("sha256")
670
+ .update(input.idempotencyKey)
671
+ .digest("hex")
672
+ .slice(0, 24);
673
+ options.nonce = nonce;
674
+ options.enforceNonce = true;
675
+ }
676
+ const message = await channel.send(options);
677
+ if (message.guildId !== guild.id ||
678
+ message.channelId !== channel.id ||
679
+ !discordSnowflake.test(message.id)) {
680
+ throw new Error("Discord sent message validation failed");
681
+ }
682
+ return { messageId: message.id };
683
+ }
684
+ async sendDirect(input) {
685
+ validateSnowflake(input.userId, "user");
686
+ try {
687
+ const user = await this.client.users.fetch(input.userId);
688
+ if (user.id !== input.userId)
689
+ throw new Error("Discord user validation failed");
690
+ const options = toReplyOptions({ ...input.response, ephemeral: false });
691
+ const message = await user.send(options);
692
+ if (!discordSnowflake.test(message.id)) {
693
+ return { outcome: "failed" };
694
+ }
695
+ return { outcome: "sent", messageId: message.id };
696
+ }
697
+ catch (error) {
698
+ return {
699
+ outcome: isClosedDirectMessage(error) ? "closed" : "failed",
700
+ };
701
+ }
702
+ }
703
+ }
704
+ export class DiscordMemberEnforcementService {
705
+ client;
706
+ constructor(client) {
707
+ this.client = client;
708
+ }
709
+ async kick(input) {
710
+ validateEnforcementMetadata(input);
711
+ const directMessage = input.directMessage
712
+ ? await this.#sendBestEffortDirectMessage(input.memberUserId, input.directMessage)
713
+ : "not_requested";
714
+ const guild = await this.client.guilds
715
+ .fetch(input.guildId)
716
+ .catch(() => null);
717
+ if (!guild)
718
+ return { outcome: "failed", directMessage };
719
+ if (guild.id !== input.guildId)
720
+ throw new Error("Discord guild validation failed");
721
+ let member;
722
+ try {
723
+ member = await guild.members.fetch(input.memberUserId);
724
+ }
725
+ catch (error) {
726
+ if (isUnknownMember(error))
727
+ return { outcome: "member_missing", directMessage };
728
+ return { outcome: "failed", directMessage };
729
+ }
730
+ if (!member)
731
+ return { outcome: "member_missing", directMessage };
732
+ if (member.id !== input.memberUserId || member.guild.id !== guild.id)
733
+ throw new Error("Discord member validation failed");
734
+ const me = guild.members.me ?? (await guild.members.fetchMe());
735
+ if (!me.permissions.has(PermissionFlagsBits.KickMembers)) {
736
+ return { outcome: "missing_permission", directMessage };
737
+ }
738
+ if (!member.kickable) {
739
+ return { outcome: "hierarchy_blocked", directMessage };
740
+ }
741
+ let stillRequired;
742
+ try {
743
+ stillRequired = await input.revalidate();
744
+ }
745
+ catch {
746
+ return { outcome: "failed", directMessage };
747
+ }
748
+ if (!stillRequired) {
749
+ return { outcome: "no_longer_required", directMessage };
750
+ }
751
+ try {
752
+ await member.kick(discordAuditReason(input));
753
+ return { outcome: "kicked", directMessage };
754
+ }
755
+ catch (error) {
756
+ if (isUnknownMember(error)) {
757
+ return { outcome: "member_missing", directMessage };
758
+ }
759
+ return { outcome: "failed", directMessage };
760
+ }
761
+ }
762
+ async ban(input) {
763
+ validateEnforcementMetadata(input);
764
+ const directMessage = input.directMessage
765
+ ? await this.#sendBestEffortDirectMessage(input.memberUserId, input.directMessage)
766
+ : "not_requested";
767
+ const guild = await this.client.guilds
768
+ .fetch(input.guildId)
769
+ .catch(() => null);
770
+ if (!guild)
771
+ return { outcome: "failed", directMessage };
772
+ const me = guild.members.me ?? (await guild.members.fetchMe());
773
+ if (!me.permissions.has(PermissionFlagsBits.BanMembers))
774
+ return { outcome: "missing_permission", directMessage };
775
+ const member = await guild.members
776
+ .fetch(input.memberUserId)
777
+ .catch(() => null);
778
+ if (member && !member.bannable)
779
+ return { outcome: "hierarchy_blocked", directMessage };
780
+ let stillRequired;
781
+ try {
782
+ stillRequired = await input.revalidate();
783
+ }
784
+ catch {
785
+ return { outcome: "failed", directMessage };
786
+ }
787
+ if (!stillRequired)
788
+ return { outcome: "no_longer_required", directMessage };
789
+ try {
790
+ await guild.members.ban(input.memberUserId, {
791
+ deleteMessageSeconds: 0,
792
+ reason: discordAuditReason(input),
793
+ });
794
+ return { outcome: "banned", directMessage };
795
+ }
796
+ catch {
797
+ return { outcome: "failed", directMessage };
798
+ }
799
+ }
800
+ async #sendBestEffortDirectMessage(memberUserId, response) {
801
+ const attempt = async () => {
802
+ try {
803
+ const user = await this.client.users.fetch(memberUserId);
804
+ if (user.id !== memberUserId)
805
+ throw new Error("Discord user validation failed");
806
+ const options = toReplyOptions({ ...response, ephemeral: false });
807
+ await user.send(options);
808
+ return "sent";
809
+ }
810
+ catch (error) {
811
+ return isClosedDirectMessage(error) ? "closed" : "failed";
812
+ }
813
+ };
814
+ return new Promise((resolve) => {
815
+ let settled = false;
816
+ const timer = setTimeout(() => {
817
+ settled = true;
818
+ resolve("failed");
819
+ }, directMessageAttemptTimeoutMs);
820
+ timer.unref();
821
+ void attempt().then((result) => {
822
+ if (settled)
823
+ return;
824
+ settled = true;
825
+ clearTimeout(timer);
826
+ resolve(result);
827
+ });
828
+ });
829
+ }
830
+ }
831
+ const reviewedPublicFlags = [
832
+ [UserFlags.Staff, "staff"],
833
+ [UserFlags.Partner, "partner"],
834
+ [UserFlags.Hypesquad, "hypesquad"],
835
+ [UserFlags.BugHunterLevel1, "bug_hunter_level_1"],
836
+ [UserFlags.BugHunterLevel2, "bug_hunter_level_2"],
837
+ [UserFlags.VerifiedDeveloper, "verified_bot_developer"],
838
+ [UserFlags.ActiveDeveloper, "active_developer"],
839
+ ];
840
+ const reviewedMemberFlags = [
841
+ [GuildMemberFlags.DidRejoin, "did_rejoin"],
842
+ [GuildMemberFlags.CompletedOnboarding, "completed_onboarding"],
843
+ [GuildMemberFlags.BypassesVerification, "bypasses_verification"],
844
+ [GuildMemberFlags.StartedOnboarding, "started_onboarding"],
845
+ ];
846
+ export class DiscordVerificationMemberService {
847
+ client;
848
+ constructor(client) {
849
+ this.client = client;
850
+ }
851
+ async captureSnapshot(input) {
852
+ validateSnowflake(input.guildId, "guild");
853
+ validateSnowflake(input.userId, "user");
854
+ validateSnowflake(input.verifiedRoleId, "verified role");
855
+ const guild = await this.client.guilds.fetch(input.guildId);
856
+ const [member, verifiedRole] = await Promise.all([
857
+ guild.members.fetch(input.userId),
858
+ guild.roles.fetch(input.verifiedRoleId),
859
+ ]);
860
+ if (!verifiedRole || verifiedRole.guild.id !== guild.id)
861
+ throw new Error("The configured verified role is unavailable");
862
+ const me = guild.members.me ?? (await guild.members.fetchMe());
863
+ const publicFlags = await member.user.fetchFlags(true);
864
+ const publicBitfield = BigInt(publicFlags.bitfield);
865
+ const memberBitfield = BigInt(member.flags.bitfield);
866
+ const publicKnownMask = reviewedPublicFlags.reduce((mask, [flag]) => mask | BigInt(flag), 0n);
867
+ const memberKnownMask = reviewedMemberFlags.reduce((mask, [flag]) => mask | BigInt(flag), 0n);
868
+ const unknownPublic = publicBitfield & ~publicKnownMask;
869
+ const unknownMember = memberBitfield & ~memberKnownMask;
870
+ return {
871
+ sourceClassification: input.sourceClassification,
872
+ capturedAt: new Date().toISOString(),
873
+ safeDisplayName: (member.displayName || member.user.username)
874
+ .normalize("NFC")
875
+ .slice(0, 100),
876
+ guildOwnerId: guild.ownerId,
877
+ accountCreatedAt: member.user.createdAt.toISOString(),
878
+ joinedAt: member.joinedAt?.toISOString() ?? null,
879
+ publicFlags: reviewedPublicFlags
880
+ .filter(([flag]) => publicFlags.has(flag))
881
+ .map(([, name]) => name),
882
+ memberFlags: reviewedMemberFlags
883
+ .filter(([flag]) => member.flags.has(flag))
884
+ .map(([, name]) => name),
885
+ ...(unknownPublic === 0n
886
+ ? {}
887
+ : { unknownPublicFlagsBitset: unknownPublic.toString() }),
888
+ ...(unknownMember === 0n
889
+ ? {}
890
+ : { unknownMemberFlagsBitset: unknownMember.toString() }),
891
+ screeningPending: member.pending,
892
+ rejoined: member.flags.has(GuildMemberFlags.DidRejoin),
893
+ nameQuarantined: member.flags.has(GuildMemberFlags.AutomodQuarantinedUsernameOrGuildNickname),
894
+ memberRoleIds: [...member.roles.cache.keys()].slice(0, 100),
895
+ memberHighestRolePosition: member.roles.highest.position,
896
+ botHighestRolePosition: me.roles.highest.position,
897
+ verifiedRolePosition: verifiedRole.position,
898
+ verifiedRoleManageable: verifiedRole.editable,
899
+ isBot: member.user.bot,
900
+ isSystem: member.user.system ?? false,
901
+ };
902
+ }
903
+ async isCurrentlyBanned(input) {
904
+ validateSnowflake(input.guildId, "guild");
905
+ validateSnowflake(input.userId, "user");
906
+ const guild = await this.client.guilds.fetch(input.guildId);
907
+ try {
908
+ const ban = await guild.bans.fetch(input.userId);
909
+ return ban.user.id === input.userId;
910
+ }
911
+ catch (error) {
912
+ if (typeof error === "object" &&
913
+ error !== null &&
914
+ "code" in error &&
915
+ error.code === RESTJSONErrorCodes.UnknownBan)
916
+ return false;
917
+ throw error;
918
+ }
919
+ }
920
+ }
921
+ const verificationSetupMaximumAccessTargets = 50;
922
+ const verificationSetupLeaseMs = 120_000;
923
+ const verificationRoleNameMaximum = 100;
924
+ const verificationChannelNamePattern = /^[a-z0-9][a-z0-9-_]{0,99}$/u;
925
+ const verificationMembershipScreeningFeature = "MEMBER_VERIFICATION_GATE_ENABLED";
926
+ /**
927
+ * The single Discord mutation boundary for Verification setup. It deliberately
928
+ * exposes a preview/apply protocol rather than raw role or overwrite writes.
929
+ */
930
+ export class DiscordVerificationSetupService {
931
+ client;
932
+ persistence;
933
+ #guildLocks = new Map();
934
+ constructor(client, persistence) {
935
+ this.client = client;
936
+ this.persistence = persistence;
937
+ }
938
+ async inspect(input) {
939
+ validateSnowflake(input.guildId, "guild");
940
+ const guild = await this.client.guilds.fetch(input.guildId);
941
+ if (guild.id !== input.guildId)
942
+ throw new Error("Discord guild validation failed");
943
+ const me = guild.members.me ?? (await guild.members.fetchMe());
944
+ const permissions = {
945
+ viewChannel: me.permissions.has(PermissionFlagsBits.ViewChannel),
946
+ sendMessages: me.permissions.has(PermissionFlagsBits.SendMessages),
947
+ embedLinks: me.permissions.has(PermissionFlagsBits.EmbedLinks),
948
+ manageRoles: me.permissions.has(PermissionFlagsBits.ManageRoles),
949
+ manageChannels: me.permissions.has(PermissionFlagsBits.ManageChannels),
950
+ kickMembers: me.permissions.has(PermissionFlagsBits.KickMembers),
951
+ banMembers: me.permissions.has(PermissionFlagsBits.BanMembers),
952
+ administrator: me.permissions.has(PermissionFlagsBits.Administrator),
953
+ };
954
+ const membershipScreeningEnabled = guild.features.includes(verificationMembershipScreeningFeature);
955
+ try {
956
+ const onboarding = await guild.fetchOnboarding();
957
+ return {
958
+ guildId: guild.id,
959
+ permissions,
960
+ onboarding: {
961
+ status: onboarding.enabled ? "enabled" : "disabled",
962
+ mode: onboarding.mode === GuildOnboardingMode.OnboardingDefault
963
+ ? "default"
964
+ : onboarding.mode === GuildOnboardingMode.OnboardingAdvanced
965
+ ? "advanced"
966
+ : "unknown",
967
+ membershipScreeningEnabled,
968
+ defaultChannelIds: [...onboarding.defaultChannels.keys()]
969
+ .slice(0, verificationSetupMaximumAccessTargets)
970
+ .sort((left, right) => left.localeCompare(right, "en")),
971
+ unverifiedInteractionChannelRecommended: onboarding.enabled || membershipScreeningEnabled,
972
+ },
973
+ };
974
+ }
975
+ catch {
976
+ return {
977
+ guildId: guild.id,
978
+ permissions,
979
+ onboarding: {
980
+ status: "unavailable",
981
+ mode: "unknown",
982
+ membershipScreeningEnabled,
983
+ defaultChannelIds: [],
984
+ unverifiedInteractionChannelRecommended: membershipScreeningEnabled,
985
+ },
986
+ };
987
+ }
988
+ }
989
+ async preview(input) {
990
+ validateVerificationSetupRequest(input);
991
+ const guild = await this.client.guilds.fetch(input.guildId);
992
+ if (guild.id !== input.guildId)
993
+ throw new Error("Discord guild validation failed");
994
+ const me = guild.members.me ?? (await guild.members.fetchMe());
995
+ const [inspection, verifiedRole, verificationChannel] = await Promise.all([
996
+ this.inspect({ guildId: guild.id }),
997
+ resolveSetupRole(guild, me, input.verifiedRole),
998
+ resolveSetupChannel(guild, me, input.verificationChannel),
999
+ ]);
1000
+ const accessOverwrites = await Promise.all(input.accessTargetIds.map(async (targetId) => {
1001
+ const channel = await fetchVerificationAccessTarget(guild, targetId);
1002
+ const current = verifiedRole.role
1003
+ ? viewChannelOverwrite(channel, verifiedRole.role.id)
1004
+ : null;
1005
+ return {
1006
+ targetId: channel.id,
1007
+ targetType: verificationAccessTargetType(channel),
1008
+ action: current === true
1009
+ ? "unchanged"
1010
+ : "set_verified_view",
1011
+ };
1012
+ }));
1013
+ const missingPermissions = verificationSetupMissingPermissions({
1014
+ inspection,
1015
+ me,
1016
+ verificationChannel,
1017
+ needsManageChannels: verificationChannel.action === "create" ||
1018
+ accessOverwrites.some(({ action }) => action !== "unchanged"),
1019
+ });
1020
+ const unsigned = {
1021
+ inspection,
1022
+ missingPermissions,
1023
+ verifiedRole: {
1024
+ action: verifiedRole.action,
1025
+ name: verifiedRole.name,
1026
+ ...(verifiedRole.role ? { roleId: verifiedRole.role.id } : {}),
1027
+ manageable: verifiedRole.manageable,
1028
+ },
1029
+ verificationChannel: {
1030
+ action: verificationChannel.action,
1031
+ name: verificationChannel.name,
1032
+ ...(verificationChannel.channel
1033
+ ? { channelId: verificationChannel.channel.id }
1034
+ : {}),
1035
+ usable: verificationChannel.usable,
1036
+ },
1037
+ accessOverwrites,
1038
+ };
1039
+ return {
1040
+ fingerprint: verificationSetupFingerprint(input, unsigned),
1041
+ ...unsigned,
1042
+ };
1043
+ }
1044
+ async apply(input) {
1045
+ if (input.confirmed !== true)
1046
+ throw new Error("Verification setup requires explicit confirmation");
1047
+ if (!/^[a-f0-9]{64}$/u.test(input.previewFingerprint))
1048
+ throw new Error("Verification setup preview fingerprint is invalid");
1049
+ return this.#withGuildLock(input.guildId, async () => {
1050
+ const request = {
1051
+ guildId: input.guildId,
1052
+ verifiedRole: input.verifiedRole,
1053
+ verificationChannel: input.verificationChannel,
1054
+ accessTargetIds: input.accessTargetIds,
1055
+ };
1056
+ const preview = await this.preview(request);
1057
+ if (preview.fingerprint !== input.previewFingerprint)
1058
+ throw new Error("Verification setup changed after preview; review the new plan");
1059
+ if (preview.missingPermissions.length > 0)
1060
+ throw new Error(`Verification setup is missing permissions: ${preview.missingPermissions.join(", ")}`);
1061
+ if (!preview.verifiedRole.manageable)
1062
+ throw new Error("The verified role cannot be managed safely by Helyx");
1063
+ if (!preview.verificationChannel.usable)
1064
+ throw new Error("The verification channel is not usable by Helyx");
1065
+ const guild = await this.client.guilds.fetch(input.guildId);
1066
+ const reason = "Helyx Verification: confirmed setup plan";
1067
+ const leaseOwner = `hosted-bot:${randomUUID()}`;
1068
+ for (let claimAttempt = 0; claimAttempt < 2; claimAttempt += 1) {
1069
+ const claim = await this.persistence.begin({
1070
+ guildId: input.guildId,
1071
+ fingerprint: input.previewFingerprint,
1072
+ selectedTargetIds: input.accessTargetIds,
1073
+ leaseOwner,
1074
+ leaseMs: verificationSetupLeaseMs,
1075
+ });
1076
+ if (claim.kind === "applied")
1077
+ return claim.result;
1078
+ if (claim.kind === "busy")
1079
+ throw new Error("Another Verification setup operation is in progress");
1080
+ if (claim.kind === "recover") {
1081
+ await this.#recoverSetup(guild, claim.state, reason);
1082
+ await this.persistence.abort(claim.state);
1083
+ continue;
1084
+ }
1085
+ return this.#applyClaimedSetup(guild, preview, claim.state, reason);
1086
+ }
1087
+ throw new Error("Verification setup recovery did not converge");
1088
+ });
1089
+ }
1090
+ async #applyClaimedSetup(guild, preview, initialState, reason) {
1091
+ let state = initialState;
1092
+ let uncheckpointedRole = null;
1093
+ let uncheckpointedChannel = null;
1094
+ try {
1095
+ let role = state.roleId ? await guild.roles.fetch(state.roleId) : null;
1096
+ if (!role) {
1097
+ if (preview.verifiedRole.action === "create") {
1098
+ role = await findPendingVerificationRole(guild, state.requestedFingerprint);
1099
+ if (!role)
1100
+ try {
1101
+ role = await guild.roles.create({
1102
+ name: verificationPendingRoleName(state.requestedFingerprint),
1103
+ permissions: [],
1104
+ reason,
1105
+ });
1106
+ }
1107
+ catch (error) {
1108
+ role = await findPendingVerificationRole(guild, state.requestedFingerprint);
1109
+ if (!role)
1110
+ throw error;
1111
+ }
1112
+ uncheckpointedRole = role;
1113
+ state = await this.persistence.checkpointResources(state, { roleId: role.id, createdRole: true }, verificationSetupLeaseMs);
1114
+ uncheckpointedRole = null;
1115
+ if (role.name !== preview.verifiedRole.name)
1116
+ role = await role.setName(preview.verifiedRole.name, reason);
1117
+ }
1118
+ else {
1119
+ role = await guild.roles.fetch(preview.verifiedRole.roleId);
1120
+ if (role)
1121
+ state = await this.persistence.checkpointResources(state, { roleId: role.id, createdRole: false }, verificationSetupLeaseMs);
1122
+ }
1123
+ }
1124
+ if (!role || role.guild.id !== guild.id)
1125
+ throw new Error("The verified role became unavailable during setup");
1126
+ let channel = state.channelId
1127
+ ? await fetchVerificationTextChannel(guild, state.channelId)
1128
+ : null;
1129
+ if (!channel) {
1130
+ if (preview.verificationChannel.action === "create") {
1131
+ channel = await findPendingVerificationChannel(guild, state.requestedFingerprint);
1132
+ if (!channel)
1133
+ try {
1134
+ channel = await guild.channels.create({
1135
+ name: verificationPendingChannelName(state.requestedFingerprint),
1136
+ type: ChannelType.GuildText,
1137
+ reason,
1138
+ });
1139
+ }
1140
+ catch (error) {
1141
+ channel = await findPendingVerificationChannel(guild, state.requestedFingerprint);
1142
+ if (!channel)
1143
+ throw error;
1144
+ }
1145
+ uncheckpointedChannel = channel;
1146
+ state = await this.persistence.checkpointResources(state, { channelId: channel.id, createdChannel: true }, verificationSetupLeaseMs);
1147
+ uncheckpointedChannel = null;
1148
+ if (channel.name !== preview.verificationChannel.name)
1149
+ channel = await channel.setName(preview.verificationChannel.name, reason);
1150
+ }
1151
+ else {
1152
+ channel = await fetchVerificationTextChannel(guild, preview.verificationChannel.channelId);
1153
+ if (channel)
1154
+ state = await this.persistence.checkpointResources(state, { channelId: channel.id, createdChannel: false }, verificationSetupLeaseMs);
1155
+ }
1156
+ }
1157
+ if (!channel || channel.guild.id !== guild.id)
1158
+ throw new Error("The verification channel became unavailable during setup");
1159
+ const desiredKeys = new Set(state.selectedTargetIds.map((targetId) => verificationManagedOverwriteKey(targetId, role.id)));
1160
+ const removesPreviousManagedOverwrite = state.managedOverwrites.some((overwrite) => !desiredKeys.has(verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId)));
1161
+ await assertVerificationSetupStillSafe(guild, role, channel, {
1162
+ needsManageChannels: state.createdChannel ||
1163
+ removesPreviousManagedOverwrite ||
1164
+ preview.accessOverwrites.some(({ action }) => action !== "unchanged"),
1165
+ });
1166
+ for (const overwrite of [...state.managedOverwrites]) {
1167
+ const key = verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId);
1168
+ if (desiredKeys.has(key))
1169
+ continue;
1170
+ state = await this.#checkpointOverwriteState(state, {
1171
+ ...overwrite,
1172
+ status: "restoring",
1173
+ });
1174
+ await restoreVerificationViewOverwrite(guild, overwrite, reason);
1175
+ state = await this.#removeCheckpointedOverwrite(state, key);
1176
+ }
1177
+ for (const targetId of state.selectedTargetIds) {
1178
+ const target = await fetchVerificationAccessTarget(guild, targetId);
1179
+ const key = verificationManagedOverwriteKey(target.id, role.id);
1180
+ let tracked = state.managedOverwrites.find((overwrite) => verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId) === key);
1181
+ const current = viewChannelOverwrite(target, role.id);
1182
+ if (!tracked && current === true)
1183
+ continue;
1184
+ if (!tracked) {
1185
+ tracked = {
1186
+ targetId: target.id,
1187
+ roleId: role.id,
1188
+ previousView: current,
1189
+ status: "planned",
1190
+ };
1191
+ state = await this.#checkpointOverwriteState(state, tracked);
1192
+ }
1193
+ else if (tracked.status !== "planned" && current !== true) {
1194
+ tracked = { ...tracked, status: "planned" };
1195
+ state = await this.#checkpointOverwriteState(state, tracked);
1196
+ }
1197
+ if (current !== true) {
1198
+ await target.permissionOverwrites.edit(role, { ViewChannel: true }, { reason });
1199
+ }
1200
+ tracked = { ...tracked, status: "applied" };
1201
+ state = await this.#checkpointOverwriteState(state, tracked, current === true ? undefined : target.id);
1202
+ }
1203
+ return this.persistence.complete(state, {
1204
+ verifiedRoleId: role.id,
1205
+ verificationChannelId: channel.id,
1206
+ createdRole: state.createdRole,
1207
+ createdChannel: state.createdChannel,
1208
+ changedAccessTargetIds: state.changedTargetIds,
1209
+ });
1210
+ }
1211
+ catch (error) {
1212
+ if (error instanceof VerificationSetupLeaseLostError)
1213
+ throw error;
1214
+ const rollbackFailures = [];
1215
+ for (const resource of [uncheckpointedChannel, uncheckpointedRole]) {
1216
+ if (!resource)
1217
+ continue;
1218
+ try {
1219
+ await resource.delete(`${reason} rollback`);
1220
+ }
1221
+ catch (rollbackError) {
1222
+ rollbackFailures.push(rollbackError);
1223
+ }
1224
+ }
1225
+ try {
1226
+ await this.#recoverSetup(guild, state, `${reason} rollback`);
1227
+ await this.persistence.abort(state);
1228
+ }
1229
+ catch (rollbackError) {
1230
+ rollbackFailures.push(rollbackError);
1231
+ }
1232
+ if (rollbackFailures.length > 0)
1233
+ throw new AggregateError(rollbackFailures, "Verification setup failed and rollback was incomplete", { cause: error });
1234
+ throw error;
1235
+ }
1236
+ }
1237
+ async #recoverSetup(guild, state, reason) {
1238
+ const applied = new Map(state.appliedManagedOverwrites.map((overwrite) => [
1239
+ verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId),
1240
+ overwrite,
1241
+ ]));
1242
+ for (const pending of state.managedOverwrites) {
1243
+ const key = verificationManagedOverwriteKey(pending.targetId, pending.roleId);
1244
+ if (applied.has(key)) {
1245
+ await setVerificationViewOverwrite(guild, pending.targetId, pending.roleId, true, reason);
1246
+ applied.delete(key);
1247
+ }
1248
+ else {
1249
+ await restoreVerificationViewOverwrite(guild, pending, reason);
1250
+ }
1251
+ }
1252
+ for (const previous of applied.values()) {
1253
+ await setVerificationViewOverwrite(guild, previous.targetId, previous.roleId, true, reason);
1254
+ }
1255
+ const appliedResult = state.appliedResult;
1256
+ if (state.createdChannel &&
1257
+ state.channelId &&
1258
+ state.channelId !== appliedResult?.verificationChannelId) {
1259
+ const createdChannel = await guild.channels.fetch(state.channelId);
1260
+ if (createdChannel)
1261
+ await createdChannel.delete(reason);
1262
+ }
1263
+ if (state.createdRole &&
1264
+ state.roleId &&
1265
+ state.roleId !== appliedResult?.verifiedRoleId) {
1266
+ const createdRole = await guild.roles.fetch(state.roleId);
1267
+ if (createdRole)
1268
+ await createdRole.delete(reason);
1269
+ }
1270
+ const pendingChannel = await findPendingVerificationChannel(guild, state.requestedFingerprint);
1271
+ if (pendingChannel &&
1272
+ pendingChannel.id !== appliedResult?.verificationChannelId)
1273
+ await pendingChannel.delete(reason);
1274
+ const pendingRole = await findPendingVerificationRole(guild, state.requestedFingerprint);
1275
+ if (pendingRole && pendingRole.id !== appliedResult?.verifiedRoleId)
1276
+ await pendingRole.delete(reason);
1277
+ }
1278
+ async #checkpointOverwriteState(state, overwrite, changedTargetId) {
1279
+ const key = verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId);
1280
+ const next = state.managedOverwrites.filter((existing) => verificationManagedOverwriteKey(existing.targetId, existing.roleId) !==
1281
+ key);
1282
+ next.push(overwrite);
1283
+ const changed = changedTargetId
1284
+ ? [...new Set([...state.changedTargetIds, changedTargetId])]
1285
+ : state.changedTargetIds;
1286
+ return this.persistence.checkpointOverwrites(state, next, changed, verificationSetupLeaseMs);
1287
+ }
1288
+ #removeCheckpointedOverwrite(state, key) {
1289
+ return this.persistence.checkpointOverwrites(state, state.managedOverwrites.filter((overwrite) => verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId) !== key), state.changedTargetIds, verificationSetupLeaseMs);
1290
+ }
1291
+ async hideVerificationChannelForVerifiedMember(input) {
1292
+ validateMutationMetadata({
1293
+ guildId: input.guildId,
1294
+ memberUserId: input.memberUserId,
1295
+ roleId: input.verifiedRoleId,
1296
+ moduleId: input.moduleId,
1297
+ reason: input.reason,
1298
+ });
1299
+ validateSnowflake(input.channelId, "channel");
1300
+ const guild = await this.client.guilds.fetch(input.guildId);
1301
+ const [member, role, fetchedChannel] = await Promise.all([
1302
+ guild.members.fetch(input.memberUserId),
1303
+ guild.roles.fetch(input.verifiedRoleId),
1304
+ guild.channels.fetch(input.channelId),
1305
+ ]);
1306
+ if (!role || role.guild.id !== guild.id)
1307
+ throw new Error("The verified role is unavailable");
1308
+ if (!fetchedChannel ||
1309
+ fetchedChannel.guild.id !== guild.id ||
1310
+ fetchedChannel.type !== ChannelType.GuildText)
1311
+ throw new Error("The verification channel is unavailable");
1312
+ if (!member.roles.cache.has(role.id))
1313
+ throw new Error("The member does not hold the verified role");
1314
+ const me = guild.members.me ?? (await guild.members.fetchMe());
1315
+ if (!me.permissions.has(PermissionFlagsBits.ManageChannels))
1316
+ throw new Error("Helyx requires Manage Channels to hide this channel");
1317
+ if (!verificationRoleManageable(role, guild, me))
1318
+ throw new Error("The verified role cannot be managed safely by Helyx");
1319
+ if (viewChannelOverwrite(fetchedChannel, role.id) === false)
1320
+ return { changed: false };
1321
+ await fetchedChannel.permissionOverwrites.edit(role, { ViewChannel: false }, { reason: discordAuditReason(input) });
1322
+ return { changed: true };
1323
+ }
1324
+ async #withGuildLock(guildId, operation) {
1325
+ validateSnowflake(guildId, "guild");
1326
+ const previous = this.#guildLocks.get(guildId) ?? Promise.resolve();
1327
+ let release;
1328
+ const gate = new Promise((resolve) => {
1329
+ release = resolve;
1330
+ });
1331
+ const current = previous.catch(() => undefined).then(() => gate);
1332
+ this.#guildLocks.set(guildId, current);
1333
+ await previous.catch(() => undefined);
1334
+ try {
1335
+ return await operation();
1336
+ }
1337
+ finally {
1338
+ release();
1339
+ if (this.#guildLocks.get(guildId) === current)
1340
+ this.#guildLocks.delete(guildId);
1341
+ }
1342
+ }
1343
+ }
1344
+ async function resolveSetupRole(guild, me, selection) {
1345
+ if (selection.mode === "create") {
1346
+ const role = selection.trackedRoleId
1347
+ ? await guild.roles.fetch(selection.trackedRoleId)
1348
+ : null;
1349
+ if (!role)
1350
+ return {
1351
+ action: "create",
1352
+ name: selection.name,
1353
+ role: null,
1354
+ manageable: me.permissions.has(PermissionFlagsBits.ManageRoles),
1355
+ };
1356
+ return {
1357
+ action: "reuse",
1358
+ name: role.name,
1359
+ role,
1360
+ manageable: verificationRoleManageable(role, guild, me),
1361
+ };
1362
+ }
1363
+ const role = await guild.roles.fetch(selection.roleId);
1364
+ if (!role || role.guild.id !== guild.id)
1365
+ throw new Error("The selected verified role is unavailable");
1366
+ return {
1367
+ action: "select",
1368
+ name: role.name,
1369
+ role,
1370
+ manageable: verificationRoleManageable(role, guild, me),
1371
+ };
1372
+ }
1373
+ async function resolveSetupChannel(guild, me, selection) {
1374
+ if (selection.mode === "create") {
1375
+ const fetched = selection.trackedChannelId
1376
+ ? await guild.channels.fetch(selection.trackedChannelId)
1377
+ : null;
1378
+ if (!fetched)
1379
+ return {
1380
+ action: "create",
1381
+ name: selection.name,
1382
+ channel: null,
1383
+ usable: me.permissions.has(PermissionFlagsBits.ViewChannel) &&
1384
+ me.permissions.has(PermissionFlagsBits.SendMessages) &&
1385
+ me.permissions.has(PermissionFlagsBits.EmbedLinks) &&
1386
+ me.permissions.has(PermissionFlagsBits.ManageChannels),
1387
+ };
1388
+ if (fetched.type !== ChannelType.GuildText)
1389
+ throw new Error("The tracked verification channel is not a text channel");
1390
+ return {
1391
+ action: "reuse",
1392
+ name: fetched.name,
1393
+ channel: fetched,
1394
+ usable: verificationChannelUsable(fetched, me),
1395
+ };
1396
+ }
1397
+ const fetched = await guild.channels.fetch(selection.channelId);
1398
+ if (!fetched || fetched.guild.id !== guild.id)
1399
+ throw new Error("The selected verification channel is unavailable");
1400
+ if (fetched.type !== ChannelType.GuildText)
1401
+ throw new Error("The selected verification channel is not a text channel");
1402
+ return {
1403
+ action: "select",
1404
+ name: fetched.name,
1405
+ channel: fetched,
1406
+ usable: verificationChannelUsable(fetched, me),
1407
+ };
1408
+ }
1409
+ function verificationChannelUsable(channel, me) {
1410
+ const effective = channel.permissionsFor(me);
1411
+ return Boolean(effective?.has(PermissionFlagsBits.ViewChannel) &&
1412
+ effective.has(PermissionFlagsBits.SendMessages) &&
1413
+ effective.has(PermissionFlagsBits.EmbedLinks));
1414
+ }
1415
+ function verificationRoleManageable(role, guild, me) {
1416
+ return (role.guild.id === guild.id &&
1417
+ role.id !== guild.id &&
1418
+ !role.managed &&
1419
+ !role.permissions.has(PermissionFlagsBits.Administrator) &&
1420
+ me.permissions.has(PermissionFlagsBits.ManageRoles) &&
1421
+ role.comparePositionTo(me.roles.highest) < 0);
1422
+ }
1423
+ async function fetchVerificationAccessTarget(guild, targetId) {
1424
+ const channel = await guild.channels.fetch(targetId);
1425
+ if (!channel || channel.guild.id !== guild.id)
1426
+ throw new Error("A selected verification access target is unavailable");
1427
+ if (channel.type !== ChannelType.GuildText &&
1428
+ channel.type !== ChannelType.GuildAnnouncement &&
1429
+ channel.type !== ChannelType.GuildCategory)
1430
+ throw new Error("Verification access targets must be text channels or categories");
1431
+ return channel;
1432
+ }
1433
+ async function fetchVerificationTextChannel(guild, channelId) {
1434
+ const channel = await guild.channels.fetch(channelId);
1435
+ return channel?.guild.id === guild.id &&
1436
+ channel.type === ChannelType.GuildText
1437
+ ? channel
1438
+ : null;
1439
+ }
1440
+ function verificationPendingRoleName(fingerprint) {
1441
+ return `Helyx verify pending ${fingerprint.slice(0, 12)}`;
1442
+ }
1443
+ function verificationPendingChannelName(fingerprint) {
1444
+ return `helyx-verify-pending-${fingerprint.slice(0, 12)}`;
1445
+ }
1446
+ async function findPendingVerificationRole(guild, fingerprint) {
1447
+ const expectedName = verificationPendingRoleName(fingerprint);
1448
+ const roles = await guild.roles.fetch();
1449
+ return roles.find((role) => role.name === expectedName) ?? null;
1450
+ }
1451
+ async function findPendingVerificationChannel(guild, fingerprint) {
1452
+ const expectedName = verificationPendingChannelName(fingerprint);
1453
+ const channels = await guild.channels.fetch();
1454
+ const channel = channels.find((candidate) => candidate !== null &&
1455
+ candidate.type === ChannelType.GuildText &&
1456
+ candidate.name === expectedName);
1457
+ return channel?.type === ChannelType.GuildText ? channel : null;
1458
+ }
1459
+ function verificationManagedOverwriteKey(targetId, roleId) {
1460
+ return `${targetId}:${roleId}`;
1461
+ }
1462
+ async function restoreVerificationViewOverwrite(guild, overwrite, reason) {
1463
+ await setVerificationViewOverwrite(guild, overwrite.targetId, overwrite.roleId, overwrite.previousView, reason);
1464
+ }
1465
+ async function setVerificationViewOverwrite(guild, targetId, roleId, viewChannel, reason) {
1466
+ const [fetched, role] = await Promise.all([
1467
+ guild.channels.fetch(targetId),
1468
+ guild.roles.fetch(roleId),
1469
+ ]);
1470
+ if (!fetched || !role)
1471
+ return;
1472
+ if (fetched.guild.id !== guild.id ||
1473
+ (fetched.type !== ChannelType.GuildText &&
1474
+ fetched.type !== ChannelType.GuildAnnouncement &&
1475
+ fetched.type !== ChannelType.GuildCategory))
1476
+ throw new Error("A tracked verification access target is invalid");
1477
+ if (viewChannelOverwrite(fetched, roleId) === viewChannel)
1478
+ return;
1479
+ await fetched.permissionOverwrites.edit(roleId, { ViewChannel: viewChannel }, { reason });
1480
+ }
1481
+ function verificationAccessTargetType(channel) {
1482
+ if (channel.type === ChannelType.GuildCategory)
1483
+ return "category";
1484
+ if (channel.type === ChannelType.GuildAnnouncement)
1485
+ return "announcement";
1486
+ return "text";
1487
+ }
1488
+ function viewChannelOverwrite(channel, roleId) {
1489
+ const overwrite = channel.permissionOverwrites.cache.get(roleId);
1490
+ if (!overwrite)
1491
+ return null;
1492
+ if (overwrite.allow.has(PermissionFlagsBits.ViewChannel))
1493
+ return true;
1494
+ if (overwrite.deny.has(PermissionFlagsBits.ViewChannel))
1495
+ return false;
1496
+ return null;
1497
+ }
1498
+ function verificationSetupMissingPermissions(input) {
1499
+ const missing = [];
1500
+ const effective = input.verificationChannel.channel?.permissionsFor(input.me);
1501
+ const has = (permission, guildFallback) => effective ? effective.has(permission) : guildFallback;
1502
+ if (!has(PermissionFlagsBits.ViewChannel, input.inspection.permissions.viewChannel))
1503
+ missing.push("ViewChannel");
1504
+ if (!has(PermissionFlagsBits.SendMessages, input.inspection.permissions.sendMessages))
1505
+ missing.push("SendMessages");
1506
+ if (!has(PermissionFlagsBits.EmbedLinks, input.inspection.permissions.embedLinks))
1507
+ missing.push("EmbedLinks");
1508
+ if (!input.inspection.permissions.manageRoles)
1509
+ missing.push("ManageRoles");
1510
+ if (input.needsManageChannels && !input.inspection.permissions.manageChannels)
1511
+ missing.push("ManageChannels");
1512
+ return missing;
1513
+ }
1514
+ async function assertVerificationSetupStillSafe(guild, role, channel, input) {
1515
+ const me = guild.members.me ?? (await guild.members.fetchMe());
1516
+ if (!verificationRoleManageable(role, guild, me))
1517
+ throw new Error("The verified role is no longer manageable by Helyx");
1518
+ if (!verificationChannelUsable(channel, me))
1519
+ throw new Error("The verification channel is no longer usable by Helyx");
1520
+ if (input.needsManageChannels &&
1521
+ !me.permissions.has(PermissionFlagsBits.ManageChannels))
1522
+ throw new Error("Helyx no longer has Manage Channels");
1523
+ }
1524
+ function validateVerificationSetupRequest(input) {
1525
+ validateSnowflake(input.guildId, "guild");
1526
+ if (input.verifiedRole.mode === "existing")
1527
+ validateSnowflake(input.verifiedRole.roleId, "verified role");
1528
+ else {
1529
+ validateVerificationRoleName(input.verifiedRole.name);
1530
+ if (input.verifiedRole.trackedRoleId)
1531
+ validateSnowflake(input.verifiedRole.trackedRoleId, "tracked role");
1532
+ }
1533
+ if (input.verificationChannel.mode === "existing")
1534
+ validateSnowflake(input.verificationChannel.channelId, "verification channel");
1535
+ else {
1536
+ if (!verificationChannelNamePattern.test(input.verificationChannel.name))
1537
+ throw new Error("Verification channel name is invalid");
1538
+ if (input.verificationChannel.trackedChannelId)
1539
+ validateSnowflake(input.verificationChannel.trackedChannelId, "tracked channel");
1540
+ }
1541
+ if (input.accessTargetIds.length > verificationSetupMaximumAccessTargets ||
1542
+ new Set(input.accessTargetIds).size !== input.accessTargetIds.length)
1543
+ throw new Error("Verification access targets must be unique and bounded");
1544
+ for (const targetId of input.accessTargetIds)
1545
+ validateSnowflake(targetId, "access target");
1546
+ }
1547
+ function validateVerificationRoleName(name) {
1548
+ const normalized = name.trim();
1549
+ if (normalized.length === 0 ||
1550
+ normalized.length > verificationRoleNameMaximum ||
1551
+ /[\r\n]/u.test(normalized) ||
1552
+ normalized === "@everyone" ||
1553
+ normalized === "@here")
1554
+ throw new Error("Verification role name is invalid");
1555
+ }
1556
+ function verificationSetupFingerprint(request, preview) {
1557
+ return createHash("sha256")
1558
+ .update(stableVerificationSetupJson({
1559
+ protocol: "helyx-verification-setup-v1",
1560
+ request,
1561
+ preview,
1562
+ }))
1563
+ .digest("hex");
1564
+ }
1565
+ function stableVerificationSetupJson(value) {
1566
+ if (value === null || typeof value !== "object")
1567
+ return JSON.stringify(value);
1568
+ if (Array.isArray(value))
1569
+ return `[${value.map(stableVerificationSetupJson).join(",")}]`;
1570
+ const record = value;
1571
+ return `{${Object.keys(record)
1572
+ .sort((left, right) => left.localeCompare(right, "en"))
1573
+ .map((key) => `${JSON.stringify(key)}:${stableVerificationSetupJson(record[key])}`)
1574
+ .join(",")}}`;
1575
+ }
408
1576
  export class DiscordMemberRoleService {
409
1577
  client;
410
1578
  constructor(client) {
411
1579
  this.client = client;
412
1580
  }
1581
+ async createApprovedRole(input) {
1582
+ validateRoleCreationMetadata(input);
1583
+ const guild = await this.client.guilds.fetch(input.guildId);
1584
+ if (guild.id !== input.guildId)
1585
+ throw new Error("Discord guild validation failed");
1586
+ const me = guild.members.me ?? (await guild.members.fetchMe());
1587
+ if (!me.permissions.has(PermissionFlagsBits.ManageRoles))
1588
+ throw new Error("Helyx requires Manage Roles to create this reward role");
1589
+ const role = await guild.roles.create({
1590
+ name: input.name,
1591
+ permissions: [],
1592
+ reason: discordAuditReason(input),
1593
+ });
1594
+ if (role.guild.id !== guild.id)
1595
+ throw new Error("Created Discord role did not belong to this guild");
1596
+ await fetchSafeRole(guild, role.id);
1597
+ return { roleId: role.id, name: role.name };
1598
+ }
413
1599
  async getCurrentRoleIds(input) {
414
1600
  const { member } = await this.fetchGuildAndMember(input);
415
1601
  return [...member.roles.cache.keys()];
@@ -452,10 +1638,10 @@ export class DiscordMemberRoleService {
452
1638
  const addedRoles = addedIds.map((roleId) => approvedRoles.get(roleId));
453
1639
  const removedRoles = removedIds.map((roleId) => approvedRoles.get(roleId));
454
1640
  const reason = discordAuditReason(input);
455
- if (removedRoles.length > 0)
456
- await member.roles.remove(removedRoles, reason);
457
1641
  if (addedRoles.length > 0)
458
1642
  await member.roles.add(addedRoles, reason);
1643
+ if (removedRoles.length > 0)
1644
+ await member.roles.remove(removedRoles, reason);
459
1645
  return { added: addedIds, removed: removedIds };
460
1646
  }
461
1647
  async fetchGuildAndMember(input) {
@@ -475,6 +1661,32 @@ export class DiscordResourceInspection {
475
1661
  constructor(client) {
476
1662
  this.client = client;
477
1663
  }
1664
+ async inspectGuildCapabilities(input) {
1665
+ validateSnowflake(input.guildId, "guild");
1666
+ const guild = await this.client.guilds.fetch(input.guildId);
1667
+ if (guild.id !== input.guildId)
1668
+ throw new Error("Discord guild validation failed");
1669
+ const me = guild.members.me ?? (await guild.members.fetchMe());
1670
+ const effectivePermissionNames = me.permissions
1671
+ .toArray()
1672
+ .map(normalizePermissionName)
1673
+ .sort();
1674
+ return {
1675
+ guildId: guild.id,
1676
+ effectivePermissionNames,
1677
+ manageRoles: me.permissions.has(PermissionFlagsBits.ManageRoles),
1678
+ kickMembers: me.permissions.has(PermissionFlagsBits.KickMembers),
1679
+ };
1680
+ }
1681
+ inspectRuntimeCapabilities() {
1682
+ const intents = this.client.options.intents;
1683
+ return Promise.resolve({
1684
+ guildMembers: intents.has(GatewayIntentBits.GuildMembers),
1685
+ guildMessages: intents.has(GatewayIntentBits.GuildMessages),
1686
+ guildMessageReactions: intents.has(GatewayIntentBits.GuildMessageReactions),
1687
+ messageContent: intents.has(GatewayIntentBits.MessageContent),
1688
+ });
1689
+ }
478
1690
  async resolveMemberSnapshots(input) {
479
1691
  const guild = await this.#resolveMemberGuild(input);
480
1692
  return Promise.all(input.userIds.map(async (userId) => {
@@ -502,12 +1714,41 @@ export class DiscordResourceInspection {
502
1714
  return null;
503
1715
  throw error;
504
1716
  });
1717
+ const avatarUrl = memberAvatarUrl(member);
505
1718
  return {
506
1719
  userId,
507
1720
  displayName: member?.displayName ?? null,
1721
+ ...(avatarUrl ? { avatarUrl } : {}),
508
1722
  };
509
1723
  }));
510
1724
  }
1725
+ async searchMembers(input) {
1726
+ validateSnowflake(input.guildId, "guild");
1727
+ const query = input.query.trim();
1728
+ if (query.length < 1 || query.length > 100)
1729
+ throw new Error("Discord member search query is invalid");
1730
+ if (!Number.isSafeInteger(input.limit) ||
1731
+ input.limit < 1 ||
1732
+ input.limit > 25)
1733
+ throw new Error("Discord member search limit is invalid");
1734
+ const guild = await this.client.guilds.fetch(input.guildId);
1735
+ if (guild.id !== input.guildId)
1736
+ throw new Error("Discord guild validation failed");
1737
+ const members = await guild.members.search({
1738
+ query,
1739
+ limit: input.limit,
1740
+ cache: false,
1741
+ });
1742
+ return [...members.values()].map((member) => {
1743
+ const avatarUrl = memberAvatarUrl(member);
1744
+ return {
1745
+ userId: member.id,
1746
+ displayName: member.displayName,
1747
+ ...(avatarUrl ? { avatarUrl } : {}),
1748
+ isBot: member.user.bot,
1749
+ };
1750
+ });
1751
+ }
511
1752
  async #resolveMemberGuild(input) {
512
1753
  validateSnowflake(input.guildId, "guild");
513
1754
  if (input.userIds.length > 50 ||
@@ -640,6 +1881,7 @@ export class DiscordResourceInspection {
640
1881
  sendMessages: false,
641
1882
  addReactions: false,
642
1883
  readMessageHistory: false,
1884
+ manageMessages: false,
643
1885
  };
644
1886
  const guildMatches = channel.guild.id === guild.id;
645
1887
  const me = guild.members.me ?? (await guild.members.fetchMe());
@@ -665,8 +1907,198 @@ export class DiscordResourceInspection {
665
1907
  (effective?.has(PermissionFlagsBits.AddReactions) ?? false),
666
1908
  readMessageHistory: guildMatches &&
667
1909
  (effective?.has(PermissionFlagsBits.ReadMessageHistory) ?? false),
1910
+ manageMessages: guildMatches &&
1911
+ (effective?.has(PermissionFlagsBits.ManageMessages) ?? false),
668
1912
  };
669
1913
  }
1914
+ async inspectMessage(input) {
1915
+ validateSnowflake(input.guildId, "guild");
1916
+ validateSnowflake(input.channelId, "channel");
1917
+ validateSnowflake(input.messageId, "message");
1918
+ const guild = await this.client.guilds.fetch(input.guildId);
1919
+ if (guild.id !== input.guildId)
1920
+ throw new Error("Discord guild validation failed");
1921
+ const channel = await guild.channels.fetch(input.channelId);
1922
+ if (!channel ||
1923
+ channel.guild.id !== guild.id ||
1924
+ !channel.isTextBased() ||
1925
+ channel.isDMBased()) {
1926
+ return missingMessageInspection(input, false);
1927
+ }
1928
+ const message = await channel.messages
1929
+ .fetch(input.messageId)
1930
+ .catch((error) => {
1931
+ if (isUnknownMessage(error))
1932
+ return null;
1933
+ throw error;
1934
+ });
1935
+ if (!message)
1936
+ return missingMessageInspection(input, true);
1937
+ const guildMatches = message.guildId === guild.id;
1938
+ const channelMatches = message.channelId === channel.id;
1939
+ return {
1940
+ guildId: guild.id,
1941
+ channelId: channel.id,
1942
+ messageId: message.id,
1943
+ exists: guildMatches && channelMatches && message.id === input.messageId,
1944
+ guildMatches,
1945
+ channelMatches,
1946
+ authorUserId: message.author.id,
1947
+ };
1948
+ }
1949
+ async inspectChannelAudience(input) {
1950
+ validateSnowflake(input.guildId, "guild");
1951
+ validateSnowflake(input.channelId, "channel");
1952
+ validateBoundedRoleSet(input.roleIds, "audience");
1953
+ const guild = await this.client.guilds.fetch(input.guildId);
1954
+ if (guild.id !== input.guildId)
1955
+ throw new Error("Discord guild validation failed");
1956
+ const roles = await Promise.all(input.roleIds.map(async (roleId) => ({
1957
+ roleId,
1958
+ role: await guild.roles.fetch(roleId),
1959
+ })));
1960
+ const channel = await guild.channels.fetch(input.channelId);
1961
+ if (!channel) {
1962
+ return {
1963
+ guildId: guild.id,
1964
+ channelId: input.channelId,
1965
+ exists: false,
1966
+ guildMatches: false,
1967
+ everyoneCanView: false,
1968
+ roles: roles.map(({ roleId, role }) => ({
1969
+ roleId,
1970
+ exists: role !== null && role.guild.id === guild.id,
1971
+ canView: false,
1972
+ })),
1973
+ };
1974
+ }
1975
+ const guildMatches = channel.guild.id === guild.id;
1976
+ return {
1977
+ guildId: guild.id,
1978
+ channelId: channel.id,
1979
+ exists: true,
1980
+ guildMatches,
1981
+ everyoneCanView: guildMatches &&
1982
+ (channel
1983
+ .permissionsFor(guild.roles.everyone)
1984
+ ?.has(PermissionFlagsBits.ViewChannel) ??
1985
+ false),
1986
+ roles: roles.map(({ roleId, role }) => {
1987
+ const exists = role !== null && role.guild.id === guild.id;
1988
+ return {
1989
+ roleId,
1990
+ exists,
1991
+ canView: guildMatches &&
1992
+ role !== null &&
1993
+ role.guild.id === guild.id &&
1994
+ (channel
1995
+ .permissionsFor(role)
1996
+ ?.has(PermissionFlagsBits.ViewChannel) ??
1997
+ false),
1998
+ };
1999
+ }),
2000
+ };
2001
+ }
2002
+ async inspectGuildChannelAudience(input) {
2003
+ validateSnowflake(input.guildId, "guild");
2004
+ validateBoundedRoleSet(input.roleIds, "audience");
2005
+ const guild = await this.client.guilds.fetch(input.guildId);
2006
+ if (guild.id !== input.guildId)
2007
+ throw new Error("Discord guild validation failed");
2008
+ const resolvedRoles = await Promise.all(input.roleIds.map(async (roleId) => ({
2009
+ roleId,
2010
+ role: await guild.roles.fetch(roleId),
2011
+ })));
2012
+ const missingRoleIds = resolvedRoles
2013
+ .filter(({ role }) => role === null || role.guild.id !== guild.id)
2014
+ .map(({ roleId }) => roleId);
2015
+ const fetched = await guild.channels.fetch();
2016
+ const relevant = [];
2017
+ for (const channel of fetched.values()) {
2018
+ if (channel &&
2019
+ (channel.type === ChannelType.GuildText ||
2020
+ channel.type === ChannelType.GuildAnnouncement)) {
2021
+ if (channel.guild.id !== guild.id)
2022
+ throw new Error("Discord channel guild validation failed");
2023
+ relevant.push(channel);
2024
+ }
2025
+ }
2026
+ relevant.sort((left, right) => left.id.localeCompare(right.id, "en"));
2027
+ const truncated = relevant.length > guildChannelAudienceInspectionLimit;
2028
+ const channels = relevant
2029
+ .slice(0, guildChannelAudienceInspectionLimit)
2030
+ .map((channel) => {
2031
+ const everyoneCanView = channel
2032
+ .permissionsFor(guild.roles.everyone)
2033
+ ?.has(PermissionFlagsBits.ViewChannel) ?? false;
2034
+ const visibleRoleIds = resolvedRoles
2035
+ .filter(({ role }) => role !== null &&
2036
+ role.guild.id === guild.id &&
2037
+ (channel
2038
+ .permissionsFor(role)
2039
+ ?.has(PermissionFlagsBits.ViewChannel) ??
2040
+ false))
2041
+ .map(({ roleId }) => roleId);
2042
+ return {
2043
+ channelId: channel.id,
2044
+ type: channel.type === ChannelType.GuildText
2045
+ ? "guild_text"
2046
+ : "guild_announcement",
2047
+ everyoneCanView,
2048
+ visibleRoleIds,
2049
+ };
2050
+ })
2051
+ .filter(({ everyoneCanView, visibleRoleIds }) => everyoneCanView || visibleRoleIds.length > 0);
2052
+ return {
2053
+ guildId: guild.id,
2054
+ channels,
2055
+ missingRoleIds,
2056
+ truncated,
2057
+ };
2058
+ }
2059
+ async seedMessageReaction(input) {
2060
+ validateSnowflake(input.guildId, "guild");
2061
+ validateSnowflake(input.channelId, "channel");
2062
+ validateSnowflake(input.messageId, "message");
2063
+ const guild = await this.client.guilds.fetch(input.guildId);
2064
+ if (guild.id !== input.guildId)
2065
+ throw new Error("Discord guild validation failed");
2066
+ const channel = await guild.channels.fetch(input.channelId);
2067
+ if (!channel ||
2068
+ channel.guild.id !== guild.id ||
2069
+ !channel.isTextBased() ||
2070
+ channel.isDMBased()) {
2071
+ throw new Error("Discord reaction message channel is unavailable");
2072
+ }
2073
+ const message = await channel.messages.fetch(input.messageId);
2074
+ if (message.id !== input.messageId ||
2075
+ message.guildId !== guild.id ||
2076
+ message.channelId !== channel.id) {
2077
+ throw new Error("Discord reaction message validation failed");
2078
+ }
2079
+ const emoji = await this.inspectEmoji({
2080
+ guildId: guild.id,
2081
+ emojiIdentifier: input.emojiIdentifier,
2082
+ });
2083
+ if (!emoji.exists || !emoji.guildMatches || !emoji.usable) {
2084
+ throw new Error("Discord reaction emoji is unavailable");
2085
+ }
2086
+ const cacheKey = emoji.kind === "custom" ? emoji.emojiId : (emoji.name ?? "");
2087
+ if (message.reactions.cache.get(cacheKey)?.me)
2088
+ return { added: false };
2089
+ const resolvedEmoji = emoji.kind === "custom"
2090
+ ? await guild.emojis.fetch(emoji.emojiId)
2091
+ : emoji.name;
2092
+ if (!resolvedEmoji)
2093
+ throw new Error("Discord reaction emoji is unavailable");
2094
+ await message.react(resolvedEmoji);
2095
+ return { added: true };
2096
+ }
2097
+ }
2098
+ function memberAvatarUrl(member) {
2099
+ return member && typeof member.displayAvatarURL === "function"
2100
+ ? member.displayAvatarURL({ extension: "webp", size: 64 })
2101
+ : "";
670
2102
  }
671
2103
  async function fetchSafeRole(guild, roleId) {
672
2104
  validateSnowflake(roleId, "role");
@@ -710,6 +2142,24 @@ function missingRoleInspection(input, hierarchy) {
710
2142
  dangerousPermissionNames: [],
711
2143
  };
712
2144
  }
2145
+ function missingMessageInspection(input, channelMatches) {
2146
+ return {
2147
+ ...input,
2148
+ exists: false,
2149
+ guildMatches: channelMatches,
2150
+ channelMatches,
2151
+ };
2152
+ }
2153
+ function validateEnforcementMetadata(input) {
2154
+ validateSnowflake(input.guildId, "guild");
2155
+ validateSnowflake(input.memberUserId, "member");
2156
+ if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
2157
+ throw new Error("Member enforcement requires a valid Helyx module ID");
2158
+ if (input.reason.trim().length === 0 ||
2159
+ input.reason.length > 400 ||
2160
+ /[\r\n]/u.test(input.reason))
2161
+ throw new Error("Member enforcement requires a bounded audit reason");
2162
+ }
713
2163
  function validateMutationMetadata(input) {
714
2164
  validateSnowflake(input.guildId, "guild");
715
2165
  validateSnowflake(input.memberUserId, "member");
@@ -722,6 +2172,22 @@ function validateMutationMetadata(input) {
722
2172
  /[\r\n]/u.test(input.reason))
723
2173
  throw new Error("Role mutation requires a bounded audit reason");
724
2174
  }
2175
+ function validateRoleCreationMetadata(input) {
2176
+ validateSnowflake(input.guildId, "guild");
2177
+ if (input.name.trim() !== input.name ||
2178
+ input.name.length < 1 ||
2179
+ input.name.length > 100 ||
2180
+ /[\r\n\0]/u.test(input.name) ||
2181
+ input.name.toLowerCase() === "@everyone" ||
2182
+ input.name.toLowerCase() === "@here")
2183
+ throw new Error("Role creation requires a safe name from 1 to 100 characters");
2184
+ if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
2185
+ throw new Error("Role creation requires a valid Helyx module ID");
2186
+ if (input.reason.trim().length === 0 ||
2187
+ input.reason.length > 400 ||
2188
+ /[\r\n]/u.test(input.reason))
2189
+ throw new Error("Role creation requires a bounded audit reason");
2190
+ }
725
2191
  async function fetchExactMember(guild, memberUserId, label) {
726
2192
  validateSnowflake(memberUserId, label);
727
2193
  const member = await guild.members.fetch(memberUserId);
@@ -735,6 +2201,18 @@ function isUnknownMember(error) {
735
2201
  "code" in error &&
736
2202
  error.code === RESTJSONErrorCodes.UnknownMember);
737
2203
  }
2204
+ function isUnknownMessage(error) {
2205
+ return (typeof error === "object" &&
2206
+ error !== null &&
2207
+ "code" in error &&
2208
+ error.code === RESTJSONErrorCodes.UnknownMessage);
2209
+ }
2210
+ function isClosedDirectMessage(error) {
2211
+ return (typeof error === "object" &&
2212
+ error !== null &&
2213
+ "code" in error &&
2214
+ error.code === RESTJSONErrorCodes.CannotSendMessagesToThisUser);
2215
+ }
738
2216
  function validateBoundedRoleSet(roleIds, label) {
739
2217
  if (roleIds.length > 100 || new Set(roleIds).size !== roleIds.length)
740
2218
  throw new Error(`${label} role IDs must be unique and bounded`);