@helyx/bot 0.4.33 → 0.4.36

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 (40) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cloud-management-module.js +7 -1
  3. package/dist/control-server.d.ts +6 -7
  4. package/dist/control-server.js +38 -45
  5. package/dist/dashboard-commands.d.ts +10 -0
  6. package/dist/dashboard-commands.js +47 -0
  7. package/dist/developer-operations-module.js +5 -1
  8. package/dist/discord-guild-health.d.ts +4 -3
  9. package/dist/discord-guild-health.js +6 -3
  10. package/dist/events.js +38 -16
  11. package/dist/index.js +43 -3
  12. package/dist/interactions.d.ts +3 -2
  13. package/dist/interactions.js +132 -45
  14. package/dist/internal-module-manifest.d.ts +13 -0
  15. package/dist/internal-module-manifest.js +14 -0
  16. package/dist/management-module.js +7 -2
  17. package/dist/module-availability-control.d.ts +24 -0
  18. package/dist/module-availability-control.js +61 -0
  19. package/dist/modules.js +24 -1
  20. package/dist/nexus-commands.d.ts +13 -0
  21. package/dist/nexus-commands.js +100 -0
  22. package/dist/numeric-barriers.d.ts +11 -0
  23. package/dist/numeric-barriers.js +14 -0
  24. package/dist/numeric-write-behind-proxy.d.ts +4 -4
  25. package/dist/numeric-write-behind-proxy.js +45 -24
  26. package/dist/numeric-write-behind-redis.d.ts +4 -4
  27. package/dist/numeric-write-behind-redis.js +132 -70
  28. package/dist/oauth-module.js +3 -1
  29. package/dist/permissions-module.js +18 -5
  30. package/dist/renewable-lease.d.ts +16 -0
  31. package/dist/renewable-lease.js +66 -0
  32. package/dist/scheduled-task-runner.js +15 -1
  33. package/dist/shard-router.d.ts +2 -0
  34. package/dist/shard-router.js +5 -0
  35. package/dist/shard-supervisor.d.ts +5 -0
  36. package/dist/shard-supervisor.js +63 -4
  37. package/dist/shard-worker.d.ts +7 -1
  38. package/dist/shard-worker.js +4 -1
  39. package/dist/support-module.js +3 -1
  40. package/package.json +5 -4
@@ -0,0 +1,61 @@
1
+ import { isCustomerSelectableModule } from "@helyx/module-manifest";
2
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
3
+ import { createHash } from "node:crypto";
4
+ export function availabilityCatalogueDefinitions(modules) {
5
+ return modules
6
+ .filter((module) => isCustomerSelectableModule(module.manifest))
7
+ .map(({ manifest }) => ({
8
+ id: manifest.id,
9
+ name: manifest.name,
10
+ version: manifest.version,
11
+ releaseStatus: manifest.releaseStatus,
12
+ }))
13
+ .sort((left, right) => left.name.localeCompare(right.name, "en-GB"));
14
+ }
15
+ export function availabilityCatalogueSnapshot(definitions) {
16
+ const modules = definitions.map((module) => ({ ...module }));
17
+ return {
18
+ catalogueDigest: createHash("sha256")
19
+ .update(JSON.stringify(modules))
20
+ .digest("hex"),
21
+ modules,
22
+ };
23
+ }
24
+ export function availabilityCatalogue(definitions) {
25
+ return Promise.resolve(availabilityCatalogueSnapshot(definitions));
26
+ }
27
+ export function moduleAvailabilityService(services) {
28
+ if (!services.has(HELYX_SERVICE_NAMES.moduleAvailability))
29
+ return undefined;
30
+ const service = services.get(HELYX_SERVICE_NAMES.moduleAvailability);
31
+ return typeof service?.get === "function" &&
32
+ typeof service.isAvailable === "function"
33
+ ? service
34
+ : undefined;
35
+ }
36
+ export function moduleAvailabilityStates(services, moduleIds) {
37
+ const availability = moduleAvailabilityService(services);
38
+ return Promise.all(moduleIds.map((moduleId) => availability
39
+ ? availability.get(moduleId)
40
+ : Promise.resolve({
41
+ moduleId,
42
+ globallyDisabled: false,
43
+ revision: 0n,
44
+ generation: 0n,
45
+ updatedAt: null,
46
+ })));
47
+ }
48
+ export function dashboardModuleStatus(globallyDisabled, moduleId, installation) {
49
+ if (globallyDisabled.has(moduleId))
50
+ return "unavailable";
51
+ if (!installation)
52
+ return "unavailable";
53
+ return installation.enabled ? "enabled" : "disabled";
54
+ }
55
+ export function disabledModuleSummary(states) {
56
+ const ids = states
57
+ .filter(({ globallyDisabled }) => globallyDisabled)
58
+ .map(({ moduleId }) => moduleId);
59
+ return { ids, set: new Set(ids) };
60
+ }
61
+ //# sourceMappingURL=module-availability-control.js.map
package/dist/modules.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { orderModuleManifests, parseModuleManifest, } from "@helyx/module-manifest";
2
+ import { NEXUS_COMMAND_SPECS, NEXUS_ROOT_MODULE_ID } from "@helyx/sdk";
2
3
  import { createHmac } from "node:crypto";
