@helyx/bot 0.4.36 → 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 (65) hide show
  1. package/CHANGELOG.md +20 -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-guild-health.d.ts +2 -2
  14. package/dist/discord-rest.d.ts +5 -0
  15. package/dist/discord-rest.js +9 -0
  16. package/dist/events.js +4 -2
  17. package/dist/first-time-command-setup.d.ts +25 -0
  18. package/dist/first-time-command-setup.js +22 -0
  19. package/dist/guild-command-control-routes.d.ts +5 -0
  20. package/dist/guild-command-control-routes.js +191 -0
  21. package/dist/guild-command-discord-client.d.ts +21 -0
  22. package/dist/guild-command-discord-client.js +50 -0
  23. package/dist/guild-command-projection.d.ts +46 -0
  24. package/dist/guild-command-projection.js +254 -0
  25. package/dist/guild-command-read-service.d.ts +201 -0
  26. package/dist/guild-command-read-service.js +272 -0
  27. package/dist/guild-command-release.d.ts +19 -0
  28. package/dist/guild-command-release.js +47 -0
  29. package/dist/guild-command-worker.d.ts +49 -0
  30. package/dist/guild-command-worker.js +443 -0
  31. package/dist/guilds.d.ts +1 -1
  32. package/dist/guilds.js +41 -26
  33. package/dist/index.js +75 -4
  34. package/dist/interactions.d.ts +15 -4
  35. package/dist/interactions.js +285 -70
  36. package/dist/management-module.js +117 -41
  37. package/dist/module-logging.d.ts +1 -0
  38. package/dist/module-logging.js +5 -5
  39. package/dist/module-status-control.d.ts +19 -0
  40. package/dist/module-status-control.js +69 -0
  41. package/dist/modules.d.ts +2 -0
  42. package/dist/modules.js +21 -2
  43. package/dist/nexus-commands.d.ts +1 -12
  44. package/dist/nexus-commands.js +1 -99
  45. package/dist/oauth-module.js +134 -88
  46. package/dist/permissions-module.js +112 -39
  47. package/dist/privacy-module.d.ts +2 -0
  48. package/dist/privacy-module.js +60 -0
  49. package/dist/scheduled-task-runner.js +36 -1
  50. package/dist/shard-contracts.d.ts +24 -0
  51. package/dist/shard-contracts.js +20 -0
  52. package/dist/shard-router.d.ts +1 -0
  53. package/dist/shard-router.js +7 -0
  54. package/dist/shard-supervisor.d.ts +2 -1
  55. package/dist/shard-supervisor.js +94 -16
  56. package/dist/shard-worker.d.ts +6 -1
  57. package/dist/shard-worker.js +9 -1
  58. package/dist/shared-commands.d.ts +13 -0
  59. package/dist/shared-commands.js +163 -0
  60. package/dist/support-module.js +51 -51
  61. package/dist/trivia-data-provider.d.ts +26 -0
  62. package/dist/trivia-data-provider.js +51 -0
  63. package/package.json +5 -4
  64. package/dist/oauth-module.test.d.ts +0 -2
  65. package/dist/oauth-module.test.js +0 -172
@@ -17,6 +17,7 @@ const MODULE_NEXT_ID = "helyx.management.module.next";
17
17
  const SETTING_PREVIOUS_ID = "helyx.management.setting.previous";
18
18
  const SETTING_NEXT_ID = "helyx.management.setting.next";
19
19
  const VALUE_MODAL_ID = "helyx.management.value.modal";
20
+ const OPEN_VALUE_MODAL_ID = "helyx.management.value.open";
20
21
  const VALUE_FIELD_ID = "value";
21
22
  const SESSION_PURPOSE = "modules.manage";
22
23
  const PAGE_SIZE = 25;
