@helyx/bot 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +13 -0
  3. package/dist/cloud-connector.d.ts +7 -0
  4. package/dist/cloud-connector.js +118 -1
  5. package/dist/cloud-control-dispatcher.js +84 -0
  6. package/dist/control-server.d.ts +187 -5
  7. package/dist/control-server.js +673 -6
  8. package/dist/developer-operations-module.js +8 -0
  9. package/dist/discord-owned-message-service.d.ts +39 -0
  10. package/dist/discord-owned-message-service.js +138 -0
  11. package/dist/events.d.ts +70 -1
  12. package/dist/events.js +557 -5
  13. package/dist/index.js +61 -13
  14. package/dist/interactions.d.ts +3 -2
  15. package/dist/interactions.js +66 -31
  16. package/dist/module-logging.d.ts +1 -0
  17. package/dist/module-logging.js +19 -1
  18. package/dist/modules.d.ts +9 -0
  19. package/dist/modules.js +45 -0
  20. package/dist/resource-authorization.d.ts +7 -0
  21. package/dist/resource-authorization.js +12 -0
  22. package/dist/scheduled-task-runner.d.ts +130 -0
  23. package/dist/scheduled-task-runner.js +417 -0
  24. package/dist/shard-cloud-proxy.d.ts +4 -0
  25. package/dist/shard-cloud-proxy.js +34 -0
  26. package/dist/shard-contracts.d.ts +574 -23
  27. package/dist/shard-contracts.js +346 -0
  28. package/dist/shard-coordinator.d.ts +5 -3
  29. package/dist/shard-coordinator.js +85 -2
  30. package/dist/shard-router.d.ts +10 -0
  31. package/dist/shard-router.js +58 -0
  32. package/dist/shard-supervisor.d.ts +2 -1
  33. package/dist/shard-supervisor.js +187 -5
  34. package/dist/shard-worker.d.ts +9 -3
  35. package/dist/shard-worker.js +97 -16
  36. package/dist/support-module.d.ts +9 -0
  37. package/dist/support-module.js +172 -0
  38. package/package.json +9 -9
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { safeError, ServiceRegistry } from "@helyx/core";
3
3
  import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
4
  import { createRuntime } from "@helyx/framework";
