@helyx/bot 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +13 -0
  3. package/dist/cloud-connector.d.ts +7 -0
  4. package/dist/cloud-connector.js +118 -1
  5. package/dist/cloud-control-dispatcher.js +84 -0
  6. package/dist/control-server.d.ts +187 -5
  7. package/dist/control-server.js +673 -6
  8. package/dist/developer-operations-module.js +8 -0
  9. package/dist/discord-owned-message-service.d.ts +39 -0
  10. package/dist/discord-owned-message-service.js +138 -0
  11. package/dist/events.d.ts +70 -1
  12. package/dist/events.js +557 -5
  13. package/dist/index.js +61 -13
  14. package/dist/interactions.d.ts +3 -2
  15. package/dist/interactions.js +66 -31
  16. package/dist/module-logging.d.ts +1 -0
  17. package/dist/module-logging.js +19 -1
  18. package/dist/modules.d.ts +9 -0
  19. package/dist/modules.js +45 -0
  20. package/dist/resource-authorization.d.ts +7 -0
  21. package/dist/resource-authorization.js +12 -0
  22. package/dist/scheduled-task-runner.d.ts +130 -0
  23. package/dist/scheduled-task-runner.js +417 -0
  24. package/dist/shard-cloud-proxy.d.ts +4 -0
  25. package/dist/shard-cloud-proxy.js +34 -0
  26. package/dist/shard-contracts.d.ts +574 -23
  27. package/dist/shard-contracts.js +346 -0
  28. package/dist/shard-coordinator.d.ts +5 -3
  29. package/dist/shard-coordinator.js +85 -2
  30. package/dist/shard-router.d.ts +10 -0
  31. package/dist/shard-router.js +58 -0
  32. package/dist/shard-supervisor.d.ts +2 -1
  33. package/dist/shard-supervisor.js +187 -5
  34. package/dist/shard-worker.d.ts +9 -3
  35. package/dist/shard-worker.js +97 -16
  36. package/dist/support-module.d.ts +9 -0
  37. package/dist/support-module.js +172 -0
  38. package/package.json +9 -9
package/dist/events.js CHANGED
@@ -1,23 +1,77 @@
1
1
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
- import { Events, } from "discord.js";
2
+ import { ChannelType, Events, PermissionFlagsBits, RESTJSONErrorCodes, } from "discord.js";
3
3
  import { toReplyOptions } from "./interactions.js";