3
4
  import { readFile } from "node:fs/promises";
4
5
  import { createRequire } from "node:module";
@@ -102,6 +103,28 @@ export function validateContributionParity(definition) {
102
103
  throw new Error("Module requests an unsupported or duplicate Discord capability");
103
104
  }
104
105
  const executableCommands = (definition.interactions?.commands ?? []).map(({ name }) => name);
106
+ const nexusCommandPaths = new Set();
107
+ for (const command of definition.nexusCommands ?? []) {
108
+ const spec = NEXUS_COMMAND_SPECS.find(({ path }) => path === command.path);
109
+ if (!spec || spec.ownerModuleId !== definition.manifest.id) {
110
+ throw new Error(`Module ${definition.manifest.id} cannot contribute Nexus command ${command.path}`);
111
+ }
112
+ if (nexusCommandPaths.has(command.path)) {
113
+ throw new Error(`Duplicate Nexus command path for ${definition.manifest.id}: ${command.path}`);
114
+ }
115
+ nexusCommandPaths.add(command.path);
116
+ }
117
+ if (definition.manifest.id !== NEXUS_ROOT_MODULE_ID) {
118
+ executableCommands.push(...[...nexusCommandPaths].map((path) => `nexus.${path}`));
119
+ if (nexusCommandPaths.size > 0 &&
120
+ !definition.manifest.dependencies.some(({ id }) => id === NEXUS_ROOT_MODULE_ID)) {
121
+ throw new Error(`Module ${definition.manifest.id} contributes Nexus commands without depending on Nexus Core`);
122
+ }
123
+ }
124
+ if (definition.manifest.id !== NEXUS_ROOT_MODULE_ID &&
125
+ definition.interactions?.commands?.some(({ name }) => name === "nexus")) {
126
+ throw new Error("Nexus Core must be the sole owner of /nexus");
127
+ }
105
128
  const executableEvents = [
106
129
  ...(definition.events?.privilegedMessageCreate ?? []),
107
130
  ...(definition.events?.guildMessageCreate ?? []),
@@ -135,7 +158,7 @@ export function validateContributionParity(definition) {
135
158
  function validatePrivacyParity(definition) {
136
159
  if (definition.manifest.manifestVersion === "0.1") {
137
160
  if (definition.privacy !== undefined) {
138
- throw new Error("Module executable privacy provider requires manifest version 0.2");
161
+ throw new Error("Module executable privacy provider requires manifest version 0.2 or later");
139
162
  }
140
163
  return;
141
164
  }
@@ -0,0 +1,13 @@
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>;
13
+ //# sourceMappingURL=nexus-commands.d.ts.map
@@ -0,0 +1,100 @@
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
+ }
100
+ //# sourceMappingURL=nexus-commands.js.map
@@ -0,0 +1,11 @@
1
+ import type { NumericWriteBehindScope, NumericWriteBehindService } from "@helyx/sdk";
2
+ type GuildScope = NumericWriteBehindScope & {
3
+ guildId: string;
4
+ };
5
+ export declare function runNestedGuildBarriers(input: {
6
+ scopes: readonly GuildScope[];
7
+ writeBehind: NumericWriteBehindService;
8
+ operation(): Promise<unknown>;
9
+ }): Promise<void>;
10
+ export {};
11
+ //# sourceMappingURL=numeric-barriers.d.ts.map
@@ -0,0 +1,14 @@
1
+ export async function runNestedGuildBarriers(input) {
2
+ const run = async (index, inheritedSignal) => {
3
+ inheritedSignal?.throwIfAborted();
4
+ const scope = input.scopes[index];
5
+ if (!scope) {
6
+ await input.operation();
7
+ inheritedSignal?.throwIfAborted();
8
+ return;
9
+ }
10
+ await input.writeBehind.runGuildBarrier(scope, (signal) => run(index + 1, inheritedSignal ? AbortSignal.any([inheritedSignal, signal]) : signal));
11
+ };
12
+ await run(0);
13
+ }
14
+ //# sourceMappingURL=numeric-barriers.js.map
@@ -19,13 +19,13 @@ export declare class WorkerNumericWriteBehindProxy implements NumericWriteBehind
19
19
  subjectId: string;
20
20
  }): Promise<void>;