5
5
  import { createPlatformServices, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
6
- import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
6
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
7
7
  import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
8
8
  import pino from "pino";
9
9
  import { randomUUID } from "node:crypto";
@@ -14,17 +14,21 @@ import { ClientCloudDiscordDirectory, CloudConnectorController, } from "./cloud-
14
14
  import { createCloudManagementModule } from "./cloud-management-module.js";
15
15
  import { installInteractions } from "./interactions.js";
16
16
  import { installGuildTracking } from "./guilds.js";
17
- import { loadConfiguredModulePackages } from "./modules.js";
17
+ import { loadConfiguredModulePackages, platformServiceOptions, } from "./modules.js";
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 { installModuleEvents } from "./events.js";
21
+ import { 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";
25
25
  import { parseShardWorkerLaunchContract, startShardWorker, } from "./shard-worker.js";
26
26
  import { createDeveloperOperationsModule } from "./developer-operations-module.js";
27
27
  import { shardHealthSnapshotSchema } from "./shard-contracts.js";
28
+ import { createSupportModule } from "./support-module.js";
29
+ import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
30
+ import { CoreResourceAuthorizationService } from "./resource-authorization.js";
31
+ import { createLocalScheduledTaskRoute, createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
28
32
  export { loadConfiguredModulePackages } from "./modules.js";
29
33
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
30
34
  export * from "./control-server.js";
@@ -143,12 +147,15 @@ async function startUnshardedBot(processEnvironment) {
143
147
  configuredOwnerUserIds: environment.HELYX_OWNER_USER_IDS,
144
148
  })
145
149
  : undefined;
150
+ const supportModule = createSupportModule(cloudConnector);
146
151
  const publicModules = [
147
152
  managementModule,
148
153
  permissionsModule,
154
+ supportModule,
149
155
  ...(cloudManagementModule ? [cloudManagementModule] : []),
150
156
  ...featureModules,
151
157
  ];
158
+ let scheduledTasks;
152
159
  const unshardedSupervisorId = randomUUID();
153
160
  const deployedCommit = deploymentReference(processEnvironment);
154
161
  const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
@@ -160,6 +167,7 @@ async function startUnshardedBot(processEnvironment) {
160
167
  coreFunctions: [
161
168
  managementModule,
162
169
  permissionsModule,
170
+ supportModule,
163
171
  ...(cloudManagementModule ? [cloudManagementModule] : []),
164
172
  ].map(({ manifest }) => manifest),
165
173
  modules: featureModules.map(({ manifest }) => manifest),
@@ -174,6 +182,12 @@ async function startUnshardedBot(processEnvironment) {
174
182
  databaseHealthy = false;
175
183
  }
176
184
  const ready = client.isReady();
185
+ const scheduler = scheduledTasks
186
+ ? await scheduledTasks
187
+ .getHealth()
188
+ .then(schedulerOperationalHealth)
189
+ .catch(() => ({ status: "unavailable" }))
190
+ : { status: "unavailable" };
177
191
  const observedAt = new Date().toISOString();
178
192
  const gatewayPingMs = Number.isFinite(client.ws.ping)
179
193
  ? client.ws.ping
@@ -182,7 +196,7 @@ async function startUnshardedBot(processEnvironment) {
182
196
  version: 1,
183
197
  mode: "off",
184
198
  scope: "local",
185
- status: ready && databaseHealthy
199
+ status: ready && databaseHealthy && scheduler.status === "available"
186
200
  ? "healthy"
187
201
  : ready
188
202
  ? "degraded"
@@ -196,6 +210,7 @@ async function startUnshardedBot(processEnvironment) {
196
210
  redis: "not_required",
197
211
  database: databaseHealthy ? "healthy" : "unavailable",
198
212
  },
213
+ scheduler,
199
214
  shards: [
200
215
  {
201
216
  shardId: 0,
@@ -219,7 +234,11 @@ async function startUnshardedBot(processEnvironment) {
219
234
  ...publicModules,
220
235
  ...(operationsModule ? [operationsModule] : []),
221
236
  ];
222
- const platformServices = new Map(createPlatformServices(database, publicModules));
237
+ const platformServices = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
238
+ platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
239
+ platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
240
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
241
+ platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
223
242
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
224
243
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
225
244
  }
@@ -232,6 +251,7 @@ async function startUnshardedBot(processEnvironment) {
232
251
  const enabledCoreModuleIds = new Set([
233
252
  managementModule.manifest.id,
234
253
  permissionsModule.manifest.id,
254
+ supportModule.manifest.id,
235
255
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
236
256
  ...(operationsModule ? [operationsModule.manifest.id] : []),
237
257
  ]);
@@ -241,13 +261,10 @@ async function startUnshardedBot(processEnvironment) {
241
261
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
242
262
  : {}),
243
263
  prepare: prepareGuilds,
244
- ...(operationsModule
245
- ? {
246
- selfAuthorizingGuildModuleIds: new Set([
247
- operationsModule.manifest.id,
248
- ]),
249
- }
250
- : {}),
264
+ selfAuthorizingGuildModuleIds: new Set([
265
+ supportModule.manifest.id,
266
+ ...(operationsModule ? [operationsModule.manifest.id] : []),
267
+ ]),
251
268
  }, bootstrapLogger, serviceAccess);
252
269
  const moduleEvents = installModuleEvents(client, publicModules, bootstrapLogger, serviceAccess);
253
270
  const controlService = new BotControlService(database, publicModules, guildResources, serviceAccess, privacyDatabase
@@ -256,6 +273,12 @@ async function startUnshardedBot(processEnvironment) {
256
273
  executorPool: privacyDatabase,
257
274
  modules: featureModules,
258
275
  })
276
+ : undefined, environment.DISCORD_INTERNAL_GUILD_ID &&
277
+ environment.HELYX_SUPPORT_STAFF_CHANNEL_ID
278
+ ? {
279
+ guildId: environment.DISCORD_INTERNAL_GUILD_ID,
280
+ channelId: environment.HELYX_SUPPORT_STAFF_CHANNEL_ID,
281
+ }
259
282
  : undefined);
260
283
  cloudConnector.setControlHandler(controlService);
261
284
  let controlServer;
@@ -268,6 +291,10 @@ async function startUnshardedBot(processEnvironment) {
268
291
  process.off("SIGTERM", onSigterm);
269
292
  if (retentionCleanupTimer)
270
293
  clearInterval(retentionCleanupTimer);
294
+ await scheduledTasks?.stop().catch((error) => {
295
+ process.exitCode = 1;
296
+ bootstrapLogger.error({ event: "scheduler.stop_failed", error: safeError(error) }, "Scheduled task runner shutdown failed");
297
+ });
271
298
  bootstrapLogger.info({ event: "bot.shutdown", reason }, "Bot shutdown requested");
272
299
  await cloudConnector.stop().catch((error) => {
273
300
  process.exitCode = 1;
@@ -300,6 +327,18 @@ async function startUnshardedBot(processEnvironment) {
300
327
  await runtime.start();
301
328
  await client.login(environment.DISCORD_TOKEN);
302
329
  await commandsReady;
330
+ scheduledTasks = createScheduledTaskRunner({
331
+ database,
332
+ modules: publicModules,
333
+ route: createLocalScheduledTaskRoute({
334
+ modules: publicModules,
335
+ services: serviceAccess,
336
+ logger: bootstrapLogger,
337
+ }),
338
+ logger: bootstrapLogger,
339
+ workerId: `unsharded:${unshardedSupervisorId}`,
340
+ });
341
+ scheduledTasks.start();
303
342
  await cloudConnector.start();
304
343
  if (environment.HELYX_BOT_CONTROL_SECRET) {
305
344
  controlServer = await createBotControlServer({
@@ -321,6 +360,7 @@ async function startUnshardedBot(processEnvironment) {
321
360
  }
322
361
  }
323
362
  catch (error) {
363
+ await scheduledTasks?.stop().catch(() => undefined);
324
364
  await cloudConnector.stop().catch(() => undefined);
325
365
  if (controlServer)
326
366
  await controlServer.close().catch(() => undefined);
@@ -369,12 +409,20 @@ function deploymentReference(processEnvironment) {
369
409
  }
370
410
  export function gatewayIntentsForModules(modules) {
371
411
  const intents = [GatewayIntentBits.Guilds];
372
- if (modules.some((module) => module.events?.messageReactionAdd?.length)) {
412
+ if (modules.some((module) => module.events?.messageReactionAdd?.length ||
413
+ module.events?.messageReactionRemove?.length)) {
373
414
  intents.push(GatewayIntentBits.GuildMessageReactions);
374
415
  }
416
+ if (modules.some((module) => module.events?.messageDelete?.length)) {
417
+ intents.push(GatewayIntentBits.GuildMessages);
418
+ }
375
419
  if (modules.some((module) => module.events?.guildMemberAdd?.length)) {
376
420
  intents.push(GatewayIntentBits.GuildMembers);
377
421
  }
422
+ if (modules.some((module) => module.events?.guildEmojiDelete?.length ||
423
+ module.events?.guildEmojiUpdate?.length)) {
424
+ intents.push(GatewayIntentBits.GuildExpressions);
425
+ }
378
426
  return intents;
379
427
  }
380
428
  if (process.argv[1] &&
@@ -21,12 +21,13 @@ export declare function toCommandDefinition(registered: {
21
21
  contribution: CommandContribution;
22
22
  }): import("discord.js").RESTPostAPIChatInputApplicationCommandsJSONBody;
23
23
  export declare function uniqueMap<T>(items: readonly T[], keyOf: (item: T) => string, label: string): Map<string, T>;
24
- export declare function sendToChannel(client: Client, channelId: string, response: MessageResponse): Promise<{
24
+ export declare function sendToChannel(client: Client, channelId: string, response: MessageResponse, deliveryId?: string): Promise<{
25
25
  messageId: string;
26
26
  }>;
27
27
  export declare function deleteChannelMessage(client: Client, channelId: string, messageId: string): Promise<void>;
28
28
  export declare function editChannelMessage(client: Client, channelId: string, messageId: string, response: MessageResponse): Promise<void>;
29
- export declare function authorizeCommand(interaction: ChatInputCommandInteraction, moduleId: string, commandId: string, rateLimit: CommandContribution["rateLimit"], services: ServiceAccess, selfAuthorizingGuildCommand: boolean): Promise<boolean>;
29
+ 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>;
30
31
  export declare function normalizeDiscordPermission(permission: string): string;
31
32
  export declare function toReplyOptions(response: MessageResponse): InteractionReplyOptions;
32
33
  export declare function toUpdateOptions(response: MessageResponse): InteractionUpdateOptions;
@@ -98,8 +98,9 @@ export function installInteractions(client, modules, registration, logger, servi
98
98
  const commandId = subcommandName
99
99
  ? `${command.name}.${subcommandName}`
100
100
  : command.name;
101
- if (await authorizeCommand(interaction, moduleId, commandId, subcommand?.rateLimit ?? command.rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(moduleId) ??
102
- false)) {
101
+ const permissionId = subcommand?.permissionId ?? command.permissionId ?? commandId;
102
+ if (await authorizeCommand(interaction, moduleId, permissionId, subcommand?.rateLimit ?? command.rateLimit, services, registration.selfAuthorizingGuildModuleIds?.has(moduleId) ??
103
+ false, subcommand?.authorization === "resource-scoped")) {
103
104
  const context = createCommandContext(interaction, services);
104
105
  await execute(context);
105
106
  }
@@ -131,9 +132,17 @@ export function installInteractions(client, modules, registration, logger, servi
131
132
  interaction.isChannelSelectMenu() ||
132
133
  interaction.isRoleSelectMenu()) {
133
134
  const select = selects.get(interaction.customId);
135
+ const context = select
136
+ ? createSelectContext(interaction, services)
137
+ : undefined;
138
+ if (context &&
139
+ select?.contribution.acknowledgement === "deferred-ephemeral") {
140
+ await context.deferReply?.(true);
141
+ }
134
142
  if (select &&
143
+ context &&
135
144
  (await authorizeComponent(interaction, select.moduleId, services))) {
136
- await select.contribution.execute(createSelectContext(interaction, services));
145
+ await select.contribution.execute(context);
137
146
  }
138
147
  }
139
148
  }
@@ -269,6 +278,10 @@ function createContext(interaction, services) {
269
278
  userRoleIds: interaction.member instanceof GuildMember
270
279
  ? [...interaction.member.roles.cache.keys()]
271
280
  : (interaction.member?.roles ?? []),
281
+ userPermissions: interaction.memberPermissions
282
+ ?.toArray()
283
+ .map(normalizeDiscordPermission) ?? [],
284
+ isServerOwner: interaction.guild?.ownerId === interaction.user.id,
272
285
  isAdministrator: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ??
273
286
  false,
274
287
  ...(interaction.guildId ? { serverId: interaction.guildId } : {}),
@@ -331,6 +344,10 @@ function createModalContext(interaction, services) {
331
344
  userRoleIds: interaction.member instanceof GuildMember
332
345
  ? [...interaction.member.roles.cache.keys()]
333
346
  : (interaction.member?.roles ?? []),
347
+ userPermissions: interaction.memberPermissions
348
+ ?.toArray()
349
+ .map(normalizeDiscordPermission) ?? [],
350
+ isServerOwner: interaction.guild?.ownerId === interaction.user.id,
334
351
  isAdministrator: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ??
335
352
  false,
336
353
  ...(interaction.guildId ? { serverId: interaction.guildId } : {}),
@@ -376,12 +393,20 @@ function createSelectContext(interaction, services) {
376
393
  selectedValues: interaction.values,
377
394
  };
378
395
  }
379
- export async function sendToChannel(client, channelId, response) {
396
+ export async function sendToChannel(client, channelId, response, deliveryId) {
380
397
  const channel = await client.channels.fetch(channelId);
381
398
  if (!channel?.isSendable())
382
399
  throw new Error("Configured channel is not sendable");
383
400
  const options = toReplyOptions({ ...response, ephemeral: false });
384
- const message = await channel.send(options);
401
+ const message = await channel.send({
402
+ ...options,
403
+ ...(deliveryId
404
+ ? {
405
+ nonce: deliveryId.replaceAll("-", "").slice(0, 25),
406
+ enforceNonce: true,
407
+ }
408
+ : {}),
409
+ });
385
410
  return { messageId: message.id };
386
411
  }
387
412
  export async function deleteChannelMessage(client, channelId, messageId) {
@@ -406,7 +431,7 @@ async function replyToInteraction(interaction, response) {
406
431
  }
407
432
  await interaction.reply(toReplyOptions(response));
408
433
  }
409
- async function addChannelMessageReaction(client, channelId, messageId, emojiIdentifier) {
434
+ export async function addChannelMessageReaction(client, channelId, messageId, emojiIdentifier) {
410
435
  const channel = await client.channels.fetch(channelId);
411
436
  if (!channel?.isTextBased()) {
412
437
  throw new Error("Configured channel cannot contain messages");
@@ -431,32 +456,33 @@ async function replyEphemeralText(interaction, content) {
431
456
  }
432
457
  await interaction.reply({ content, flags: MessageFlags.Ephemeral });
433
458
  }
434
- export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand) {
459
+ export async function authorizeCommand(interaction, moduleId, commandId, rateLimit, services, selfAuthorizingGuildCommand, resourceScopedCommand = false) {
435
460
  if (!interaction.guildId)
436
461
  return true;
437
462
  if (!selfAuthorizingGuildCommand) {
438
463
  if (!(await authorizeComponent(interaction, moduleId, services)))
439
464
  return false;
440
- const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
441
- const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
442
- const actor = {
443
- userId: interaction.user.id,
444
- roleIds: interaction.member instanceof GuildMember
445
- ? [...interaction.member.roles.cache.keys()]
446
- : (interaction.member?.roles ?? []),
447
- permissions: interaction.memberPermissions
448
- ?.toArray()
449
- .map(normalizeDiscordPermission) ?? [],
450
- isServerOwner: interaction.guild?.ownerId === interaction.user.id,
451
- isAdministrator: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ??
452
- false,
453
- };
454
- if (!canUseCommand(actor, policy)) {
455
- await interaction.reply({
456
- content: "You do not have permission to use this command.",
457
- flags: MessageFlags.Ephemeral,
458
- });
459
- return false;
465
+ if (!resourceScopedCommand) {
466
+ const permissions = services.get(HELYX_SERVICE_NAMES.permissions);
467
+ const policy = await permissions.getEffectivePolicy(interaction.guildId, moduleId, commandId);
468
+ const actor = {
469
+ userId: interaction.user.id,
470
+ roleIds: interaction.member instanceof GuildMember
471
+ ? [...interaction.member.roles.cache.keys()]
472
+ : (interaction.member?.roles ?? []),
473
+ permissions: interaction.memberPermissions
474
+ ?.toArray()
475
+ .map(normalizeDiscordPermission) ?? [],
476
+ isServerOwner: interaction.guild?.ownerId === interaction.user.id,
477
+ isAdministrator: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ?? false,
478
+ };
479
+ if (!canUseCommand(actor, policy)) {
480
+ await interaction.reply({
481
+ content: "You do not have permission to use this command.",
482
+ flags: MessageFlags.Ephemeral,
483
+ });
484
+ return false;
485
+ }
460
486
  }
461
487
  }
462
488
  if (rateLimit) {
@@ -517,6 +543,7 @@ export function toReplyOptions(response) {
517
543
  ...(button.emojiIdentifier
518
544
  ? { emoji: toButtonEmoji(button.emojiIdentifier) }
519
545
  : {}),
546
+ ...(button.disabled ? { disabled: true } : {}),
520
547
  })),
521
548
  },
522
549
  ]
@@ -542,6 +569,9 @@ export function toReplyOptions(response) {
542
569
  if (button.emojiIdentifier) {
543
570
  builder.setEmoji(toButtonEmoji(button.emojiIdentifier));
544
571
  }
572
+ if (button.disabled !== undefined) {
573
+ builder.setDisabled(button.disabled);
574
+ }
545
575
  return builder;
546
576
  })),
547
577
  ]
@@ -576,7 +606,7 @@ function toSelectRows(response) {
576
606
  .setMaxValues(select.maxValues ?? 1)));
577
607
  continue;
578
608
  }
579
- rows.push(new ActionRowBuilder().addComponents(new StringSelectMenuBuilder()
609
+ const builder = new StringSelectMenuBuilder()
580
610
  .setCustomId(select.customId)
581
611
  .setPlaceholder(select.placeholder)
582
612
  .setMinValues(select.minValues ?? 1)
@@ -584,10 +614,15 @@ function toSelectRows(response) {
584
614
  .addOptions(select.options.map((option) => ({
585
615
  label: option.label,
586
616
  value: option.value,
587
- ...(option.description
588
- ? { description: option.description }
617
+ ...(option.description ? { description: option.description } : {}),
618
+ ...(option.emojiIdentifier
619
+ ? { emoji: toButtonEmoji(option.emojiIdentifier) }
589
620
  : {}),
590
- })))));
621
+ })));
622
+ if (select.disabled !== undefined) {
623
+ builder.setDisabled(select.disabled);
624
+ }
625
+ rows.push(new ActionRowBuilder().addComponents(builder));
591
626
  }
592
627
  return rows;
593
628
  }
@@ -14,6 +14,7 @@ export declare class DiscordModuleLoggingService implements ModuleLoggingService
14
14
  eventId: string;
15
15
  summary: string;
16
16
  details?: readonly ModuleLogDetail[];
17
+ idempotencyKey?: string;
17
18
  }): Promise<{
18
19
  delivered: boolean;
19
20
  }>;
@@ -1,5 +1,6 @@
1
1
  import { InstallationRepository, ModuleConfigurationRepository, ModuleLoggingPolicyRepository, } from "@helyx/database";
2
2
  import { toReplyOptions } from "./interactions.js";
3
+ import { createHash } from "node:crypto";
3
4
  const LOGGING_MODULE_ID = "helyx.logging";
4
5
  const VIOLET = 0x7c3aed;
5
6
  export class DiscordModuleLoggingService {
@@ -68,7 +69,13 @@ export class DiscordModuleLoggingService {
68
69
  ],
69
70
  },
70
71
  });
71
- await channel.send(response);
72
+ const delivery = input.idempotencyKey
73
+ ? loggingDeliveryNonce(input)
74
+ : undefined;
75
+ await channel.send({
76
+ ...response,
77
+ ...(delivery ? { nonce: delivery, enforceNonce: true } : {}),
78
+ });
72
79
  return { delivered: true };
73
80
  }
74
81
  catch (error) {
@@ -83,6 +90,17 @@ export class DiscordModuleLoggingService {
83
90
  }
84
91
  }
85
92
  }
93
+ function loggingDeliveryNonce(input) {
94
+ if (!input.idempotencyKey)
95
+ return undefined;
96
+ if (input.idempotencyKey.length > 200 ||
97
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(input.idempotencyKey))
98
+ throw new Error("Module logging idempotency key is invalid");
99
+ return createHash("sha256")
100
+ .update(`${input.guildId}\u0000${input.moduleId}\u0000${input.eventId}\u0000${input.idempotencyKey}`)
101
+ .digest("hex")
102
+ .slice(0, 25);
103
+ }
86
104
  function normalizeText(value, maximum, label) {
87
105
  const normalized = value.normalize("NFKC").trim();
88
106
  if (normalized.length === 0 || normalized.length > maximum) {
package/dist/modules.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BotEnvironment } from "@helyx/config";
1
2
  import type { ModuleDefinition } from "@helyx/sdk";
2
3
  type ModuleImporter = (specifier: string) => Promise<unknown>;
3
4
  type PackageJsonResolver = (specifier: string) => string;
@@ -9,6 +10,14 @@ interface LoadedDefinition {
9
10
  export interface ConfiguredModulePackage extends LoadedDefinition {
10
11
  packageRoot: string;
11
12
  }
13
+ export declare const secureRandomSelectionWorkerKeyName = "HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY";
14
+ interface PlatformServiceEnvironment extends Pick<BotEnvironment, "HELYX_DATA_ENCRYPTION_KEY"> {
15
+ HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY?: string;
16
+ }
17
+ export declare function deriveSecureRandomSelectionKey(encodedDataEncryptionKey: string): Uint8Array;
18
+ export declare function platformServiceOptions(environment: PlatformServiceEnvironment, modules: readonly ModuleDefinition[]): {
19
+ secureRandomSelectionKey?: Uint8Array;
20
+ };
12
21
  export declare function loadConfiguredModules(specifiers: readonly string[], importer?: ModuleImporter): Promise<ModuleDefinition[]>;
13
22
  export declare function loadConfiguredModulePackages(specifiers: readonly string[], importer?: ModuleImporter, resolvePackageJson?: PackageJsonResolver, readPackageJson?: PackageJsonReader): Promise<ConfiguredModulePackage[]>;
14
23
  export declare function validateContributionParity(definition: ModuleDefinition): void;
package/dist/modules.js CHANGED
@@ -1,8 +1,43 @@
1
1
  import { orderModuleManifests, parseModuleManifest, } from "@helyx/module-manifest";
2
+ import { createHmac } from "node:crypto";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { createRequire } from "node:module";
4
5
  import { dirname } from "node:path";
5
6
  const officialModulePackage = /^@helyx\/module-[a-z0-9][a-z0-9-]*$/u;
7
+ const giveawaysModuleId = "helyx.giveaways";
8
+ export const secureRandomSelectionWorkerKeyName = "HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY";
9
+ const secureRandomSelectionKeyContext = "helyx.secure-random-selection.worker.v1";
10
+ function decodeSecureRandomSelectionKey(encodedKey, variableName) {
11
+ const key = Buffer.from(encodedKey, "base64");
12
+ if (key.byteLength !== 32) {
13
+ throw new Error(`${variableName} must contain exactly 32 bytes`);
14
+ }
15
+ return key;
16
+ }
17
+ export function deriveSecureRandomSelectionKey(encodedDataEncryptionKey) {
18
+ const dataEncryptionKey = decodeSecureRandomSelectionKey(encodedDataEncryptionKey, "HELYX_DATA_ENCRYPTION_KEY");
19
+ return createHmac("sha256", dataEncryptionKey)
20
+ .update(secureRandomSelectionKeyContext, "utf8")
21
+ .digest();
22
+ }
23
+ export function platformServiceOptions(environment, modules) {
24
+ const internalSelectionKey = environment.HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY;
25
+ if (internalSelectionKey) {
26
+ return {
27
+ secureRandomSelectionKey: decodeSecureRandomSelectionKey(internalSelectionKey, secureRandomSelectionWorkerKeyName),
28
+ };
29
+ }
30
+ const encodedKey = environment.HELYX_DATA_ENCRYPTION_KEY;
31
+ if (!encodedKey) {
32
+ if (modules.some(({ manifest }) => manifest.id === giveawaysModuleId)) {
33
+ throw new Error("HELYX_DATA_ENCRYPTION_KEY is required when Giveaways is loaded");
34
+ }
35
+ return {};
36
+ }
37
+ return {
38
+ secureRandomSelectionKey: deriveSecureRandomSelectionKey(encodedKey),
39
+ };
40
+ }
6
41
  function isModuleDefinition(value) {
7
42
  if (typeof value !== "object" || value === null)
8
43
  return false;
@@ -58,14 +93,24 @@ export function validateContributionParity(definition) {
58
93
  const executableCommands = (definition.interactions?.commands ?? []).map(({ name }) => name);
59
94
  const executableEvents = [
60
95
  ...(definition.events?.messageReactionAdd ?? []),
96
+ ...(definition.events?.messageReactionRemove ?? []),
97
+ ...(definition.events?.messageDelete ?? []),
61
98
  ...(definition.events?.guildMemberAdd ?? []),
99
+ ...(definition.events?.guildRoleDelete ?? []),
100
+ ...(definition.events?.guildRoleUpdate ?? []),
101
+ ...(definition.events?.channelDelete ?? []),
102
+ ...(definition.events?.channelUpdate ?? []),
103
+ ...(definition.events?.guildEmojiDelete ?? []),
104
+ ...(definition.events?.guildEmojiUpdate ?? []),
62
105
  ].map(({ type }) => type);
63
106
  const executableLogs = (definition.logging?.events ?? []).map(({ id }) => id);
64
107
  const executableDashboardActions = (definition.dashboardActions ?? []).map(({ id }) => id);
108
+ const executableManagedResources = (definition.managedResources ?? []).map(({ id }) => id);
65
109
  assertSameContributionIds(definition.manifest.contributions.commands, executableCommands, "commands");
66
110
  assertSameContributionIds(definition.manifest.contributions.events, executableEvents, "events");
67
111
  assertSameContributionIds(definition.manifest.contributions.logs ?? [], executableLogs, "logs");
68
112
  assertSameContributionIds(definition.manifest.dashboard?.actions?.map(({ id }) => id) ?? [], executableDashboardActions, "dashboard actions");
113
+ assertSameContributionIds(definition.manifest.dashboard?.resources?.map(({ id }) => id) ?? [], executableManagedResources, "dashboard resources");
69
114
  validatePrivacyParity(definition);
70
115
  }
71
116
  function validatePrivacyParity(definition) {
@@ -0,0 +1,7 @@
1
+ import type { PermissionPolicyService, ResourceAuthorizationService } from "@helyx/sdk";
2
+ export declare class CoreResourceAuthorizationService implements ResourceAuthorizationService {
3
+ #private;
4
+ constructor(permissions: PermissionPolicyService);
5
+ canUse(input: Parameters<ResourceAuthorizationService["canUse"]>[0]): Promise<boolean>;
6
+ }
7
+ //# sourceMappingURL=resource-authorization.d.ts.map
@@ -0,0 +1,12 @@
1
+ import { canUseCommand } from "@helyx/core";
2
+ export class CoreResourceAuthorizationService {
3
+ #permissions;
4
+ constructor(permissions) {
5
+ this.#permissions = permissions;
6
+ }
7
+ async canUse(input) {
8
+ const policy = await this.#permissions.getEffectivePolicy(input.guildId, input.moduleId, input.permissionId);
9
+ return canUseCommand(input.actor, policy);
10
+ }
11
+ }
12
+ //# sourceMappingURL=resource-authorization.js.map