@helyx/bot 0.4.8 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -9,5 +9,5 @@ export interface BotHandle {
9
9
  export declare function startBot(processEnvironment?: NodeJS.ProcessEnv): Promise<BotHandle>;
10
10
  export declare function resolveBotWorkerEntryPoint(entryUrl?: string): string;
11
11
  export declare function readBotVersion(): string;
12
- export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[]): GatewayIntentBits[];
12
+ export declare function gatewayIntentsForModules(modules: readonly ModuleDefinition[], messageContentEnabled?: boolean): GatewayIntentBits[];
13
13
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { loadConfiguredModulePackages, platformServiceOptions, } from "./modules
18
18
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
19
19
  import { createPermissionsModule } from "./permissions-module.js";
20
20
  import { createManagementModule } from "./management-module.js";
21
- import { DiscordMemberRoleService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
21
+ import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
22
22
  import { DiscordModuleLoggingService } from "./module-logging.js";
23
23
  import { BotControlService, createBotControlServer, DiscordGuildResourceDirectory, } from "./control-server.js";
24
24
  import { startShardSupervisor } from "./shard-supervisor.js";
@@ -29,6 +29,7 @@ import { createSupportModule } from "./support-module.js";
29
29
  import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
30
30
  import { CoreResourceAuthorizationService } from "./resource-authorization.js";
31
31
  import { createLocalScheduledTaskRoute, createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
32
+ import { createLocalNumericWriteBehind } from "./numeric-write-behind.js";
32
33
  export { loadConfiguredModulePackages } from "./modules.js";
33
34
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
34
35
  export * from "./control-server.js";
@@ -125,7 +126,7 @@ async function startUnshardedBot(processEnvironment) {
125
126
  }, "Module databases ready");
126
127
  const featureModules = modulePackages.map(({ definition }) => definition);
127
128
  const client = new Client({
128
- intents: gatewayIntentsForModules(featureModules),
129
+ intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED),
129
130
  partials: [Partials.Message, Partials.Channel, Partials.Reaction],
130
131
  allowedMentions: { parse: [] },
131
132
  });
@@ -234,15 +235,25 @@ async function startUnshardedBot(processEnvironment) {
234
235
  ...publicModules,
235
236
  ...(operationsModule ? [operationsModule] : []),
236
237
  ];
237
- const platformServices = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
238
+ const platformServices = new Map();
239
+ const serviceAccess = new ServiceRegistry(platformServices);
240
+ for (const [name, service] of createPlatformServices(database, publicModules, {
241
+ ...platformServiceOptions(environment, publicModules),
242
+ activationServices: serviceAccess,
243
+ })) {
244
+ platformServices.set(name, service);
245
+ }
246
+ const numericWriteBehind = createLocalNumericWriteBehind(platformServices, publicModules);
247
+ platformServices.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
238
248
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
249
+ platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
239
250
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
251
+ platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
240
252
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
241
253
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
242
254
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
243
255
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
244
256
  }
245
- const serviceAccess = new ServiceRegistry(platformServices);
246
257
  const runtime = createRuntime({
247
258
  modules,
248
259
  logger: bootstrapLogger,
@@ -305,14 +316,29 @@ async function startUnshardedBot(processEnvironment) {
305
316
  process.exitCode = 1;
306
317
  bootstrapLogger.error({ event: "control.stop_failed", error: safeError(error) }, "Private bot control shutdown failed");
307
318
  });
308
- try {
309
- await moduleEvents.stop();
310
- await runtime.stop();
311
- }
312
- catch (error) {
319
+ await moduleEvents.stop().catch((error) => {
320
+ process.exitCode = 1;
321
+ bootstrapLogger.error({ event: "module_events.stop_failed", error: safeError(error) }, "Module event shutdown failed");
322
+ });
323
+ await runtime.stop().catch((error) => {
313
324
  process.exitCode = 1;
314
325
  bootstrapLogger.error({ event: "runtime.stop_failed", error: safeError(error) }, "Runtime shutdown failed");
315
- }
326
+ });
327
+ await numericWriteBehind
328
+ .stop()
329
+ .then(({ drained }) => {
330
+ if (drained)
331
+ return;
332
+ process.exitCode = 1;
333
+ bootstrapLogger.error({ event: "numeric_write_behind.shutdown_not_drained" }, "Numeric write-behind exceeded its bounded shutdown drain");
334
+ })
335
+ .catch((error) => {
336
+ process.exitCode = 1;
337
+ bootstrapLogger.error({
338
+ event: "numeric_write_behind.shutdown_failed",
339
+ error: safeError(error),
340
+ }, "Numeric write-behind shutdown failed");
341
+ });
316
342
  await client.destroy();
317
343
  await closeDatabase();
318
344
  }