4
4
  export function installModuleEvents(client, modules, logger, services) {
5
5
  const reactionContributions = modules.flatMap((module) => (module.events?.messageReactionAdd ?? []).map((contribution) => ({
6
6
  moduleId: module.manifest.id,
7
7
  contribution,
8
8
  })));
9
+ const reactionRemoveContributions = modules.flatMap((module) => (module.events?.messageReactionRemove ?? []).map((contribution) => ({
10
+ moduleId: module.manifest.id,
11
+ contribution,
12
+ })));
13
+ const messageDeleteContributions = modules.flatMap((module) => (module.events?.messageDelete ?? []).map((contribution) => ({
14
+ moduleId: module.manifest.id,
15
+ contribution,
16
+ })));
9
17
  const memberAddContributions = modules.flatMap((module) => (module.events?.guildMemberAdd ?? []).map((contribution) => ({
10
18
  moduleId: module.manifest.id,
11
19
  contribution,
12
20
  })));
13
- if (reactionContributions.length === 0 && memberAddContributions.length === 0)
21
+ const guildRoleDeleteContributions = modules.flatMap((module) => (module.events?.guildRoleDelete ?? []).map((contribution) => ({
22
+ moduleId: module.manifest.id,
23
+ contribution,
24
+ })));
25
+ const guildRoleUpdateContributions = modules.flatMap((module) => (module.events?.guildRoleUpdate ?? []).map((contribution) => ({
26
+ moduleId: module.manifest.id,
27
+ contribution,
28
+ })));
29
+ const channelDeleteContributions = modules.flatMap((module) => (module.events?.channelDelete ?? []).map((contribution) => ({
30
+ moduleId: module.manifest.id,
31
+ contribution,
32
+ })));
33
+ const channelUpdateContributions = modules.flatMap((module) => (module.events?.channelUpdate ?? []).map((contribution) => ({
34
+ moduleId: module.manifest.id,
35
+ contribution,
36
+ })));
37
+ const guildEmojiDeleteContributions = modules.flatMap((module) => (module.events?.guildEmojiDelete ?? []).map((contribution) => ({
38
+ moduleId: module.manifest.id,
39
+ contribution,
40
+ })));
41
+ const guildEmojiUpdateContributions = modules.flatMap((module) => (module.events?.guildEmojiUpdate ?? []).map((contribution) => ({
42
+ moduleId: module.manifest.id,
43
+ contribution,
44
+ })));
45
+ if (reactionContributions.length === 0 &&
46
+ reactionRemoveContributions.length === 0 &&
47
+ messageDeleteContributions.length === 0 &&
48
+ memberAddContributions.length === 0 &&
49
+ guildRoleDeleteContributions.length === 0 &&
50
+ guildRoleUpdateContributions.length === 0 &&
51
+ channelDeleteContributions.length === 0 &&
52
+ channelUpdateContributions.length === 0 &&
53
+ guildEmojiDeleteContributions.length === 0 &&
54
+ guildEmojiUpdateContributions.length === 0)
14
55
  return { stop: () => Promise.resolve(), queueDepth: () => 0 };
15
56
  const queue = new BoundedGuildEventQueue(logger);
16
57
  if (reactionContributions.length > 0) {
17
- client.on(Events.MessageReactionAdd, (reaction, user) => {
58
+ client.on(Events.MessageReactionAdd, (reaction, user, details) => {
18
59
  const guildId = reaction.message.guildId;
19
60
  if (guildId)
20
- queue.enqueue(guildId, () => handleReaction(reaction, user));
61
+ queue.enqueue(guildId, () => handleReaction(reaction, user, details.burst));
62
+ });
63
+ }
64
+ if (reactionRemoveContributions.length > 0) {
65
+ client.on(Events.MessageReactionRemove, (reaction, user, details) => {
66
+ const guildId = reaction.message.guildId;
67
+ if (guildId)
68
+ queue.enqueue(guildId, () => handleReactionRemove(reaction, user, details.burst));
69
+ });
70
+ }
71
+ if (messageDeleteContributions.length > 0) {
72
+ client.on(Events.MessageDelete, (message) => {
73
+ if (message.guildId)
74
+ queue.enqueue(message.guildId, () => handleMessageDelete(message));
21
75
  });
22
76
  }
23
77
  if (memberAddContributions.length > 0) {
@@ -25,7 +79,65 @@ export function installModuleEvents(client, modules, logger, services) {
25
79
  queue.enqueue(member.guild.id, () => handleMemberAdd(member));
26
80
  });
27
81
  }
28
- async function handleReaction(receivedReaction, receivedUser) {
82
+ if (guildRoleDeleteContributions.length > 0) {
83
+ client.on(Events.GuildRoleDelete, (role) => {
84
+ queue.enqueue(role.guild.id, () => handleResourceLifecycle(guildRoleDeleteContributions, role.guild.id, "roleId", role.id, "guild_role_delete"));
85
+ });
86
+ }
87
+ if (guildRoleUpdateContributions.length > 0) {
88
+ client.on(Events.GuildRoleUpdate, (_oldRole, role) => {
89
+ queue.enqueue(role.guild.id, () => handleResourceLifecycle(guildRoleUpdateContributions, role.guild.id, "roleId", role.id, "guild_role_update"));
90
+ });
91
+ }
92
+ if (channelDeleteContributions.length > 0) {
93
+ client.on(Events.ChannelDelete, (channel) => {
94
+ if (channel.isDMBased())
95
+ return;
96
+ queue.enqueue(channel.guild.id, () => handleResourceLifecycle(channelDeleteContributions, channel.guild.id, "channelId", channel.id, "channel_delete"));
97
+ });
98
+ }
99
+ if (channelUpdateContributions.length > 0) {
100
+ client.on(Events.ChannelUpdate, (_oldChannel, channel) => {
101
+ if (channel.isDMBased())
102
+ return;
103
+ queue.enqueue(channel.guild.id, () => handleResourceLifecycle(channelUpdateContributions, channel.guild.id, "channelId", channel.id, "channel_update"));
104
+ });
105
+ }
106
+ if (guildEmojiDeleteContributions.length > 0) {
107
+ client.on(Events.GuildEmojiDelete, (emoji) => {
108
+ queue.enqueue(emoji.guild.id, () => handleResourceLifecycle(guildEmojiDeleteContributions, emoji.guild.id, "emojiId", emoji.id, "guild_emoji_delete"));
109
+ });
110
+ }
111
+ if (guildEmojiUpdateContributions.length > 0) {
112
+ client.on(Events.GuildEmojiUpdate, (_oldEmoji, emoji) => {
113
+ queue.enqueue(emoji.guild.id, () => handleResourceLifecycle(guildEmojiUpdateContributions, emoji.guild.id, "emojiId", emoji.id, "guild_emoji_update"));
114
+ });
115
+ }
116
+ async function handleResourceLifecycle(contributions, serverId, identifier, resourceId, eventName) {
117
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
118
+ for (const { moduleId, contribution } of contributions) {
119
+ try {
120
+ if (!(await installations.isModuleEnabled(serverId, moduleId))) {
121
+ continue;
122
+ }
123
+ await contribution.execute({
124
+ serverId,
125
+ [identifier]: resourceId,
126
+ services,
127
+ });
128
+ }
129
+ catch (error) {
130
+ logger.warn({
131
+ event: `module.${eventName}_failed`,
132
+ moduleId,
133
+ error: safeError(error),
134
+ guildId: serverId,
135
+ [identifier]: resourceId,
136
+ }, "Module resource lifecycle event failed");
137
+ }
138
+ }
139
+ }
140
+ async function handleReaction(receivedReaction, receivedUser, burst) {
29
141
  try {
30
142
  const user = receivedUser.partial
31
143
  ? await receivedUser.fetch()
@@ -55,6 +167,7 @@ export function installModuleEvents(client, modules, logger, services) {
55
167
  userId: receivedUser.id,
56
168
  userRoleIds,
57
169
  emojiKey: reactionEmojiKey(reaction),
170
+ reactionType: burst ? "burst" : "normal",
58
171
  services,
59
172
  removeAddedReaction: () => reaction.users.remove(user.id).then(() => undefined),
60
173
  removeUserReaction: async (emojiIdentifier) => {
@@ -112,6 +225,74 @@ export function installModuleEvents(client, modules, logger, services) {
112
225
  }
113
226
  }
114
227
  }
228
+ async function handleReactionRemove(receivedReaction, receivedUser, burst) {
229
+ try {
230
+ if (receivedUser.bot)
231
+ return;
232
+ const message = receivedReaction.message;
233
+ if (!message.guildId || !message.channelId)
234
+ return;
235
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
236
+ for (const { moduleId, contribution } of reactionRemoveContributions) {
237
+ if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
238
+ continue;
239
+ }
240
+ try {
241
+ await contribution.execute({
242
+ serverId: message.guildId,
243
+ channelId: message.channelId,
244
+ messageId: message.id,
245
+ userId: receivedUser.id,
246
+ emojiKey: reactionEmojiKey(receivedReaction),
247
+ reactionType: burst ? "burst" : "normal",
248
+ services,
249
+ });
250
+ }
251
+ catch (error) {
252
+ logger.warn({
253
+ event: "module.reaction_remove_failed",
254
+ moduleId,
255
+ error: safeError(error),
256
+ userId: receivedUser.id,
257
+ }, "Module reaction-remove event failed");
258
+ }
259
+ }
260
+ }
261
+ catch (error) {
262
+ logger.warn({
263
+ event: "module.reaction_remove_failed",
264
+ error: safeError(error),
265
+ userId: receivedUser.id,
266
+ }, "Module reaction-remove event failed");
267
+ }
268
+ }
269
+ async function handleMessageDelete(message) {
270
+ if (!message.guildId || !message.channelId)
271
+ return;
272
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
273
+ for (const { moduleId, contribution } of messageDeleteContributions) {
274
+ try {
275
+ if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
276
+ continue;
277
+ }
278
+ await contribution.execute({
279
+ serverId: message.guildId,
280
+ channelId: message.channelId,
281
+ messageId: message.id,
282
+ services,
283
+ });
284
+ }
285
+ catch (error) {
286
+ logger.warn({
287
+ event: "module.message_delete_failed",
288
+ moduleId,
289
+ error: safeError(error),
290
+ messageId: message.id,
291
+ guildId: message.guildId,
292
+ }, "Module message-delete event failed");
293
+ }
294
+ }
295
+ }
115
296
  return { stop: () => queue.stop(), queueDepth: () => queue.queueDepth() };
116
297
  }
117
298
  class BoundedGuildEventQueue {
@@ -201,6 +382,377 @@ export function reactionCacheKey(emojiIdentifier) {
201
382
  const custom = /^(?:[A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(emojiIdentifier);
202
383
  return custom?.[1] ?? emojiIdentifier;
203
384
  }
385
+ const discordSnowflake = /^\d{17,20}$/u;
386
+ const moduleIdentifier = /^helyx\.[a-z0-9][a-z0-9-]*$/u;
387
+ const customEmojiIdentifier = /^(?:<a?:([A-Za-z0-9_]{2,32}):(\d{17,20})>|([A-Za-z0-9_]{2,32}):(\d{17,20}))$/u;
388
+ const dangerousRolePermissionNames = new Set([
389
+ "ADMINISTRATOR",
390
+ "BAN_MEMBERS",
391
+ "DEAFEN_MEMBERS",
392
+ "KICK_MEMBERS",
393
+ "MANAGE_CHANNELS",
394
+ "MANAGE_EVENTS",
395
+ "MANAGE_GUILD",
396
+ "MANAGE_GUILD_EXPRESSIONS",
397
+ "MANAGE_MESSAGES",
398
+ "MANAGE_NICKNAMES",
399
+ "MANAGE_ROLES",
400
+ "MANAGE_THREADS",
401
+ "MANAGE_WEBHOOKS",
402
+ "MENTION_EVERYONE",
403
+ "MODERATE_MEMBERS",
404
+ "MOVE_MEMBERS",
405
+ "MUTE_MEMBERS",
406
+ "VIEW_AUDIT_LOG",
407
+ ]);
408
+ export class DiscordMemberRoleService {
409
+ client;
410
+ constructor(client) {
411
+ this.client = client;
412
+ }
413
+ async getCurrentRoleIds(input) {
414
+ const { member } = await this.fetchGuildAndMember(input);
415
+ return [...member.roles.cache.keys()];
416
+ }
417
+ async addApprovedRole(input) {
418
+ validateMutationMetadata(input);
419
+ const { guild, member } = await this.fetchGuildAndMember(input);
420
+ const role = await fetchSafeRole(guild, input.roleId);
421
+ if (member.roles.cache.has(role.id))
422
+ return { changed: false };
423
+ await member.roles.add(role, discordAuditReason(input));
424
+ return { changed: true };
425
+ }
426
+ async removeApprovedRole(input) {
427
+ validateMutationMetadata(input);
428
+ const { guild, member } = await this.fetchGuildAndMember(input);
429
+ const role = await fetchSafeRole(guild, input.roleId);
430
+ if (!member.roles.cache.has(role.id))
431
+ return { changed: false };
432
+ await member.roles.remove(role, discordAuditReason(input));
433
+ return { changed: true };
434
+ }
435
+ async reconcileApprovedRoles(input) {
436
+ validateMutationMetadata({
437
+ ...input,
438
+ roleId: input.allowedRoleIds[0] ?? "",
439
+ });
440
+ validateBoundedRoleSet(input.allowedRoleIds, "allowed");
441
+ validateBoundedRoleSet(input.desiredRoleIds, "desired");
442
+ const allowed = new Set(input.allowedRoleIds);
443
+ if (input.desiredRoleIds.some((roleId) => !allowed.has(roleId))) {
444
+ throw new Error("Desired roles must be within the approved role set");
445
+ }
446
+ const { guild, member } = await this.fetchGuildAndMember(input);
447
+ const current = member.roles.cache;
448
+ const desired = new Set(input.desiredRoleIds);
449
+ const approvedRoles = new Map(await Promise.all(input.allowedRoleIds.map(async (roleId) => [roleId, await fetchSafeRole(guild, roleId)])));
450
+ const addedIds = input.desiredRoleIds.filter((roleId) => !current.has(roleId));
451
+ const removedIds = input.allowedRoleIds.filter((roleId) => current.has(roleId) && !desired.has(roleId));
452
+ const addedRoles = addedIds.map((roleId) => approvedRoles.get(roleId));
453
+ const removedRoles = removedIds.map((roleId) => approvedRoles.get(roleId));
454
+ const reason = discordAuditReason(input);
455
+ if (removedRoles.length > 0)
456
+ await member.roles.remove(removedRoles, reason);
457
+ if (addedRoles.length > 0)
458
+ await member.roles.add(addedRoles, reason);
459
+ return { added: addedIds, removed: removedIds };
460
+ }
461
+ async fetchGuildAndMember(input) {
462
+ validateSnowflake(input.guildId, "guild");
463
+ validateSnowflake(input.memberUserId, "member");
464
+ const guild = await this.client.guilds.fetch(input.guildId);
465
+ if (guild.id !== input.guildId)
466
+ throw new Error("Discord guild validation failed");
467
+ const member = await guild.members.fetch(input.memberUserId);
468
+ if (member.id !== input.memberUserId || member.guild.id !== guild.id)
469
+ throw new Error("Discord member validation failed");
470
+ return { guild, member };
471
+ }
472
+ }
473
+ export class DiscordResourceInspection {
474
+ client;
475
+ constructor(client) {
476
+ this.client = client;
477
+ }
478
+ async resolveMemberSnapshots(input) {
479
+ const guild = await this.#resolveMemberGuild(input);
480
+ return Promise.all(input.userIds.map(async (userId) => {
481
+ const member = await fetchExactMember(guild, userId, "member").catch((error) => {
482
+ if (isUnknownMember(error))
483
+ return null;
484
+ throw error;
485
+ });
486
+ return {
487
+ userId,
488
+ exists: member !== null,
489
+ isBot: member?.user.bot ?? false,
490
+ roleIds: member
491
+ ? [...member.roles.cache.keys()].sort((left, right) => left.localeCompare(right, "en"))
492
+ : [],
493
+ joinedAt: member?.joinedAt ?? null,
494
+ };
495
+ }));
496
+ }
497
+ async resolveMembers(input) {
498
+ const guild = await this.#resolveMemberGuild(input);
499
+ return Promise.all(input.userIds.map(async (userId) => {
500
+ const member = await fetchExactMember(guild, userId, "member").catch((error) => {
501
+ if (isUnknownMember(error))
502
+ return null;
503
+ throw error;
504
+ });
505
+ return {
506
+ userId,
507
+ displayName: member?.displayName ?? null,
508
+ };
509
+ }));
510
+ }
511
+ async #resolveMemberGuild(input) {
512
+ validateSnowflake(input.guildId, "guild");
513
+ if (input.userIds.length > 50 ||
514
+ new Set(input.userIds).size !== input.userIds.length)
515
+ throw new Error("Discord member IDs must be unique and bounded");
516
+ for (const userId of input.userIds)
517
+ validateSnowflake(userId, "member");
518
+ const guild = await this.client.guilds.fetch(input.guildId);
519
+ if (guild.id !== input.guildId)
520
+ throw new Error("Discord guild validation failed");
521
+ return guild;
522
+ }
523
+ async inspectRole(input) {
524
+ validateSnowflake(input.guildId, "guild");
525
+ validateSnowflake(input.roleId, "role");
526
+ const guild = await this.client.guilds.fetch(input.guildId);
527
+ if (guild.id !== input.guildId)
528
+ throw new Error("Discord guild validation failed");
529
+ const role = await guild.roles.fetch(input.roleId);
530
+ if (!role)
531
+ return missingRoleInspection(input, "missing");
532
+ if (role.guild.id !== guild.id)
533
+ return missingRoleInspection(input, "guild-mismatch");
534
+ const me = guild.members.me ?? (await guild.members.fetchMe());
535
+ const actor = input.actorUserId
536
+ ? await fetchExactMember(guild, input.actorUserId, "actor")
537
+ : undefined;
538
+ const permissions = role.permissions
539
+ .toArray()
540
+ .map(normalizePermissionName)
541
+ .sort();
542
+ const dangerous = permissions.filter((permission) => dangerousRolePermissionNames.has(permission));
543
+ const botHierarchy = roleHierarchy(role, guild, me);
544
+ const actorAssignable = actor === undefined
545
+ ? botHierarchy === "assignable"
546
+ : botHierarchy === "assignable" &&
547
+ (guild.ownerId === actor.id ||
548
+ role.comparePositionTo(actor.roles.highest) < 0);
549
+ const hierarchy = botHierarchy === "assignable" && !actorAssignable
550
+ ? "above-actor"
551
+ : botHierarchy;
552
+ return {
553
+ guildId: guild.id,
554
+ roleId: role.id,
555
+ exists: true,
556
+ guildMatches: true,
557
+ name: role.name,
558
+ managed: role.managed,
559
+ everyone: role.id === guild.id,
560
+ assignable: hierarchy === "assignable" && actorAssignable && dangerous.length === 0,
561
+ actorAssignable,
562
+ hierarchy,
563
+ permissionNames: permissions,
564
+ dangerousPermissionNames: dangerous,
565
+ helyxHighestRoleId: me.roles.highest.id,
566
+ };
567
+ }
568
+ async inspectEmoji(input) {
569
+ validateSnowflake(input.guildId, "guild");
570
+ if (input.emojiIdentifier.length === 0 ||
571
+ input.emojiIdentifier.length > 128 ||
572
+ /[\r\n]/u.test(input.emojiIdentifier))
573
+ throw new Error("Invalid bounded Discord emoji identifier");
574
+ const guild = await this.client.guilds.fetch(input.guildId);
575
+ if (guild.id !== input.guildId)
576
+ throw new Error("Discord guild validation failed");
577
+ const custom = customEmojiIdentifier.exec(input.emojiIdentifier);
578
+ if (!custom) {
579
+ const normalized = input.emojiIdentifier.normalize("NFKC").trim();
580
+ const valid = isSingleUnicodeEmoji(normalized);
581
+ return {
582
+ guildId: guild.id,
583
+ emojiIdentifier: input.emojiIdentifier,
584
+ exists: valid,
585
+ guildMatches: true,
586
+ usable: valid,
587
+ kind: "unicode",
588
+ ...(valid
589
+ ? { canonicalEmojiKey: `unicode:${normalized}`, name: normalized }
590
+ : {}),
591
+ };
592
+ }
593
+ const emojiId = custom[2] ?? custom[4];
594
+ const emoji = await guild.emojis.fetch(emojiId).catch(() => null);
595
+ if (!emoji)
596
+ return {
597
+ guildId: guild.id,
598
+ emojiIdentifier: input.emojiIdentifier,
599
+ exists: false,
600
+ guildMatches: false,
601
+ usable: false,
602
+ kind: "custom",
603
+ emojiId,
604
+ };
605
+ const guildMatches = emoji.guild.id === guild.id;
606
+ return {
607
+ guildId: guild.id,
608
+ emojiIdentifier: input.emojiIdentifier,
609
+ exists: true,
610
+ guildMatches,
611
+ usable: guildMatches && emoji.available === true,
612
+ kind: "custom",
613
+ canonicalEmojiKey: `custom:${emoji.id}`,
614
+ emojiId: emoji.id,
615
+ name: emoji.name ?? custom[1] ?? custom[3] ?? "emoji",
616
+ };
617
+ }
618
+ async inspectChannel(input) {
619
+ validateSnowflake(input.guildId, "guild");
620
+ validateSnowflake(input.channelId, "channel");
621
+ const guild = await this.client.guilds.fetch(input.guildId);
622
+ if (guild.id !== input.guildId)
623
+ throw new Error("Discord guild validation failed");
624
+ const channel = await guild.channels.fetch(input.channelId);
625
+ if (!channel)
626
+ return {
627
+ guildId: guild.id,
628
+ channelId: input.channelId,
629
+ exists: false,
630
+ guildMatches: false,
631
+ type: "missing",
632
+ effectivePermissionNames: [],
633
+ viewable: false,
634
+ sendMessages: false,
635
+ addReactions: false,
636
+ readMessageHistory: false,
637
+ };
638
+ const guildMatches = channel.guild.id === guild.id;
639
+ const me = guild.members.me ?? (await guild.members.fetchMe());
640
+ const effective = channel.permissionsFor(me);
641
+ const permissionNames = effective?.toArray().map(normalizePermissionName).sort() ?? [];
642
+ return {
643
+ guildId: guild.id,
644
+ channelId: channel.id,
645
+ exists: true,
646
+ guildMatches,
647
+ name: channel.name,
648
+ type: channel.type === ChannelType.GuildText
649
+ ? "guild_text"
650
+ : channel.type === ChannelType.GuildAnnouncement
651
+ ? "guild_announcement"
652
+ : "other",
653
+ effectivePermissionNames: permissionNames,
654
+ viewable: guildMatches &&
655
+ (effective?.has(PermissionFlagsBits.ViewChannel) ?? false),
656
+ sendMessages: guildMatches &&
657
+ (effective?.has(PermissionFlagsBits.SendMessages) ?? false),
658
+ addReactions: guildMatches &&
659
+ (effective?.has(PermissionFlagsBits.AddReactions) ?? false),
660
+ readMessageHistory: guildMatches &&
661
+ (effective?.has(PermissionFlagsBits.ReadMessageHistory) ?? false),
662
+ };
663
+ }
664
+ }
665
+ async function fetchSafeRole(guild, roleId) {
666
+ validateSnowflake(roleId, "role");
667
+ const role = await guild.roles.fetch(roleId);
668
+ if (!role || role.guild.id !== guild.id)
669
+ throw new Error("Approved Discord role was not found in this guild");
670
+ const me = guild.members.me ?? (await guild.members.fetchMe());
671
+ const hierarchy = roleHierarchy(role, guild, me);
672
+ if (hierarchy !== "assignable")
673
+ throw new Error("Approved Discord role cannot be managed by Helyx");
674
+ if (role.permissions
675
+ .toArray()
676
+ .map(normalizePermissionName)
677
+ .some((permission) => dangerousRolePermissionNames.has(permission)))
678
+ throw new Error("Approved Discord role has dangerous permissions");
679
+ return role;
680
+ }
681
+ function roleHierarchy(role, guild, me) {
682
+ if (role.id === guild.id)
683
+ return "everyone";
684
+ if (role.managed)
685
+ return "managed";
686
+ if (!me.permissions.has(PermissionFlagsBits.ManageRoles))
687
+ return "missing-manage-roles";
688
+ if (role.comparePositionTo(me.roles.highest) >= 0)
689
+ return "above-helyx";
690
+ return "assignable";
691
+ }
692
+ function missingRoleInspection(input, hierarchy) {
693
+ return {
694
+ guildId: input.guildId,
695
+ roleId: input.roleId,
696
+ exists: false,
697
+ guildMatches: false,
698
+ managed: false,
699
+ everyone: false,
700
+ assignable: false,
701
+ actorAssignable: false,
702
+ hierarchy,
703
+ permissionNames: [],
704
+ dangerousPermissionNames: [],
705
+ };
706
+ }
707
+ function validateMutationMetadata(input) {
708
+ validateSnowflake(input.guildId, "guild");
709
+ validateSnowflake(input.memberUserId, "member");
710
+ if (input.roleId)
711
+ validateSnowflake(input.roleId, "role");
712
+ if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
713
+ throw new Error("Role mutation requires a valid Helyx module ID");
714
+ if (input.reason.trim().length === 0 ||
715
+ input.reason.length > 400 ||
716
+ /[\r\n]/u.test(input.reason))
717
+ throw new Error("Role mutation requires a bounded audit reason");
718
+ }
719
+ async function fetchExactMember(guild, memberUserId, label) {
720
+ validateSnowflake(memberUserId, label);
721
+ const member = await guild.members.fetch(memberUserId);
722
+ if (member.id !== memberUserId || member.guild.id !== guild.id)
723
+ throw new Error(`Discord ${label} validation failed`);
724
+ return member;
725
+ }
726
+ function isUnknownMember(error) {
727
+ return (typeof error === "object" &&
728
+ error !== null &&
729
+ "code" in error &&
730
+ error.code === RESTJSONErrorCodes.UnknownMember);
731
+ }
732
+ function validateBoundedRoleSet(roleIds, label) {
733
+ if (roleIds.length > 100 || new Set(roleIds).size !== roleIds.length)
734
+ throw new Error(`${label} role IDs must be unique and bounded`);
735
+ for (const roleId of roleIds)
736
+ validateSnowflake(roleId, `${label} role`);
737
+ }
738
+ function validateSnowflake(value, label) {
739
+ if (!discordSnowflake.test(value))
740
+ throw new Error(`Invalid Discord ${label} ID`);
741
+ }
742
+ function discordAuditReason(input) {
743
+ return `Helyx ${input.moduleId}: ${input.reason.trim()}`;
744
+ }
745
+ function normalizePermissionName(permission) {
746
+ return permission.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
747
+ }
748
+ function isSingleUnicodeEmoji(value) {
749
+ if (!value || !/\p{Emoji}/u.test(value))
750
+ return false;
751
+ const graphemes = [
752
+ ...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(value),
753
+ ];
754
+ return graphemes.length === 1;
755
+ }
204
756
  function safeError(error) {
205
757
  const value = error instanceof Error ? error : new Error(String(error));
206
758
  return { name: value.name, message: value.message };