@helyx/bot 0.4.37 → 0.4.38

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 (64) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +17 -2
  3. package/dist/cloud-management-module.js +275 -206
  4. package/dist/command-registration-policy.d.ts +8 -0
  5. package/dist/command-registration-policy.js +39 -0
  6. package/dist/command-sync.d.ts +5 -1
  7. package/dist/command-sync.js +67 -3
  8. package/dist/control-server.d.ts +15 -3
  9. package/dist/control-server.js +34 -48
  10. package/dist/dashboard-commands.js +5 -1
  11. package/dist/developer-operations-module.d.ts +2 -0
  12. package/dist/developer-operations-module.js +65 -4
  13. package/dist/discord-rest.d.ts +5 -0
  14. package/dist/discord-rest.js +9 -0
  15. package/dist/events.js +4 -2
  16. package/dist/first-time-command-setup.d.ts +25 -0
  17. package/dist/first-time-command-setup.js +22 -0
  18. package/dist/guild-command-control-routes.d.ts +5 -0
  19. package/dist/guild-command-control-routes.js +191 -0
  20. package/dist/guild-command-discord-client.d.ts +21 -0
  21. package/dist/guild-command-discord-client.js +50 -0
  22. package/dist/guild-command-projection.d.ts +46 -0
  23. package/dist/guild-command-projection.js +254 -0
  24. package/dist/guild-command-read-service.d.ts +201 -0
  25. package/dist/guild-command-read-service.js +272 -0
  26. package/dist/guild-command-release.d.ts +19 -0
  27. package/dist/guild-command-release.js +47 -0
  28. package/dist/guild-command-worker.d.ts +49 -0
  29. package/dist/guild-command-worker.js +443 -0
  30. package/dist/guilds.d.ts +1 -1
  31. package/dist/guilds.js +41 -26
  32. package/dist/index.js +75 -4
  33. package/dist/interactions.d.ts +15 -4
  34. package/dist/interactions.js +285 -70
  35. package/dist/management-module.js +117 -41
  36. package/dist/module-logging.d.ts +1 -0
  37. package/dist/module-logging.js +5 -5
  38. package/dist/module-status-control.d.ts +19 -0
  39. package/dist/module-status-control.js +69 -0
  40. package/dist/modules.d.ts +2 -0
  41. package/dist/modules.js +21 -2
  42. package/dist/nexus-commands.d.ts +1 -12
  43. package/dist/nexus-commands.js +1 -99
  44. package/dist/oauth-module.js +134 -88
  45. package/dist/permissions-module.js +112 -39
  46. package/dist/privacy-module.d.ts +2 -0
  47. package/dist/privacy-module.js +60 -0
  48. package/dist/scheduled-task-runner.js +36 -1
  49. package/dist/shard-contracts.d.ts +24 -0
  50. package/dist/shard-contracts.js +20 -0
  51. package/dist/shard-router.d.ts +1 -0
  52. package/dist/shard-router.js +7 -0
  53. package/dist/shard-supervisor.d.ts +2 -1
  54. package/dist/shard-supervisor.js +94 -16
  55. package/dist/shard-worker.d.ts +5 -0
  56. package/dist/shard-worker.js +9 -1
  57. package/dist/shared-commands.d.ts +13 -0
  58. package/dist/shared-commands.js +163 -0
  59. package/dist/support-module.js +51 -51
  60. package/dist/trivia-data-provider.d.ts +26 -0
  61. package/dist/trivia-data-provider.js +51 -0
  62. package/package.json +5 -4
  63. package/dist/oauth-module.test.d.ts +0 -2
  64. package/dist/oauth-module.test.js +0 -172
