@helyx/bot 0.4.9 → 0.4.11

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,22 @@
1
1
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
- import { ChannelType, Events, PermissionFlagsBits, RESTJSONErrorCodes, } from "discord.js";
2
+ import { ChannelType, Events, GatewayIntentBits, PermissionFlagsBits, RESTJSONErrorCodes, } from "discord.js";
3
3
  import { toReplyOptions } from "./interactions.js";
4
+ import { createHash } from "node:crypto";
5
+ import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
4
6
  export function installModuleEvents(client, modules, logger, services) {
7
+ const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? []).map((contribution) => ({
8
+ moduleId: module.manifest.id,
9
+ contribution,
10
+ })));
11
+ const guildMessageContributions = modules.flatMap((module) => (module.events?.guildMessageCreate ?? []).map((contribution) => ({
12
+ moduleId: module.manifest.id,
13
+ classificationRequested: module.requiredDiscordCapabilities?.includes("message-content") ??
14
+ false,
15
+ contribution,
16
+ })));
17
+ const messageClassificationRequested = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
18
+ const messageClassificationAvailable = messageClassificationRequested &&
19
+ client.options.intents.has(GatewayIntentBits.MessageContent);
5
20
  const reactionContributions = modules.flatMap((module) => (module.events?.messageReactionAdd ?? []).map((contribution) => ({
6
21
  moduleId: module.manifest.id,
7
22
  contribution,
@@ -18,6 +33,10 @@ export function installModuleEvents(client, modules, logger, services) {
18
33
  moduleId: module.manifest.id,
19
34
  contribution,
20
35
  })));
36
+ const memberRemoveContributions = modules.flatMap((module) => (module.events?.guildMemberRemove ?? []).map((contribution) => ({
37
+ moduleId: module.manifest.id,
38
+ contribution,
39
+ })));
21
40
  const guildRoleDeleteContributions = modules.flatMap((module) => (module.events?.guildRoleDelete ?? []).map((contribution) => ({
22
41
  moduleId: module.manifest.id,
23
42
  contribution,
@@ -42,10 +61,13 @@ export function installModuleEvents(client, modules, logger, services) {
42
61
  moduleId: module.manifest.id,
43
62
  contribution,
44
63
  })));
45
- if (reactionContributions.length === 0 &&
64
+ if (privilegedMessageContributions.length === 0 &&
65
+ guildMessageContributions.length === 0 &&
66
+ reactionContributions.length === 0 &&
46
67
  reactionRemoveContributions.length === 0 &&
47
68
  messageDeleteContributions.length === 0 &&
48
69
  memberAddContributions.length === 0 &&
70
+ memberRemoveContributions.length === 0 &&
49
71
  guildRoleDeleteContributions.length === 0 &&
50
72
  guildRoleUpdateContributions.length === 0 &&
51
73
  channelDeleteContributions.length === 0 &&
@@ -54,6 +76,59 @@ export function installModuleEvents(client, modules, logger, services) {
54
76
  guildEmojiUpdateContributions.length === 0)
55
77
  return { stop: () => Promise.resolve(), queueDepth: () => 0 };
56
78
  const queue = new BoundedGuildEventQueue(logger);
79
+ const privilegedEligibilityQueue = privilegedMessageContributions.length > 0
80
+ ? new BoundedGuildEventQueue(logger)
81
+ : null;
82
+ const guildMessageQueue = guildMessageContributions.length > 0
83
+ ? new BoundedGuildEventQueue(logger)
84
+ : null;
85
+ if (privilegedMessageContributions.length > 0) {
86
+ client.on(Events.MessageCreate, (message) => {
87
+ if (!message.guildId || !privilegedEligibilityQueue)
88
+ return;
89
+ privilegedEligibilityQueue.enqueue(message.guildId, async () => {
90
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
91
+ const eligible = [];
92
+ for (const item of privilegedMessageContributions) {
93
+ try {
94
+ if ((await installations.isModuleEnabled(message.guildId, item.moduleId)) &&
95
+ (await item.contribution.isEligible({
96
+ serverId: message.guildId,
97
+ channelId: message.channelId,
98
+ services,
99
+ }))) {
100
+ eligible.push(item);
101
+ }
102
+ }
103
+ catch (error) {
104
+ logger.warn({
105
+ event: "module.privileged_message_eligibility_failed",
106
+ moduleId: item.moduleId,
107
+ error: safeError(error),
108
+ guildId: message.guildId,
109
+ channelId: message.channelId,
110
+ }, "Module privileged message eligibility failed");
111
+ }
112
+ }
113
+ if (eligible.length > 0) {
114
+ queue.enqueue(message.guildId, () => handlePrivilegedMessageCreate(message, eligible));
115
+ }
116
+ });
117
+ });
118
+ }
119
+ if (guildMessageContributions.length > 0) {
120
+ client.on(Events.MessageCreate, (message) => {
121
+ if (!message.guildId ||
122
+ !guildMessageQueue ||
123
+ message.author.bot ||
124
+ message.author.system ||
125
+ Boolean(message.webhookId) ||
126
+ message.system) {
127
+ return;
128
+ }
129
+ guildMessageQueue.enqueue(message.guildId, () => handleGuildMessageCreate(message));
130
+ });
131
+ }
57
132
  if (reactionContributions.length > 0) {
58
133
  client.on(Events.MessageReactionAdd, (reaction, user, details) => {
59
134
  const guildId = reaction.message.guildId;
@@ -79,6 +154,11 @@ export function installModuleEvents(client, modules, logger, services) {
79
154
  queue.enqueue(member.guild.id, () => handleMemberAdd(member));
80
155
  });
81
156
  }
157
+ if (memberRemoveContributions.length > 0) {
158
+ client.on(Events.GuildMemberRemove, (member) => {
159
+ queue.enqueue(member.guild.id, () => handleMemberRemove(member));
160
+ });
161
+ }
82
162
  if (guildRoleDeleteContributions.length > 0) {
83
163
  client.on(Events.GuildRoleDelete, (role) => {
84
164
  queue.enqueue(role.guild.id, () => handleResourceLifecycle(guildRoleDeleteContributions, role.guild.id, "roleId", role.id, "guild_role_delete"));
@@ -137,6 +217,116 @@ export function installModuleEvents(client, modules, logger, services) {
137
217
  }
138
218
  }
139
219
  }
220
+ async function handlePrivilegedMessageCreate(message, contributions) {
221
+ if (!message.guildId || !message.channelId)
222
+ return;
223
+ for (const { moduleId, contribution } of contributions) {
224
+ try {
225
+ await contribution.execute({
226
+ serverId: message.guildId,
227
+ channelId: message.channelId,
228
+ messageId: message.id,
229
+ userId: message.author.id,
230
+ userIsBot: message.author.bot,
231
+ webhookId: message.webhookId,
232
+ content: message.content,
233
+ services,
234
+ deleteSource: async () => {
235
+ await message.delete();
236
+ },
237
+ });
238
+ }
239
+ catch (error) {
240
+ logger.warn({
241
+ event: "module.privileged_message_create_failed",
242
+ moduleId,
243
+ error: safeError(error),
244
+ messageId: message.id,
245
+ guildId: message.guildId,
246
+ }, "Module privileged message-create event failed");
247
+ }
248
+ }
249
+ }
250
+ async function handleGuildMessageCreate(message) {
251
+ if (!message.guildId || !message.channelId)
252
+ return;
253
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
254
+ const parentChannelId = message.channel.isThread()
255
+ ? (message.channel.parentId ?? undefined)
256
+ : undefined;
257
+ const authorRoleIds = message.member
258
+ ? [...message.member.roles.cache.keys()].sort((left, right) => left.localeCompare(right, "en"))
259
+ : [];
260
+ const authorDisplayName = message.member?.displayName ??
261
+ message.author.globalName ??
262
+ message.author.username;
263
+ let classifiedMessage;
264
+ for (const { moduleId, classificationRequested, contribution, } of guildMessageContributions) {
265
+ try {
266
+ if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
267
+ continue;
268
+ }
269
+ await contribution.execute({
270
+ serverId: message.guildId,
271
+ channelId: message.channelId,
272
+ ...(parentChannelId ? { parentChannelId } : {}),
273
+ messageId: message.id,
274
+ authorId: message.author.id,
275
+ authorDisplayName,
276
+ authorRoleIds,
277
+ authorIsBot: false,
278
+ authorIsWebhook: false,
279
+ authorIsSystem: false,
280
+ get classification() {
281
+ if (!classificationRequested || !messageClassificationAvailable)
282
+ return unavailableGuildMessageClassification;
283
+ classifiedMessage ??= classifyGuildMessage(message);
284
+ return classifiedMessage;
285
+ },
286
+ createdAt: message.createdAt,
287
+ services,
288
+ addAuthorRole: async (roleId) => {
289
+ await services
290
+ .get(HELYX_SERVICE_NAMES.memberRoles)
291
+ .addApprovedRole({
292
+ guildId: message.guildId,
293
+ memberUserId: message.author.id,
294
+ roleId,
295
+ moduleId,
296
+ reason: "Guild message contribution author role addition",
297
+ });
298
+ },
299
+ removeAuthorRole: async (roleId) => {
300
+ await services
301
+ .get(HELYX_SERVICE_NAMES.memberRoles)
302
+ .removeApprovedRole({
303
+ guildId: message.guildId,
304
+ memberUserId: message.author.id,
305
+ roleId,
306
+ moduleId,
307
+ reason: "Guild message contribution author role removal",
308
+ });
309
+ },
310
+ sendToChannel: (channelId, response) => services
311
+ .get(HELYX_SERVICE_NAMES.discordMessages)
312
+ .send({
313
+ guildId: message.guildId,
314
+ channelId,
315
+ response,
316
+ }),
317
+ });
318
+ }
319
+ catch (error) {
320
+ logger.warn({
321
+ event: "module.guild_message_create_failed",
322
+ moduleId,
323
+ error: safeError(error),
324
+ messageId: message.id,
325
+ guildId: message.guildId,
326
+ }, "Module guild message-create event failed");
327
+ }
328
+ }
329
+ }
140
330
  async function handleReaction(receivedReaction, receivedUser, burst) {
141
331
  try {
142
332
  const user = receivedUser.partial
@@ -197,6 +387,8 @@ export function installModuleEvents(client, modules, logger, services) {
197
387
  }
198
388
  }
199
389
  async function handleMemberAdd(member) {
390
+ if (!member.joinedAt)
391
+ throw new Error("Discord member join timestamp is unavailable");
200
392
  const installations = services.get(HELYX_SERVICE_NAMES.installations);
201
393
  for (const { moduleId, contribution } of memberAddContributions) {
202
394
  try {
@@ -209,6 +401,8 @@ export function installModuleEvents(client, modules, logger, services) {
209
401
  memberId: member.id,
210
402
  memberDisplayName: member.displayName,
211
403
  memberIsBot: member.user.bot,
404
+ accountCreatedAt: member.user.createdAt,
405
+ joinedAt: member.joinedAt,
212
406
  services,
213
407
  assignRole: (roleId) => assignMemberRole(member, roleId),
214
408
  sendToChannel: (channelId, response) => sendToGuildChannel(member, channelId, response),
@@ -225,6 +419,33 @@ export function installModuleEvents(client, modules, logger, services) {
225
419
  }
226
420
  }
227
421
  }
422
+ async function handleMemberRemove(member) {
423
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
424
+ for (const { moduleId, contribution } of memberRemoveContributions) {
425
+ try {
426
+ if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
427
+ continue;
428
+ }
429
+ await contribution.execute({
430
+ serverId: member.guild.id,
431
+ memberId: member.id,
432
+ memberIsBot: member.user.bot,
433
+ accountCreatedAt: member.user.createdAt,
434
+ joinedAt: member.joinedAt,
435
+ services,
436
+ });
437
+ }
438
+ catch (error) {
439
+ logger.warn({
440
+ event: "module.member_remove_failed",
441
+ moduleId,
442
+ error: safeError(error),
443
+ memberId: member.id,
444
+ guildId: member.guild.id,
445
+ }, "Module member-remove event failed");
446
+ }
447
+ }
448
+ }
228
449
  async function handleReactionRemove(receivedReaction, receivedUser, burst) {
229
450
  try {
230
451
  if (receivedUser.bot)
@@ -293,7 +514,16 @@ export function installModuleEvents(client, modules, logger, services) {
293
514
  }
294
515
  }
295
516
  }
296
- return { stop: () => queue.stop(), queueDepth: () => queue.queueDepth() };
517
+ return {
518
+ stop: async () => {
519
+ await privilegedEligibilityQueue?.stop();
520
+ await guildMessageQueue?.stop();
521
+ await queue.stop();
522
+ },
523
+ queueDepth: () => queue.queueDepth() +
524
+ (privilegedEligibilityQueue?.queueDepth() ?? 0) +
525
+ (guildMessageQueue?.queueDepth() ?? 0),
526
+ };
297
527
  }
298
528
  class BoundedGuildEventQueue {
299
529
  logger;
@@ -405,11 +635,183 @@ const dangerousRolePermissionNames = new Set([
405
635
  "MUTE_MEMBERS",
406
636
  "VIEW_AUDIT_LOG",
407
637
  ]);
638
+ const directMessageAttemptTimeoutMs = 5_000;
639
+ const guildChannelAudienceInspectionLimit = 500;
640
+ export class DiscordMessageDeliveryService {
641
+ client;
642
+ constructor(client) {
643
+ this.client = client;
644
+ }
645
+ async send(input) {
646
+ validateSnowflake(input.guildId, "guild");
647
+ validateSnowflake(input.channelId, "channel");
648
+ const guild = await this.client.guilds.fetch(input.guildId);
649
+ if (guild.id !== input.guildId)
650
+ throw new Error("Discord guild validation failed");
651
+ const channel = await guild.channels.fetch(input.channelId);
652
+ if (!channel ||
653
+ channel.guild.id !== guild.id ||
654
+ !channel.isSendable() ||
655
+ channel.isDMBased()) {
656
+ throw new Error("Discord message channel is unavailable");
657
+ }
658
+ const me = guild.members.me ?? (await guild.members.fetchMe());
659
+ const effective = channel.permissionsFor(me);
660
+ if (!effective?.has(PermissionFlagsBits.ViewChannel) ||
661
+ !effective.has(PermissionFlagsBits.SendMessages)) {
662
+ throw new Error("Discord message channel permissions are unavailable");
663
+ }
664
+ const options = {
665
+ ...toReplyOptions({ ...input.response, ephemeral: false }),
666
+ };
667
+ if (input.idempotencyKey) {
668
+ const nonce = createHash("sha256")
669
+ .update(input.idempotencyKey)
670
+ .digest("hex")
671
+ .slice(0, 24);
672
+ options.nonce = nonce;
673
+ options.enforceNonce = true;
674
+ }
675
+ const message = await channel.send(options);
676
+ if (message.guildId !== guild.id ||
677
+ message.channelId !== channel.id ||
678
+ !discordSnowflake.test(message.id)) {
679
+ throw new Error("Discord sent message validation failed");
680
+ }
681
+ return { messageId: message.id };
682
+ }
683
+ async sendDirect(input) {
684
+ validateSnowflake(input.userId, "user");
685
+ try {
686
+ const user = await this.client.users.fetch(input.userId);
687
+ if (user.id !== input.userId)
688
+ throw new Error("Discord user validation failed");
689
+ const options = toReplyOptions({ ...input.response, ephemeral: false });
690
+ const message = await user.send(options);
691
+ if (!discordSnowflake.test(message.id)) {
692
+ return { outcome: "failed" };
693
+ }
694
+ return { outcome: "sent", messageId: message.id };
695
+ }
696
+ catch (error) {
697
+ return {
698
+ outcome: isClosedDirectMessage(error) ? "closed" : "failed",
699
+ };
700
+ }
701
+ }
702
+ }
703
+ export class DiscordMemberEnforcementService {
704
+ client;
705
+ constructor(client) {
706
+ this.client = client;
707
+ }
708
+ async kick(input) {
709
+ validateEnforcementMetadata(input);
710
+ const directMessage = input.directMessage
711
+ ? await this.#sendBestEffortDirectMessage(input.memberUserId, input.directMessage)
712
+ : "not_requested";
713
+ const guild = await this.client.guilds
714
+ .fetch(input.guildId)
715
+ .catch(() => null);
716
+ if (!guild)
717
+ return { outcome: "failed", directMessage };
718
+ if (guild.id !== input.guildId)
719
+ throw new Error("Discord guild validation failed");
720
+ let member;
721
+ try {
722
+ member = await guild.members.fetch(input.memberUserId);
723
+ }
724
+ catch (error) {
725
+ if (isUnknownMember(error))
726
+ return { outcome: "member_missing", directMessage };
727
+ return { outcome: "failed", directMessage };
728
+ }
729
+ if (!member)
730
+ return { outcome: "member_missing", directMessage };
731
+ if (member.id !== input.memberUserId || member.guild.id !== guild.id)
732
+ throw new Error("Discord member validation failed");
733
+ const me = guild.members.me ?? (await guild.members.fetchMe());
734
+ if (!me.permissions.has(PermissionFlagsBits.KickMembers)) {
735
+ return { outcome: "missing_permission", directMessage };
736
+ }
737
+ if (!member.kickable) {
738
+ return { outcome: "hierarchy_blocked", directMessage };
739
+ }
740
+ let stillRequired;
741
+ try {
742
+ stillRequired = await input.revalidate();
743
+ }
744
+ catch {
745
+ return { outcome: "failed", directMessage };
746
+ }
747
+ if (!stillRequired) {
748
+ return { outcome: "no_longer_required", directMessage };
749
+ }
750
+ try {
751
+ await member.kick(discordAuditReason(input));
752
+ return { outcome: "kicked", directMessage };
753
+ }
754
+ catch (error) {
755
+ if (isUnknownMember(error)) {
756
+ return { outcome: "member_missing", directMessage };
757
+ }
758
+ return { outcome: "failed", directMessage };
759
+ }
760
+ }
761
+ async #sendBestEffortDirectMessage(memberUserId, response) {
762
+ const attempt = async () => {
763
+ try {
764
+ const user = await this.client.users.fetch(memberUserId);
765
+ if (user.id !== memberUserId)
766
+ throw new Error("Discord user validation failed");
767
+ const options = toReplyOptions({ ...response, ephemeral: false });
768
+ await user.send(options);
769
+ return "sent";
770
+ }
771
+ catch (error) {
772
+ return isClosedDirectMessage(error) ? "closed" : "failed";
773
+ }
774
+ };
775
+ return new Promise((resolve) => {
776
+ let settled = false;
777
+ const timer = setTimeout(() => {
778
+ settled = true;
779
+ resolve("failed");
780
+ }, directMessageAttemptTimeoutMs);
781
+ timer.unref();
782
+ void attempt().then((result) => {
783
+ if (settled)
784
+ return;
785
+ settled = true;
786
+ clearTimeout(timer);
787
+ resolve(result);
788
+ });
789
+ });
790
+ }
791
+ }
408
792
  export class DiscordMemberRoleService {
409
793
  client;
410
794
  constructor(client) {
411
795
  this.client = client;
412
796
  }
797
+ async createApprovedRole(input) {
798
+ validateRoleCreationMetadata(input);
799
+ const guild = await this.client.guilds.fetch(input.guildId);
800
+ if (guild.id !== input.guildId)
801
+ throw new Error("Discord guild validation failed");
802
+ const me = guild.members.me ?? (await guild.members.fetchMe());
803
+ if (!me.permissions.has(PermissionFlagsBits.ManageRoles))
804
+ throw new Error("Helyx requires Manage Roles to create this reward role");
805
+ const role = await guild.roles.create({
806
+ name: input.name,
807
+ permissions: [],
808
+ reason: discordAuditReason(input),
809
+ });
810
+ if (role.guild.id !== guild.id)
811
+ throw new Error("Created Discord role did not belong to this guild");
812
+ await fetchSafeRole(guild, role.id);
813
+ return { roleId: role.id, name: role.name };
814
+ }
413
815
  async getCurrentRoleIds(input) {
414
816
  const { member } = await this.fetchGuildAndMember(input);
415
817
  return [...member.roles.cache.keys()];
@@ -452,10 +854,10 @@ export class DiscordMemberRoleService {
452
854
  const addedRoles = addedIds.map((roleId) => approvedRoles.get(roleId));
453
855
  const removedRoles = removedIds.map((roleId) => approvedRoles.get(roleId));
454
856
  const reason = discordAuditReason(input);
455
- if (removedRoles.length > 0)
456
- await member.roles.remove(removedRoles, reason);
457
857
  if (addedRoles.length > 0)
458
858
  await member.roles.add(addedRoles, reason);
859
+ if (removedRoles.length > 0)
860
+ await member.roles.remove(removedRoles, reason);
459
861
  return { added: addedIds, removed: removedIds };
460
862
  }
461
863
  async fetchGuildAndMember(input) {
@@ -475,6 +877,32 @@ export class DiscordResourceInspection {
475
877
  constructor(client) {
476
878
  this.client = client;
477
879
  }
880
+ async inspectGuildCapabilities(input) {
881
+ validateSnowflake(input.guildId, "guild");
882
+ const guild = await this.client.guilds.fetch(input.guildId);
883
+ if (guild.id !== input.guildId)
884
+ throw new Error("Discord guild validation failed");
885
+ const me = guild.members.me ?? (await guild.members.fetchMe());
886
+ const effectivePermissionNames = me.permissions
887
+ .toArray()
888
+ .map(normalizePermissionName)
889
+ .sort();
890
+ return {
891
+ guildId: guild.id,
892
+ effectivePermissionNames,
893
+ manageRoles: me.permissions.has(PermissionFlagsBits.ManageRoles),
894
+ kickMembers: me.permissions.has(PermissionFlagsBits.KickMembers),
895
+ };
896
+ }
897
+ inspectRuntimeCapabilities() {
898
+ const intents = this.client.options.intents;
899
+ return Promise.resolve({
900
+ guildMembers: intents.has(GatewayIntentBits.GuildMembers),
901
+ guildMessages: intents.has(GatewayIntentBits.GuildMessages),
902
+ guildMessageReactions: intents.has(GatewayIntentBits.GuildMessageReactions),
903
+ messageContent: intents.has(GatewayIntentBits.MessageContent),
904
+ });
905
+ }
478
906
  async resolveMemberSnapshots(input) {
479
907
  const guild = await this.#resolveMemberGuild(input);
480
908
  return Promise.all(input.userIds.map(async (userId) => {
@@ -502,12 +930,41 @@ export class DiscordResourceInspection {
502
930
  return null;
503
931
  throw error;
504
932
  });
933
+ const avatarUrl = memberAvatarUrl(member);
505
934
  return {
506
935
  userId,
507
936
  displayName: member?.displayName ?? null,
937
+ ...(avatarUrl ? { avatarUrl } : {}),
508
938
  };
509
939
  }));
510
940
  }
941
+ async searchMembers(input) {
942
+ validateSnowflake(input.guildId, "guild");
943
+ const query = input.query.trim();
944
+ if (query.length < 1 || query.length > 100)
945
+ throw new Error("Discord member search query is invalid");
946
+ if (!Number.isSafeInteger(input.limit) ||
947
+ input.limit < 1 ||
948
+ input.limit > 25)
949
+ throw new Error("Discord member search limit is invalid");
950
+ const guild = await this.client.guilds.fetch(input.guildId);
951
+ if (guild.id !== input.guildId)
952
+ throw new Error("Discord guild validation failed");
953
+ const members = await guild.members.search({
954
+ query,
955
+ limit: input.limit,
956
+ cache: false,
957
+ });
958
+ return [...members.values()].map((member) => {
959
+ const avatarUrl = memberAvatarUrl(member);
960
+ return {
961
+ userId: member.id,
962
+ displayName: member.displayName,
963
+ ...(avatarUrl ? { avatarUrl } : {}),
964
+ isBot: member.user.bot,
965
+ };
966
+ });
967
+ }
511
968
  async #resolveMemberGuild(input) {
512
969
  validateSnowflake(input.guildId, "guild");
513
970
  if (input.userIds.length > 50 ||
@@ -640,6 +1097,7 @@ export class DiscordResourceInspection {
640
1097
  sendMessages: false,
641
1098
  addReactions: false,
642
1099
  readMessageHistory: false,
1100
+ manageMessages: false,
643
1101
  };
644
1102
  const guildMatches = channel.guild.id === guild.id;
645
1103
  const me = guild.members.me ?? (await guild.members.fetchMe());
@@ -665,8 +1123,198 @@ export class DiscordResourceInspection {
665
1123
  (effective?.has(PermissionFlagsBits.AddReactions) ?? false),
666
1124
  readMessageHistory: guildMatches &&
667
1125
  (effective?.has(PermissionFlagsBits.ReadMessageHistory) ?? false),
1126
+ manageMessages: guildMatches &&
1127
+ (effective?.has(PermissionFlagsBits.ManageMessages) ?? false),
1128
+ };
1129
+ }
1130
+ async inspectMessage(input) {
1131
+ validateSnowflake(input.guildId, "guild");
1132
+ validateSnowflake(input.channelId, "channel");
1133
+ validateSnowflake(input.messageId, "message");
1134
+ const guild = await this.client.guilds.fetch(input.guildId);
1135
+ if (guild.id !== input.guildId)
1136
+ throw new Error("Discord guild validation failed");
1137
+ const channel = await guild.channels.fetch(input.channelId);
1138
+ if (!channel ||
1139
+ channel.guild.id !== guild.id ||
1140
+ !channel.isTextBased() ||
1141
+ channel.isDMBased()) {
1142
+ return missingMessageInspection(input, false);
1143
+ }
1144
+ const message = await channel.messages
1145
+ .fetch(input.messageId)
1146
+ .catch((error) => {
1147
+ if (isUnknownMessage(error))
1148
+ return null;
1149
+ throw error;
1150
+ });
1151
+ if (!message)
1152
+ return missingMessageInspection(input, true);
1153
+ const guildMatches = message.guildId === guild.id;
1154
+ const channelMatches = message.channelId === channel.id;
1155
+ return {
1156
+ guildId: guild.id,
1157
+ channelId: channel.id,
1158
+ messageId: message.id,
1159
+ exists: guildMatches && channelMatches && message.id === input.messageId,
1160
+ guildMatches,
1161
+ channelMatches,
1162
+ authorUserId: message.author.id,
668
1163
  };
669
1164
  }
1165
+ async inspectChannelAudience(input) {
1166
+ validateSnowflake(input.guildId, "guild");
1167
+ validateSnowflake(input.channelId, "channel");
1168
+ validateBoundedRoleSet(input.roleIds, "audience");
1169
+ const guild = await this.client.guilds.fetch(input.guildId);
1170
+ if (guild.id !== input.guildId)
1171
+ throw new Error("Discord guild validation failed");
1172
+ const roles = await Promise.all(input.roleIds.map(async (roleId) => ({
1173
+ roleId,
1174
+ role: await guild.roles.fetch(roleId),
1175
+ })));
1176
+ const channel = await guild.channels.fetch(input.channelId);
1177
+ if (!channel) {
1178
+ return {
1179
+ guildId: guild.id,
1180
+ channelId: input.channelId,
1181
+ exists: false,
1182
+ guildMatches: false,
1183
+ everyoneCanView: false,
1184
+ roles: roles.map(({ roleId, role }) => ({
1185
+ roleId,
1186
+ exists: role !== null && role.guild.id === guild.id,
1187
+ canView: false,
1188
+ })),
1189
+ };
1190
+ }
1191
+ const guildMatches = channel.guild.id === guild.id;
1192
+ return {
1193
+ guildId: guild.id,
1194
+ channelId: channel.id,
1195
+ exists: true,
1196
+ guildMatches,
1197
+ everyoneCanView: guildMatches &&
1198
+ (channel
1199
+ .permissionsFor(guild.roles.everyone)
1200
+ ?.has(PermissionFlagsBits.ViewChannel) ??
1201
+ false),
1202
+ roles: roles.map(({ roleId, role }) => {
1203
+ const exists = role !== null && role.guild.id === guild.id;
1204
+ return {
1205
+ roleId,
1206
+ exists,
1207
+ canView: guildMatches &&
1208
+ role !== null &&
1209
+ role.guild.id === guild.id &&
1210
+ (channel
1211
+ .permissionsFor(role)
1212
+ ?.has(PermissionFlagsBits.ViewChannel) ??
1213
+ false),
1214
+ };
1215
+ }),
1216
+ };
1217
+ }
1218
+ async inspectGuildChannelAudience(input) {
1219
+ validateSnowflake(input.guildId, "guild");
1220
+ validateBoundedRoleSet(input.roleIds, "audience");
1221
+ const guild = await this.client.guilds.fetch(input.guildId);
1222
+ if (guild.id !== input.guildId)
1223
+ throw new Error("Discord guild validation failed");
1224
+ const resolvedRoles = await Promise.all(input.roleIds.map(async (roleId) => ({
1225
+ roleId,
1226
+ role: await guild.roles.fetch(roleId),
1227
+ })));
1228
+ const missingRoleIds = resolvedRoles
1229
+ .filter(({ role }) => role === null || role.guild.id !== guild.id)
1230
+ .map(({ roleId }) => roleId);
1231
+ const fetched = await guild.channels.fetch();
1232
+ const relevant = [];
1233
+ for (const channel of fetched.values()) {
1234
+ if (channel &&
1235
+ (channel.type === ChannelType.GuildText ||
1236
+ channel.type === ChannelType.GuildAnnouncement)) {
1237
+ if (channel.guild.id !== guild.id)
1238
+ throw new Error("Discord channel guild validation failed");
1239
+ relevant.push(channel);
1240
+ }
1241
+ }
1242
+ relevant.sort((left, right) => left.id.localeCompare(right.id, "en"));
1243
+ const truncated = relevant.length > guildChannelAudienceInspectionLimit;
1244
+ const channels = relevant
1245
+ .slice(0, guildChannelAudienceInspectionLimit)
1246
+ .map((channel) => {
1247
+ const everyoneCanView = channel
1248
+ .permissionsFor(guild.roles.everyone)
1249
+ ?.has(PermissionFlagsBits.ViewChannel) ?? false;
1250
+ const visibleRoleIds = resolvedRoles
1251
+ .filter(({ role }) => role !== null &&
1252
+ role.guild.id === guild.id &&
1253
+ (channel
1254
+ .permissionsFor(role)
1255
+ ?.has(PermissionFlagsBits.ViewChannel) ??
1256
+ false))
1257
+ .map(({ roleId }) => roleId);
1258
+ return {
1259
+ channelId: channel.id,
1260
+ type: channel.type === ChannelType.GuildText
1261
+ ? "guild_text"
1262
+ : "guild_announcement",
1263
+ everyoneCanView,
1264
+ visibleRoleIds,
1265
+ };
1266
+ })
1267
+ .filter(({ everyoneCanView, visibleRoleIds }) => everyoneCanView || visibleRoleIds.length > 0);
1268
+ return {
1269
+ guildId: guild.id,
1270
+ channels,
1271
+ missingRoleIds,
1272
+ truncated,
1273
+ };
1274
+ }
1275
+ async seedMessageReaction(input) {
1276
+ validateSnowflake(input.guildId, "guild");
1277
+ validateSnowflake(input.channelId, "channel");
1278
+ validateSnowflake(input.messageId, "message");
1279
+ const guild = await this.client.guilds.fetch(input.guildId);
1280
+ if (guild.id !== input.guildId)
1281
+ throw new Error("Discord guild validation failed");
1282
+ const channel = await guild.channels.fetch(input.channelId);
1283
+ if (!channel ||
1284
+ channel.guild.id !== guild.id ||
1285
+ !channel.isTextBased() ||
1286
+ channel.isDMBased()) {
1287
+ throw new Error("Discord reaction message channel is unavailable");
1288
+ }
1289
+ const message = await channel.messages.fetch(input.messageId);
1290
+ if (message.id !== input.messageId ||
1291
+ message.guildId !== guild.id ||
1292
+ message.channelId !== channel.id) {
1293
+ throw new Error("Discord reaction message validation failed");
1294
+ }
1295
+ const emoji = await this.inspectEmoji({
1296
+ guildId: guild.id,
1297
+ emojiIdentifier: input.emojiIdentifier,
1298
+ });
1299
+ if (!emoji.exists || !emoji.guildMatches || !emoji.usable) {
1300
+ throw new Error("Discord reaction emoji is unavailable");
1301
+ }
1302
+ const cacheKey = emoji.kind === "custom" ? emoji.emojiId : (emoji.name ?? "");
1303
+ if (message.reactions.cache.get(cacheKey)?.me)
1304
+ return { added: false };
1305
+ const resolvedEmoji = emoji.kind === "custom"
1306
+ ? await guild.emojis.fetch(emoji.emojiId)
1307
+ : emoji.name;
1308
+ if (!resolvedEmoji)
1309
+ throw new Error("Discord reaction emoji is unavailable");
1310
+ await message.react(resolvedEmoji);
1311
+ return { added: true };
1312
+ }
1313
+ }
1314
+ function memberAvatarUrl(member) {
1315
+ return member && typeof member.displayAvatarURL === "function"
1316
+ ? member.displayAvatarURL({ extension: "webp", size: 64 })
1317
+ : "";
670
1318
  }
671
1319
  async function fetchSafeRole(guild, roleId) {
672
1320
  validateSnowflake(roleId, "role");
@@ -710,6 +1358,24 @@ function missingRoleInspection(input, hierarchy) {
710
1358
  dangerousPermissionNames: [],
711
1359
  };
712
1360
  }
1361
+ function missingMessageInspection(input, channelMatches) {
1362
+ return {
1363
+ ...input,
1364
+ exists: false,
1365
+ guildMatches: channelMatches,
1366
+ channelMatches,
1367
+ };
1368
+ }
1369
+ function validateEnforcementMetadata(input) {
1370
+ validateSnowflake(input.guildId, "guild");
1371
+ validateSnowflake(input.memberUserId, "member");
1372
+ if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
1373
+ throw new Error("Member enforcement requires a valid Helyx module ID");
1374
+ if (input.reason.trim().length === 0 ||
1375
+ input.reason.length > 400 ||
1376
+ /[\r\n]/u.test(input.reason))
1377
+ throw new Error("Member enforcement requires a bounded audit reason");
1378
+ }
713
1379
  function validateMutationMetadata(input) {
714
1380
  validateSnowflake(input.guildId, "guild");
715
1381
  validateSnowflake(input.memberUserId, "member");
@@ -722,6 +1388,22 @@ function validateMutationMetadata(input) {
722
1388
  /[\r\n]/u.test(input.reason))
723
1389
  throw new Error("Role mutation requires a bounded audit reason");
724
1390
  }
1391
+ function validateRoleCreationMetadata(input) {
1392
+ validateSnowflake(input.guildId, "guild");
1393
+ if (input.name.trim() !== input.name ||
1394
+ input.name.length < 1 ||
1395
+ input.name.length > 100 ||
1396
+ /[\r\n\0]/u.test(input.name) ||
1397
+ input.name.toLowerCase() === "@everyone" ||
1398
+ input.name.toLowerCase() === "@here")
1399
+ throw new Error("Role creation requires a safe name from 1 to 100 characters");
1400
+ if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
1401
+ throw new Error("Role creation requires a valid Helyx module ID");
1402
+ if (input.reason.trim().length === 0 ||
1403
+ input.reason.length > 400 ||
1404
+ /[\r\n]/u.test(input.reason))
1405
+ throw new Error("Role creation requires a bounded audit reason");
1406
+ }
725
1407
  async function fetchExactMember(guild, memberUserId, label) {
726
1408
  validateSnowflake(memberUserId, label);
727
1409
  const member = await guild.members.fetch(memberUserId);
@@ -735,6 +1417,18 @@ function isUnknownMember(error) {
735
1417
  "code" in error &&
736
1418
  error.code === RESTJSONErrorCodes.UnknownMember);
737
1419
  }
1420
+ function isUnknownMessage(error) {
1421
+ return (typeof error === "object" &&
1422
+ error !== null &&
1423
+ "code" in error &&
1424
+ error.code === RESTJSONErrorCodes.UnknownMessage);
1425
+ }
1426
+ function isClosedDirectMessage(error) {
1427
+ return (typeof error === "object" &&
1428
+ error !== null &&
1429
+ "code" in error &&
1430
+ error.code === RESTJSONErrorCodes.CannotSendMessagesToThisUser);
1431
+ }
738
1432
  function validateBoundedRoleSet(roleIds, label) {
739
1433
  if (roleIds.length > 100 || new Set(roleIds).size !== roleIds.length)
740
1434
  throw new Error(`${label} role IDs must be unique and bounded`);