@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
@@ -0,0 +1,201 @@
1
+ import { type GuildCommandBudgetClass, type GuildCommandHealth, type GuildCommandHistoryRecord, type GuildCommandRequestRecord, type GuildCommandRequestSource, type GuildCommandStateRecord, type GuildCommandVisibilityProfile } from "@helyx/database";
2
+ import type { ModuleDefinition } from "@helyx/sdk";
3
+ import type { Pool } from "pg";
4
+ import type { GuildCommandWorkerHealth } from "./guild-command-worker.js";
5
+ export interface GuildCommandReadStatus {
6
+ mode: "dry_run" | "live";
7
+ discordWritesEnabled: boolean;
8
+ state: ReturnType<typeof serializeState> | null;
9
+ activeRequest: ReturnType<typeof serializeRequest> | null;
10
+ capacity: {
11
+ customerUsed: number;
12
+ customerLimit: number;
13
+ ownerSystemUsed: number;
14
+ ownerSystemLimit: number;
15
+ windowStartedAt: string;
16
+ };
17
+ }
18
+ export interface GuildCommandReadHandler {
19
+ getStatus(guildId: string): Promise<GuildCommandReadStatus>;
20
+ requestReconciliation(input: {
21
+ guildId: string;
22
+ actorUserId: string;
23
+ correlationId: string;
24
+ expectedRevision: bigint;
25
+ source: Extract<GuildCommandRequestSource, "customer_rebuild" | "owner_repair">;
26
+ budgetClass: Extract<GuildCommandBudgetClass, "customer" | "owner">;
27
+ delayMs: number;
28
+ summary: string;
29
+ }): Promise<GuildCommandReadStatus>;
30
+ refreshStatus(input: {
31
+ guildId: string;
32
+ actorUserId: string;
33
+ correlationId: string;
34
+ }): Promise<GuildCommandReadStatus>;
35
+ changeProfile(input: {
36
+ guildId: string;
37
+ actorUserId: string;
38
+ correlationId: string;
39
+ expectedRevision: bigint;
40
+ profile: GuildCommandVisibilityProfile;
41
+ reasonSummary: string;
42
+ }): Promise<GuildCommandReadStatus>;
43
+ setMigrationHold(input: {
44
+ guildId: string;
45
+ actorUserId: string;
46
+ correlationId: string;
47
+ expectedRevision: bigint;
48
+ enabled: boolean;
49
+ }): Promise<GuildCommandReadStatus>;
50
+ listHistory(input: {
51
+ guildId: string;
52
+ beforeEventId?: bigint;
53
+ since?: Date;
54
+ limit?: number;
55
+ }): Promise<{
56
+ items: Array<ReturnType<typeof serializeHistory>>;
57
+ nextCursor: string | null;
58
+ }>;
59
+ getHealth(): Promise<GuildCommandHealth & {
60
+ mode: "dry_run" | "live";
61
+ workerRunning: boolean;
62
+ activeCount: number;
63
+ discordWritesEnabled: boolean;
64
+ }>;
65
+ previewFirstTimeSetup(moduleIds: string[]): Promise<{
66
+ selectedModuleIds: string[];
67
+ rootCount: number;
68
+ subcommandCount: number;
69
+ commands: Array<{
70
+ name: string;
71
+ subcommands: string[];
72
+ }>;
73
+ }>;
74
+ reconcileModuleAvailability(input: {
75
+ moduleId: string;
76
+ correlationId: string;
77
+ }): Promise<{
78
+ scheduledGuildCount: number;
79
+ }>;
80
+ }
81
+ export declare class GuildCommandReadService implements GuildCommandReadHandler {
82
+ #private;
83
+ private readonly database;
84
+ private readonly applicationId;
85
+ private readonly workerHealth?;
86
+ private readonly discordToken?;
87
+ private readonly guildCommandWritesEnabled;
88
+ private readonly writeGuildIds;
89
+ private readonly writeAllGuilds;
90
+ private readonly modules;
91
+ constructor(database: Pool, applicationId: string, workerHealth?: (() => Promise<GuildCommandWorkerHealth> | undefined) | undefined, discordToken?: string | undefined, guildCommandWritesEnabled?: boolean, writeGuildIds?: readonly string[], writeAllGuilds?: boolean, modules?: readonly ModuleDefinition[]);
92
+ previewFirstTimeSetup(moduleIds: string[]): Promise<{
93
+ selectedModuleIds: string[];
94
+ rootCount: number;
95
+ subcommandCount: number;
96
+ commands: {
97
+ name: string;
98
+ subcommands: string[];
99
+ }[];
100
+ }>;
101
+ getStatus(guildId: string): Promise<GuildCommandReadStatus>;
102
+ requestReconciliation(input: {
103
+ guildId: string;
104
+ actorUserId: string;
105
+ correlationId: string;
106
+ expectedRevision: bigint;
107
+ source: "customer_rebuild" | "owner_repair";
108
+ budgetClass: "customer" | "owner";
109
+ delayMs: number;
110
+ summary: string;
111
+ }): Promise<GuildCommandReadStatus>;
112
+ reconcileModuleAvailability(input: {
113
+ moduleId: string;
114
+ correlationId: string;
115
+ }): Promise<{
116
+ scheduledGuildCount: number;
117
+ }>;
118
+ refreshStatus(input: {
119
+ guildId: string;
120
+ actorUserId: string;
121
+ correlationId: string;
122
+ }): Promise<GuildCommandReadStatus>;
123
+ changeProfile(input: {
124
+ guildId: string;
125
+ actorUserId: string;
126
+ correlationId: string;
127
+ expectedRevision: bigint;
128
+ profile: GuildCommandVisibilityProfile;
129
+ reasonSummary: string;
130
+ }): Promise<GuildCommandReadStatus>;
131
+ setMigrationHold(input: {
132
+ guildId: string;
133
+ actorUserId: string;
134
+ correlationId: string;
135
+ expectedRevision: bigint;
136
+ enabled: boolean;
137
+ }): Promise<GuildCommandReadStatus>;
138
+ listHistory(input: {
139
+ guildId: string;
140
+ beforeEventId?: bigint;
141
+ since?: Date;
142
+ limit?: number;
143
+ }): Promise<{
144
+ items: Array<ReturnType<typeof serializeHistory>>;
145
+ nextCursor: string | null;
146
+ }>;
147
+ getHealth(): Promise<GuildCommandWorkerHealth>;
148
+ }
149
+ declare function serializeState(state: GuildCommandStateRecord): {
150
+ applicationId: string;
151
+ guildId: string;
152
+ visibilityProfile: GuildCommandVisibilityProfile;
153
+ desiredDefinitionHash: string | null;
154
+ desiredRootCount: number;
155
+ desiredSubcommandCount: number;
156
+ lastObservedDefinitionHash: string | null;
157
+ installedRootCount: number;
158
+ state: import("@helyx/database").GuildCommandState;
159
+ desiredRevision: string;
160
+ scheduledAt: string | null;
161
+ startedAt: string | null;
162
+ lastAttemptedAt: string | null;
163
+ lastSuccessfulAt: string | null;
164
+ lastSafeCode: string | null;
165
+ setupState: import("@helyx/database").GuildCommandSetupState;
166
+ updatedAt: string;
167
+ };
168
+ declare function serializeRequest(request: GuildCommandRequestRecord): {
169
+ requestId: string;
170
+ source: GuildCommandRequestSource;
171
+ requestedRevision: string;
172
+ notBefore: string;
173
+ budgetClass: GuildCommandBudgetClass;
174
+ status: import("@helyx/database").GuildCommandRequestStatus;
175
+ attemptCount: number;
176
+ attemptLimit: number;
177
+ correlationId: string;
178
+ lastSafeCode: string | null;
179
+ createdAt: string;
180
+ updatedAt: string;
181
+ };
182
+ declare function serializeHistory(history: GuildCommandHistoryRecord): {
183
+ eventId: string;
184
+ eventType: string;
185
+ summary: string;
186
+ actorUserId: string | null;
187
+ moduleId: string | null;
188
+ requestId: string | null;
189
+ correlationId: string;
190
+ createdCount: number;
191
+ updatedCount: number;
192
+ removedCount: number;
193
+ createdCommands: string[];
194
+ updatedCommands: string[];
195
+ removedCommands: string[];
196
+ creationsUsed: number;
197
+ resultState: import("@helyx/database").GuildCommandState | null;
198
+ createdAt: string;
199
+ };
200
+ export {};
201
+ //# sourceMappingURL=guild-command-read-service.d.ts.map
@@ -0,0 +1,272 @@
1
+ import { GuildCommandRepository, GuildCommandRevisionConflictError, InstallationRepository, } from "@helyx/database";
2
+ import { isCustomerSelectableModule } from "@helyx/module-manifest";
3
+ import { createHash } from "node:crypto";
4
+ import { REST, Routes } from "discord.js";
5
+ import { guildCommandDefinitionHash, projectGuildCommands, } from "./guild-command-projection.js";
6
+ export class GuildCommandReadService {
7
+ database;
8
+ applicationId;
9
+ workerHealth;
10
+ discordToken;
11
+ guildCommandWritesEnabled;
12
+ writeGuildIds;
13
+ writeAllGuilds;
14
+ modules;
15
+ #repository;
16
+ constructor(database, applicationId, workerHealth, discordToken, guildCommandWritesEnabled = false, writeGuildIds = [], writeAllGuilds = false, modules = []) {
17
+ this.database = database;
18
+ this.applicationId = applicationId;
19
+ this.workerHealth = workerHealth;
20
+ this.discordToken = discordToken;
21
+ this.guildCommandWritesEnabled = guildCommandWritesEnabled;
22
+ this.writeGuildIds = writeGuildIds;
23
+ this.writeAllGuilds = writeAllGuilds;
24
+ this.modules = modules;
25
+ this.#repository = new GuildCommandRepository(this.database);
26
+ }
27
+ previewFirstTimeSetup(moduleIds) {
28
+ const requested = new Set(moduleIds);
29
+ const byId = new Map(this.modules.map((module) => [module.manifest.id, module]));
30
+ for (const moduleId of requested) {
31
+ const module = byId.get(moduleId);
32
+ if (!module ||
33
+ !isCustomerSelectableModule(module.manifest) ||
34
+ !["live", "beta"].includes(module.manifest.releaseStatus))
35
+ throw new Error("Command preview contains an unavailable module");
36
+ }
37
+ const projection = projectGuildCommands({
38
+ modules: this.modules,
39
+ enabledModuleIds: requested,
40
+ profile: "enabled_only",
41
+ });
42
+ return Promise.resolve({
43
+ selectedModuleIds: [...projection.selectedModuleIds],
44
+ rootCount: projection.rootCount,
45
+ subcommandCount: projection.subcommandCount,
46
+ commands: projection.definitions.map((command) => ({
47
+ name: `/${command.name}`,
48
+ subcommands: command.executablePaths.map((path) => path.replaceAll(".", " ")),
49
+ })),
50
+ });
51
+ }
52
+ async getStatus(guildId) {
53
+ // This is a display snapshot, not a mutation boundary. Command mutations
54
+ // retain their transactional revision, lease, fencing and ledger checks.
55
+ const [existingState, activeRequest, capacity] = await Promise.all([
56
+ this.#repository.getState(this.applicationId, guildId),
57
+ this.#repository.getActiveRequest(this.applicationId, guildId),
58
+ this.#repository.getCapacity(this.applicationId, guildId),
59
+ ]);
60
+ const state = existingState ??
61
+ (await this.#repository.ensureState({
62
+ applicationId: this.applicationId,
63
+ guildId,
64
+ setupState: "incomplete",
65
+ }));
66
+ return {
67
+ mode: this.guildCommandWritesEnabled &&
68
+ (this.writeAllGuilds || this.writeGuildIds.includes(guildId))
69
+ ? "live"
70
+ : "dry_run",
71
+ discordWritesEnabled: this.guildCommandWritesEnabled &&
72
+ (this.writeAllGuilds || this.writeGuildIds.includes(guildId)),
73
+ state: state ? serializeState(state) : null,
74
+ activeRequest: activeRequest ? serializeRequest(activeRequest) : null,
75
+ capacity: {
76
+ ...capacity,
77
+ windowStartedAt: capacity.windowStartedAt.toISOString(),
78
+ },
79
+ };
80
+ }
81
+ async requestReconciliation(input) {
82
+ const idempotencyHash = createHash("sha256")
83
+ .update(`${input.source}:${input.correlationId}`)
84
+ .digest("hex");
85
+ await this.#repository.schedule({
86
+ applicationId: this.applicationId,
87
+ guildId: input.guildId,
88
+ idempotencyKey: `${input.source}:${idempotencyHash}`,
89
+ source: input.source,
90
+ budgetClass: input.budgetClass,
91
+ correlationId: input.correlationId,
92
+ actorUserId: input.actorUserId,
93
+ summary: input.summary,
94
+ delayMs: input.delayMs,
95
+ expectedRevision: input.expectedRevision,
96
+ });
97
+ return this.getStatus(input.guildId);
98
+ }
99
+ async reconcileModuleAvailability(input) {
100
+ const installations = new InstallationRepository(this.database);
101
+ let cursor;
102
+ let scheduledGuildCount = 0;
103
+ do {
104
+ const page = await installations.listActiveGuildIdsWithEnabledModule({
105
+ moduleId: input.moduleId,
106
+ ...(cursor ? { cursor } : {}),
107
+ limit: 500,
108
+ });
109
+ for (let offset = 0; offset < page.guildIds.length; offset += 25) {
110
+ const guildIds = page.guildIds.slice(offset, offset + 25);
111
+ await Promise.all(guildIds.map((guildId) => this.#repository.schedule({
112
+ applicationId: this.applicationId,
113
+ guildId,
114
+ idempotencyKey: `availability:${input.moduleId}:${input.correlationId}`,
115
+ source: "release_migration",
116
+ budgetClass: "system",
117
+ correlationId: input.correlationId,
118
+ moduleId: input.moduleId,
119
+ summary: `Global availability changed for ${input.moduleId}.`,
120
+ delayMs: 0,
121
+ })));
122
+ scheduledGuildCount += guildIds.length;
123
+ }
124
+ cursor = page.nextCursor ?? undefined;
125
+ } while (cursor);
126
+ return { scheduledGuildCount };
127
+ }
128
+ async refreshStatus(input) {
129
+ if (!this.discordToken)
130
+ throw new Error("Discord command reads are unavailable");
131
+ const baseline = (await this.#repository.getState(this.applicationId, input.guildId)) ??
132
+ (await this.#repository.ensureState({
133
+ applicationId: this.applicationId,
134
+ guildId: input.guildId,
135
+ setupState: "incomplete",
136
+ }));
137
+ const observed = await new REST({ version: "10" })
138
+ .setToken(this.discordToken)
139
+ .get(Routes.applicationGuildCommands(this.applicationId, input.guildId));
140
+ if (!Array.isArray(observed))
141
+ throw new Error("Discord returned an invalid command collection");
142
+ try {
143
+ await this.#repository.recordStatusRefresh({
144
+ applicationId: this.applicationId,
145
+ guildId: input.guildId,
146
+ installedRootCount: observed.length,
147
+ observedDefinitionHash: guildCommandDefinitionHash(observed),
148
+ expectedRevision: baseline.desiredRevision,
149
+ expectedUpdatedAt: baseline.updatedAt,
150
+ actorUserId: input.actorUserId,
151
+ correlationId: input.correlationId,
152
+ });
153
+ }
154
+ catch (error) {
155
+ if (!(error instanceof GuildCommandRevisionConflictError))
156
+ throw error;
157
+ }
158
+ return this.getStatus(input.guildId);
159
+ }
160
+ async changeProfile(input) {
161
+ const idempotencyHash = createHash("sha256")
162
+ .update(`profile:${input.correlationId}`)
163
+ .digest("hex");
164
+ await this.#repository.changeVisibilityProfile({
165
+ applicationId: this.applicationId,
166
+ guildId: input.guildId,
167
+ profile: input.profile,
168
+ expectedRevision: input.expectedRevision,
169
+ idempotencyKey: `profile:${idempotencyHash}`,
170
+ actorUserId: input.actorUserId,
171
+ correlationId: input.correlationId,
172
+ summary: input.reasonSummary,
173
+ });
174
+ return this.getStatus(input.guildId);
175
+ }
176
+ async setMigrationHold(input) {
177
+ await this.#repository.setMigrationHold({
178
+ applicationId: this.applicationId,
179
+ ...input,
180
+ });
181
+ return this.getStatus(input.guildId);
182
+ }
183
+ async listHistory(input) {
184
+ const page = await this.#repository.listHistory({
185
+ applicationId: this.applicationId,
186
+ guildId: input.guildId,
187
+ ...(input.beforeEventId === undefined
188
+ ? {}
189
+ : { beforeEventId: input.beforeEventId }),
190
+ ...(input.since === undefined ? {} : { since: input.since }),
191
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
192
+ });
193
+ return {
194
+ items: page.items.map(serializeHistory),
195
+ nextCursor: page.nextCursor?.toString() ?? null,
196
+ };
197
+ }
198
+ async getHealth() {
199
+ const workerHealth = this.workerHealth?.();
200
+ const health = workerHealth
201
+ ? await workerHealth
202
+ : {
203
+ ...(await this.#repository.getHealth()),
204
+ mode: "dry_run",
205
+ discordWritesEnabled: false,
206
+ workerRunning: false,
207
+ activeCount: 0,
208
+ };
209
+ return health;
210
+ }
211
+ }
212
+ function serializeState(state) {
213
+ return {
214
+ applicationId: state.applicationId,
215
+ guildId: state.guildId,
216
+ visibilityProfile: state.visibilityProfile,
217
+ desiredDefinitionHash: state.desiredDefinitionHash,
218
+ desiredRootCount: state.desiredRootCount,
219
+ desiredSubcommandCount: state.desiredSubcommandCount,
220
+ lastObservedDefinitionHash: state.lastObservedDefinitionHash,
221
+ installedRootCount: state.installedRootCount,
222
+ state: state.state,
223
+ desiredRevision: state.desiredRevision.toString(),
224
+ scheduledAt: iso(state.scheduledAt),
225
+ startedAt: iso(state.startedAt),
226
+ lastAttemptedAt: iso(state.lastAttemptedAt),
227
+ lastSuccessfulAt: iso(state.lastSuccessfulAt),
228
+ lastSafeCode: state.lastSafeCode,
229
+ setupState: state.setupState,
230
+ updatedAt: state.updatedAt.toISOString(),
231
+ };
232
+ }
233
+ function serializeRequest(request) {
234
+ return {
235
+ requestId: request.requestId,
236
+ source: request.source,
237
+ requestedRevision: request.requestedRevision.toString(),
238
+ notBefore: request.notBefore.toISOString(),
239
+ budgetClass: request.budgetClass,
240
+ status: request.status,
241
+ attemptCount: request.attemptCount,
242
+ attemptLimit: request.attemptLimit,
243
+ correlationId: request.correlationId,
244
+ lastSafeCode: request.lastSafeCode,
245
+ createdAt: request.createdAt.toISOString(),
246
+ updatedAt: request.updatedAt.toISOString(),
247
+ };
248
+ }
249
+ function serializeHistory(history) {
250
+ return {
251
+ eventId: history.eventId.toString(),
252
+ eventType: history.eventType,
253
+ summary: history.summary,
254
+ actorUserId: history.actorUserId,
255
+ moduleId: history.moduleId,
256
+ requestId: history.requestId,
257
+ correlationId: history.correlationId,
258
+ createdCount: history.createdCount,
259
+ updatedCount: history.updatedCount,
260
+ removedCount: history.removedCount,
261
+ createdCommands: history.createdCommands,
262
+ updatedCommands: history.updatedCommands,
263
+ removedCommands: history.removedCommands,
264
+ creationsUsed: history.creationsUsed,
265
+ resultState: history.resultState,
266
+ createdAt: history.createdAt.toISOString(),
267
+ };
268
+ }
269
+ function iso(value) {
270
+ return value?.toISOString() ?? null;
271
+ }
272
+ //# sourceMappingURL=guild-command-read-service.js.map
@@ -0,0 +1,19 @@
1
+ import { GuildCommandRepository, InstallationRepository, RuntimeStateRepository, type DatabasePool } from "@helyx/database";
2
+ type ReleaseInstallationRepository = Pick<InstallationRepository, "listActiveGuilds">;
3
+ type ReleaseCommandRepository = Pick<GuildCommandRepository, "getState" | "schedule">;
4
+ type ReleaseRuntimeRepository = Pick<RuntimeStateRepository, "get" | "compareAndSet">;
5
+ export declare function scheduleGuildCommandRelease(input: {
6
+ database: DatabasePool;
7
+ applicationId: string;
8
+ releaseId: string;
9
+ staggerMs?: number;
10
+ installations?: ReleaseInstallationRepository;
11
+ commands?: ReleaseCommandRepository;
12
+ runtime?: ReleaseRuntimeRepository;
13
+ }): Promise<{
14
+ scheduled: number;
15
+ skippedIncomplete: number;
16
+ replay: boolean;
17
+ }>;
18
+ export {};
19
+ //# sourceMappingURL=guild-command-release.d.ts.map
@@ -0,0 +1,47 @@
1
+ import { GuildCommandRepository, InstallationRepository, RuntimeStateRepository, } from "@helyx/database";
2
+ const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
3
+ const DEFAULT_STAGGER_MS = 600;
4
+ const MAX_STAGGER_MS = 10 * 60 * 1_000;
5
+ export async function scheduleGuildCommandRelease(input) {
6
+ if (!RELEASE_ID.test(input.releaseId))
7
+ throw new Error("Guild command release ID is invalid");
8
+ const markerKey = `guild-commands:release:${input.applicationId}:${input.releaseId}`;
9
+ const installations = input.installations ?? new InstallationRepository(input.database);
10
+ const commands = input.commands ?? new GuildCommandRepository(input.database);
11
+ const runtime = input.runtime ?? new RuntimeStateRepository(input.database);
12
+ if (await runtime.get(markerKey))
13
+ return { scheduled: 0, skippedIncomplete: 0, replay: true };
14
+ const guilds = await installations.listActiveGuilds();
15
+ const staggerMs = Math.max(0, input.staggerMs ?? DEFAULT_STAGGER_MS);
16
+ let scheduled = 0;
17
+ let skippedIncomplete = 0;
18
+ for (const guild of guilds) {
19
+ const state = await commands.getState(input.applicationId, guild.guildId);
20
+ if (state?.setupState !== "complete") {
21
+ skippedIncomplete += 1;
22
+ continue;
23
+ }
24
+ await commands.schedule({
25
+ applicationId: input.applicationId,
26
+ guildId: guild.guildId,
27
+ idempotencyKey: `release:${input.releaseId}:${guild.guildId}`,
28
+ source: "release_migration",
29
+ budgetClass: "system",
30
+ correlationId: `release:${input.releaseId}:${guild.guildId}`,
31
+ summary: "Helyx scheduled this server's command release update.",
32
+ delayMs: Math.min(scheduled * staggerMs, MAX_STAGGER_MS),
33
+ });
34
+ scheduled += 1;
35
+ }
36
+ await runtime.compareAndSet({
37
+ key: markerKey,
38
+ expectedVersion: null,
39
+ value: {
40
+ releaseId: input.releaseId,
41
+ scheduledGuilds: scheduled,
42
+ completedAt: new Date().toISOString(),
43
+ },
44
+ });
45
+ return { scheduled, skippedIncomplete, replay: false };
46
+ }
47
+ //# sourceMappingURL=guild-command-release.js.map
@@ -0,0 +1,49 @@
1
+ import { GuildCommandRepository, type GuildCommandHealth, type GuildCommandRequestRecord } from "@helyx/database";
2
+ import type { Logger, ModuleDefinition } from "@helyx/sdk";
3
+ import type { Pool } from "pg";
4
+ import { type GuildCommandDiff, type GuildCommandProjection } from "./guild-command-projection.js";
5
+ import type { GuildCommandDiscordClient } from "./guild-command-discord-client.js";
6
+ export interface GuildCommandWorkerHealth extends GuildCommandHealth {
7
+ mode: "dry_run" | "live";
8
+ discordWritesEnabled: boolean;
9
+ workerRunning: boolean;
10
+ activeCount: number;
11
+ }
12
+ export declare class GuildCommandWorker {
13
+ #private;
14
+ constructor(input: {
15
+ database: Pool;
16
+ modules: readonly ModuleDefinition[];
17
+ logger: Logger;
18
+ workerId?: string;
19
+ fencingToken: bigint;
20
+ discord?: Pick<GuildCommandDiscordClient, "canWrite" | "observe" | "replace">;
21
+ internalGuildId?: string;
22
+ pollIntervalMs?: number;
23
+ leaseMs?: number;
24
+ claimLimit?: number;
25
+ concurrency?: number;
26
+ stopTimeoutMs?: number;
27
+ });
28
+ start(): void;
29
+ runOnce(): Promise<void>;
30
+ stop(): Promise<void>;
31
+ getHealth(): Promise<GuildCommandWorkerHealth>;
32
+ }
33
+ type LiveRepository = Pick<GuildCommandRepository, "completeLive" | "isLeaseCurrent" | "reserveCreations" | "settleCreation">;
34
+ export declare function reconcileLiveGuildCommands(input: {
35
+ repository: LiveRepository;
36
+ discord: Pick<GuildCommandDiscordClient, "observe" | "replace">;
37
+ request: GuildCommandRequestRecord;
38
+ projection: GuildCommandProjection;
39
+ workerId: string;
40
+ fencingToken: bigint;
41
+ leaseMs: number;
42
+ verificationDelayMs?: number;
43
+ signal?: AbortSignal;
44
+ }): Promise<{
45
+ outcome: "completed" | "stale" | "fenced";
46
+ diff: GuildCommandDiff;
47
+ } | null>;
48
+ export {};
49
+ //# sourceMappingURL=guild-command-worker.d.ts.map