@@ -1,7 +1,9 @@
1
+ import { lookup as lookupDns } from "node:dns/promises";
1
2
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
3
  import { canUseCommand } from "@helyx/core";
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
+ import { commandRegistrationPolicy, contributionRegistrationPolicy, } from "./command-registration-policy.js";
5
+ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelSelectMenuBuilder, EmbedBuilder, Events, GuildMember, ApplicationIntegrationType, ChannelType, InteractionContextType, LabelBuilder, MessageFlags, MentionableSelectMenuBuilder, ModalBuilder, RoleSelectMenuBuilder, Routes, StringSelectMenuBuilder, SlashCommandBuilder, TextInputBuilder, TextInputStyle, UserSelectMenuBuilder, TextDisplayBuilder, PermissionFlagsBits, escapeMarkdown, } from "discord.js";
6
+ import { collectCommandRegistrations, } from "./shared-commands.js";
5
7
  const buttonStyles = {
6
8
  primary: ButtonStyle.Primary,
7
9
  secondary: ButtonStyle.Secondary,
@@ -10,6 +12,8 @@ const buttonStyles = {
10
12
  link: ButtonStyle.Link,
11
13
  };
12
14
  export const GLOBAL_MODULE_UNAVAILABLE_MESSAGE = "This module is temporarily unavailable for all hosted servers.";
15
+ export const AUTOCOMPLETE_RESPONSE_DEADLINE_MS = 2_000;
16
+ export const DISCORD_DNS_REFRESH_INTERVAL_MS = 30_000;
13
17
  async function moduleIsGloballyAvailable(services, moduleId) {
14
18
  if (typeof services.has !== "function" ||
15
19
  !services.has(HELYX_SERVICE_NAMES.moduleAvailability))
@@ -21,8 +25,10 @@ async function moduleIsGloballyAvailable(services, moduleId) {
21
25
  }
22
26
  export function installInteractions(client, modules, registration, logger, services) {
23
27
  const commands = collectCommandRegistrations(modules);
24
- const globalCommands = [...commands.values()].filter(({ contribution }) => contribution.registration === "global");
25
- const guildCommands = [...commands.values()].filter(({ contribution }) => contribution.registration === "guild");
28
+ const modulesById = new Map(modules.map((module) => [module.manifest.id, module]));
29
+ const allowLegacyCustomerGlobals = (registration.globalCommandMode ?? "legacy") === "legacy";
30
+ const globalCommands = [...commands.values()].filter(({ moduleId, contribution }) => isApplicationGlobalPolicy(contributionRegistrationPolicy(requiredCommandModule(modulesById, moduleId), contribution, { allowLegacyCustomerGlobals })));
31
+ const guildCommands = [...commands.values()].filter(({ moduleId, contribution }) => contributionRegistrationPolicy(requiredCommandModule(modulesById, moduleId), contribution, { allowLegacyCustomerGlobals }) === "internal-guild");
26
32
  if (guildCommands.length > 0 && !registration.internalGuildId) {
27
33
  throw new Error("DISCORD_INTERNAL_GUILD_ID is required when guild commands are loaded");
28
34
  }
@@ -54,6 +60,8 @@ export function installInteractions(client, modules, registration, logger, servi
54
60
  rejectRegistration(error);
55
61
  });
56
62
  async function registerCommands() {
63
+ await warmDiscordRestConnection(readyClient, logger);
64
+ startDiscordDnsRefresh(logger);
57
65
  await registration.prepare?.(readyClient);
58
66
  if (registration.synchronise === false) {
59
67
  logger.info({ event: "commands.registration_delegated" }, "Application command registration is owned by the supervisor");
@@ -61,7 +69,8 @@ export function installInteractions(client, modules, registration, logger, servi
61
69
  }
62
70
  const globalDefinitions = globalCommands.map(toCommandDefinition);
63
71
  await readyClient.application.commands.set(globalDefinitions);
64
- if (registration.internalGuildId) {
72
+ if (registration.internalGuildId &&
73
+ registration.synchroniseInternalGuild !== false) {
65
74
  const guildDefinitions = guildCommands.map(toCommandDefinition);
66
75
  await readyClient.application.commands.set(guildDefinitions, registration.internalGuildId);
67
76
  }
@@ -69,6 +78,9 @@ export function installInteractions(client, modules, registration, logger, servi
69
78
  event: "commands.registered",
70
79
  globalCount: globalCommands.length,
71
80
  guildCount: guildCommands.length,
81
+ internalGuildSynchronisation: registration.synchroniseInternalGuild === false
82
+ ? "delegated"
83
+ : "startup",
72
84
  ...(registration.internalGuildId
73
85
  ? { internalGuildId: registration.internalGuildId }
74
86
  : {}),
@@ -81,25 +93,48 @@ export function installInteractions(client, modules, registration, logger, servi
81
93
  async function handleInteraction(interaction) {
82
94
  try {
83
95
  if (interaction.isAutocomplete()) {
84
- const registered = commands.get(interaction.commandName);
85
- const groupName = registered?.contribution.subcommandGroups?.length
86
- ? interaction.options.getSubcommandGroup(false)
87
- : null;
88
- const subcommandName = hasNestedCommands(registered?.contribution)
89
- ? interaction.options.getSubcommand(false)
90
- : null;
91
- const subcommand = resolveCommandSubcommand(registered?.contribution, subcommandName, groupName);
92
- const owner = resolveCommandOwner(registered, subcommandName, groupName);
93
- if (!registered ||
94
- !subcommand?.autocomplete ||
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"))) {
99
- await interaction.respond([]);
100
- return;
96
+ const response = createAutocompleteResponseController(interaction, logger);
97
+ try {
98
+ const registered = commands.get(interaction.commandName);
99
+ const groupName = registered?.contribution.subcommandGroups?.length
100
+ ? interaction.options.getSubcommandGroup(false)
101
+ : null;
102
+ const subcommandName = hasNestedCommands(registered?.contribution)
103
+ ? interaction.options.getSubcommand(false)
104
+ : null;
105
+ const subcommand = resolveCommandSubcommand(registered?.contribution, subcommandName, groupName);
106
+ const owner = resolveCommandOwner(registered, subcommandName, groupName);
107
+ const ownerModuleId = owner?.moduleId ?? registered?.moduleId;
108
+ if (!registered ||
109
+ !subcommand?.autocomplete ||
110
+ !ownerModuleId ||
111
+ !(await authorizeAutocomplete(interaction, ownerModuleId, subcommand.permissionId ??
112
+ [registered.contribution.name, groupName, subcommand.name]
113
+ .filter(Boolean)
114
+ .join("."), services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
115
+ false, subcommand.authorization === "resource-scoped", isInstallationIndependentModule(modulesById, ownerModuleId), subcommand.authorization))) {
116
+ await response.respond([]);
117
+ return;
118
+ }
119
+ await subcommand.autocomplete(createAutocompleteContext(interaction, services, response.respond));
120
+ }
121
+ catch (error) {
122
+ logger.error({
123
+ event: "interaction.failed",
124
+ ...interactionLogContext(interaction),
125
+ error: safeError(error),
126
+ }, "Module autocomplete interaction failed");
127
+ }
128
+ finally {
129
+ await response.ensureResponded().catch((error) => {
130
+ logger.warn({
131
+ event: "interaction.autocomplete_response_failed",
132
+ ...interactionLogContext(interaction),
133
+ error: safeError(error),
134
+ }, "Autocomplete response failed");
135
+ });
101
136
  }
102
- await subcommand.autocomplete(createAutocompleteContext(interaction, services));
137
+ return;
103
138
  }
104
139
  else if (interaction.isChatInputCommand()) {
105
140
  const registered = commands.get(interaction.commandName);
@@ -148,12 +183,21 @@ export function installInteractions(client, modules, registration, logger, servi
148
183
  const permissionId = subcommand?.permissionId ?? command.permissionId ?? commandId;
149
184
  const owner = resolveCommandOwner(registered, subcommandName, groupName);
150
185
  const ownerModuleId = owner?.moduleId ?? moduleId;
186
+ const installationIndependentCommand = isInstallationIndependentModule(modulesById, ownerModuleId);
151
187
  const acknowledgement = subcommand?.acknowledgement ?? command.acknowledgement;
188
+ const authorization = subcommand?.authorization ?? command.authorization;
189
+ const rateLimit = subcommand?.rateLimit ?? command.rateLimit;
190
+ if (installationIndependentCommand &&
191
+ authorization === "public" &&
192
+ !rateLimit) {
193
+ await execute(createCommandContext(interaction, services));
194
+ return;
195
+ }
152
196
  if (acknowledgement === "deferred-ephemeral") {
153
197
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
154
198
  }
155
- if (await authorizeCommand(interaction, ownerModuleId, permissionId, subcommand?.rateLimit ?? command.rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
156
- false, subcommand?.authorization === "resource-scoped", owner?.moduleName)) {
199
+ if (await authorizeCommand(interaction, ownerModuleId, permissionId, rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(ownerModuleId) ??
200
+ false, subcommand?.authorization === "resource-scoped", owner?.moduleName, installationIndependentCommand, authorization)) {
157
201
  const context = createCommandContext(interaction, services);
158
202
  await execute(context);
159
203
  }
@@ -162,6 +206,10 @@ export function installInteractions(client, modules, registration, logger, servi
162
206
  else if (interaction.isButton()) {
163
207
  const button = buttons.get(interaction.customId) ??
164
208
  resolveComponentPrefix(buttonPrefixes, interaction.customId);
209
+ if (!button) {
210
+ await interaction.reply(staleComponentResponse());
211
+ return;
212
+ }
165
213
  const context = button
166
214
  ? createContext(interaction, services)
167
215
  : undefined;
@@ -176,15 +224,29 @@ export function installInteractions(client, modules, registration, logger, servi
176
224
  }
177
225
  if (button &&
178
226
  context &&
179
- (await authorizeComponent(interaction, button.moduleId, services))) {
227
+ button.contribution.authorization === "public") {
228
+ await button.contribution.execute(context);
229
+ }
230
+ else if (button &&
231
+ context &&
232
+ (await authorizeComponent(interaction, button.moduleId, services, undefined, isInstallationIndependentModule(modulesById, button.moduleId)))) {
180
233
  await button.contribution.execute(context);
181
234
  }
182
235
  }
183
236
  else if (interaction.isModalSubmit()) {
184
237
  const modal = modals.get(interaction.customId) ??
185
238
  resolveComponentPrefix(modalPrefixes, interaction.customId);
239
+ if (!modal) {
240
+ await interaction.reply(staleComponentResponse());
241
+ return;
242
+ }
243
+ if (modal?.contribution.acknowledgement === "deferred-ephemeral" &&
244
+ !interaction.deferred &&
245
+ !interaction.replied) {
246
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
247
+ }
186
248
  if (modal &&
187
- (await authorizeComponent(interaction, modal.moduleId, services))) {
249
+ (await authorizeComponent(interaction, modal.moduleId, services, undefined, isInstallationIndependentModule(modulesById, modal.moduleId)))) {
188
250
  await modal.contribution.execute(createModalContext(interaction, services));
189
251
  }
190
252
  }
@@ -192,6 +254,10 @@ export function installInteractions(client, modules, registration, logger, servi
192
254
  interaction.isChannelSelectMenu() ||
193
255
  interaction.isRoleSelectMenu()) {
194
256
  const select = selects.get(interaction.customId);
257
+ if (!select) {
258
+ await interaction.reply(staleComponentResponse());
259
+ return;
260
+ }
195
261
  const context = select
196
262
  ? createSelectContext(interaction, services)
197
263
  : undefined;
@@ -199,9 +265,13 @@ export function installInteractions(client, modules, registration, logger, servi
199
265
  select?.contribution.acknowledgement === "deferred-ephemeral") {
200
266
  await context.deferReply?.(true);
201
267
  }
268
+ if (select &&
269
+ select.contribution.acknowledgement === "deferred-update") {
270
+ await interaction.deferUpdate();
271
+ }
202
272
  if (select &&
203
273
  context &&
204
- (await authorizeComponent(interaction, select.moduleId, services))) {
274
+ (await authorizeComponent(interaction, select.moduleId, services, undefined, isInstallationIndependentModule(modulesById, select.moduleId)))) {
205
275
  await select.contribution.execute(context);
206
276
  }
207
277
  }
@@ -217,18 +287,69 @@ export function installInteractions(client, modules, registration, logger, servi
217
287
  }
218
288
  return registrationReady;
219
289
  }
220
- export function buildApplicationCommandDefinitions(modules, internalGuildId) {
290
+ export async function warmDiscordRestConnection(client, logger) {
291
+ const rest = client.rest;
292
+ if (!rest)
293
+ return;
294
+ try {
295
+ await rest.get(Routes.gateway());
296
+ logger.info({ event: "discord.rest_connection_warmed" }, "Discord REST connection warmed");
297
+ }
298
+ catch (error) {
299
+ logger.warn({
300
+ event: "discord.rest_connection_warm_failed",
301
+ error: safeError(error),
302
+ }, "Discord REST connection warm-up failed");
303
+ }
304
+ }
305
+ export function startDiscordDnsRefresh(logger, lookup = lookupDns, intervalMilliseconds = DISCORD_DNS_REFRESH_INTERVAL_MS) {
306
+ let unavailable = false;
307
+ const refresh = () => {
308
+ void lookup("discord.com").then(() => {
309
+ if (unavailable) {
310
+ logger.info({ event: "discord.dns_refresh_recovered" }, "Discord DNS refresh recovered");
311
+ }
312
+ unavailable = false;
313
+ }, (error) => {
314
+ if (!unavailable) {
315
+ logger.warn({
316
+ event: "discord.dns_refresh_failed",
317
+ error: safeError(error),
318
+ }, "Discord DNS refresh failed");
319
+ }
320
+ unavailable = true;
321
+ });
322
+ };
323
+ const timer = setInterval(refresh, intervalMilliseconds);
324
+ timer.unref();
325
+ return timer;
326
+ }
327
+ export function buildApplicationCommandDefinitions(modules, internalGuildId, globalCommandMode = "legacy") {
221
328
  const commands = collectCommandRegistrations(modules);
329
+ const modulesById = new Map(modules.map((module) => [module.manifest.id, module]));
330
+ const allowLegacyCustomerGlobals = globalCommandMode === "legacy";
222
331
  const global = [...commands.values()]
223
- .filter(({ contribution }) => contribution.registration === "global")
332
+ .filter(({ moduleId, contribution }) => isApplicationGlobalPolicy(contributionRegistrationPolicy(requiredCommandModule(modulesById, moduleId), contribution, { allowLegacyCustomerGlobals })))
224
333
  .map(toCommandDefinition);
225
334
  const guild = [...commands.values()]
226
- .filter(({ contribution }) => contribution.registration === "guild")
335
+ .filter(({ moduleId, contribution }) => contributionRegistrationPolicy(requiredCommandModule(modulesById, moduleId), contribution, { allowLegacyCustomerGlobals }) === "internal-guild")
227
336
  .map(toCommandDefinition);
228
337
  if (guild.length > 0 && !internalGuildId)
229
338
  throw new Error("DISCORD_INTERNAL_GUILD_ID is required when guild commands are loaded");
230
339
  return { global, guild };
231
340
  }
341
+ function isApplicationGlobalPolicy(policy) {
342
+ return policy === "platform-global" || policy === "deployment";
343
+ }
344
+ function requiredCommandModule(modulesById, moduleId) {
345
+ const module = modulesById.get(moduleId);
346
+ if (!module)
347
+ throw new Error(`Command owner is not loaded: ${moduleId}`);
348
+ return module;
349
+ }
350
+ function isInstallationIndependentModule(modulesById, moduleId) {
351
+ return isApplicationGlobalPolicy(commandRegistrationPolicy(requiredCommandModule(modulesById, moduleId)));
352
+ }
232
353
  export function resolveCommandExecutor(command, subcommandName, groupName = null) {
233
354
  if (hasNestedCommands(command)) {
234
355
  if (!subcommandName)
@@ -448,6 +569,12 @@ function resolveComponentPrefix(items, customId) {
448
569
  }
449
570
  return items.find(({ contribution }) => customId.startsWith(contribution.customIdPrefix ?? ""));
450
571
  }
572
+ function staleComponentResponse() {
573
+ return {
574
+ content: "This control is no longer available. Open the latest Helyx message and try again.",
575
+ flags: MessageFlags.Ephemeral,
576
+ };
577
+ }
451
578
  function createContext(interaction, services) {
452
579
  return {
453
580
  interactionId: interaction.id,
@@ -483,7 +610,13 @@ function createContext(interaction, services) {
483
610
  reply: (response) => replyToInteraction(interaction, response),
484
611
  ...(interaction.isMessageComponent()
485
612
  ? {
486
- updateResponse: async (response) => interaction.update(toUpdateOptions(response)).then(() => undefined),
613
+ updateResponse: async (response) => {
614
+ if (interaction.deferred || interaction.replied) {
615
+ await interaction.editReply(toUpdateOptions(response));
616
+ return;
617
+ }
618
+ await interaction.update(toUpdateOptions(response));
619
+ },
487
620
  editSourceMessage: async (response) => interaction.webhook
488
621
  .editMessage(interaction.message.id, toUpdateOptions(response))
489
622
  .then(() => undefined),
@@ -532,7 +665,7 @@ function createCommandContext(interaction, services) {
532
665
  },
533
666
  };
534
667
  }
535
- function createAutocompleteContext(interaction, services) {
668
+ function createAutocompleteContext(interaction, services, respond) {
536
669
  const focused = interaction.options.getFocused(true);
537
670
  const actor = permissionActor(interaction);
538
671
  return {
@@ -546,27 +679,60 @@ function createAutocompleteContext(interaction, services) {
546
679
  focusedOptionName: focused.name,
547
680
  focusedValue: String(focused.value),
548
681
  services,
549
- respond: async (choices) => {
550
- if (choices.length > 25)
551
- throw new Error("Autocomplete supports at most 25 choices");
552
- const values = new Set();
553
- const normalized = choices.map(({ name, value }) => {
554
- const choiceName = name.trim().normalize("NFC");
555
- const choiceValue = value.trim().normalize("NFC");
556
- if (choiceName.length < 1 ||
557
- choiceName.length > 100 ||
558
- choiceValue.length < 1 ||
559
- choiceValue.length > 100)
560
- throw new Error("Autocomplete names and values must be between 1 and 100 characters");
561
- if (values.has(choiceValue))
562
- throw new Error("Autocomplete choice values must be unique");
563
- values.add(choiceValue);
564
- return { name: choiceName, value: choiceValue };
565
- });
566
- await interaction.respond(normalized);
682
+ respond,
683
+ };
684
+ }
685
+ export function createAutocompleteResponseController(interaction, logger) {
686
+ let attempted = false;
687
+ const respond = async (choices) => {
688
+ const normalized = normalizeAutocompleteChoices(choices);
689
+ if (attempted)
690
+ return;
691
+ attempted = true;
692
+ clearTimeout(deadline);
693
+ await interaction.respond(normalized);
694
+ };
695
+ const deadline = setTimeout(() => {
696
+ logger.warn({
697
+ event: "interaction.autocomplete_deadline",
698
+ ...interactionLogContext(interaction),
699
+ }, "Autocomplete reached its response deadline");
700
+ void respond([]).catch((error) => {
701
+ logger.warn({
702
+ event: "interaction.autocomplete_response_failed",
703
+ ...interactionLogContext(interaction),
704
+ error: safeError(error),
705
+ }, "Autocomplete fallback response failed");
706
+ });
707
+ }, AUTOCOMPLETE_RESPONSE_DEADLINE_MS);
708
+ deadline.unref();
709
+ return {
710
+ respond,
711
+ async ensureResponded() {
712
+ clearTimeout(deadline);
713
+ if (!attempted)
714
+ await respond([]);
567
715
  },
568
716
  };
569
717
  }
718
+ function normalizeAutocompleteChoices(choices) {
719
+ if (choices.length > 25)
720
+ throw new Error("Autocomplete supports at most 25 choices");
721
+ const values = new Set();
722
+ return choices.map(({ name, value }) => {
723
+ const choiceName = name.trim().normalize("NFC");
724
+ const choiceValue = value.trim().normalize("NFC");
725
+ if (choiceName.length < 1 ||
726
+ choiceName.length > 100 ||
727
+ choiceValue.length < 1 ||
728
+ choiceValue.length > 100)
729
+ throw new Error("Autocomplete names and values must be between 1 and 100 characters");
730
+ if (values.has(choiceValue))
731
+ throw new Error("Autocomplete choice values must be unique");
732
+ values.add(choiceValue);
733
+ return { name: choiceName, value: choiceValue };
734
+ });
735
+ }
570
736
  function createModalContext(interaction, services) {
571
737
  const updateResponse = interaction.isFromMessage()
572
738
  ? async (response) => interaction.update(toUpdateOptions(response)).then(() => undefined)
@@ -694,7 +860,9 @@ export async function addChannelMessageReaction(client, channelId, messageId, em
694
860
  const message = await channel.messages.fetch(messageId);
695
861
  await message.react(emojiIdentifier);
696
862
  }
697
- async function authorizeComponent(interaction, moduleId, services, requiredModuleName) {
863
+ async function authorizeComponent(interaction, moduleId, services, requiredModuleName, installationIndependent = false) {
864
+ if (installationIndependent)
865
+ return true;
698
866
  if (!(await moduleIsGloballyAvailable(services, moduleId))) {
699
867
  await replyEphemeralText(interaction, GLOBAL_MODULE_UNAVAILABLE_MESSAGE);
700
868
  return false;
@@ -717,17 +885,39 @@ async function replyEphemeralText(interaction, content) {
717
885
  }
718
886
  await interaction.reply({ content, flags: MessageFlags.Ephemeral });
719
887
  }
720
- export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand, resourceScopedCommand = false, requiredModuleName) {
721
- if (!interaction.guildId)
722
- return true;
723
- if (!selfAuthorizingGuildCommand) {
724
- if (!(await authorizeComponent(interaction, moduleId, services, requiredModuleName)))
888
+ export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand, resourceScopedCommand = false, requiredModuleName, installationIndependentCommand = false, authorization) {
889
+ const bypassPlatformAuthorization = authorization === "public" || authorization === "self-authorized";
890
+ if (!installationIndependentCommand && !selfAuthorizingGuildCommand) {
891
+ const installations = interaction.guildId
892
+ ? services.get(HELYX_SERVICE_NAMES.installations)
893
+ : undefined;
894
+ const [globallyAvailable, moduleEnabled] = await Promise.all([
895
+ moduleIsGloballyAvailable(services, moduleId),
896
+ installations && !selfAuthorizingGuildCommand
897
+ ? installations.isModuleEnabled(interaction.guildId, moduleId)
898
+ : Promise.resolve(true),
899
+ ]);
900
+ if (!globallyAvailable) {
901
+ await replyEphemeralText(interaction, GLOBAL_MODULE_UNAVAILABLE_MESSAGE);
902
+ return false;
903
+ }
904
+ if (!moduleEnabled) {
905
+ await replyEphemeralText(interaction, requiredModuleName
906
+ ? `This command needs ${requiredModuleName}. Ask a server administrator to enable it in the Helyx dashboard.`
907
+ : "This module is disabled in this server. Ask a server administrator to enable it with `/helyx` or in the Helyx dashboard.");
725
908
  return false;
909
+ }
910
+ }
911
+ if (!bypassPlatformAuthorization &&
912
+ interaction.guildId &&
913
+ !selfAuthorizingGuildCommand) {
726
914
  if (!resourceScopedCommand) {
727
915
  const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
728
- const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
916
+ const [policy, guildAccess] = await Promise.all([
917
+ permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId),
918
+ permissions.getGuildAccessPolicy?.(interaction.guildId),
919
+ ]);
729
920
  const actor = permissionActor(interaction);
730
- const guildAccess = await permissions.getGuildAccessPolicy?.(interaction.guildId);
731
921
  if (!canUseCommand(actor, policy, guildAccess)) {
732
922
  await replyEphemeralText(interaction, "You do not have permission to use this command.");
733
923
  return false;
@@ -738,7 +928,9 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
738
928
  const limits = services.get(HELYX_SERVICE_NAMES.rateLimits);
739
929
  const result = await limits.consume({
740
930
  scope: rateLimit.scope,
741
- subjectId: rateLimit.scope === "guild" ? interaction.guildId : interaction.user.id,
931
+ subjectId: rateLimit.scope === "guild"
932
+ ? (interaction.guildId ?? interaction.user.id)
933
+ : interaction.user.id,
742
934
  action: `${moduleId}:${commandId}`,
743
935
  limit: rateLimit.limit,
744
936
  windowSeconds: rateLimit.windowSeconds,
@@ -750,19 +942,42 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
750
942
  }
751
943
  return true;
752
944
  }
753
- async function authorizeAutocomplete(interaction, moduleId, commandId, services, selfAuthorizingGuildCommand, resourceScopedCommand) {
754
- if (!(await moduleIsGloballyAvailable(services, moduleId)))
755
- return false;
756
- if (!interaction.guildId || selfAuthorizingGuildCommand)
945
+ async function authorizeAutocomplete(interaction, moduleId, commandId, services, selfAuthorizingGuildCommand, resourceScopedCommand, installationIndependentCommand = false, authorization) {
946
+ const bypassPlatformAuthorization = authorization === "public" || authorization === "self-authorized";
947
+ if (!installationIndependentCommand && !selfAuthorizingGuildCommand) {
948
+ const installations = interaction.guildId
949
+ ? services.get(HELYX_SERVICE_NAMES.installations)
950
+ : undefined;
951
+ const [globallyAvailable, moduleEnabled] = await Promise.all([
952
+ moduleIsGloballyAvailable(services, moduleId),
953
+ installations && !selfAuthorizingGuildCommand
954
+ ? installations.isModuleEnabled(interaction.guildId, moduleId)
955
+ : Promise.resolve(true),
956
+ ]);
957
+ if (!globallyAvailable || !moduleEnabled)
958
+ return false;
959
+ }
960
+ if (bypassPlatformAuthorization ||
961
+ !interaction.guildId ||
962
+ selfAuthorizingGuildCommand)
757
963
  return true;
758
- const installations = services.get(HELYX_SERVICE_NAMES.installations);
759
- if (!(await installations.isModuleEnabled(interaction.guildId, moduleId)))
760
- return false;
964
+ if (installationIndependentCommand) {
965
+ if (resourceScopedCommand)
966
+ return true;
967
+ const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
968
+ const [policy, guildAccess] = await Promise.all([
969
+ permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId),
970
+ permissions.getGuildAccessPolicy?.(interaction.guildId),
971
+ ]);
972
+ return canUseCommand(permissionActor(interaction), policy, guildAccess);
973
+ }
761
974
  if (resourceScopedCommand)
762
975
  return true;
763
976
  const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
764
- const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
765
- const guildAccess = await permissions.getGuildAccessPolicy?.(interaction.guildId);
977
+ const [policy, guildAccess] = await Promise.all([
978
+ permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId),
979
+ permissions.getGuildAccessPolicy?.(interaction.guildId),
980
+ ]);
766
981
  return canUseCommand(permissionActor(interaction), policy, guildAccess);
767
982
  }
768
983
  function permissionActor(interaction) {
@@ -995,7 +1210,7 @@ function toModalLabel(field) {
995
1210
  input.setMaxLength(field.maxLength);
996
1211
  if (field.placeholder !== undefined)
997
1212
  input.setPlaceholder(field.placeholder);
998
- if (field.value !== undefined)
1213
+ if (field.value !== undefined && field.value.length > 0)
999
1214
  input.setValue(field.value);
1000
1215
  label.setTextInputComponent(input);
1001
1216
  }