@helyx/bot 0.4.37 → 0.4.39

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 (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +22 -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/documentation/README.md +32 -0
  63. package/documentation/examples/.env.example +117 -0
  64. package/documentation/guides/getting-started.md +51 -0
  65. package/documentation/guides/self-hosting.md +40 -0
  66. package/documentation/modules/building-a-module.md +37 -0
  67. package/documentation/modules/publishing-a-module.md +27 -0
  68. package/documentation/modules/testing-a-module.md +46 -0
  69. package/documentation/reference/environment.md +42 -0
  70. package/documentation/runbooks/backup-and-recovery.md +41 -0
  71. package/documentation/runbooks/installation-and-upgrades.md +48 -0
  72. package/package.json +7 -4
  73. package/dist/oauth-module.test.d.ts +0 -2
  74. package/dist/oauth-module.test.js +0 -172
@@ -0,0 +1,443 @@
1
+ import { GuildCommandBudgetExceededError, GuildCommandRepository, InstallationRepository, ModuleAvailabilityRepository, } from "@helyx/database";
2
+ import { randomUUID } from "node:crypto";
3
+ import { diffDryRunGuildCommands, diffGuildCommands, projectGuildCommands, } from "./guild-command-projection.js";
4
+ const DEFAULT_POLL_INTERVAL_MS = 1_000;
5
+ const DEFAULT_LEASE_MS = 45_000;
6
+ const DEFAULT_CLAIM_LIMIT = 4;
7
+ const DEFAULT_CONCURRENCY = 2;
8
+ const DEFAULT_STOP_TIMEOUT_MS = 5_000;
9
+ export class GuildCommandWorker {
10
+ #repository;
11
+ #database;
12
+ #modules;
13
+ #logger;
14
+ #workerId;
15
+ #fencingToken;
16
+ #pollIntervalMs;
17
+ #leaseMs;
18
+ #claimLimit;
19
+ #concurrency;
20
+ #stopTimeoutMs;
21
+ #discord;
22
+ #internalGuildId;
23
+ #controller = new AbortController();
24
+ #active = new Set();
25
+ #timer;
26
+ #claiming;
27
+ #stopping = false;
28
+ constructor(input) {
29
+ if (input.fencingToken < 1n)
30
+ throw new Error("Guild command worker fencing token must be positive");
31
+ this.#database = input.database;
32
+ this.#repository = new GuildCommandRepository(input.database);
33
+ this.#modules = input.modules;
34
+ this.#logger = input.logger;
35
+ this.#workerId = input.workerId ?? `guild-command:${randomUUID()}`;
36
+ this.#fencingToken = input.fencingToken;
37
+ this.#discord = input.discord ?? null;
38
+ this.#internalGuildId = input.internalGuildId;
39
+ this.#pollIntervalMs = boundedInteger(input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 100, 60_000, "poll interval");
40
+ this.#leaseMs = boundedInteger(input.leaseMs ?? DEFAULT_LEASE_MS, 5_000, 300_000, "lease duration");
41
+ this.#claimLimit = boundedInteger(input.claimLimit ?? DEFAULT_CLAIM_LIMIT, 1, 25, "claim limit");
42
+ this.#concurrency = boundedInteger(input.concurrency ?? DEFAULT_CONCURRENCY, 1, 8, "worker concurrency");
43
+ this.#stopTimeoutMs = boundedInteger(input.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, 100, 30_000, "stop timeout");
44
+ }
45
+ start() {
46
+ if (this.#timer || this.#stopping)
47
+ throw new Error("Guild command worker is already started or stopped");
48
+ this.#runSoon();
49
+ this.#timer = setInterval(() => this.#runSoon(), this.#pollIntervalMs);
50
+ this.#timer.unref();
51
+ }
52
+ async runOnce() {
53
+ if (this.#stopping)
54
+ return;
55
+ const capacity = Math.max(0, this.#concurrency - this.#active.size);
56
+ if (capacity === 0)
57
+ return;
58
+ const requests = await this.#repository.claimDue({
59
+ workerId: this.#workerId,
60
+ fencingToken: this.#fencingToken,
61
+ leaseMs: this.#leaseMs,
62
+ limit: Math.min(capacity, this.#claimLimit),
63
+ });
64
+ for (const request of requests) {
65
+ const task = this.#execute(request)
66
+ .catch((error) => {
67
+ this.#logger.error({
68
+ event: "guild_commands.reconcile_failed",
69
+ requestId: request.requestId,
70
+ guildId: request.guildId,
71
+ errorName: error instanceof Error ? error.name : "Error",
72
+ }, "Guild command reconciliation failed");
73
+ })
74
+ .finally(() => this.#active.delete(task));
75
+ this.#active.add(task);
76
+ }
77
+ }
78
+ async stop() {
79
+ if (!this.#stopping) {
80
+ this.#stopping = true;
81
+ if (this.#timer)
82
+ clearInterval(this.#timer);
83
+ this.#timer = undefined;
84
+ this.#controller.abort();
85
+ }
86
+ const active = [...this.#active];
87
+ if (this.#claiming)
88
+ active.push(this.#claiming);
89
+ if (active.length === 0)
90
+ return;
91
+ const drained = await Promise.race([
92
+ Promise.allSettled(active).then(() => true),
93
+ wait(this.#stopTimeoutMs).then(() => false),
94
+ ]);
95
+ if (!drained) {
96
+ this.#logger.warn({ event: "guild_commands.worker_stop_deadline_exceeded" }, "Guild command worker is still draining");
97
+ }
98
+ }
99
+ async getHealth() {
100
+ return {
101
+ ...(await this.#repository.getHealth()),
102
+ mode: this.#discord ? "live" : "dry_run",
103
+ discordWritesEnabled: Boolean(this.#discord),
104
+ workerRunning: Boolean(this.#timer) && !this.#stopping,
105
+ activeCount: this.#active.size,
106
+ };
107
+ }
108
+ #runSoon() {
109
+ if (this.#claiming || this.#stopping)
110
+ return;
111
+ this.#claiming = this.runOnce()
112
+ .catch((error) => {
113
+ this.#logger.error({
114
+ event: "guild_commands.claim_failed",
115
+ errorName: error instanceof Error ? error.name : "Error",
116
+ }, "Guild command claim failed");
117
+ })
118
+ .finally(() => {
119
+ this.#claiming = undefined;
120
+ });
121
+ }
122
+ async #execute(request) {
123
+ if (this.#controller.signal.aborted)
124
+ return;
125
+ let leaseAlive = true;
126
+ const renew = () => {
127
+ if (!leaseAlive || this.#controller.signal.aborted)
128
+ return;
129
+ void this.#repository
130
+ .heartbeat({
131
+ requestId: request.requestId,
132
+ workerId: this.#workerId,
133
+ leaseGeneration: request.leaseGeneration,
134
+ fencingToken: this.#fencingToken,
135
+ leaseMs: this.#leaseMs,
136
+ })
137
+ .then((alive) => {
138
+ leaseAlive = alive;
139
+ })
140
+ .catch(() => {
141
+ leaseAlive = false;
142
+ });
143
+ };
144
+ const heartbeat = setInterval(renew, Math.max(1_000, Math.floor(this.#leaseMs / 3)));
145
+ heartbeat.unref();
146
+ try {
147
+ const [enabled, availability, state] = await Promise.all([
148
+ new InstallationRepository(this.#database).listEnabledModules(request.guildId),
149
+ new ModuleAvailabilityRepository(this.#database).list(),
150
+ this.#repository.getState(request.applicationId, request.guildId),
151
+ ]);
152
+ if (!state || state.desiredRevision !== request.requestedRevision) {
153
+ await this.#repository.completeDryRun({
154
+ request,
155
+ workerId: this.#workerId,
156
+ fencingToken: this.#fencingToken,
157
+ definitionHash: "0".repeat(64),
158
+ rootCount: 0,
159
+ subcommandCount: 0,
160
+ createdCount: 0,
161
+ updatedCount: 0,
162
+ removedCount: 0,
163
+ noChange: false,
164
+ });
165
+ return;
166
+ }
167
+ const projection = projectGuildCommands({
168
+ modules: this.#modules,
169
+ enabledModuleIds: new Set(enabled.map(({ moduleId }) => moduleId)),
170
+ globallyDisabledModuleIds: new Set(availability
171
+ .filter(({ globallyDisabled }) => globallyDisabled)
172
+ .map(({ moduleId }) => moduleId)),
173
+ profile: state.visibilityProfile,
174
+ includeInternalCommands: request.guildId === this.#internalGuildId,
175
+ });
176
+ if (this.#discord?.canWrite(request.guildId) &&
177
+ leaseAlive &&
178
+ !this.#controller.signal.aborted) {
179
+ await this.#executeLive(request, projection);
180
+ return;
181
+ }
182
+ const diff = diffDryRunGuildCommands(projection, state.desiredDefinitionHash);
183
+ const outcome = await this.#completeDryRun(request, projection, diff);
184
+ const noChange = hasNoChanges(diff);
185
+ this.#logger.info({
186
+ event: noChange
187
+ ? "guild_commands.dry_run_no_change"
188
+ : "guild_commands.dry_run_completed",
189
+ requestId: request.requestId,
190
+ guildId: request.guildId,
191
+ revision: request.requestedRevision.toString(),
192
+ rootCount: projection.rootCount,
193
+ subcommandCount: projection.subcommandCount,
194
+ predictedCreates: diff.create.length,
195
+ noChange,
196
+ outcome,
197
+ }, noChange
198
+ ? "Guild command dry run found no changes"
199
+ : "Guild command dry run completed");
200
+ }
201
+ catch (error) {
202
+ if (!this.#controller.signal.aborted && leaseAlive) {
203
+ const safeCode = error instanceof GuildCommandLiveError
204
+ ? error.safeCode
205
+ : "projection_failed";
206
+ if (safeCode === "discord_observation_failed" ||
207
+ safeCode === "discord_rate_limited" ||
208
+ safeCode === "discord_write_ambiguous" ||
209
+ safeCode === "discord_write_mismatch")
210
+ await this.#repository.defer({
211
+ request,
212
+ workerId: this.#workerId,
213
+ fencingToken: this.#fencingToken,
214
+ delayMs: retryDelay(request.attemptCount),
215
+ safeCode,
216
+ });
217
+ else if (safeCode === "creation_budget_exhausted")
218
+ await this.#repository.markBlocked({
219
+ request,
220
+ workerId: this.#workerId,
221
+ fencingToken: this.#fencingToken,
222
+ safeCode,
223
+ });
224
+ else
225
+ await this.#repository.markFailed({
226
+ request,
227
+ workerId: this.#workerId,
228
+ fencingToken: this.#fencingToken,
229
+ safeCode,
230
+ });
231
+ }
232
+ throw error;
233
+ }
234
+ finally {
235
+ clearInterval(heartbeat);
236
+ }
237
+ }
238
+ async #completeDryRun(request, projection, diff) {
239
+ if (diff.conflicts.length > 0)
240
+ throw new Error("Guild command projection produced conflicts");
241
+ if (this.#controller.signal.aborted)
242
+ return "fenced";
243
+ return this.#repository.completeDryRun({
244
+ request,
245
+ workerId: this.#workerId,
246
+ fencingToken: this.#fencingToken,
247
+ definitionHash: projection.definitionHash,
248
+ rootCount: projection.rootCount,
249
+ subcommandCount: projection.subcommandCount,
250
+ createdCount: diff.create.length,
251
+ updatedCount: diff.update.length,
252
+ removedCount: diff.remove.length,
253
+ noChange: hasNoChanges(diff),
254
+ });
255
+ }
256
+ async #executeLive(request, projection) {
257
+ if (!this.#discord)
258
+ throw new GuildCommandLiveError("discord_write_disabled");
259
+ const result = await reconcileLiveGuildCommands({
260
+ repository: this.#repository,
261
+ discord: this.#discord,
262
+ request,
263
+ projection,
264
+ workerId: this.#workerId,
265
+ fencingToken: this.#fencingToken,
266
+ leaseMs: this.#leaseMs,
267
+ verificationDelayMs: 1_000,
268
+ signal: this.#controller.signal,
269
+ });
270
+ if (!result)
271
+ return;
272
+ this.#logger.info({
273
+ event: "guild_commands.live_reconciled",
274
+ requestId: request.requestId,
275
+ guildId: request.guildId,
276
+ revision: request.requestedRevision.toString(),
277
+ rootCount: projection.rootCount,
278
+ createdCount: result.diff.create.length,
279
+ updatedCount: result.diff.update.length,
280
+ removedCount: result.diff.remove.length,
281
+ outcome: result.outcome,
282
+ }, "Guild commands reconciled");
283
+ }
284
+ }
285
+ export async function reconcileLiveGuildCommands(input) {
286
+ let observed;
287
+ try {
288
+ observed = await input.discord.observe(input.request.guildId);
289
+ }
290
+ catch (error) {
291
+ throw new GuildCommandLiveError(isDiscordRateLimit(error)
292
+ ? "discord_rate_limited"
293
+ : "discord_observation_failed");
294
+ }
295
+ const diff = diffGuildCommands(input.projection, observed);
296
+ if (diff.conflicts.length > 0)
297
+ throw new GuildCommandLiveError("discord_command_conflict");
298
+ let ledgerId;
299
+ if (diff.create.length > 0) {
300
+ try {
301
+ ledgerId = (await input.repository.reserveCreations({
302
+ applicationId: input.request.applicationId,
303
+ guildId: input.request.guildId,
304
+ requestId: input.request.requestId,
305
+ correlationId: input.request.correlationId,
306
+ bucket: input.request.budgetClass,
307
+ commandCount: diff.create.length,
308
+ reservationMs: Math.min(300_000, Math.max(60_000, input.leaseMs * 2)),
309
+ reasonCode: "guild_command_create",
310
+ })).ledgerId;
311
+ }
312
+ catch (error) {
313
+ if (error instanceof GuildCommandBudgetExceededError)
314
+ throw new GuildCommandLiveError("creation_budget_exhausted");
315
+ throw error;
316
+ }
317
+ }
318
+ const current = await input.repository.isLeaseCurrent({
319
+ request: input.request,
320
+ workerId: input.workerId,
321
+ fencingToken: input.fencingToken,
322
+ });
323
+ if (!current || input.signal?.aborted) {
324
+ if (ledgerId)
325
+ await input.repository.settleCreation({
326
+ ledgerId,
327
+ state: "released",
328
+ observedReference: "write_not_started",
329
+ });
330
+ return null;
331
+ }
332
+ if (!hasNoChanges(diff)) {
333
+ let written;
334
+ try {
335
+ written = await input.discord.replace(input.request.guildId, input.projection.definitions);
336
+ }
337
+ catch {
338
+ try {
339
+ written = await input.discord.observe(input.request.guildId);
340
+ }
341
+ catch {
342
+ if (ledgerId)
343
+ await input.repository.settleCreation({
344
+ ledgerId,
345
+ state: "committed",
346
+ observedReference: `discord:guild:${input.request.guildId}:write_ambiguous`,
347
+ });
348
+ throw new GuildCommandLiveError("discord_write_ambiguous");
349
+ }
350
+ }
351
+ let verifiedDiff = diffGuildCommands(input.projection, written);
352
+ if (!hasNoChanges(verifiedDiff)) {
353
+ try {
354
+ await waitForPropagation(input.verificationDelayMs ?? 0, input.signal);
355
+ written = await input.discord.observe(input.request.guildId);
356
+ verifiedDiff = diffGuildCommands(input.projection, written);
357
+ }
358
+ catch {
359
+ if (ledgerId)
360
+ await input.repository.settleCreation({
361
+ ledgerId,
362
+ state: "committed",
363
+ observedReference: `discord:guild:${input.request.guildId}:verification_ambiguous`,
364
+ });
365
+ throw new GuildCommandLiveError("discord_write_ambiguous");
366
+ }
367
+ }
368
+ if (!hasNoChanges(verifiedDiff)) {
369
+ if (ledgerId)
370
+ await input.repository.settleCreation({
371
+ ledgerId,
372
+ state: "committed",
373
+ observedReference: `discord:guild:${input.request.guildId}:roots:${written.length}:verification_mismatch`,
374
+ });
375
+ throw new GuildCommandLiveError("discord_write_mismatch");
376
+ }
377
+ }
378
+ const outcome = await input.repository.completeLive({
379
+ request: input.request,
380
+ workerId: input.workerId,
381
+ fencingToken: input.fencingToken,
382
+ definitionHash: input.projection.definitionHash,
383
+ rootCount: input.projection.rootCount,
384
+ subcommandCount: input.projection.subcommandCount,
385
+ observedDefinitionHash: input.projection.definitionHash,
386
+ installedRootCount: input.projection.rootCount,
387
+ createdCount: diff.create.length,
388
+ updatedCount: diff.update.length,
389
+ removedCount: diff.remove.length,
390
+ createdCommands: diff.create.map(({ name }) => `/${name}`),
391
+ updatedCommands: diff.update.map(({ name }) => `/${name}`),
392
+ removedCommands: diff.remove.map(({ name }) => `/${name}`),
393
+ ...(ledgerId ? { ledgerId } : {}),
394
+ observedReference: `discord:guild:${input.request.guildId}:roots:${input.projection.rootCount}`,
395
+ });
396
+ return { outcome, diff };
397
+ }
398
+ class GuildCommandLiveError extends Error {
399
+ safeCode;
400
+ constructor(safeCode) {
401
+ super(safeCode);
402
+ this.safeCode = safeCode;
403
+ this.name = "GuildCommandLiveError";
404
+ }
405
+ }
406
+ function hasNoChanges(diff) {
407
+ return (!diff.observationRequired &&
408
+ diff.create.length === 0 &&
409
+ diff.update.length === 0 &&
410
+ diff.remove.length === 0 &&
411
+ diff.conflicts.length === 0);
412
+ }
413
+ function retryDelay(attemptCount) {
414
+ return Math.min(60_000, 2_000 * 2 ** Math.max(0, attemptCount - 1));
415
+ }
416
+ function isDiscordRateLimit(error) {
417
+ if (!error || typeof error !== "object")
418
+ return false;
419
+ const candidate = error;
420
+ return candidate.status === 429 || candidate.statusCode === 429;
421
+ }
422
+ function boundedInteger(value, minimum, maximum, label) {
423
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
424
+ throw new Error(`${label} must be between ${minimum} and ${maximum}`);
425
+ return value;
426
+ }
427
+ function wait(milliseconds) {
428
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
429
+ }
430
+ function waitForPropagation(milliseconds, signal) {
431
+ if (milliseconds <= 0 || signal?.aborted)
432
+ return Promise.resolve();
433
+ return new Promise((resolve) => {
434
+ const timeout = setTimeout(finish, milliseconds);
435
+ signal?.addEventListener("abort", finish, { once: true });
436
+ function finish() {
437
+ clearTimeout(timeout);
438
+ signal?.removeEventListener("abort", finish);
439
+ resolve();
440
+ }
441
+ });
442
+ }
443
+ //# sourceMappingURL=guild-command-worker.js.map
package/dist/guilds.d.ts CHANGED
@@ -21,5 +21,5 @@ export interface GuildTrackingPreparation {
21
21
  health(): GuildReconciliationHealth;
22
22
  stop(): void;
23
23
  }
24
- export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number): GuildTrackingPreparation;
24
+ export declare function installGuildTracking(client: Client, database: DatabasePool, modules: readonly ModuleDefinition[], logger: InteractionLogger, enabledByDefaultModuleIds?: ReadonlySet<string>, ownership?: GuildShardOwnership, reconciliationIntervalMs?: number, shardCount?: number, applicationId?: string): GuildTrackingPreparation;
25
25
  //# sourceMappingURL=guilds.d.ts.map
