@helyx/bot 0.4.33 → 0.4.36

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 (40) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cloud-management-module.js +7 -1
  3. package/dist/control-server.d.ts +6 -7
  4. package/dist/control-server.js +38 -45
  5. package/dist/dashboard-commands.d.ts +10 -0
  6. package/dist/dashboard-commands.js +47 -0
  7. package/dist/developer-operations-module.js +5 -1
  8. package/dist/discord-guild-health.d.ts +4 -3
  9. package/dist/discord-guild-health.js +6 -3
  10. package/dist/events.js +38 -16
  11. package/dist/index.js +43 -3
  12. package/dist/interactions.d.ts +3 -2
  13. package/dist/interactions.js +132 -45
  14. package/dist/internal-module-manifest.d.ts +13 -0
  15. package/dist/internal-module-manifest.js +14 -0
  16. package/dist/management-module.js +7 -2
  17. package/dist/module-availability-control.d.ts +24 -0
  18. package/dist/module-availability-control.js +61 -0
  19. package/dist/modules.js +24 -1
  20. package/dist/nexus-commands.d.ts +13 -0
  21. package/dist/nexus-commands.js +100 -0
  22. package/dist/numeric-barriers.d.ts +11 -0
  23. package/dist/numeric-barriers.js +14 -0
  24. package/dist/numeric-write-behind-proxy.d.ts +4 -4
  25. package/dist/numeric-write-behind-proxy.js +45 -24
  26. package/dist/numeric-write-behind-redis.d.ts +4 -4
  27. package/dist/numeric-write-behind-redis.js +132 -70
  28. package/dist/oauth-module.js +3 -1
  29. package/dist/permissions-module.js +18 -5
  30. package/dist/renewable-lease.d.ts +16 -0
  31. package/dist/renewable-lease.js +66 -0
  32. package/dist/scheduled-task-runner.js +15 -1
  33. package/dist/shard-router.d.ts +2 -0
  34. package/dist/shard-router.js +5 -0
  35. package/dist/shard-supervisor.d.ts +5 -0
  36. package/dist/shard-supervisor.js +63 -4
  37. package/dist/shard-worker.d.ts +7 -1
  38. package/dist/shard-worker.js +4 -1
  39. package/dist/support-module.js +3 -1
  40. package/package.json +5 -4
