@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,4 +1,5 @@
1
1
  import type { ModuleDefinition } from "@helyx/sdk";
2
+ import type { GlobalCommandRegistrationMode } from "./command-registration-policy.js";
2
3
  export interface CommandHashState {
3
4
  value: unknown;
4
5
  version: bigint;
@@ -8,13 +9,14 @@ export interface CommandHashStore {
8
9
  set(key: string, value: unknown, expectedVersion?: bigint | null): Promise<CommandHashState>;
9
10
  }
10
11
  export interface DiscordCommandRest {
12
+ get?(route: `/${string}`): Promise<unknown>;
11
13
  put(route: `/${string}`, options: {
12
14
  body: unknown;
13
15
  }): Promise<unknown>;
14
16
  }
15
17
  export interface CommandSyncResult {
16
18
  global: "updated" | "unchanged";
17
- guild: "updated" | "unchanged" | "not_configured";
19
+ guild: "updated" | "unchanged" | "not_configured" | "delegated";
18
20
  globalCount: number;
19
21
  guildCount: number;
20
22
  globalHash: string;
@@ -24,7 +26,9 @@ export declare function synchroniseApplicationCommands(input: {
24
26
  applicationId: string;
25
27
  token: string;
26
28
  modules: readonly ModuleDefinition[];
29
+ globalCommandMode?: GlobalCommandRegistrationMode;
27
30
  internalGuildId?: string;
31
+ synchroniseInternalGuild?: boolean;
28
32
  state: CommandHashStore;
29
33
  rest?: DiscordCommandRest;
30
34
  }): Promise<CommandSyncResult>;
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { REST, Routes } from "discord.js";
3
3
  import { buildApplicationCommandDefinitions } from "./interactions.js";
4
4
  export async function synchroniseApplicationCommands(input) {
5
- const definitions = buildApplicationCommandDefinitions(input.modules, input.internalGuildId);
5
+ const definitions = buildApplicationCommandDefinitions(input.modules, input.internalGuildId, input.globalCommandMode);
6
6
  definitions.global.sort((left, right) => left.name.localeCompare(right.name));
7
7
  definitions.guild.sort((left, right) => left.name.localeCompare(right.name));
8
8
  const rest = input.rest ?? new REST({ version: "10" }).setToken(input.token);
@@ -13,6 +13,7 @@ export async function synchroniseApplicationCommands(input) {
13
13
  hash: globalHash,
14
14
  definitions: definitions.global,
15
15
  state: input.state,
16
+ observe: () => rest.get?.(Routes.applicationCommands(input.applicationId)),
16
17
  update: () => rest.put(Routes.applicationCommands(input.applicationId), {
17
18
  body: definitions.global,
18
19
  }),
@@ -25,6 +26,15 @@ export async function synchroniseApplicationCommands(input) {
25
26
  guildCount: 0,
26
27
  globalHash,
27
28
  };
29
+ if (input.synchroniseInternalGuild === false)
30
+ return {
31
+ global,
32
+ guild: "delegated",
33
+ globalCount: definitions.global.length,
34
+ guildCount: definitions.guild.length,
35
+ globalHash,
36
+ guildHash: commandDefinitionsHash(definitions.guild),
37
+ };
28
38
  const guildKey = `commands:${input.applicationId}:guild:${input.internalGuildId}`;
29
39
  const guildHash = commandDefinitionsHash(definitions.guild);
30
40
  const guild = await synchroniseCollection({
@@ -32,6 +42,7 @@ export async function synchroniseApplicationCommands(input) {
32
42
  hash: guildHash,
33
43
  definitions: definitions.guild,
34
44
  state: input.state,
45
+ observe: () => rest.get?.(Routes.applicationGuildCommands(input.applicationId, input.internalGuildId)),
35
46
  update: () => rest.put(Routes.applicationGuildCommands(input.applicationId, input.internalGuildId), { body: definitions.guild }),
36
47
  });
37
48
  return {
@@ -48,8 +59,13 @@ export function commandDefinitionsHash(definitions) {
48
59
  }
49
60
  async function synchroniseCollection(input) {
50
61
  const current = await input.state.get(input.key);
51
- if (isCommandHashState(current?.value, input.hash))
52
- return "unchanged";
62
+ if (isCommandHashState(current?.value, input.hash)) {
63
+ const observed = input.observe();
64
+ if (observed &&
65
+ commandDefinitionsHash(normalizeObservedCommands(await observed)) ===
66
+ commandDefinitionsHash(normalizeDesiredCommands(input.definitions)))
67
+ return "unchanged";
68
+ }
53
69
  await input.update();
54
70
  await input.state.set(input.key, {
55
71
  hash: input.hash,
@@ -58,6 +74,54 @@ async function synchroniseCollection(input) {
58
74
  }, current?.version ?? null);
59
75
  return "updated";
60
76
  }
77
+ function normalizeObservedCommands(value) {
78
+ if (!Array.isArray(value))
79
+ return [];
80
+ return value
81
+ .map((command) => normalizeDiscordCommand(command))
82
+ .sort((left, right) => commandName(left).localeCompare(commandName(right)));
83
+ }
84
+ function normalizeDesiredCommands(value) {
85
+ return value
86
+ .map((command) => normalizeDiscordCommand(command))
87
+ .sort((left, right) => commandName(left).localeCompare(commandName(right)));
88
+ }
89
+ function normalizeDiscordCommand(value, root = true) {
90
+ if (Array.isArray(value))
91
+ return value.map((item) => normalizeDiscordCommand(item, false));
92
+ if (!value || typeof value !== "object")
93
+ return value;
94
+ const ignored = new Set(["id", "application_id", "guild_id", "version"]);
95
+ const result = Object.fromEntries(Object.entries(value)
96
+ .filter(([key, nested]) => {
97
+ if (ignored.has(key) || nested === undefined)
98
+ return false;
99
+ if (key === "nsfw" && nested === false)
100
+ return false;
101
+ if (key === "default_permission" && nested === true)
102
+ return false;
103
+ if (key === "default_member_permissions" && nested === null)
104
+ return false;
105
+ if (key === "required" && nested === false)
106
+ return false;
107
+ if (key.endsWith("_localizations") && nested === null)
108
+ return false;
109
+ if (key === "options" && Array.isArray(nested) && nested.length === 0)
110
+ return false;
111
+ return true;
112
+ })
113
+ .map(([key, nested]) => [key, normalizeDiscordCommand(nested, false)]));
114
+ if (root) {
115
+ result.contexts ??= [0];
116
+ result.integration_types ??= [0];
117
+ }
118
+ return result;
119
+ }
120
+ function commandName(value) {
121
+ return value && typeof value === "object" && "name" in value
122
+ ? String(value.name)
123
+ : "";
124
+ }
61
125
  function isCommandHashState(value, expectedHash) {
62
126
  return (typeof value === "object" &&
63
127
  value !== null &&
@@ -3,10 +3,13 @@ import { type DashboardActionResult, type GuildAccessPolicy, type ManagedResourc
3
3
  import { type ActionReceipt, type SignedAssessmentEnvelope } from "@helyx/verification-contracts";
4
4
  import { type Client } from "discord.js";
5
5
  import { type FastifyInstance } from "fastify";
6
+ import { type BotModuleStatusMutationResult } from "./module-status-control.js";
7
+ import { type FirstTimeCommandSetupHandler } from "./first-time-command-setup.js";
6
8
  import { type ShardHealthSnapshot } from "./shard-contracts.js";
7
9
  import { type DashboardPermissionState } from "./control-permissions.js";
8
10
  import { type DashboardCommandDefinition } from "./dashboard-commands.js";
9
11
  import { type AvailabilityModuleCatalogue } from "./module-availability-control.js";
12
+ import { registerGuildCommandControlRoutes } from "./guild-command-control-routes.js";
10
13
  interface DashboardLogEventDefinition {
11
14
  id: string;
12
15
  name: string;
@@ -139,7 +142,7 @@ export interface DashboardGuildAccessState extends GuildAccessPolicy {
139
142
  actorHighestRolePosition: number | null;
140
143
  updatedAt: string | null;
141
144
  }
142
- export interface BotControlHandler {
145
+ export interface BotControlHandler extends FirstTimeCommandSetupHandler {
143
146
  cleanupOAuthConsumer(input: {
144
147
  consumerBindingId: string;
145
148
  moduleId: string;
@@ -193,7 +196,7 @@ export interface BotControlHandler {
193
196
  enabled: boolean;
194
197
  actorUserId: string;
195
198
  correlationId: string;
196
- }): Promise<BotControlState>;
199
+ }): Promise<BotModuleStatusMutationResult>;
197
200
  updateConfiguration(input: {
198
201
  guildId: string;
199
202
  moduleId: string;
@@ -422,7 +425,15 @@ export declare class BotControlService implements BotControlHandler {
422
425
  enabled: boolean;
423
426
  actorUserId: string;
424
427
  correlationId: string;
425
- }): Promise<BotControlState>;
428
+ }): Promise<BotModuleStatusMutationResult>;
429
+ completeFirstTimeCommandSetup(input: {
430
+ guildId: string;
431
+ moduleIds: string[];
432
+ expectedRevision: bigint;
433
+ idempotencyKey: string;
434
+ actorUserId: string;
435
+ correlationId: string;
436
+ }): Promise<BotModuleStatusMutationResult>;
426
437
  updateConfiguration(input: {
427
438
  guildId: string;
428
439
  moduleId: string;
@@ -548,6 +559,7 @@ export declare function createBotControlServer(input: {
548
559
  level: string;
549
560
  };
550
561
  shardHealth?: () => Promise<ShardHealthSnapshot>;
562
+ guildCommands?: Parameters<typeof registerGuildCommandControlRoutes>[1];
551
563
  }): Promise<FastifyInstance>;
552
564
  export declare function mapBotControlError(error: unknown): {
553
565
  status: 400 | 403 | 404 | 409;
@@ -1,4 +1,4 @@
1
- import { AuditRepository, GuildAccessPolicyRepository, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
1
+ import { AuditRepository, GuildAccessPolicyRepository, GuildCommandActiveLeaseError, GuildCommandRevisionConflictError, GuildCommandSetupConflictError, InstallationRepository, ModuleLoggingPolicyRepository, OptimisticConcurrencyError, PermissionPolicyRepository, withTransaction, } from "@helyx/database";
2
2
  import { DashboardActionValidationError, DEFAULT_GUILD_ACCESS_POLICY, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
3
  import { actionReceiptSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
4
4
  import { canAccessGuildDashboard, canMutatePermissionPolicy, canUseCommand, permissionMutationDenialMessage, resolveHelyxAuthority, } from "@helyx/core";
@@ -7,12 +7,14 @@ import Fastify, {} from "fastify";
7
7
  import { createHash, timingSafeEqual } from "node:crypto";
8
8
  import { z, ZodError } from "zod";
9
9
  import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteChannelMessage as deleteDiscordChannelMessage, editChannelMessage as editDiscordChannelMessage, normalizeDiscordPermission, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
10
- import { runNestedGuildBarriers } from "./numeric-barriers.js";
10
+ import { mutateDashboardModuleStatus, } from "./module-status-control.js";
11
+ import { completeFirstTimeCommandSetup, } from "./first-time-command-setup.js";
11
12
  import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
12
13
  import { dashboardCategoryLabel } from "./dashboard-category.js";
13
14
  import { assertVersion, dashboardActionPermissionId, declaredManagedResourcePermission, fromDashboardPolicy, managedResourcePermissionId, permissionPoliciesEqual, permissionPolicyKey, safeOptionalVersion, safeVersion, toDashboardPolicy, } from "./control-permissions.js";
14
15
  import { dashboardCommands, } from "./dashboard-commands.js";
15
16
  import { availabilityCatalogueDefinitions, availabilityCatalogue, dashboardModuleStatus, disabledModuleSummary, moduleAvailabilityService, moduleAvailabilityStates, } from "./module-availability-control.js";
17
+ import { registerGuildCommandControlRoutes } from "./guild-command-control-routes.js";
16
18
  const snowflake = z.string().regex(/^\d{17,20}$/u);
17
19
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
18
20
  const MAX_DISCORD_SETTING_SELECTIONS = 25;
@@ -628,53 +630,21 @@ export class BotControlService {
628
630
  }
629
631
  async setModuleStatus(input) {
630
632
  const module = this.#requireDashboardModule(input.moduleId);
631
- await this.#requireModuleAvailable(input.moduleId);
632
- const scopes = Object.entries(module.records ?? {})
633
- .filter(([, collection]) => (collection.incrementableFields?.length ?? 0) > 0)
634
- .map(([collection]) => ({
635
- moduleId: input.moduleId,
636
- collection,
637
- guildId: input.guildId,
638
- }));
639
- const writeBehind = scopes.length > 0 &&
640
- this.#services.has(HELYX_SERVICE_NAMES.numericWriteBehind)
641
- ? this.#services.get(HELYX_SERVICE_NAMES.numericWriteBehind)
642
- : null;
643
- const setEnabled = () => this.#installations.setModuleEnabled({
644
- guildId: input.guildId,
645
- moduleId: input.moduleId,
646
- enabled: input.enabled,
647
- context: {
648
- actorUserId: input.actorUserId,
649
- correlationId: input.correlationId,
650
- source: "dashboard",
651
- },
633
+ return mutateDashboardModuleStatus({
634
+ change: input,
635
+ module,
636
+ dashboardModuleIds: this.#dashboardModules.map(({ id }) => id),
637
+ installations: this.#installations,
638
+ services: this.#services,
639
+ requireModuleAvailable: () => this.#requireModuleAvailable(input.moduleId),
640
+ });
641
+ }
642
+ async completeFirstTimeCommandSetup(input) {
643
+ return completeFirstTimeCommandSetup({
644
+ installations: this.#installations,
645
+ dashboardModuleIds: this.#dashboardModules.map(({ id }) => id),
646
+ change: input,
652
647
  });
653
- if (!writeBehind) {
654
- await setEnabled();
655
- }
656
- else if (input.enabled) {
657
- for (const scope of scopes) {
658
- const suppression = await writeBehind.suppressGuild(scope);
659
- await writeBehind.releaseGuild({
660
- ...scope,
661
- suppressionId: suppression.suppressionId,
662
- });
663
- }
664
- await setEnabled();
665
- }
666
- else {
667
- await runNestedGuildBarriers({
668
- scopes,
669
- writeBehind,
670
- operation: setEnabled,
671
- });
672
- for (const scope of scopes)
673
- await writeBehind.flushGuild(scope);
674
- for (const scope of scopes)
675
- await writeBehind.suppressGuild(scope);
676
- }
677
- return this.getGuildState(input.guildId);
678
648
  }
679
649
  async updateConfiguration(input) {
680
650
  const module = this.#requireDashboardModule(input.moduleId);
@@ -1253,6 +1223,7 @@ export async function createBotControlServer(input) {
1253
1223
  }
1254
1224
  server.get("/health", (_request, reply) => readiness(reply));
1255
1225
  server.get("/v1/health", (_request, reply) => readiness(reply));
1226
+ registerGuildCommandControlRoutes(server, input.guildCommands, input.service);
1256
1227
  server.post("/v1/verification/results", async (request) => input.service.acceptVerificationResult(signedAssessmentEnvelopeSchema.parse(request.body)));
1257
1228
  server.post("/v1/oauth/consumers/cleanup", async (request) => {
1258
1229
  const body = z
@@ -1747,6 +1718,21 @@ export function mapBotControlError(error) {
1747
1718
  status: 409,
1748
1719
  message: "Configuration changed in another session. Reload and try again.",
1749
1720
  };
1721
+ if (error instanceof GuildCommandRevisionConflictError)
1722
+ return {
1723
+ status: 409,
1724
+ message: "Command state changed in another session. Refresh and try again.",
1725
+ };
1726
+ if (error instanceof GuildCommandActiveLeaseError)
1727
+ return {
1728
+ status: 409,
1729
+ message: "A command build is already running. Wait for it to finish.",
1730
+ };
1731
+ if (error instanceof GuildCommandSetupConflictError)
1732
+ return {
1733
+ status: 409,
1734
+ message: "Command setup changed. Reload the page and try again.",
1735
+ };
1750
1736
  return undefined;
1751
1737
  }
1752
1738
  class BotControlValidationError extends Error {
@@ -27,7 +27,11 @@ export function dashboardCommands(module) {
27
27
  }),
28
28
  ];
29
29
  });
30
- commands.push(...(module.nexusCommands ?? []).map((command) => dashboardCommand(module, {
30
+ commands.push(...(module.sharedCommands ?? []).map((command) => dashboardCommand(module, {
31
+ id: command.permissionId ?? `${command.family}.${command.path}`,
32
+ name: `/${command.family} ${command.path.replace(".", " ")}`,
33
+ description: command.description,
34
+ })), ...(module.nexusCommands ?? []).map((command) => dashboardCommand(module, {
31
35
  id: `nexus.${command.path}`,
32
36
  name: `/nexus ${command.path.replace(".", " ")}`,
33
37
  description: command.description,
@@ -1,5 +1,6 @@
1
1
  import { type ModuleDefinition } from "@helyx/sdk";
2
2
  import { type ShardHealthSnapshot } from "./shard-contracts.js";
3
+ import type { TriviaDataProvider } from "./trivia-data-provider.js";
3
4
  export interface ShardHealthProvider {
4
5
  getHealth(): Promise<ShardHealthSnapshot>;
5
6
  }
@@ -17,6 +18,7 @@ export interface DeveloperOperationsInput {
17
18
  modules: readonly DeveloperModuleSummary[];
18
19
  processLabel: string;
19
20
  health: ShardHealthProvider;
21
+ triviaData: TriviaDataProvider;
20
22
  }
21
23
  export declare function createDeveloperOperationsModule(input: DeveloperOperationsInput): ModuleDefinition;
22
24
  export declare class WorkerShardHealthProvider implements ShardHealthProvider {
@@ -35,6 +35,7 @@ export function createDeveloperOperationsModule(input) {
35
35
  name: "shards",
36
36
  description: "Show bounded read-only shard health",
37
37
  acknowledgement: "deferred-ephemeral",
38
+ authorization: "self-authorized",
38
39
  rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
39
40
  async execute(context) {
40
41
  if (!(await authorizeDeveloperCommand(context)))
@@ -83,8 +84,8 @@ export function createDeveloperOperationsModule(input) {
83
84
  {
84
85
  name: "bot",
85
86
  description: "Show the bot build and installed module versions",
86
- acknowledgement: "deferred-ephemeral",
87
- rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
87
+ acknowledgement: "immediate-response",
88
+ authorization: "self-authorized",
88
89
  async execute(context) {
89
90
  if (!(await authorizeDeveloperCommand(context)))
90
91
  return;
@@ -137,8 +138,8 @@ export function createDeveloperOperationsModule(input) {
137
138
  {
138
139
  name: "proc",
139
140
  description: "Show safe process resource and uptime information",
140
- acknowledgement: "deferred-ephemeral",
141
- rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
141
+ acknowledgement: "immediate-response",
142
+ authorization: "self-authorized",
142
143
  async execute(context) {
143
144
  if (!(await authorizeDeveloperCommand(context)))
144
145
  return;
@@ -168,6 +169,57 @@ export function createDeveloperOperationsModule(input) {
168
169
  });
169
170
  },
170
171
  },
172
+ {
173
+ name: "trivia-data",
174
+ description: "Show question counts from the Trivia database",
175
+ acknowledgement: "deferred-ephemeral",
176
+ authorization: "self-authorized",
177
+ rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
178
+ async execute(context) {
179
+ if (!(await authorizeDeveloperCommand(context)))
180
+ return;
181
+ let summary;
182
+ try {
183
+ summary = await input.triviaData.getSummary();
184
+ }
185
+ catch {
186
+ await context.reply({
187
+ ephemeral: true,
188
+ componentsV2: {
189
+ accentColor: 0xdc2626,
190
+ text: [
191
+ {
192
+ content: "Trivia data is unavailable. Check the bot database connection and Trivia migrations.",
193
+ },
194
+ ],
195
+ },
196
+ });
197
+ return;
198
+ }
199
+ const categoryLines = summary.categories.map((entry) => {
200
+ const total = entry.easy + entry.medium + entry.hard;
201
+ return `**${categoryLabel(entry.category)}**: Easy ${entry.easy.toLocaleString("en-GB")} | Medium ${entry.medium.toLocaleString("en-GB")} | Hard ${entry.hard.toLocaleString("en-GB")} | Total ${total.toLocaleString("en-GB")}`;
202
+ });
203
+ await context.reply({
204
+ ephemeral: true,
205
+ componentsV2: {
206
+ accentColor: 0x2563eb,
207
+ text: [
208
+ {
209
+ content: [
210
+ "## Trivia data",
211
+ `**Unique questions:** ${summary.uniqueQuestions.toLocaleString("en-GB")}`,
212
+ `**Multiple Choice:** ${summary.types.multipleChoice.toLocaleString("en-GB")} | **Guess:** ${summary.types.guess.toLocaleString("en-GB")} | **True or False:** ${summary.types.trueFalse.toLocaleString("en-GB")}`,
213
+ "",
214
+ "### Questions by category",
215
+ ...categoryLines,
216
+ ].join("\n"),
217
+ },
218
+ ],
219
+ },
220
+ });
221
+ },
222
+ },
171
223
  ],
172
224
  },
173
225
  ],
@@ -198,6 +250,15 @@ export function createDeveloperOperationsModule(input) {
198
250
  function safeInlineCode(value) {
199
251
  return `\`${value.replaceAll("`", "'").slice(0, 120)}\``;
200
252
  }
253
+ function categoryLabel(category) {
254
+ if (category === "science")
255
+ return "Science and Nature";
256
+ return category
257
+ .split("_")
258
+ .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
259
+ .join(" ")
260
+ .replace("And", "and");
261
+ }
201
262
  function formatBytes(value) {
202
263
  return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
203
264
  }
@@ -0,0 +1,5 @@
1
+ import { Agent } from "undici";
2
+ export declare const discordRestOptions: {
3
+ readonly agent: Agent;
4
+ };
5
+ //# sourceMappingURL=discord-rest.d.ts.map
@@ -0,0 +1,9 @@
1
+ import { Agent } from "undici";
2
+ const discordRestAgent = new Agent({
3
+ keepAliveTimeout: 60_000,
4
+ keepAliveMaxTimeout: 120_000,
5
+ connections: 10,
6
+ pipelining: 1,
7
+ });
8
+ export const discordRestOptions = { agent: discordRestAgent };
9
+ //# sourceMappingURL=discord-rest.js.map
package/dist/events.js CHANGED
@@ -1002,10 +1002,12 @@ export class DiscordMessageDeliveryService {
1002
1002
  async send(input) {
1003
1003
  validateSnowflake(input.guildId, "guild");
1004
1004
  validateSnowflake(input.channelId, "channel");
1005
- const guild = await this.client.guilds.fetch(input.guildId);
1005
+ const guild = this.client.guilds.cache.get(input.guildId) ??
1006
+ (await this.client.guilds.fetch(input.guildId));
1006
1007
  if (guild.id !== input.guildId)
1007
1008
  throw new Error("Discord guild validation failed");
1008
- const channel = await guild.channels.fetch(input.channelId);
1009
+ const channel = guild.channels.cache.get(input.channelId) ??
1010
+ (await guild.channels.fetch(input.channelId));
1009
1011
  if (!channel ||
1010
1012
  channel.guild.id !== guild.id ||
1011
1013
  !channel.isSendable() ||
@@ -0,0 +1,25 @@
1
+ import type { InstallationService } from "@helyx/sdk";
2
+ import type { BotModuleStatusMutationResult } from "./module-status-control.js";
3
+ export interface FirstTimeCommandSetupHandler {
4
+ completeFirstTimeCommandSetup(input: {
5
+ guildId: string;
6
+ moduleIds: string[];
7
+ expectedRevision: bigint;
8
+ idempotencyKey: string;
9
+ actorUserId: string;
10
+ correlationId: string;
11
+ }): Promise<BotModuleStatusMutationResult>;
12
+ }
13
+ export declare function completeFirstTimeCommandSetup(input: {
14
+ installations: InstallationService;
15
+ dashboardModuleIds: readonly string[];
16
+ change: {
17
+ guildId: string;
18
+ moduleIds: string[];
19
+ expectedRevision: bigint;
20
+ idempotencyKey: string;
21
+ actorUserId: string;
22
+ correlationId: string;
23
+ };
24
+ }): Promise<BotModuleStatusMutationResult>;
25
+ //# sourceMappingURL=first-time-command-setup.d.ts.map
@@ -0,0 +1,22 @@
1
+ export async function completeFirstTimeCommandSetup(input) {
2
+ if (!input.installations.completeFirstTimeSetup)
3
+ throw new Error("First-time setup is unavailable");
4
+ const result = await input.installations.completeFirstTimeSetup({
5
+ guildId: input.change.guildId,
6
+ moduleIds: input.change.moduleIds,
7
+ expectedRevision: input.change.expectedRevision,
8
+ idempotencyKey: input.change.idempotencyKey,
9
+ context: {
10
+ actorUserId: input.change.actorUserId,
11
+ correlationId: input.change.correlationId,
12
+ source: "dashboard",
13
+ },
14
+ });
15
+ const dashboardModuleIds = new Set(input.dashboardModuleIds);
16
+ return {
17
+ moduleStatuses: Object.fromEntries(result.transitionedModuleIds
18
+ .filter((moduleId) => dashboardModuleIds.has(moduleId))
19
+ .map((moduleId) => [moduleId, "enabled"])),
20
+ };
21
+ }
22
+ //# sourceMappingURL=first-time-command-setup.js.map
@@ -0,0 +1,5 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { GuildCommandReadHandler } from "./guild-command-read-service.js";
3
+ import type { BotControlHandler } from "./control-server.js";
4
+ export declare function registerGuildCommandControlRoutes(server: FastifyInstance, commands: GuildCommandReadHandler | undefined, setup: Pick<BotControlHandler, "completeFirstTimeCommandSetup">): void;
5
+ //# sourceMappingURL=guild-command-control-routes.d.ts.map