@@ -364,13 +390,26 @@ async function startUnshardedBot(processEnvironment) {
364
390
  await cloudConnector.stop().catch(() => undefined);
365
391
  if (controlServer)
366
392
  await controlServer.close().catch(() => undefined);
367
- try {
368
- await moduleEvents.stop();
369
- await runtime.stop();
370
- }
371
- catch (stopError) {
393
+ await moduleEvents.stop().catch((stopError) => {
394
+ bootstrapLogger.error({
395
+ event: "module_events.cleanup_failed",
396
+ error: safeError(stopError),
397
+ }, "Module event cleanup failed");
398
+ });
399
+ await runtime.stop().catch((stopError) => {
372
400
  bootstrapLogger.error({ event: "runtime.cleanup_failed", error: safeError(stopError) }, "Runtime cleanup failed");
373
- }
401
+ });
402
+ const numericCleanup = await numericWriteBehind
403
+ .stop()
404
+ .catch((stopError) => {
405
+ bootstrapLogger.error({
406
+ event: "numeric_write_behind.cleanup_failed",
407
+ error: safeError(stopError),
408
+ }, "Numeric write-behind cleanup failed");
409
+ return { drained: false };
410
+ });
411
+ if (!numericCleanup.drained)
412
+ bootstrapLogger.error({ event: "numeric_write_behind.cleanup_not_drained" }, "Numeric write-behind cleanup did not drain");
374
413
  await client.destroy();
375
414
  throw error;
376
415
  }
@@ -407,16 +446,27 @@ function deploymentReference(processEnvironment) {
407
446
  processEnvironment.GIT_COMMIT_SHA;
408
447
  return value?.trim().slice(0, 12) || undefined;
409
448
  }