21
21
  flushModule(input: NumericWriteBehindScope): Promise<void>;
22
- runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: () => Promise<Result>): Promise<Result>;
22
+ runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
23
23
  runGuildBarrier<Result>(input: NumericWriteBehindScope & {
24
24
  guildId: string;
25
- }, operation: () => Promise<Result>): Promise<Result>;
25
+ }, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
26
26
  runSubjectBarrier<Result>(input: NumericWriteBehindScope & {
27
27
  subjectId: string;
28
- }, operation: () => Promise<Result>): Promise<Result>;
28
+ }, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
29
29
  suppressSubject(input: NumericWriteBehindScope & {
30
30
  subjectId: string;
31
31
  cutoff: Date;
@@ -55,7 +55,7 @@ export declare class WorkerNumericWriteBehindProxy implements NumericWriteBehind
55
55
  clearSubjectCooldown(input: NumericWriteBehindScope & {
56
56
  subjectId: string;
57
57
  }): Promise<void>;
58
- subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch) => void | Promise<void>): () => void;
58
+ subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch, signal: AbortSignal) => void | Promise<void>): () => void;
59
59
  stop(): void;
60
60
  }
61
61
  export declare function encodeNumericIpcValue(input: unknown): unknown;
@@ -1,6 +1,7 @@
1
1
  import {} from "@helyx/sdk";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { assertShardMessageSize, SHARD_CONTROL_TIMEOUT_MS, shardNumericCommittedSchema, shardNumericResponseSchema, } from "./shard-contracts.js";
4
+ import { RenewableLease } from "./renewable-lease.js";
4
5
  export class WorkerNumericWriteBehindProxy {
5
6
  #identity;
6
7
  #pending = new Map();
@@ -157,37 +158,44 @@ export class WorkerNumericWriteBehindProxy {
157
158
  ...(input.guildId ? { guildId: input.guildId } : {}),
158
159
  ...(input.subjectId ? { subjectId: input.subjectId } : {}),
159
160
  }));