package/dist/guilds.js CHANGED
@@ -1,8 +1,8 @@
1
- import { InstallationRepository, } from "@helyx/database";
1
+ import { InstallationRepository, GuildCommandRepository, withTransaction, } from "@helyx/database";
2
2
  import { Events } from "discord.js";
3
3
  const RECONCILIATION_PAGE_SIZE = 250;
4
4
  const DEFAULT_RECONCILIATION_INTERVAL_MS = 15 * 60 * 1_000;
5
- export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount) {
5
+ export function installGuildTracking(client, database, modules, logger, enabledByDefaultModuleIds = new Set(), ownership, reconciliationIntervalMs = DEFAULT_RECONCILIATION_INTERVAL_MS, shardCount, applicationId) {
6
6
  if (!Number.isSafeInteger(reconciliationIntervalMs) ||
7
7
  reconciliationIntervalMs < 60_000)
8
8
  throw new Error("Guild reconciliation interval must be at least one minute");
@@ -19,33 +19,46 @@ export function installGuildTracking(client, database, modules, logger, enabledB
19
19
  rejectedFences: 0,
20
20
  unavailableGuilds: 0,
21
21
  };
22
- async function syncGuild(guild) {
23
- const record = await repository.syncGuild({
24
- guildId: guild.id,
25
- ownerUserId: guild.ownerId,
26
- name: guild.name,
27
- ...(ownership ? { ownership } : {}),
28
- });
29
- if (ownership &&
30
- (record.shardGeneration !== ownership.generation ||
31
- record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
32
- record.shardSupervisorId !== ownership.supervisorId ||
33
- record.shardId !== ownership.shardId ||
34
- record.shardFencingToken !== ownership.fencingToken))
35
- throw new Error("Guild observation was rejected by a newer shard owner");
36
- for (const module of modules) {
37
- await repository.ensureModule({
22
+ async function syncGuild(guild, firstObservation = false) {
23
+ const persist = async (connection) => {
24
+ const installations = new InstallationRepository(connection);
25
+ const record = await installations.syncGuild({
38
26
  guildId: guild.id,
39
- moduleId: module.manifest.id,
40
- moduleVersion: module.manifest.version,
41
- configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
42
- enabledBy: guild.ownerId,
43
- enabledByDefault: enabledByDefaultModuleIds.has(module.manifest.id),
27
+ ownerUserId: guild.ownerId,
28
+ name: guild.name,
29
+ ...(ownership ? { ownership } : {}),
44
30
  });
45
- }
31
+ if (ownership &&
32
+ (record.shardGeneration !== ownership.generation ||
33
+ record.shardDeploymentOrder !== (ownership.deploymentOrder ?? 1n) ||
34
+ record.shardSupervisorId !== ownership.supervisorId ||
35
+ record.shardId !== ownership.shardId ||
36
+ record.shardFencingToken !== ownership.fencingToken))
37
+ throw new Error("Guild observation was rejected by a newer shard owner");
38
+ if (firstObservation && applicationId)
39
+ await new GuildCommandRepository(connection).ensureState({
40
+ applicationId,
41
+ guildId: guild.id,
42
+ setupState: "incomplete",
43
+ });
44
+ for (const module of modules) {
45
+ await installations.ensureModule({
46
+ guildId: guild.id,
47
+ moduleId: module.manifest.id,
48
+ moduleVersion: module.manifest.version,
49
+ configurationSchemaVersion: module.configuration?.schemaVersion ?? 1,
50
+ enabledBy: guild.ownerId,
51
+ enabledByDefault: enabledByDefaultModuleIds.has(module.manifest.id),
52
+ });
53
+ }
54
+ };
55
+ if (firstObservation)
56
+ await withTransaction(database, persist);
57
+ else
58
+ await persist(database);
46
59
  }
47
60
  client.on(Events.GuildCreate, (guild) => {
48
- void syncGuild(guild).catch((error) => {
61
+ void syncGuild(guild, true).catch((error) => {
49
62
  logger.error({
50
63
  event: "guild.sync_failed",
51
64
  guildId: guild.id,
@@ -56,7 +69,9 @@ export function installGuildTracking(client, database, modules, logger, enabledB
56
69
  client.on(Events.GuildDelete, (guild) => {
57
70
  void repository
58
71
  .markGuildRemoved(guild.id, ownership)
59
- .then((changed) => {
72
+ .then(async (changed) => {
73
+ if (changed && applicationId)
74
+ await new GuildCommandRepository(database).cancelGuildWork(applicationId, guild.id);
60
75
  logger.info({
61
76
  event: "guild.removed",
62
77
  outcome: changed ? "changed" : "rejected_or_duplicate",