package/dist/events.js CHANGED
@@ -3,6 +3,20 @@ import { ChannelType, Events, GatewayIntentBits, GuildMemberFlags, PermissionFla
3
3
  import { toReplyOptions } from "./interactions.js";
4
4
  import { createHash } from "node:crypto";
5
5
  import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
6
+ function availabilityAwareInstallations(services) {
7
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
8
+ const candidate = typeof services.has === "function" &&
9
+ services.has(HELYX_SERVICE_NAMES.moduleAvailability)
10
+ ? services.get(HELYX_SERVICE_NAMES.moduleAvailability)
11
+ : null;
12
+ const availability = typeof candidate?.isAvailable === "function" ? candidate : null;
13
+ return {
14
+ async isModuleEnabled(guildId, moduleId) {
15
+ return ((!availability || (await availability.isAvailable(moduleId))) &&
16
+ installations.isModuleEnabled(guildId, moduleId));
17
+ },
18
+ };
19
+ }
6
20
  export function installModuleEvents(client, modules, logger, services) {
7
21
  const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? []).map((contribution) => ({
8
22
  moduleId: module.manifest.id,
@@ -113,7 +127,7 @@ export function installModuleEvents(client, modules, logger, services) {
113
127
  if (!message.guildId || !privilegedEligibilityQueue)
114
128
  return;
115
129
  privilegedEligibilityQueue.enqueue(message.guildId, async () => {
116
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
130
+ const installations = availabilityAwareInstallations(services);
117
131
  const eligible = [];
118
132
  for (const item of privilegedMessageContributions) {
119
133
  try {
@@ -260,7 +274,7 @@ export function installModuleEvents(client, modules, logger, services) {
260
274
  });
261
275
  }
262
276
  async function handleResourceLifecycle(contributions, serverId, identifier, resourceId, eventName) {
263
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
277
+ const installations = availabilityAwareInstallations(services);
264
278
  for (const { moduleId, contribution } of contributions) {
265
279
  try {
266
280
  if (!(await installations.isModuleEnabled(serverId, moduleId))) {
@@ -286,8 +300,11 @@ export function installModuleEvents(client, modules, logger, services) {
286
300
  async function handlePrivilegedMessageCreate(message, contributions) {
287
301
  if (!message.guildId || !message.channelId)
288
302
  return;
303
+ const installations = availabilityAwareInstallations(services);
289
304
  for (const { moduleId, contribution } of contributions) {
290
305
  try {
306
+ if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
307
+ continue;
291
308
  await contribution.execute({
292
309
  serverId: message.guildId,
293
310
  channelId: message.channelId,
@@ -316,7 +333,7 @@ export function installModuleEvents(client, modules, logger, services) {
316
333
  async function handleGuildMessageCreate(message) {
317
334
  if (!message.guildId || !message.channelId)
318
335
  return;
319
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
336
+ const installations = availabilityAwareInstallations(services);
320
337
  const parentChannelId = message.channel.isThread()
321
338
  ? (message.channel.parentId ?? undefined)
322
339
  : undefined;
@@ -410,7 +427,7 @@ export function installModuleEvents(client, modules, logger, services) {
410
427
  : reaction.message;
411
428
  if (!message.guildId || !message.channelId)
412
429
  return;
413
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
430
+ const installations = availabilityAwareInstallations(services);
414
431
  const member = await message.guild?.members.fetch(user.id);
415
432
  const userRoleIds = member ? [...member.roles.cache.keys()] : [];
416
433
  const parentChannelId = messageParentChannelId(message);
@@ -467,7 +484,7 @@ export function installModuleEvents(client, modules, logger, services) {
467
484
  async function handleMemberAdd(member) {
468
485
  if (!member.joinedAt)
469
486
  throw new Error("Discord member join timestamp is unavailable");
470
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
487
+ const installations = availabilityAwareInstallations(services);
471
488
  for (const { moduleId, contribution } of memberAddContributions) {
472
489
  try {
473
490
  if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
@@ -498,7 +515,7 @@ export function installModuleEvents(client, modules, logger, services) {
498
515
  }
499
516
  }
500
517
  async function handleMemberRemove(member) {
501
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
518
+ const installations = availabilityAwareInstallations(services);
502
519
  for (const { moduleId, contribution } of memberRemoveContributions) {
503
520
  try {
504
521
  if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
@@ -536,7 +553,7 @@ export function installModuleEvents(client, modules, logger, services) {
536
553
  : reaction.message;
537
554
  if (!message.guildId || !message.channelId)
538
555
  return;
539
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
556
+ const installations = availabilityAwareInstallations(services);
540
557
  const parentChannelId = messageParentChannelId(message);
541
558
  for (const { moduleId, contribution } of reactionRemoveContributions) {
542
559
  if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
@@ -580,7 +597,7 @@ export function installModuleEvents(client, modules, logger, services) {
580
597
  : receivedMessage;
581
598
  if (!message.guildId || !message.channelId)
582
599
  return;
583
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
600
+ const installations = availabilityAwareInstallations(services);
584
601
  for (const { moduleId, contribution } of reactionRemoveAllContributions) {
585
602
  try {
586
603
  if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
@@ -610,7 +627,7 @@ export function installModuleEvents(client, modules, logger, services) {
610
627
  const message = reaction.message;
611
628
  if (!message.guildId || !message.channelId)
612
629
  return;
613
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
630
+ const installations = availabilityAwareInstallations(services);
614
631
  for (const { moduleId, contribution } of reactionRemoveEmojiContributions) {
615
632
  try {
616
633
  if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
@@ -638,7 +655,7 @@ export function installModuleEvents(client, modules, logger, services) {
638
655
  async function handleMessageUpdate(receivedMessage) {
639
656
  if (!receivedMessage.guildId || !receivedMessage.channelId)
640
657
  return;
641
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
658
+ const installations = availabilityAwareInstallations(services);
642
659
  for (const { moduleId, contribution } of messageUpdateContributions) {
643
660
  try {
644
661
  if (!(await installations.isModuleEnabled(receivedMessage.guildId, moduleId)))
@@ -672,7 +689,7 @@ export function installModuleEvents(client, modules, logger, services) {
672
689
  async function handleMessageDelete(message) {
673
690
  if (!message.guildId || !message.channelId)
674
691
  return;
675
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
692
+ const installations = availabilityAwareInstallations(services);
676
693
  for (const { moduleId, contribution } of messageDeleteContributions) {
677
694
  try {
678
695
  if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
@@ -699,7 +716,7 @@ export function installModuleEvents(client, modules, logger, services) {
699
716
  async function handleMessageDeleteBulk(guildId, channelId, messageIds) {
700
717
  if (messageIds.length === 0)
701
718
  return;
702
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
719
+ const installations = availabilityAwareInstallations(services);
703
720
  const boundedIds = messageIds.slice(0, 100);
704
721
  for (const { moduleId, contribution } of messageDeleteBulkContributions) {
705
722
  try {
@@ -733,7 +750,7 @@ export function installModuleEvents(client, modules, logger, services) {
733
750
  voiceSessions.delete(key);
734
751
  const endedAt = new Date();
735
752
  const durationSeconds = Math.max(1, Math.floor((endedAt.getTime() - previous.startedAt.getTime()) / 1_000));
736
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
753
+ const installations = availabilityAwareInstallations(services);
737
754
  for (const { moduleId, contribution } of guildVoiceSessionContributions) {
738
755
  try {
739
756
  if (!(await installations.isModuleEnabled(previous.guildId, moduleId)))
@@ -813,13 +830,18 @@ class BoundedGuildEventQueue {
813
830
  this.#accepting = false;
814
831
  if (this.#active === 0 && this.#pending.length === 0)
815
832
  return;
816
- await Promise.race([
817
- new Promise((resolve) => this.#drainWaiters.add(resolve)),
833
+ const drain = new Promise((resolve) => this.#drainWaiters.add(resolve));
834
+ const drainedBeforeDeadline = await Promise.race([
835
+ drain.then(() => true),
818
836
  new Promise((resolve) => {
819
- const timer = setTimeout(resolve, 5_000);
837
+ const timer = setTimeout(() => resolve(false), 5_000);
820
838
  timer.unref();
821
839
  }),
822
840
  ]);
841
+ if (!drainedBeforeDeadline) {
842
+ this.logger.warn({ event: "module.event_stop_deadline_exceeded" }, "Module gateway event queue is still draining after its shutdown deadline");
843
+ await drain;
844
+ }
823
845
  this.#pending.length = 0;
824
846
  }
825
847
  #pump() {
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
+ import { StatusHeartbeatReporter } from "@helyx/status-client";
2
3
  import { safeError, ServiceRegistry } from "@helyx/core";
3
4
  import { checkDatabaseConnection, createDatabasePool, AuditRepository, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
5
  import { createRuntime } from "@helyx/framework";
@@ -44,9 +45,45 @@ export async function startBot(processEnvironment = process.env) {
44
45
  return startShardWorker(processEnvironment, parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT));
45
46
  const environment = parseBotEnvironment(processEnvironment);
46
47
  bootstrapLogger.level = environment.LOG_LEVEL;
47
- if (environment.HELYX_SHARD_MODE !== "off")
48
- return startShardSupervisor(environment, processEnvironment, resolveBotWorkerEntryPoint());
49
- return startUnshardedBot(processEnvironment);
48
+ const handle = environment.HELYX_SHARD_MODE !== "off"
49
+ ? await startShardSupervisor(environment, processEnvironment, resolveBotWorkerEntryPoint())
50
+ : await startUnshardedBot(processEnvironment);
51
+ if (!environment.HELYX_STATUS_ENABLED)
52
+ return handle;
53
+ const processStartedAt = new Date().toISOString();
54
+ const reporter = new StatusHeartbeatReporter({
55
+ producer: "bot",
56
+ baseUrl: environment.HELYX_STATUS_URL,
57
+ secret: environment.HELYX_STATUS_HEARTBEAT_SECRET,
58
+ accessClientId: environment.CF_ACCESS_CLIENT_ID,
59
+ accessClientSecret: environment.CF_ACCESS_CLIENT_SECRET,
60
+ intervalSeconds: environment.HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS,
61
+ services: () => [
62
+ {
63
+ key: "discord-bot",
64
+ state: "operational",
65
+ processStartedAt,
66
+ release: readBotVersion(),
67
+ },
68
+ {
69
+ key: "creator-connections",
70
+ state: environment.HELYX_OAUTH_ENABLED ? "operational" : "major_outage",
71
+ reasonCode: environment.HELYX_OAUTH_ENABLED ? null : "feature_disabled",
72
+ processStartedAt,
73
+ release: readBotVersion(),
74
+ },
75
+ ],
76
+ onError(error) {
77
+ bootstrapLogger.warn({ event: "status.heartbeat_failed", reason: error.message }, "Status heartbeat could not be delivered");
78
+ },
79
+ });
80
+ reporter.start();
81
+ return {
82
+ async shutdown(reason) {
83
+ await reporter.stop();
84
+ await handle.shutdown(reason);
85
+ },
86
+ };
50
87
  }
51
88
  export function resolveBotWorkerEntryPoint(entryUrl = import.meta.url) {
52
89
  return fileURLToPath(entryUrl);
@@ -254,6 +291,9 @@ async function startUnshardedBot(processEnvironment) {
254
291
  })) {
255
292
  platformServices.set(name, service);
256
293
  }
294
+ await serviceAccess
295
+ .get(HELYX_SERVICE_NAMES.moduleAvailability)
296
+ .refresh();
257
297
  if (environment.HELYX_VERIFICATION_ENABLED &&
258
298
  environment.HELYX_VERIFICATION_URL &&
259
299
  environment.HELYX_VERIFICATION_INTERNAL_SECRET) {
@@ -1,5 +1,6 @@
1
1
  import type { CommandContribution, MessageResponse, ModalDefinition, ModuleDefinition, ServiceAccess } from "@helyx/sdk";
2
2
  import { ModalBuilder, type ChatInputCommandInteraction, type Client, type InteractionReplyOptions, type InteractionUpdateOptions } from "discord.js";
3
+ export declare const GLOBAL_MODULE_UNAVAILABLE_MESSAGE = "This module is temporarily unavailable for all hosted servers.";
3
4
  export interface InteractionLogger {
4
5
  info(fields: Record<string, unknown>, message: string): void;
5
6
  warn(fields: Record<string, unknown>, message: string): void;
@@ -16,7 +17,7 @@ export declare function buildApplicationCommandDefinitions(modules: readonly Mod
16
17
  global: ReturnType<typeof toCommandDefinition>[];
17
18
  guild: ReturnType<typeof toCommandDefinition>[];
18
19
  };
19
- export declare function resolveCommandExecutor(command: CommandContribution, subcommandName: string | null): CommandContribution["execute"];
20
+ export declare function resolveCommandExecutor(command: CommandContribution, subcommandName: string | null, groupName?: string | null): CommandContribution["execute"];
20
21
  export declare function toCommandDefinition(registered: {
21
22
  contribution: CommandContribution;
22
23
  }): import("discord.js").RESTPostAPIChatInputApplicationCommandsJSONBody;
@@ -27,7 +28,7 @@ export declare function sendToChannel(client: Client, channelId: string, respons
27
28
  export declare function deleteChannelMessage(client: Client, channelId: string, messageId: string): Promise<void>;
28
29
  export declare function editChannelMessage(client: Client, channelId: string, messageId: string, response: MessageResponse): Promise<void>;
29
30
  export declare function addChannelMessageReaction(client: Client, channelId: string, messageId: string, emojiIdentifier: string): Promise<void>;
30
- export declare function authorizeCommand(interaction: ChatInputCommandInteraction, moduleId: string, commandId: string, rateLimit: CommandContribution["rateLimit"], services: ServiceAccess, selfAuthorizingGuildCommand: boolean, resourceScopedCommand?: boolean): Promise<boolean>;
31
+ export declare function authorizeCommand(interaction: ChatInputCommandInteraction, moduleId: string, commandId: string, rateLimit: CommandContribution["rateLimit"], services: ServiceAccess, selfAuthorizingGuildCommand: boolean, resourceScopedCommand?: boolean, requiredModuleName?: string): Promise<boolean>;
31
32
  export declare function normalizeDiscordPermission(permission: string): string;
32
33
  export declare function toReplyOptions(response: MessageResponse): InteractionReplyOptions;
33
34
  export declare function toUpdateOptions(response: MessageResponse): InteractionUpdateOptions;
@@ -1,6 +1,7 @@
1
1
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
2
  import { canUseCommand } from "@helyx/core";
3
3
  import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelSelectMenuBuilder, EmbedBuilder, Events, GuildMember, ApplicationIntegrationType, ChannelType, InteractionContextType, LabelBuilder, MessageFlags, MentionableSelectMenuBuilder, ModalBuilder, RoleSelectMenuBuilder, StringSelectMenuBuilder, SlashCommandBuilder, TextInputBuilder, TextInputStyle, UserSelectMenuBuilder, TextDisplayBuilder, PermissionFlagsBits, escapeMarkdown, } from "discord.js";
4
+ import { collectCommandRegistrations, } from "./nexus-commands.js";
4
5
  const buttonStyles = {
5
6
  primary: ButtonStyle.Primary,
6
7
  secondary: ButtonStyle.Secondary,
@@ -8,11 +9,18 @@ const buttonStyles = {
8
9
  danger: ButtonStyle.Danger,
9
10
  link: ButtonStyle.Link,
10
11
  };
12
+ export const GLOBAL_MODULE_UNAVAILABLE_MESSAGE = "This module is temporarily unavailable for all hosted servers.";
13
+ async function moduleIsGloballyAvailable(services, moduleId) {
14
+ if (typeof services.has !== "function" ||
15
+ !services.has(HELYX_SERVICE_NAMES.moduleAvailability))
16
+ return true;
17
+ const availability = services.get(HELYX_SERVICE_NAMES.moduleAvailability);
18
+ return typeof availability?.isAvailable !== "function"
19
+ ? true
20
+ : availability.isAvailable(moduleId);
21
+ }
11
22
  export function installInteractions(client, modules, registration, logger, services) {
12
- const commands = uniqueMap(modules.flatMap((module) => (module.interactions?.commands ?? []).map((contribution) => ({
13
- moduleId: module.manifest.id,
14
- contribution,
15
- }))), ({ contribution }) => contribution.name, "command name");
23
+ const commands = collectCommandRegistrations(modules);
16
24
  const globalCommands = [...commands.values()].filter(({ contribution }) => contribution.registration === "global");
17
25
  const guildCommands = [...commands.values()].filter(({ contribution }) => contribution.registration === "guild");
18
26
  if (guildCommands.length > 0 && !registration.internalGuildId) {
@@ -74,16 +82,20 @@ export function installInteractions(client, modules, registration, logger, servi
74
82
  try {
75
83
  if (interaction.isAutocomplete()) {
76
84
  const registered = commands.get(interaction.commandName);
77
- const subcommandName = registered?.contribution.subcommands?.length
85
+ const groupName = registered?.contribution.subcommandGroups?.length
86
+ ? interaction.options.getSubcommandGroup(false)
87
+ : null;
88
+ const subcommandName = hasNestedCommands(registered?.contribution)
78
89
  ? interaction.options.getSubcommand(false)
79
90
  : null;
80
- const subcommand = subcommandName
81
- ? registered?.contribution.subcommands?.find(({ name }) => name === subcommandName)
82
- : undefined;
91
+ const subcommand = resolveCommandSubcommand(registered?.contribution, subcommandName, groupName);
92
+ const owner = resolveCommandOwner(registered, subcommandName, groupName);
83
93
  if (!registered ||
84
94
  !subcommand?.autocomplete ||
85
- !(await authorizeAutocomplete(interaction, registered.moduleId, subcommand.permissionId ??
86
- `${registered.contribution.name}.${subcommand.name}`, services, registration.selfAuthorizingGuildModuleIds?.has(registered.moduleId) ?? false, subcommand.authorization === "resource-scoped"))) {
95
+ !(await authorizeAutocomplete(interaction, owner?.moduleId ?? registered.moduleId, subcommand.permissionId ??
96
+ [registered.contribution.name, groupName, subcommand.name]
97
+ .filter(Boolean)
98
+ .join("."), services, registration.selfAuthorizingGuildModuleIds?.has(owner?.moduleId ?? registered.moduleId) ?? false, subcommand.authorization === "resource-scoped"))) {
87
99
  await interaction.respond([]);
88
100
  return;
89
101
  }
@@ -91,24 +103,35 @@ export function installInteractions(client, modules, registration, logger, servi
91
103
  }
92
104
  else if (interaction.isChatInputCommand()) {
93
105
  const registered = commands.get(interaction.commandName);
94
- if (registered) {
106
+ if (!registered) {
107
+ logger.warn({
108
+ event: "interaction.stale_command",
109
+ commandName: interaction.commandName,
110
+ }, "Interaction used a command that is no longer loaded");
111
+ await interaction.reply({
112
+ content: "This command is no longer available. Refresh Discord and try again.",
113
+ flags: MessageFlags.Ephemeral,
114
+ });
115
+ return;
116
+ }
117
+ {
95
118
  const { contribution: command, moduleId } = registered;
96
- const subcommandName = command.subcommands?.length
119
+ const groupName = command.subcommandGroups?.length
120
+ ? interaction.options.getSubcommandGroup(false)
121
+ : null;
122
+ const subcommandName = hasNestedCommands(command)
97
123
  ? interaction.options.getSubcommand(false)
98
124
  : null;
99
- const subcommand = subcommandName
100
- ? command.subcommands?.find(({ name }) => name === subcommandName)
101
- : undefined;
102
- const execute = resolveCommandExecutor(command, subcommandName);
125
+ const subcommand = resolveCommandSubcommand(command, subcommandName, groupName);
126
+ const execute = resolveCommandExecutor(command, subcommandName, groupName);
103
127
  if (!execute) {
104
128
  logger.warn({
105
129
  event: "interaction.stale_command",
106
130
  commandName: command.name,
107
131
  ...(subcommandName ? { subcommandName } : {}),
132
+ ...(groupName ? { groupName } : {}),
108
133
  }, "Interaction used an outdated command definition");
109
- const choices = (command.subcommands ?? [])
110
- .map(({ name }) => `\`/${command.name} ${name}\``)
111
- .join(" or ");
134
+ const choices = commandChoices(command).join(" or ");
112
135
  await interaction.reply({
113
136
  content: choices
114
137
  ? `This command was recently updated. Refresh Discord, then use ${choices}.`
@@ -118,11 +141,19 @@ export function installInteractions(client, modules, registration, logger, servi
118
141
  return;
119
142
  }
120
143
  const commandId = subcommandName
121
- ? `${command.name}.${subcommandName}`
144
+ ? [command.name, groupName, subcommandName]
145
+ .filter(Boolean)
146
+ .join(".")
122
147
  : command.name;
123
148
  const permissionId = subcommand?.permissionId ?? command.permissionId ?? commandId;
124
- if (await authorizeCommand(interaction, moduleId, permissionId, subcommand?.rateLimit ?? command.rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(moduleId) ??
125
- false, subcommand?.authorization === "resource-scoped")) {
149
+ const owner = resolveCommandOwner(registered, subcommandName, groupName);
150
+ const ownerModuleId = owner?.moduleId ?? moduleId;
151
+ const acknowledgement = subcommand?.acknowledgement ?? command.acknowledgement;
152
+ if (acknowledgement === "deferred-ephemeral") {
153
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
154
+ }
155
+ if (await authorizeCommand(interaction, ownerModuleId, permissionId, subcommand?.rateLimit ?? command.rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
156
+ false, subcommand?.authorization === "resource-scoped", owner?.moduleName)) {
126
157
  const context = createCommandContext(interaction, services);
127
158
  await execute(context);
128
159
  }
@@ -187,10 +218,7 @@ export function installInteractions(client, modules, registration, logger, servi
187
218
  return registrationReady;
188
219
  }
189
220
  export function buildApplicationCommandDefinitions(modules, internalGuildId) {
190
- const commands = uniqueMap(modules.flatMap((module) => (module.interactions?.commands ?? []).map((contribution) => ({
191
- moduleId: module.manifest.id,
192
- contribution,
193
- }))), ({ contribution }) => contribution.name, "command name");
221
+ const commands = collectCommandRegistrations(modules);
194
222
  const global = [...commands.values()]
195
223
  .filter(({ contribution }) => contribution.registration === "global")
196
224
  .map(toCommandDefinition);
@@ -201,11 +229,11 @@ export function buildApplicationCommandDefinitions(modules, internalGuildId) {
201
229
  throw new Error("DISCORD_INTERNAL_GUILD_ID is required when guild commands are loaded");
202
230
  return { global, guild };
203
231
  }
204
- export function resolveCommandExecutor(command, subcommandName) {
205
- if (command.subcommands?.length) {
232
+ export function resolveCommandExecutor(command, subcommandName, groupName = null) {
233
+ if (hasNestedCommands(command)) {
206
234
  if (!subcommandName)
207
235
  return undefined;
208
- const subcommand = command.subcommands.find(({ name }) => name === subcommandName);
236
+ const subcommand = resolveCommandSubcommand(command, subcommandName, groupName);
209
237
  return subcommand ? (context) => subcommand.execute(context) : undefined;
210
238
  }
211
239
  return command.execute
@@ -214,11 +242,25 @@ export function resolveCommandExecutor(command, subcommandName) {
214
242
  }
215
243
  export function toCommandDefinition(registered) {
216
244
  const command = registered.contribution;
217
- if (!!command.execute === !!command.subcommands?.length) {
218
- throw new Error(`Command ${command.name} must define either execute or subcommands`);
245
+ const hasNested = Boolean(command.subcommands?.length || command.subcommandGroups?.length);
246
+ if (Number(command.execute !== undefined) + Number(hasNested) !== 1) {
247
+ throw new Error(`Command ${command.name} must define exactly one of execute, subcommands or subcommand groups`);
248
+ }
249
+ if (command.execute && !command.acknowledgement) {
250
+ throw new Error(`Command ${command.name} must declare acknowledgement`);
251
+ }
252
+ for (const subcommand of command.subcommands ?? []) {
253
+ if (!subcommand.acknowledgement && !command.acknowledgement)
254
+ throw new Error(`Command ${command.name}.${subcommand.name} must declare acknowledgement`);
255
+ }
256
+ for (const group of command.subcommandGroups ?? []) {
257
+ for (const subcommand of group.subcommands) {
258
+ if (!subcommand.acknowledgement && !command.acknowledgement)
259
+ throw new Error(`Command ${command.name}.${group.name}.${subcommand.name} must declare acknowledgement`);
260
+ }
219
261
  }
220
- if (command.options?.length && command.subcommands?.length) {
221
- throw new Error(`Command ${command.name} cannot define both direct options and subcommands`);
262
+ if (command.options?.length && hasNested) {
263
+ throw new Error(`Command ${command.name} cannot define both direct options and nested commands`);
222
264
  }
223
265
  const contextTypes = {
224
266
  guild: InteractionContextType.Guild,
@@ -248,8 +290,50 @@ export function toCommandDefinition(registered) {
248
290
  return subcommandBuilder;
249
291
  });
250
292
  }
293
+ for (const group of command.subcommandGroups ?? []) {
294
+ builder.addSubcommandGroup((groupBuilder) => {
295
+ groupBuilder.setName(group.name).setDescription(group.description);
296
+ for (const subcommand of group.subcommands) {
297
+ groupBuilder.addSubcommand((subcommandBuilder) => {
298
+ subcommandBuilder
299
+ .setName(subcommand.name)
300
+ .setDescription(subcommand.description);
301
+ for (const option of subcommand.options ?? []) {
302
+ addCommandOption(subcommandBuilder, option);
303
+ }
304
+ return subcommandBuilder;
305
+ });
306
+ }
307
+ return groupBuilder;
308
+ });
309
+ }
251
310
  return builder.toJSON();
252
311
  }
312
+ function hasNestedCommands(command) {
313
+ return Boolean(command?.subcommands?.length || command?.subcommandGroups?.length);
314
+ }
315
+ function resolveCommandSubcommand(command, subcommandName, groupName) {
316
+ if (!command || !subcommandName)
317
+ return undefined;
318
+ if (!groupName) {
319
+ return command.subcommands?.find(({ name }) => name === subcommandName);
320
+ }
321
+ return command.subcommandGroups
322
+ ?.find(({ name }) => name === groupName)
323
+ ?.subcommands.find(({ name }) => name === subcommandName);
324
+ }
325
+ function resolveCommandOwner(registered, subcommandName, groupName) {
326
+ if (!registered?.routeOwners || !subcommandName)
327
+ return undefined;
328
+ const path = groupName ? `${groupName}.${subcommandName}` : subcommandName;
329
+ return registered.routeOwners.get(path);
330
+ }
331
+ function commandChoices(command) {
332
+ return [
333
+ ...(command.subcommands ?? []).map(({ name }) => `\`/${command.name} ${name}\``),
334
+ ...(command.subcommandGroups ?? []).flatMap((group) => group.subcommands.map(({ name }) => `\`/${command.name} ${group.name} ${name}\``)),
335
+ ];
336
+ }
253
337
  function addCommandOption(builder, option) {
254
338
  if (option.type === "string") {
255
339
  if (option.autocomplete && option.choices?.length)
@@ -610,14 +694,20 @@ export async function addChannelMessageReaction(client, channelId, messageId, em
610
694
  const message = await channel.messages.fetch(messageId);
611
695
  await message.react(emojiIdentifier);
612
696
  }
613
- async function authorizeComponent(interaction, moduleId, services) {
697
+ async function authorizeComponent(interaction, moduleId, services, requiredModuleName) {
698
+ if (!(await moduleIsGloballyAvailable(services, moduleId))) {
699
+ await replyEphemeralText(interaction, GLOBAL_MODULE_UNAVAILABLE_MESSAGE);
700
+ return false;
701
+ }
614
702
  if (!interaction.guildId)
615
703
  return true;
616
704
  const installations = services.get(HELYX_SERVICE_NAMES.installations);
617
705
  if (await installations.isModuleEnabled(interaction.guildId, moduleId)) {
618
706
  return true;
619
707
  }
620
- await replyEphemeralText(interaction, "This module is disabled in this server. Ask a server administrator to enable it with `/helyx` or in the Helyx dashboard.");
708
+ await replyEphemeralText(interaction, requiredModuleName
709
+ ? `This command needs ${requiredModuleName}. Ask a server administrator to enable it in the Helyx dashboard.`
710
+ : "This module is disabled in this server. Ask a server administrator to enable it with `/helyx` or in the Helyx dashboard.");
621
711
  return false;
622
712
  }
623
713
  async function replyEphemeralText(interaction, content) {
@@ -627,11 +717,11 @@ async function replyEphemeralText(interaction, content) {
627
717
  }
628
718
  await interaction.reply({ content, flags: MessageFlags.Ephemeral });
629
719
  }
630
- export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand, resourceScopedCommand = false) {
720
+ export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand, resourceScopedCommand = false, requiredModuleName) {
631
721
  if (!interaction.guildId)
632
722
  return true;
633
723
  if (!selfAuthorizingGuildCommand) {
634
- if (!(await authorizeComponent(interaction, moduleId, services)))
724
+ if (!(await authorizeComponent(interaction, moduleId, services, requiredModuleName)))
635
725
  return false;
636
726
  if (!resourceScopedCommand) {
637
727
  const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
@@ -639,10 +729,7 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
639
729
  const actor = permissionActor(interaction);
640
730
  const guildAccess = await permissions.getGuildAccessPolicy?.(interaction.guildId);
641
731
  if (!canUseCommand(actor, policy, guildAccess)) {
642
- await interaction.reply({
643
- content: "You do not have permission to use this command.",
644
- flags: MessageFlags.Ephemeral,
645
- });
732
+ await replyEphemeralText(interaction, "You do not have permission to use this command.");
646
733
  return false;
647
734
  }
648
735
  }
@@ -657,16 +744,15 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
657
744
  windowSeconds: rateLimit.windowSeconds,
658
745
  });
659
746
  if (!result.allowed) {
660
- await interaction.reply({
661
- content: `This command is temporarily rate limited. Try again <t:${Math.ceil(result.resetsAt.getTime() / 1_000)}:R>.`,
662
- flags: MessageFlags.Ephemeral,
663
- });
747
+ await replyEphemeralText(interaction, `This command is temporarily rate limited. Try again <t:${Math.ceil(result.resetsAt.getTime() / 1_000)}:R>.`);
664
748
  return false;
665
749
  }
666
750
  }
667
751
  return true;
668
752
  }
669
753
  async function authorizeAutocomplete(interaction, moduleId, commandId, services, selfAuthorizingGuildCommand, resourceScopedCommand) {
754
+ if (!(await moduleIsGloballyAvailable(services, moduleId)))
755
+ return false;
670
756
  if (!interaction.guildId || selfAuthorizingGuildCommand)
671
757
  return true;
672
758
  const installations = services.get(HELYX_SERVICE_NAMES.installations);
@@ -1023,6 +1109,7 @@ function interactionLogContext(interaction) {
1023
1109
  ...(interaction.isChatInputCommand()
1024
1110
  ? {
1025
1111
  commandName: interaction.commandName,
1112
+ subcommandGroupName: interaction.options.getSubcommandGroup(false) ?? undefined,
1026
1113
  subcommandName: interaction.options.getSubcommand(false) ?? undefined,
1027
1114
  }
1028
1115
  : "customId" in interaction && typeof interaction.customId === "string"
@@ -0,0 +1,13 @@
1
+ export declare const INTERNAL_MODULE_MANIFEST_FIELDS: {
2
+ readonly manifestVersion: "0.3";
3
+ readonly moduleKind: "internal_dependency";
4
+ readonly customerSelectable: false;
5
+ readonly countsAsInstalledModule: false;
6
+ readonly healthExposure: "delegated";
7
+ readonly privacy: {
8
+ containsPersonalData: false;
9
+ providerVersion: number;
10
+ collections: never[];
11
+ };
12
+ };
13
+ //# sourceMappingURL=internal-module-manifest.d.ts.map
@@ -0,0 +1,14 @@
1
+ const noOwnedPersonalData = {
2
+ containsPersonalData: false,
3
+ providerVersion: 1,
4
+ collections: [],
5
+ };
6
+ export const INTERNAL_MODULE_MANIFEST_FIELDS = {
7
+ manifestVersion: "0.3",
8
+ moduleKind: "internal_dependency",
9
+ customerSelectable: false,
10
+ countsAsInstalledModule: false,
11
+ healthExposure: "delegated",
12
+ privacy: noOwnedPersonalData,
13
+ };
14
+ //# sourceMappingURL=internal-module-manifest.js.map
@@ -1,4 +1,6 @@
1
+ import { isCustomerSelectableModule, } from "@helyx/module-manifest";
1
2
  import { defineModule, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
+ import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
2
4
  const MODULE_SELECT_ID = "helyx.management.module";
3
5
  const SETTING_SELECT_ID = "helyx.management.setting";
4
6
  const VALUE_SELECT_ID = "helyx.management.value";
@@ -24,11 +26,13 @@ const VIOLET = 0x7c3aed;
24
26
  const SUCCESS = 0x16a34a;
25
27
  const DANGER = 0xdc2626;
26
28
  export function createManagementModule(configurableModules, resources) {
27
- const modules = [...configurableModules].sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
29
+ const modules = configurableModules
30
+ .filter((module) => isCustomerSelectableModule(module.manifest))
31
+ .sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
28
32
  const byId = new Map(modules.map((module) => [module.manifest.id, module]));
29
33
  return defineModule({
30
34
  manifest: {
31
- manifestVersion: "0.1",
35
+ ...INTERNAL_MODULE_MANIFEST_FIELDS,
32
36
  releaseStatus: "live",
33
37
  id: "helyx.management",
34
38
  name: "Helyx management",
@@ -49,6 +53,7 @@ export function createManagementModule(configurableModules, resources) {
49
53
  {
50
54
  name: "helyx",
51
55
  description: "Manage Helyx modules and settings",
56
+ acknowledgement: "deferred-ephemeral",
52
57
  registration: "global",
53
58
  contexts: ["guild"],
54
59
  integrationTypes: ["guild_install"],
@@ -0,0 +1,24 @@
1
+ import { type ModuleAvailabilityService, type ModuleAvailabilityState, type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
2
+ export interface AvailabilityModuleDefinition {
3
+ id: string;
4
+ name: string;
5
+ version: string;
6
+ releaseStatus: "planned" | "testing" | "beta" | "live";
7
+ }
8
+ export interface AvailabilityModuleCatalogue {
9
+ catalogueDigest: string;
10
+ modules: AvailabilityModuleDefinition[];
11
+ }
12
+ export declare function availabilityCatalogueDefinitions(modules: readonly ModuleDefinition[]): AvailabilityModuleDefinition[];
13
+ export declare function availabilityCatalogueSnapshot(definitions: readonly AvailabilityModuleDefinition[]): AvailabilityModuleCatalogue;
14
+ export declare function availabilityCatalogue(definitions: readonly AvailabilityModuleDefinition[]): Promise<AvailabilityModuleCatalogue>;
15
+ export declare function moduleAvailabilityService(services: ServiceAccess): ModuleAvailabilityService | undefined;
16
+ export declare function moduleAvailabilityStates(services: ServiceAccess, moduleIds: readonly string[]): Promise<ModuleAvailabilityState[]>;
17
+ export declare function dashboardModuleStatus(globallyDisabled: ReadonlySet<string>, moduleId: string, installation: {
18
+ enabled: boolean;
19
+ } | undefined): "enabled" | "disabled" | "unavailable";
20
+ export declare function disabledModuleSummary(states: readonly ModuleAvailabilityState[]): {
21
+ ids: string[];
22
+ set: ReadonlySet<string>;
23
+ };
24
+ //# sourceMappingURL=module-availability-control.d.ts.map