410
- export function gatewayIntentsForModules(modules) {
449
+ export function gatewayIntentsForModules(modules, messageContentEnabled = false) {
411
450
  const intents = [GatewayIntentBits.Guilds];
412
451
  if (modules.some((module) => module.events?.messageReactionAdd?.length ||
413
452
  module.events?.messageReactionRemove?.length)) {
414
453
  intents.push(GatewayIntentBits.GuildMessageReactions);
415
454
  }
416
- if (modules.some((module) => module.events?.messageDelete?.length)) {
455
+ const hasPrivilegedMessageCreate = modules.some((module) => module.events?.privilegedMessageCreate?.length);
456
+ const requiresMessageContent = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
457
+ if (((hasPrivilegedMessageCreate || requiresMessageContent) &&
458
+ messageContentEnabled) ||
459
+ modules.some((module) => module.events?.guildMessageCreate?.length ||
460
+ module.events?.messageDelete?.length)) {
417
461
  intents.push(GatewayIntentBits.GuildMessages);
418
462
  }
419
- if (modules.some((module) => module.events?.guildMemberAdd?.length)) {
463
+ if ((hasPrivilegedMessageCreate || requiresMessageContent) &&
464
+ messageContentEnabled) {
465
+ intents.push(GatewayIntentBits.MessageContent);
466
+ }
467
+ if (modules.some((module) => module.requiredDiscordCapabilities?.includes("guild-members") ||
468
+ module.events?.guildMemberAdd?.length ||
469
+ module.events?.guildMemberRemove?.length)) {
420
470
  intents.push(GatewayIntentBits.GuildMembers);
421
471
  }
422
472
  if (modules.some((module) => module.events?.guildEmojiDelete?.length ||
@@ -17,14 +17,18 @@ export function installInteractions(client, modules, registration, logger, servi
17
17
  if (guildCommands.length > 0 && !registration.internalGuildId) {
18
18
  throw new Error("DISCORD_INTERNAL_GUILD_ID is required when guild commands are loaded");
19
19
  }
20
- const buttons = uniqueMap(modules.flatMap((module) => (module.interactions?.buttons ?? []).map((contribution) => ({
20
+ const buttonItems = modules.flatMap((module) => (module.interactions?.buttons ?? []).map((contribution) => ({
21
21
  moduleId: module.manifest.id,
22
22
  contribution,
23
- }))), ({ contribution }) => contribution.customId, "button custom ID");
24
- const modals = uniqueMap(modules.flatMap((module) => (module.interactions?.modals ?? []).map((contribution) => ({
23
+ })));
24
+ const buttons = uniqueMap(buttonItems.filter(({ contribution }) => contribution.customId !== undefined), ({ contribution }) => contribution.customId, "button custom ID");
25
+ const buttonPrefixes = componentPrefixes(buttonItems, "button");
26
+ const modalItems = modules.flatMap((module) => (module.interactions?.modals ?? []).map((contribution) => ({
25
27
  moduleId: module.manifest.id,
26
28
  contribution,
27
- }))), ({ contribution }) => contribution.customId, "modal custom ID");
29
+ })));
30
+ const modals = uniqueMap(modalItems.filter(({ contribution }) => contribution.customId !== undefined), ({ contribution }) => contribution.customId, "modal custom ID");
31
+ const modalPrefixes = componentPrefixes(modalItems, "modal");
28
32
  const selects = uniqueMap(modules.flatMap((module) => (module.interactions?.selects ?? []).map((contribution) => ({
29
33
  moduleId: module.manifest.id,
30
34
  contribution,
@@ -67,7 +71,24 @@ export function installInteractions(client, modules, registration, logger, servi
67
71
  });
68
72
  async function handleInteraction(interaction) {
69
73
  try {
70
- if (interaction.isChatInputCommand()) {
74
+ if (interaction.isAutocomplete()) {
75
+ const registered = commands.get(interaction.commandName);
76
+ const subcommandName = registered?.contribution.subcommands?.length
77
+ ? interaction.options.getSubcommand(false)
78
+ : null;
79
+ const subcommand = subcommandName
80
+ ? registered?.contribution.subcommands?.find(({ name }) => name === subcommandName)
81
+ : undefined;
82
+ if (!registered ||
83
+ !subcommand?.autocomplete ||
84
+ !(await authorizeAutocomplete(interaction, registered.moduleId, subcommand.permissionId ??
85
+ `${registered.contribution.name}.${subcommand.name}`, services, registration.selfAuthorizingGuildModuleIds?.has(registered.moduleId) ?? false, subcommand.authorization === "resource-scoped"))) {
86
+ await interaction.respond([]);
87
+ return;
88
+ }
89
+ await subcommand.autocomplete(createAutocompleteContext(interaction, services));
90
+ }
91
+ else if (interaction.isChatInputCommand()) {
71
92
  const registered = commands.get(interaction.commandName);
72
93
  if (registered) {
73
94
  const { contribution: command, moduleId } = registered;
@@ -107,7 +128,8 @@ export function installInteractions(client, modules, registration, logger, servi
107
128
  }
108
129
  }
109
130
  else if (interaction.isButton()) {
110
- const button = buttons.get(interaction.customId);
131
+ const button = buttons.get(interaction.customId) ??
132
+ resolveComponentPrefix(buttonPrefixes, interaction.customId);
111
133
  const context = button
112
134
  ? createContext(interaction, services)
113
135
  : undefined;
@@ -115,6 +137,11 @@ export function installInteractions(client, modules, registration, logger, servi
115
137
  button?.contribution.acknowledgement === "deferred-ephemeral") {
116
138
  await context.deferReply?.(true);
117
139
  }
140
+ if (button &&
141
+ interaction.isButton() &&
142
+ button.contribution.acknowledgement === "deferred-update") {
143
+ await interaction.deferUpdate();
144
+ }
118
145
  if (button &&
119
146
  context &&
120
147
  (await authorizeComponent(interaction, button.moduleId, services))) {
@@ -122,7 +149,8 @@ export function installInteractions(client, modules, registration, logger, servi
122
149
  }
123
150
  }
124
151
  else if (interaction.isModalSubmit()) {
125
- const modal = modals.get(interaction.customId);
152
+ const modal = modals.get(interaction.customId) ??
153
+ resolveComponentPrefix(modalPrefixes, interaction.customId);
126
154
  if (modal &&
127
155
  (await authorizeComponent(interaction, modal.moduleId, services))) {
128
156
  await modal.contribution.execute(createModalContext(interaction, services));
@@ -188,6 +216,9 @@ export function toCommandDefinition(registered) {
188
216
  if (!!command.execute === !!command.subcommands?.length) {
189
217
  throw new Error(`Command ${command.name} must define either execute or subcommands`);
190
218
  }
219
+ if (command.options?.length && command.subcommands?.length) {
220
+ throw new Error(`Command ${command.name} cannot define both direct options and subcommands`);
221
+ }
191
222
  const contextTypes = {
192
223
  guild: InteractionContextType.Guild,
193
224
  bot_dm: InteractionContextType.BotDM,
@@ -202,6 +233,9 @@ export function toCommandDefinition(registered) {
202
233
  .setDescription(command.description)
203
234
  .setContexts(command.contexts.map((context) => contextTypes[context]))
204
235
  .setIntegrationTypes(command.integrationTypes.map((integrationType) => integrationTypes[integrationType]));
236
+ for (const option of command.options ?? []) {
237
+ addCommandOption(builder, option);
238
+ }
205
239
  for (const subcommand of command.subcommands ?? []) {
206
240
  builder.addSubcommand((subcommandBuilder) => {
207
241
  subcommandBuilder
@@ -217,6 +251,8 @@ export function toCommandDefinition(registered) {
217
251
  }
218
252
  function addCommandOption(builder, option) {
219
253
  if (option.type === "string") {
254
+ if (option.autocomplete && option.choices?.length)
255
+ throw new Error(`Command option ${option.name} cannot use choices and autocomplete together`);
220
256
  builder.addStringOption((optionBuilder) => {
221
257
  optionBuilder
222
258
  .setName(option.name)
@@ -228,6 +264,8 @@ function addCommandOption(builder, option) {
228
264
  optionBuilder.setMaxLength(option.maxLength);
229
265
  if (option.choices)
230
266
  optionBuilder.addChoices(...option.choices);
267
+ if (option.autocomplete)
268
+ optionBuilder.setAutocomplete(option.autocomplete);
231
269
  return optionBuilder;
232
270
  });
233
271
  return;
@@ -239,6 +277,13 @@ function addCommandOption(builder, option) {
239
277
  .setRequired(option.required ?? false));
240
278
  return;
241
279
  }
280
+ if (option.type === "user") {
281
+ builder.addUserOption((optionBuilder) => optionBuilder
282
+ .setName(option.name)
283
+ .setDescription(option.description)
284
+ .setRequired(option.required ?? false));
285
+ return;
286
+ }
242
287
  if (option.type === "role") {
243
288
  builder.addRoleOption((optionBuilder) => optionBuilder
244
289
  .setName(option.name)
@@ -270,9 +315,43 @@ export function uniqueMap(items, keyOf, label) {
270
315
  }
271
316
  return result;
272
317
  }
318
+ function componentPrefixes(items, label) {
319
+ const prefixes = [];
320
+ for (const item of items) {
321
+ const { customId, customIdPrefix } = item.contribution;
322
+ if ((customId === undefined) === (customIdPrefix === undefined)) {
323
+ throw new Error(`${label} contribution must declare exactly one custom ID or prefix`);
324
+ }
325
+ const route = customId ?? customIdPrefix;
326
+ if (route.length < 1 ||
327
+ route.length > 100 ||
328
+ !/^[A-Za-z0-9._:-]+$/u.test(route)) {
329
+ throw new Error(`${label} custom ID routing value is invalid`);
330
+ }
331
+ if (customIdPrefix !== undefined)
332
+ prefixes.push(item);
333
+ }
334
+ for (let index = 0; index < prefixes.length; index += 1) {
335
+ const prefix = prefixes[index].contribution.customIdPrefix;
336
+ if (prefixes.some((candidate, candidateIndex) => candidateIndex !== index &&
337
+ (candidate.contribution.customIdPrefix ?? "").startsWith(prefix))) {
338
+ throw new Error(`Ambiguous ${label} custom ID prefix: ${prefix}`);
339
+ }
340
+ }
341
+ return prefixes;
342
+ }
343
+ function resolveComponentPrefix(items, customId) {
344
+ if (customId.length < 1 ||
345
+ customId.length > 100 ||
346
+ !/^[A-Za-z0-9._:-]+$/u.test(customId)) {
347
+ return undefined;
348
+ }
349
+ return items.find(({ contribution }) => customId.startsWith(contribution.customIdPrefix ?? ""));
350
+ }
273
351
  function createContext(interaction, services) {
274
352
  return {
275
353
  interactionId: interaction.id,
354
+ ...("customId" in interaction ? { customId: interaction.customId } : {}),
276
355
  userId: interaction.user.id,
277
356
  userDisplayName: interaction.user.globalName ?? interaction.user.username,
278
357
  userRoleIds: interaction.member instanceof GuildMember
@@ -301,6 +380,9 @@ function createContext(interaction, services) {
301
380
  ...(interaction.isMessageComponent()
302
381
  ? {
303
382
  updateResponse: async (response) => interaction.update(toUpdateOptions(response)).then(() => undefined),
383
+ editSourceMessage: async (response) => interaction.webhook
384
+ .editMessage(interaction.message.id, toUpdateOptions(response))
385
+ .then(() => undefined),
304
386
  }
305
387
  : {}),
306
388
  showModal: async (modal) => interaction.showModal(toModal(modal)).then(() => undefined),
@@ -315,6 +397,16 @@ function createCommandContext(interaction, services) {
315
397
  ...createContext(interaction, services),
316
398
  getStringOption: (name) => interaction.options.getString(name),
317
399
  getBooleanOption: (name) => interaction.options.getBoolean(name),
400
+ getUserOption: (name) => {
401
+ const user = interaction.options.getUser(name);
402
+ return user
403
+ ? {
404
+ id: user.id,
405
+ displayName: user.globalName ?? user.username,
406
+ isBot: user.bot,
407
+ }
408
+ : null;
409
+ },
318
410
  getRoleOption: (name) => {
319
411
  const role = interaction.options.getRole(name);
320
412
  return role ? { id: role.id, name: role.name } : null;
@@ -335,12 +427,48 @@ function createCommandContext(interaction, services) {
335
427
  },
336
428
  };
337
429
  }
430
+ function createAutocompleteContext(interaction, services) {
431
+ const focused = interaction.options.getFocused(true);
432
+ const actor = permissionActor(interaction);
433
+ return {
434
+ interactionId: interaction.id,
435
+ userId: actor.userId,
436
+ userRoleIds: actor.roleIds,
437
+ userPermissions: actor.permissions,
438
+ isServerOwner: actor.isServerOwner,
439
+ isAdministrator: actor.isAdministrator,
440
+ ...(interaction.guildId ? { serverId: interaction.guildId } : {}),
441
+ focusedOptionName: focused.name,
442
+ focusedValue: String(focused.value),
443
+ services,
444
+ respond: async (choices) => {
445
+ if (choices.length > 25)
446
+ throw new Error("Autocomplete supports at most 25 choices");
447
+ const values = new Set();
448
+ const normalized = choices.map(({ name, value }) => {
449
+ const choiceName = name.trim().normalize("NFC");
450
+ const choiceValue = value.trim().normalize("NFC");
451
+ if (choiceName.length < 1 ||
452
+ choiceName.length > 100 ||
453
+ choiceValue.length < 1 ||
454
+ choiceValue.length > 100)
455
+ throw new Error("Autocomplete names and values must be between 1 and 100 characters");
456
+ if (values.has(choiceValue))
457
+ throw new Error("Autocomplete choice values must be unique");
458
+ values.add(choiceValue);
459
+ return { name: choiceName, value: choiceValue };
460
+ });
461
+ await interaction.respond(normalized);
462
+ },
463
+ };
464
+ }
338
465
  function createModalContext(interaction, services) {
339
466
  const updateResponse = interaction.isFromMessage()
340
467
  ? async (response) => interaction.update(toUpdateOptions(response)).then(() => undefined)
341
468
  : undefined;
342
469
  return {
343
470
  interactionId: interaction.id,
471
+ customId: interaction.customId,
344
472
  userId: interaction.user.id,
345
473
  userDisplayName: interaction.user.globalName ?? interaction.user.username,
346
474
  userRoleIds: interaction.member instanceof GuildMember
@@ -467,17 +595,7 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
467
595
  if (!resourceScopedCommand) {
468
596
  const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
469
597
  const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
470
- const actor = {
471
- userId: interaction.user.id,
472
- roleIds: interaction.member instanceof GuildMember
473
- ? [...interaction.member.roles.cache.keys()]
474
- : (interaction.member?.roles ?? []),
475
- permissions: interaction.memberPermissions
476
- ?.toArray()
477
- .map(normalizeDiscordPermission) ?? [],
478
- isServerOwner: interaction.guild?.ownerId === interaction.user.id,
479
- isAdministrator: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ?? false,
480
- };
598
+ const actor = permissionActor(interaction);
481
599
  if (!canUseCommand(actor, policy)) {
482
600
  await interaction.reply({
483
601
  content: "You do not have permission to use this command.",
@@ -506,6 +624,32 @@ export async function authorizeCommand(interaction, moduleId, commandId, rateLim
506
624
  }
507
625
  return true;
508
626
  }
627
+ async function authorizeAutocomplete(interaction, moduleId, commandId, services, selfAuthorizingGuildCommand, resourceScopedCommand) {
628
+ if (!interaction.guildId || selfAuthorizingGuildCommand)
629
+ return true;
630
+ const installations = services.get(HELYX_SERVICE_NAMES.installations);
631
+ if (!(await installations.isModuleEnabled(interaction.guildId, moduleId)))
632
+ return false;
633
+ if (resourceScopedCommand)
634
+ return true;
635
+ const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
636
+ const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
637
+ return canUseCommand(permissionActor(interaction), policy);
638
+ }
639
+ function permissionActor(interaction) {
640
+ return {
641
+ userId: interaction.user.id,
642
+ roleIds: interaction.member instanceof GuildMember
643
+ ? [...interaction.member.roles.cache.keys()]
644
+ : (interaction.member?.roles ?? []),
645
+ permissions: interaction.memberPermissions
646
+ ?.toArray()
647
+ .map(normalizeDiscordPermission) ?? [],
648
+ isServerOwner: interaction.guild?.ownerId === interaction.user.id,
649
+ isAdministrator: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ??
650
+ false,
651
+ };
652
+ }
509
653
  export function normalizeDiscordPermission(permission) {
510
654
  return permission.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
511
655
  }
@@ -629,19 +773,10 @@ function toSelectRows(response) {
629
773
  return rows;
630
774
  }
631
775
  export function toUpdateOptions(response) {
632
- const options = toReplyOptions({
776
+ return toReplyOptions({
633
777
  ...response,
634
778
  ephemeral: false,
635
779
  });
636
- return response.componentsV2
637
- ? {
638
- ...options,
639
- // Discord requires legacy fields to be cleared when an existing
640
- // message is first converted to Components V2.
641
- content: null,
642
- embeds: [],
643
- }
644
- : options;
645
780
  }
646
781
  function toAllowedUserMentions(userIds) {
647
782
  if (userIds === undefined)
@@ -760,6 +895,10 @@ export function toModal(definition) {
760
895
  return modal;
761
896
  }
762
897
  async function sendFailure(interaction) {
898
+ if (interaction.isAutocomplete()) {
899
+ await interaction.respond([]).catch(() => undefined);
900
+ return;
901
+ }
763
902
  if (!interaction.isRepliable())
764
903
  return;
765
904
  const message = "Something went wrong while handling that interaction.";
@@ -18,6 +18,7 @@ const VALUE_MODAL_ID = "helyx.management.value.modal";
18
18
  const VALUE_FIELD_ID = "value";
19
19
  const SESSION_PURPOSE = "modules.manage";
20
20
  const PAGE_SIZE = 25;
21
+ const MAX_DISCORD_SETTING_SELECTIONS = 25;
21
22
  const SESSION_LIFETIME_MS = 15 * 60 * 1_000;
22
23
  const VIOLET = 0x7c3aed;
23
24
  const SUCCESS = 0x16a34a;
@@ -177,7 +178,14 @@ export function createManagementModule(configurableModules, resources) {
177
178
  return;
178
179
  }
179
180
  try {
180
- module.configuration?.validate(value);
181
+ const validated = module.configuration?.validate(value);
182
+ if (validated && module.configuration?.validateUpdate) {
183
+ await module.configuration.validateUpdate({
184
+ guildId: context.serverId,
185
+ value: validated,
186
+ services: context.services,
187
+ });
188
+ }
181
189
  }
182
190
  catch (error) {
183
191
  await context.reply(validationResponse(error instanceof Error
@@ -563,8 +571,12 @@ function settingValueResponse(setting) {
563
571
  {
564
572
  type: "discord-channel",
565
573
  customId: VALUE_SELECT_ID,
566
- placeholder: "Choose a channel",
574
+ placeholder: setting.multiple
575
+ ? "Choose channels"
576
+ : "Choose a channel",
567
577
  channelTypes: setting.channelTypes,
578
+ minValues: 1,
579
+ maxValues: setting.multiple ? MAX_DISCORD_SETTING_SELECTIONS : 1,
568
580
  },
569
581
  ],
570
582
  };
@@ -631,6 +643,8 @@ function initialDraft(module, existing) {
631
643
  return result;
632
644
  }
633
645
  function emptyValue(setting) {
646
+ if (setting.type === "discord-channel" && setting.multiple)
647
+ return [];
634
648
  if (setting.type === "discord-role" && setting.multiple)
635
649
  return [];
636
650
  if (setting.type === "string" ||
@@ -655,10 +669,13 @@ function selectedValue(setting, values) {
655
669
  return values[0];
656
670
  }
657
671
  if (setting.type === "discord-channel") {
658
- if (values.length !== 1 || !isSnowflake(values[0])) {
659
- throw new Error("Selected channel is invalid");
672
+ if (values.length < 1 ||
673
+ values.length > (setting.multiple ? MAX_DISCORD_SETTING_SELECTIONS : 1) ||
674
+ new Set(values).size !== values.length ||
675
+ values.some((value) => !isSnowflake(value))) {
676
+ throw new Error("Selected channel value is invalid");
660
677
  }
661
- return values[0];
678
+ return setting.multiple ? [...values] : values[0];
662
679
  }
663
680
  if (setting.type === "discord-role") {
664
681
  if (values.length < 1 ||
@@ -723,9 +740,21 @@ async function validateDiscordSettings(directory, guildId, module, values) {
723
740
  if (configured === "" || configured === undefined)
724
741
  continue;
725
742
  if (setting.type === "discord-channel") {
726
- const channel = typeof configured === "string" ? channels.get(configured) : undefined;
727
- if (!channel || !setting.channelTypes.includes(channel.type)) {
728
- return `${setting.label} must be an available supported Discord channel.`;
743
+ if (setting.multiple !== Array.isArray(configured)) {
744
+ return `${setting.label} has an invalid Discord channel selection.`;
745
+ }
746
+ const configuredChannels = Array.isArray(configured)
747
+ ? configured
748
+ : [configured];
749
+ if (configuredChannels.length > MAX_DISCORD_SETTING_SELECTIONS ||
750
+ new Set(configuredChannels).size !== configuredChannels.length ||
751
+ configuredChannels.some((channelId) => {
752
+ if (typeof channelId !== "string")
753
+ return true;
754
+ const channel = channels.get(channelId);
755
+ return !channel || !setting.channelTypes.includes(channel.type);
756
+ })) {
757
+ return `${setting.label} must contain available supported Discord channels.`;
729
758
  }
730
759
  }
731
760
  if (setting.type === "discord-role") {
@@ -776,8 +805,11 @@ function settingStatus(setting, value) {
776
805
  }
777
806
  if (setting.type === "boolean")
778
807
  return value === true ? "Enabled" : "Disabled";
779
- if (setting.type === "discord-channel")
780
- return "Channel selected";
808
+ if (setting.type === "discord-channel") {
809
+ return Array.isArray(value)
810
+ ? `${String(value.length)} channels selected`
811
+ : "Channel selected";
812
+ }
781
813
  if (setting.type === "discord-role") {
782
814
  return Array.isArray(value)
783
815
  ? `${String(value.length)} roles selected`
@@ -0,0 +1,5 @@
1
+ import { type Message } from "discord.js";
2
+ import type { GuildMessageClassification } from "@helyx/sdk";
3
+ export declare function classifyGuildMessage(message: Pick<Message, "attachments" | "content" | "embeds" | "flags" | "stickers">): GuildMessageClassification;
4
+ export declare const unavailableGuildMessageClassification: GuildMessageClassification;
5
+ //# sourceMappingURL=message-classification.d.ts.map