160
- let renewalFailure;
161
- const renew = setInterval(() => {
162
- void this.#call({
161
+ const renewal = new RenewableLease({
162
+ intervalMs: 20_000,
163
+ renew: () => this.#call({
163
164
  kind: "barrier.renew",
164
165
  ...scope(input),
165
166
  barrierType,
166
167
  ...(input.guildId ? { guildId: input.guildId } : {}),
167
168
  ...(input.subjectId ? { subjectId: input.subjectId } : {}),
168
169
  barrierId: acquired.barrierId,
169
- }).catch((error) => {
170
- renewalFailure =
171
- error instanceof Error
172
- ? error
173
- : new Error("Numeric barrier renewal failed");
170
+ }).then(() => undefined),
171
+ });
172
+ renewal.start();
173
+ const failures = [];
174
+ let result;
175
+ let completed = false;
176
+ try {
177
+ result = await renewal.run(async (signal) => {
178
+ if (barrierType === "member")
179
+ await this.flushMember(input);
180
+ else if (barrierType === "guild")
181
+ await this.flushGuild(input);
182
+ else
183
+ await this.flushSubject(input);
184
+ signal.throwIfAborted();
185
+ return operation(signal);
174
186
  });
175
- }, 20_000);
176
- renew.unref();
187
+ completed = true;
188
+ }
189
+ catch (error) {
190
+ failures.push(error);
191
+ }
177
192
  try {
178
- if (barrierType === "member")
179
- await this.flushMember(input);
180
- else if (barrierType === "guild")
181
- await this.flushGuild(input);
182
- else
183
- await this.flushSubject(input);
184
- const result = await operation();
185
- if (renewalFailure)
186
- throw renewalFailure;
187
- return result;
193
+ await renewal.stop();
188
194
  }
189
- finally {
190
- clearInterval(renew);
195
+ catch (error) {
196
+ failures.push(error);
197
+ }
198
+ try {
191
199
  await this.#call({
192
200
  kind: "barrier.release",
193
201
  ...scope(input),
@@ -197,6 +205,16 @@ export class WorkerNumericWriteBehindProxy {
197
205
  barrierId: acquired.barrierId,
198
206
  });
199
207
  }
208
+ catch (error) {
209
+ failures.push(error);
210
+ }
211
+ if (failures.length === 1)
212
+ throw failures[0];
213
+ if (failures.length > 1)
214
+ throw new AggregateError(failures, "Numeric barrier cleanup failed");
215
+ if (!completed)
216
+ throw new Error("Numeric barrier operation did not complete");
217
+ return result;
200
218
  }
201
219
  async #handleCommitted(committed) {
202
220
  let response;
@@ -204,8 +222,11 @@ export class WorkerNumericWriteBehindProxy {
204
222
  if (new Date(committed.deadlineAt).getTime() <= Date.now())
205
223
  throw new Error("Numeric committed notification expired");
206
224
  const batch = decodeIpcValue(committed.payload);
207
- for (const subscriber of this.#subscribers.get(scopeKey(committed.scope)) ?? [])
208
- await subscriber(batch);
225
+ const signal = AbortSignal.timeout(Math.max(1, new Date(committed.deadlineAt).getTime() - Date.now()));
226
+ for (const subscriber of this.#subscribers.get(scopeKey(committed.scope)) ?? []) {
227
+ signal.throwIfAborted();
228
+ await subscriber(batch, signal);
229
+ }
209
230
  response = {
210
231
  version: 1,
211
232
  type: "helyx.numeric.committed.response",
@@ -51,13 +51,13 @@ export declare class RedisNumericWriteBehindService implements NumericWriteBehin
51
51
  subjectId: string;
52
52
  }): Promise<void>;
53
53
  flushModule(input: NumericWriteBehindScope): Promise<void>;
54
- runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: () => Promise<Result>): Promise<Result>;
54
+ runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
55
55
  runGuildBarrier<Result>(input: NumericWriteBehindScope & {
56
56
  guildId: string;
57
- }, operation: () => Promise<Result>): Promise<Result>;
57
+ }, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
58
58
  runSubjectBarrier<Result>(input: NumericWriteBehindScope & {
59
59
  subjectId: string;
60
- }, operation: () => Promise<Result>): Promise<Result>;
60
+ }, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
61
61
  suppressSubject(input: NumericWriteBehindScope & {
62
62
  subjectId: string;
63
63
  cutoff: Date;
@@ -87,7 +87,7 @@ export declare class RedisNumericWriteBehindService implements NumericWriteBehin
87
87
  clearSubjectCooldown(input: NumericWriteBehindScope & {
88
88
  subjectId: string;
89
89
  }): Promise<void>;
90
- subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch) => void | Promise<void>): () => void;
90
+ subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch, signal: AbortSignal) => void | Promise<void>): () => void;
91
91
  health(): NumericWriteBehindHealth;
92
92
  acquireBarrier(input: {
93
93
  scope: NumericWriteBehindScope;