@@ -30,6 +31,7 @@ export function createManagementModule(configurableModules, resources) {
30
31
  .filter((module) => isCustomerSelectableModule(module.manifest))
31
32
  .sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
32
33
  const byId = new Map(modules.map((module) => [module.manifest.id, module]));
34
+ const preparedValueModals = new Map();
33
35
  return defineModule({
34
36
  manifest: {
35
37
  ...INTERNAL_MODULE_MANIFEST_FIELDS,
@@ -42,56 +44,58 @@ export function createManagementModule(configurableModules, resources) {
42
44
  framework: "^0.1.0",
43
45
  dependencies: [],
44
46
  permissions: [],
45
- contributions: { commands: ["helyx"], events: [] },
47
+ contributions: { commands: ["helyx.modules"], events: [] },
46
48
  },
47
49
  defaultPermissions: {
48
50
  module: restrictedPolicy(),
49
51
  commands: { helyx: restrictedPolicy() },
50
52
  },
51
- interactions: {
52
- commands: [
53
- {
54
- name: "helyx",
55
- description: "Manage Helyx modules and settings",
56
- acknowledgement: "deferred-ephemeral",
57
- registration: "global",
58
- contexts: ["guild"],
59
- integrationTypes: ["guild_install"],
60
- rateLimit: { scope: "user", limit: 30, windowSeconds: 60 },
61
- async execute(context) {
62
- if (!context.serverId)
63
- return;
64
- if (modules.length === 0) {
65
- await context.reply({
66
- ephemeral: true,
67
- componentsV2: {
68
- accentColor: DANGER,
69
- text: [
70
- {
71
- content: "## No feature modules installed\nInstall at least one official Helyx module before configuring this server.",
72
- },
73
- ],
74
- },
75
- });
76
- return;
77
- }
78
- await context.services
79
- .get(HELYX_SERVICE_NAMES.sessions)
80
- .create({
81
- guildId: context.serverId,
82
- userId: context.userId,
83
- purpose: SESSION_PURPOSE,
84
- state: { modulePage: 0 },
85
- expiresAt: sessionExpiry(),
86
- replace: true,
53
+ sharedCommands: [
54
+ {
55
+ family: "helyx",
56
+ path: "modules",
57
+ permissionId: "helyx",
58
+ description: "Manage Helyx modules and settings",
59
+ acknowledgement: "deferred-ephemeral",
60
+ rateLimit: { scope: "user", limit: 30, windowSeconds: 60 },
61
+ async execute(context) {
62
+ if (!context.serverId) {
63
+ await context.reply(serverOnlyResponse("manage modules"));
64
+ return;
65
+ }
66
+ if (modules.length === 0) {
67
+ await context.reply({
68
+ ephemeral: true,
69
+ componentsV2: {
70
+ accentColor: DANGER,
71
+ text: [
72
+ {
73
+ content: "## No feature modules installed\nInstall at least one official Helyx module before configuring this server.",
74
+ },
75
+ ],
76
+ },
87
77
  });
88
- await context.reply(await modulePickerResponse(context, modules, 0));
89
- },
78
+ return;
79
+ }
80
+ await context.services
81
+ .get(HELYX_SERVICE_NAMES.sessions)
82
+ .create({
83
+ guildId: context.serverId,
84
+ userId: context.userId,
85
+ purpose: SESSION_PURPOSE,
86
+ state: { modulePage: 0 },
87
+ expiresAt: sessionExpiry(),
88
+ replace: true,
89
+ });
90
+ await context.reply(await modulePickerResponse(context, modules, 0));
90
91
  },
91
- ],
92
+ },
93
+ ],
94
+ interactions: {
92
95
  selects: [
93
96
  {
94
97
  customId: MODULE_SELECT_ID,
98
+ acknowledgement: "deferred-update",
95
99
  async execute(context) {
96
100
  const session = await getSession(context);
97
101
  if (!session || !context.serverId)
@@ -116,6 +120,7 @@ export function createManagementModule(configurableModules, resources) {
116
120
  },
117
121
  {
118
122
  customId: SETTING_SELECT_ID,
123
+ acknowledgement: "deferred-update",
119
124
  async execute(context) {
120
125
  const session = await getSession(context);
121
126
  if (!session)
@@ -125,7 +130,16 @@ export function createManagementModule(configurableModules, resources) {
125
130
  const state = { ...session.state, selectedSettingKey: setting.key };
126
131
  await updateSession(context, session, state);
127
132
  if (setting.type === "string" || setting.type === "integer") {
128
- await context.showModal(settingModal(setting, draft(state)));
133
+ const now = Date.now();
134
+ for (const [key, prepared] of preparedValueModals) {
135
+ if (prepared.expiresAt <= now)
136
+ preparedValueModals.delete(key);
137
+ }
138
+ preparedValueModals.set(preparedModalKey(context.serverId, context.userId), {
139
+ definition: settingModal(setting, draft(state)),
140
+ expiresAt: now + 2 * 60 * 1_000,
141
+ });
142
+ await respond(context, textSettingResponse(setting));
129
143
  return;
130
144
  }
131
145
  await respond(context, settingValueResponse(setting));
@@ -133,6 +147,7 @@ export function createManagementModule(configurableModules, resources) {
133
147
  },
134
148
  {
135
149
  customId: VALUE_SELECT_ID,
150
+ acknowledgement: "deferred-update",
136
151
  async execute(context) {
137
152
  const session = await getSession(context);
138
153
  if (!session)
@@ -150,8 +165,31 @@ export function createManagementModule(configurableModules, resources) {
150
165
  buttons: [
151
166
  statusButton(ENABLE_ID, true),
152
167
  statusButton(DISABLE_ID, false),
168
+ {
169
+ customId: OPEN_VALUE_MODAL_ID,
170
+ async execute(context) {
171
+ const key = preparedModalKey(context.serverId, context.userId);
172
+ const prepared = preparedValueModals.get(key);
173
+ preparedValueModals.delete(key);
174
+ if (!prepared || prepared.expiresAt <= Date.now()) {
175
+ await context.reply({
176
+ ephemeral: true,
177
+ componentsV2: {
178
+ text: [
179
+ {
180
+ content: "This setting form expired. Choose the setting again.",
181
+ },
182
+ ],
183
+ },
184
+ });
185
+ return;
186
+ }
187
+ await context.showModal(prepared.definition);
188
+ },
189
+ },
153
190
  {
154
191
  customId: CONFIGURE_ID,
192
+ acknowledgement: "deferred-update",
155
193
  async execute(context) {
156
194
  const session = await getSession(context);
157
195
  if (!session)
@@ -166,6 +204,7 @@ export function createManagementModule(configurableModules, resources) {
166
204
  },
167
205
  {
168
206
  customId: SAVE_ID,
207
+ acknowledgement: "deferred-update",
169
208
  async execute(context) {
170
209
  const session = await getSession(context);
171
210
  if (!session || !context.serverId)
@@ -235,6 +274,7 @@ export function createManagementModule(configurableModules, resources) {
235
274
  },
236
275
  {
237
276
  customId: CLEAR_ID,
277
+ acknowledgement: "deferred-update",
238
278
  async execute(context) {
239
279
  const session = await getSession(context);
240
280
  if (!session)
@@ -256,6 +296,7 @@ export function createManagementModule(configurableModules, resources) {
256
296
  },
257
297
  {
258
298
  customId: BACK_TO_MODULES_ID,
299
+ acknowledgement: "deferred-update",
259
300
  async execute(context) {
260
301
  const session = await getSession(context);
261
302
  if (!session)
@@ -266,6 +307,7 @@ export function createManagementModule(configurableModules, resources) {
266
307
  },
267
308
  {
268
309
  customId: BACK_TO_MODULE_ID,
310
+ acknowledgement: "deferred-update",
269
311
  async execute(context) {
270
312
  const session = await getSession(context);
271
313
  if (!session)
@@ -275,6 +317,7 @@ export function createManagementModule(configurableModules, resources) {
275
317
  },
276
318
  {
277
319
  customId: CANCEL_ID,
320
+ acknowledgement: "deferred-update",
278
321
  async execute(context) {
279
322
  const session = await getSession(context);
280
323
  if (!session)
@@ -303,6 +346,7 @@ export function createManagementModule(configurableModules, resources) {
303
346
  modals: [
304
347
  {
305
348
  customId: VALUE_MODAL_ID,
349
+ acknowledgement: "deferred-ephemeral",
306
350
  async execute(context) {
307
351
  const session = await getSession(context);
308
352
  if (!session)
@@ -332,6 +376,7 @@ export function createManagementModule(configurableModules, resources) {
332
376
  function statusButton(customId, enabled) {
333
377
  return {
334
378
  customId,
379
+ acknowledgement: "deferred-update",
335
380
  async execute(context) {
336
381
  const session = await getSession(context);
337
382
  if (!session || !context.serverId)
@@ -379,6 +424,7 @@ export function createManagementModule(configurableModules, resources) {
379
424
  function modulePageButton(customId, change) {
380
425
  return {
381
426
  customId,
427
+ acknowledgement: "deferred-update",
382
428
  async execute(context) {
383
429
  const session = await getSession(context);
384
430
  if (!session)
@@ -396,6 +442,7 @@ export function createManagementModule(configurableModules, resources) {
396
442
  function settingPageButton(customId, change) {
397
443
  return {
398
444
  customId,
445
+ acknowledgement: "deferred-update",
399
446
  async execute(context) {
400
447
  const session = await getSession(context);
401
448
  if (!session)
@@ -412,6 +459,18 @@ export function createManagementModule(configurableModules, resources) {
412
459
  };
413
460
  }
414
461
  }
462
+ function serverOnlyResponse(action) {
463
+ return {
464
+ ephemeral: true,
465
+ componentsV2: {
466
+ text: [
467
+ {
468
+ content: `This command needs a server. Open a server where Helyx is installed, then use \`/helyx modules\` there to ${action}.`,
469
+ },
470
+ ],
471
+ },
472
+ };
473
+ }
415
474
  function restrictedPolicy() {
416
475
  return {
417
476
  everyone: false,
@@ -526,6 +585,20 @@ function configurationResponse(module, state) {
526
585
  ],
527
586
  };
528
587
  }
588
+ function textSettingResponse(setting) {
589
+ return {
590
+ ephemeral: true,
591
+ componentsV2: {
592
+ accentColor: VIOLET,
593
+ text: [{ content: `## ${setting.label}\n${setting.description}` }],
594
+ },
595
+ buttons: [
596
+ button(OPEN_VALUE_MODAL_ID, "Enter value", "primary"),
597
+ button(CONFIGURE_ID, "Back to settings", "secondary"),
598
+ button(CANCEL_ID, "Cancel", "secondary"),
599
+ ],
600
+ };
601
+ }
529
602
  function settingValueResponse(setting) {
530
603
  const clear = setting.required
531
604
  ? []
@@ -602,6 +675,9 @@ function settingValueResponse(setting) {
602
675
  }
603
676
  throw new Error("Selected setting requires a modal");
604
677
  }
678
+ function preparedModalKey(serverId, userId) {
679
+ return `${serverId ?? "direct"}:${userId}`;
680
+ }
605
681
  function settingModal(setting, values) {
606
682
  const current = values[setting.key];
607
683
  return {
@@ -17,6 +17,7 @@ export declare class DiscordModuleLoggingService implements ModuleLoggingService
17
17
  idempotencyKey?: string;
18
18
  }): Promise<{
19
19
  delivered: boolean;
20
+ retryable?: boolean;
20
21
  }>;
21
22
  }
22
23
  //# sourceMappingURL=module-logging.d.ts.map
@@ -19,7 +19,7 @@ export class DiscordModuleLoggingService {
19
19
  async emit(input) {
20
20
  try {
21
21
  if (!this.#loggingInstalled)
22
- return { delivered: false };
22
+ return { delivered: false, retryable: false };
23
23
  const source = this.#modules.get(input.moduleId);
24
24
  const event = source?.logging?.events.find(({ id }) => id === input.eventId);
25
25
  if (!source || !event) {
@@ -27,18 +27,18 @@ export class DiscordModuleLoggingService {
27
27
  }
28
28
  const installations = new InstallationRepository(this.database);
29
29
  if (!(await installations.isModuleEnabled(input.guildId, LOGGING_MODULE_ID))) {
30
- return { delivered: false };
30
+ return { delivered: false, retryable: false };
31
31
  }
32
32
  const [configuration, policy] = await Promise.all([
33
33
  new ModuleConfigurationRepository(this.database).get(input.guildId, LOGGING_MODULE_ID),
34
34
  new ModuleLoggingPolicyRepository(this.database).get(input.guildId, input.moduleId, input.eventId),
35
35
  ]);
36
36
  if (!(policy?.enabled ?? event.defaultEnabled)) {
37
- return { delivered: false };
37
+ return { delivered: false, retryable: false };
38
38
  }
39
39
  const channelId = configuration?.configuration.channelId;
40
40
  if (typeof channelId !== "string" || !/^\d{17,20}$/u.test(channelId)) {
41
- return { delivered: false };
41
+ return { delivered: false, retryable: false };
42
42
  }
43
43
  const summary = normalizeText(input.summary, 500, "summary");
44
44
  const details = (input.details ?? []).map(({ label, value }) => ({
@@ -86,7 +86,7 @@ export class DiscordModuleLoggingService {
86
86
  guildId: input.guildId,
87
87
  error: safeError(error),
88
88
  }, "Module activity log delivery failed");
89
- return { delivered: false };
89
+ return { delivered: false, retryable: true };
90
90
  }
91
91
  }
92
92
  }
@@ -0,0 +1,19 @@
1
+ import { type InstallationService, type ModuleDefinition, type ServiceAccess } from "@helyx/sdk";
2
+ export interface BotModuleStatusMutationResult {
3
+ moduleStatuses: Record<string, "enabled" | "disabled">;
4
+ }
5
+ export declare function mutateDashboardModuleStatus(input: {
6
+ change: {
7
+ guildId: string;
8
+ moduleId: string;
9
+ enabled: boolean;
10
+ actorUserId: string;
11
+ correlationId: string;
12
+ };
13
+ module: ModuleDefinition;
14
+ dashboardModuleIds: readonly string[];
15
+ installations: InstallationService;
16
+ services: ServiceAccess;
17
+ requireModuleAvailable(): Promise<void>;
18
+ }): Promise<BotModuleStatusMutationResult>;
19
+ //# sourceMappingURL=module-status-control.d.ts.map
@@ -0,0 +1,69 @@
1
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { runNestedGuildBarriers } from "./numeric-barriers.js";
3
+ export async function mutateDashboardModuleStatus(input) {
4
+ const { change, module } = input;
5
+ const scopes = Object.entries(module.records ?? {})
6
+ .filter(([, collection]) => (collection.incrementableFields?.length ?? 0) > 0)
7
+ .map(([collection]) => ({
8
+ moduleId: change.moduleId,
9
+ collection,
10
+ guildId: change.guildId,
11
+ }));
12
+ const writeBehind = scopes.length > 0 &&
13
+ input.services.has(HELYX_SERVICE_NAMES.numericWriteBehind)
14
+ ? input.services.get(HELYX_SERVICE_NAMES.numericWriteBehind)
15
+ : null;
16
+ if (writeBehind)
17
+ await input.requireModuleAvailable();
18
+ let mutationResult;
19
+ const setEnabled = async () => {
20
+ mutationResult = await input.installations.setModuleEnabled({
21
+ guildId: change.guildId,
22
+ moduleId: change.moduleId,
23
+ enabled: change.enabled,
24
+ context: {
25
+ actorUserId: change.actorUserId,
26
+ correlationId: change.correlationId,
27
+ source: "dashboard",
28
+ },
29
+ });
30
+ return mutationResult;
31
+ };
32
+ if (!writeBehind) {
33
+ await setEnabled();
34
+ }
35
+ else if (change.enabled) {
36
+ for (const scope of scopes) {
37
+ const suppression = await writeBehind.suppressGuild(scope);
38
+ await writeBehind.releaseGuild({
39
+ ...scope,
40
+ suppressionId: suppression.suppressionId,
41
+ });
42
+ }
43
+ await setEnabled();
44
+ }
45
+ else {
46
+ await runNestedGuildBarriers({
47
+ scopes,
48
+ writeBehind,
49
+ operation: setEnabled,
50
+ });
51
+ for (const scope of scopes)
52
+ await writeBehind.flushGuild(scope);
53
+ for (const scope of scopes)
54
+ await writeBehind.suppressGuild(scope);
55
+ }
56
+ if (!mutationResult)
57
+ throw new Error("Module status mutation completed without a result");
58
+ const dashboardModuleIds = new Set(input.dashboardModuleIds);
59
+ const transitionedIds = new Set([
60
+ change.moduleId,
61
+ ...mutationResult.transitionedModuleIds,
62
+ ]);
63
+ return {
64
+ moduleStatuses: Object.fromEntries([...transitionedIds]
65
+ .filter((moduleId) => dashboardModuleIds.has(moduleId))
66
+ .map((moduleId) => [moduleId, change.enabled ? "enabled" : "disabled"])),
67
+ };
68
+ }
69
+ //# sourceMappingURL=module-status-control.js.map
package/dist/modules.d.ts CHANGED
@@ -12,11 +12,13 @@ export interface ConfiguredModulePackage extends LoadedDefinition {
12
12
  }
13
13
  export declare const secureRandomSelectionWorkerKeyName = "HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY";
14
14
  interface PlatformServiceEnvironment extends Pick<BotEnvironment, "HELYX_DATA_ENCRYPTION_KEY"> {
15
+ DISCORD_CLIENT_ID?: string;
15
16
  HELYX_DASHBOARD_PUBLIC_URL?: string;
16
17
  HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY?: string;
17
18
  }
18
19
  export declare function deriveSecureRandomSelectionKey(encodedDataEncryptionKey: string): Uint8Array;
19
20
  export declare function platformServiceOptions(environment: PlatformServiceEnvironment, modules: readonly ModuleDefinition[]): {
21
+ applicationId?: string;
20
22
  secureRandomSelectionKey?: Uint8Array;
21
23
  dashboardBaseUrl?: string;
22
24
  };
package/dist/modules.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { orderModuleManifests, parseModuleManifest, } from "@helyx/module-manifest";
2
- import { NEXUS_COMMAND_SPECS, NEXUS_ROOT_MODULE_ID } from "@helyx/sdk";
2
+ import { NEXUS_COMMAND_SPECS, NEXUS_ROOT_MODULE_ID, sharedCommandFamilySpec, sharedCommandPathSpec, } from "@helyx/sdk";
3
3
  import { createHmac } from "node:crypto";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { createRequire } from "node:module";
@@ -22,12 +22,16 @@ export function deriveSecureRandomSelectionKey(encodedDataEncryptionKey) {
22
22
  .digest();
23
23
  }
24
24
  export function platformServiceOptions(environment, modules) {
25
+ const application = environment.DISCORD_CLIENT_ID
26
+ ? { applicationId: environment.DISCORD_CLIENT_ID }
27
+ : {};
25
28
  const urls = environment.HELYX_DASHBOARD_PUBLIC_URL
26
29
  ? { dashboardBaseUrl: environment.HELYX_DASHBOARD_PUBLIC_URL }
27
30
  : {};
28
31
  const internalSelectionKey = environment.HELYX_INTERNAL_SECURE_RANDOM_SELECTION_KEY;
29
32
  if (internalSelectionKey) {
30
33
  return {
34
+ ...application,
31
35
  ...urls,
32
36
  secureRandomSelectionKey: decodeSecureRandomSelectionKey(internalSelectionKey, secureRandomSelectionWorkerKeyName),
33
37
  };
@@ -37,9 +41,10 @@ export function platformServiceOptions(environment, modules) {
37
41
  if (modules.some(({ manifest }) => manifest.id === giveawaysModuleId)) {
38
42
  throw new Error("HELYX_DATA_ENCRYPTION_KEY is required when Giveaways is loaded");
39
43
  }
40
- return urls;
44
+ return { ...application, ...urls };
41
45
  }
42
46
  return {
47
+ ...application,
43
48
  ...urls,
44
49
  secureRandomSelectionKey: deriveSecureRandomSelectionKey(encodedKey),
45
50
  };
@@ -103,6 +108,20 @@ export function validateContributionParity(definition) {
103
108
  throw new Error("Module requests an unsupported or duplicate Discord capability");
104
109
  }
105
110
  const executableCommands = (definition.interactions?.commands ?? []).map(({ name }) => name);
111
+ const sharedCommandPaths = new Set();
112
+ for (const command of definition.sharedCommands ?? []) {
113
+ const family = sharedCommandFamilySpec(command.family);
114
+ const spec = sharedCommandPathSpec(command.family, command.path);
115
+ if (!family || !spec || spec.ownerModuleId !== definition.manifest.id) {
116
+ throw new Error(`Module ${definition.manifest.id} cannot contribute shared command ${command.family}.${command.path}`);
117
+ }
118
+ const contributionId = `${command.family}.${command.path}`;
119
+ if (sharedCommandPaths.has(contributionId)) {
120
+ throw new Error(`Duplicate shared command path for ${definition.manifest.id}: ${contributionId}`);
121
+ }
122
+ sharedCommandPaths.add(contributionId);
123
+ }
124
+ executableCommands.push(...sharedCommandPaths);
106
125
  const nexusCommandPaths = new Set();
107
126
  for (const command of definition.nexusCommands ?? []) {
108
127
  const spec = NEXUS_COMMAND_SPECS.find(({ path }) => path === command.path);
@@ -1,13 +1,2 @@
1
- import { type CommandContribution, type ModuleDefinition } from "@helyx/sdk";
2
- export interface CommandRouteOwner {
3
- moduleId: string;
4
- moduleName: string;
5
- }
6
- export interface RegisteredCommand {
7
- moduleId: string;
8
- moduleName: string;
9
- contribution: CommandContribution;
10
- routeOwners?: ReadonlyMap<string, CommandRouteOwner>;
11
- }
12
- export declare function collectCommandRegistrations(modules: readonly ModuleDefinition[]): Map<string, RegisteredCommand>;
1
+ export * from "./shared-commands.js";
13
2
  //# sourceMappingURL=nexus-commands.d.ts.map
@@ -1,100 +1,2 @@
1
- import { NEXUS_COMMAND_SPECS, NEXUS_ROOT_MODULE_ID, } from "@helyx/sdk";
2
- export function collectCommandRegistrations(modules) {
3
- const registrations = modules.flatMap((module) => (module.interactions?.commands ?? []).map((contribution) => ({
4
- moduleId: module.manifest.id,
5
- moduleName: module.manifest.name,
6
- contribution,
7
- })));
8
- const nexusContributions = modules.flatMap((module) => (module.nexusCommands ?? []).map((contribution) => ({
9
- moduleId: module.manifest.id,
10
- moduleName: module.manifest.name,
11
- contribution,
12
- })));
13
- if (nexusContributions.length > 0) {
14
- const roots = registrations.filter(({ contribution }) => contribution.name === "nexus");
15
- if (roots.length !== 1 || roots[0].moduleId !== NEXUS_ROOT_MODULE_ID) {
16
- throw new Error("Nexus Core must be the sole owner of /nexus");
17
- }
18
- const root = roots[0];
19
- if (root.contribution.execute ||
20
- root.contribution.options?.length ||
21
- root.contribution.subcommands?.length ||
22
- root.contribution.subcommandGroups?.length) {
23
- throw new Error("Nexus Core /nexus must be composed from Nexus commands");
24
- }
25
- const composed = composeNexusCommand(root, nexusContributions);
26
- registrations.splice(registrations.indexOf(root), 1, composed);
27
- }
28
- return uniqueRegistrations(registrations);
29
- }
30
- function composeNexusCommand(root, contributions) {
31
- const byPath = new Map(contributions.map((item) => {
32
- const spec = NEXUS_COMMAND_SPECS.find(({ path }) => path === item.contribution.path);
33
- if (!spec || spec.ownerModuleId !== item.moduleId) {
34
- throw new Error(`Module ${item.moduleId} cannot contribute Nexus command ${item.contribution.path}`);
35
- }
36
- return [item.contribution.path, item];
37
- }));
38
- if (byPath.size !== contributions.length) {
39
- throw new Error("Duplicate Nexus command path");
40
- }
41
- const subcommands = [];
42
- const groups = new Map();
43
- const routeOwners = new Map();
44
- for (const spec of NEXUS_COMMAND_SPECS) {
45
- const item = byPath.get(spec.path);
46
- if (!item)
47
- continue;
48
- routeOwners.set(spec.path, {
49
- moduleId: item.moduleId,
50
- moduleName: item.moduleName,
51
- });
52
- const [first, second] = spec.path.split(".");
53
- const command = toSubcommand(item.contribution, second ?? first);
54
- if (!second) {
55
- subcommands.push(command);
56
- continue;
57
- }
58
- const current = groups.get(first);
59
- const group = current ?? {
60
- name: first,
61
- description: "groupDescription" in spec
62
- ? spec.groupDescription
63
- : `Manage Nexus ${first}`,
64
- subcommands: [],
65
- };
66
- groups.set(first, {
67
- ...group,
68
- subcommands: [...group.subcommands, command],
69
- });
70
- }
71
- return {
72
- ...root,
73
- contribution: {
74
- ...root.contribution,
75
- subcommands,
76
- subcommandGroups: [...groups.values()],
77
- },
78
- routeOwners,
79
- };
80
- }
81
- function toSubcommand(contribution, name) {
82
- const subcommand = {
83
- ...contribution,
84
- name,
85
- permissionId: `nexus.${contribution.path}`,
86
- };
87
- delete subcommand.path;
88
- return subcommand;
89
- }
90
- function uniqueRegistrations(items) {
91
- const result = new Map();
92
- for (const item of items) {
93
- const key = item.contribution.name;
94
- if (result.has(key))
95
- throw new Error(`Duplicate command name: ${key}`);
96
- result.set(key, item);
97
- }
98
- return result;
99
- }
1
+ export * from "./shared-commands.js";
100
2
  //# sourceMappingURL=nexus-commands.js.map