@mastra/discord 1.2.1 → 1.3.0-alpha.0

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.
package/dist/index.d.cts CHANGED
@@ -265,6 +265,42 @@ interface DiscordInstallation {
265
265
  commandVersions?: Record<string, string>;
266
266
  /** When the installation was created. */
267
267
  installedAt: Date;
268
+ /**
269
+ * The bot's guild ids at the moment a pending invite was issued. Lets
270
+ * {@link DiscordProvider.reconcileInstallation} confirm the install after the
271
+ * operator completes the invite in another tab: exactly one guild appearing
272
+ * since the snapshot is the authorized guild, so the install activates
273
+ * without waiting for a first interaction. `undefined` when the snapshot
274
+ * couldn't be taken (or the bot is in too many guilds to page) — the install
275
+ * then activates only via the first interaction, as before. Cleared on
276
+ * activation.
277
+ */
278
+ guildSnapshot?: string[];
279
+ /**
280
+ * When the invite flow that owns {@link guildSnapshot} was last (re)started.
281
+ * The snapshot is only trusted for a bounded claim window after this moment
282
+ * (see `SNAPSHOT_TTL_MS` in `reconcile.ts`) — an abandoned Connect click must
283
+ * not claim a guild the bot joins days later. Set on every invite-flow
284
+ * `connect()`, including when the fetch that should have produced
285
+ * {@link guildSnapshot} failed. Cleared on activation.
286
+ */
287
+ snapshotAt?: Date;
288
+ /**
289
+ * The Discord application {@link guildSnapshot} was taken against. A baseline
290
+ * from app A says nothing about app B's membership — after `configure()`
291
+ * swaps the bot token, every guild B was already in would look newly
292
+ * authorized. Reconciliation and snapshot reuse require it to match the
293
+ * current app. Cleared on activation.
294
+ */
295
+ snapshotApplicationId?: string;
296
+ /**
297
+ * The guild `connect()` explicitly targeted (`options.guildId`) when the
298
+ * pending invite was issued, if any. Reconciliation only auto-activates this
299
+ * install on that guild — a different guild appearing in the bot's
300
+ * membership contradicts the caller's intent and waits for an authoritative
301
+ * first interaction instead. Cleared on activation.
302
+ */
303
+ targetGuildId?: string;
268
304
  }
269
305
  //#endregion
270
306
  //#region src/discord-provider.d.ts
@@ -372,8 +408,42 @@ declare class DiscordProvider implements ChannelProvider {
372
408
  * duration boundary. Contrast Telegram's clean `stopPolling()`.
373
409
  */
374
410
  disconnect(agentId: string): Promise<void>;
375
- /** List installations (public info only — no secrets). */
411
+ /**
412
+ * List installations (public info only — no secrets). A pure read: pending
413
+ * installs are confirmed through the write-authorized
414
+ * {@link reconcileInstallation} path, never as a listing side effect.
415
+ */
376
416
  listInstallations(): Promise<ChannelInstallationInfo[]>;
417
+ /**
418
+ * Activate an agent's pending install if its invite has since been completed.
419
+ *
420
+ * The invite URL carries no `redirect_uri` (the app would have to allowlist
421
+ * every operator origin), so nothing calls back when the operator authorizes
422
+ * the bot — and a pending install has no adapter, so no interaction or
423
+ * gateway event can reach it either. Instead, each pending install carries a
424
+ * {@link DiscordInstallation.guildSnapshot} taken when the invite was issued;
425
+ * this diffs it against the bot's current membership and activates the
426
+ * install when exactly one new, uncontested guild has appeared (the full
427
+ * claim rules live in {@link planReconcile}). The claim analysis spans every
428
+ * install — other pending invites contest the diff — but only **this**
429
+ * agent's install can activate: the caller holds write access for this agent
430
+ * alone. Anything ambiguous stays pending and retries on the next call, with
431
+ * the first interaction as the authoritative fallback. Best-effort: failures
432
+ * log and resolve rather than throw.
433
+ *
434
+ * Called by the server's reconcile route (write-authorized); Studio invokes
435
+ * it on window focus while a connect is in flight. Returns the (possibly
436
+ * just-activated) installation's public info, or `null` when the agent has
437
+ * no installation.
438
+ *
439
+ * Limitation: the bot-invite URL is per-app, not per-install, so when the
440
+ * operator invites the bot to a new server *for an already-active install*
441
+ * (as `connect()`'s already-connected error suggests) while an invite for
442
+ * another agent is outstanding, nothing distinguishes the two intents — the
443
+ * new guild can be attributed to the pending install. Reconciliation is only
444
+ * reliable while a single invite flow is in progress.
445
+ */
446
+ reconcileInstallation(agentId: string): Promise<ChannelInstallationInfo | null>;
377
447
  /** The full installation for an agent (no secrets live on it), or `null`. */
378
448
  getInstallation(agentId: string): Promise<DiscordInstallation | null>;
379
449
  /** Whether the Discord app is configured (credentials resolvable). */
package/dist/index.d.ts CHANGED
@@ -265,6 +265,42 @@ interface DiscordInstallation {
265
265
  commandVersions?: Record<string, string>;
266
266
  /** When the installation was created. */
267
267
  installedAt: Date;
268
+ /**
269
+ * The bot's guild ids at the moment a pending invite was issued. Lets
270
+ * {@link DiscordProvider.reconcileInstallation} confirm the install after the
271
+ * operator completes the invite in another tab: exactly one guild appearing
272
+ * since the snapshot is the authorized guild, so the install activates
273
+ * without waiting for a first interaction. `undefined` when the snapshot
274
+ * couldn't be taken (or the bot is in too many guilds to page) — the install
275
+ * then activates only via the first interaction, as before. Cleared on
276
+ * activation.
277
+ */
278
+ guildSnapshot?: string[];
279
+ /**
280
+ * When the invite flow that owns {@link guildSnapshot} was last (re)started.
281
+ * The snapshot is only trusted for a bounded claim window after this moment
282
+ * (see `SNAPSHOT_TTL_MS` in `reconcile.ts`) — an abandoned Connect click must
283
+ * not claim a guild the bot joins days later. Set on every invite-flow
284
+ * `connect()`, including when the fetch that should have produced
285
+ * {@link guildSnapshot} failed. Cleared on activation.
286
+ */
287
+ snapshotAt?: Date;
288
+ /**
289
+ * The Discord application {@link guildSnapshot} was taken against. A baseline
290
+ * from app A says nothing about app B's membership — after `configure()`
291
+ * swaps the bot token, every guild B was already in would look newly
292
+ * authorized. Reconciliation and snapshot reuse require it to match the
293
+ * current app. Cleared on activation.
294
+ */
295
+ snapshotApplicationId?: string;
296
+ /**
297
+ * The guild `connect()` explicitly targeted (`options.guildId`) when the
298
+ * pending invite was issued, if any. Reconciliation only auto-activates this
299
+ * install on that guild — a different guild appearing in the bot's
300
+ * membership contradicts the caller's intent and waits for an authoritative
301
+ * first interaction instead. Cleared on activation.
302
+ */
303
+ targetGuildId?: string;
268
304
  }
269
305
  //#endregion
270
306
  //#region src/discord-provider.d.ts
@@ -372,8 +408,42 @@ declare class DiscordProvider implements ChannelProvider {
372
408
  * duration boundary. Contrast Telegram's clean `stopPolling()`.
373
409
  */
374
410
  disconnect(agentId: string): Promise<void>;
375
- /** List installations (public info only — no secrets). */
411
+ /**
412
+ * List installations (public info only — no secrets). A pure read: pending
413
+ * installs are confirmed through the write-authorized
414
+ * {@link reconcileInstallation} path, never as a listing side effect.
415
+ */
376
416
  listInstallations(): Promise<ChannelInstallationInfo[]>;
417
+ /**
418
+ * Activate an agent's pending install if its invite has since been completed.
419
+ *
420
+ * The invite URL carries no `redirect_uri` (the app would have to allowlist
421
+ * every operator origin), so nothing calls back when the operator authorizes
422
+ * the bot — and a pending install has no adapter, so no interaction or
423
+ * gateway event can reach it either. Instead, each pending install carries a
424
+ * {@link DiscordInstallation.guildSnapshot} taken when the invite was issued;
425
+ * this diffs it against the bot's current membership and activates the
426
+ * install when exactly one new, uncontested guild has appeared (the full
427
+ * claim rules live in {@link planReconcile}). The claim analysis spans every
428
+ * install — other pending invites contest the diff — but only **this**
429
+ * agent's install can activate: the caller holds write access for this agent
430
+ * alone. Anything ambiguous stays pending and retries on the next call, with
431
+ * the first interaction as the authoritative fallback. Best-effort: failures
432
+ * log and resolve rather than throw.
433
+ *
434
+ * Called by the server's reconcile route (write-authorized); Studio invokes
435
+ * it on window focus while a connect is in flight. Returns the (possibly
436
+ * just-activated) installation's public info, or `null` when the agent has
437
+ * no installation.
438
+ *
439
+ * Limitation: the bot-invite URL is per-app, not per-install, so when the
440
+ * operator invites the bot to a new server *for an already-active install*
441
+ * (as `connect()`'s already-connected error suggests) while an invite for
442
+ * another agent is outstanding, nothing distinguishes the two intents — the
443
+ * new guild can be attributed to the pending install. Reconciliation is only
444
+ * reliable while a single invite flow is in progress.
445
+ */
446
+ reconcileInstallation(agentId: string): Promise<ChannelInstallationInfo | null>;
377
447
  /** The full installation for an agent (no secrets live on it), or `null`. */
378
448
  getInstallation(agentId: string): Promise<DiscordInstallation | null>;
379
449
  /** Whether the Discord app is configured (credentials resolvable). */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/discord-provider.ts","../src/install-store.ts","../src/discord-client.ts","../src/commands.ts","../src/crypto.ts"],"mappings":";;;;;;;;;;;;cAgBa;;cAGA;;;;;;;;cASA;WACX;WACA;WACA;WACA;WACA;WACA;WACA;WACA;;;;;;;;;cAUW;;cAWA;;;;;;UAOI;;EAEf;;EAEA;;;KAIU;EAAiC;EAAc;;;;;;;;UAQ1C;;EAEf;;EAEA;;EAEA;;;;;;;;;;UAWe;;;;;;;;;EASf,MAAM,QAAQ;;;;;EAKd;;;;;;EAMA,UAAU;;;;;EAKV;;;;;;;EAOA;;;;;EAKA;;;;;;EAMA,WAAW;;;;;;;;;;EAUX;;;;;EAKA,YAAY;;;;;;;;EAQZ;;;;;EAKA;;;;;;EAMA,mBAAmB;;EAEnB,SAAS;;;;;;;;EAQT,YAAY;;;;;;EAMZ;;EASA,WAAW;;EAEX,cAAc;;EAEd,cAAc;;EAEd,QAAQ;;EAER,gBAAgB;;EAEhB,cAAc;;EAEd,oBAAoB;;EAEpB,mBAAmB;;EAEnB,OAAO;;EAEP,cAAc;;;;;;;;EAQd,cAAc;;;;;;;EAOd,QAAQ;;EAER,aAAa,cAAc,+BAA+B;;;UAI3C;;;;;;;EAOf;;EAEA;;;;;EAKA,WAAW;;EAEX;;;;;;;;;UAUe;;EAEf;;EAEA;;;;;EAKA;;;;;EAKA;;EAEA;;EAEA;;EAEA,WAAW;;;;;;EAMX,kBAAkB;;EAElB,aAAa;;;;;;;;;;;iBChQC,4BACd,QAAQ,KAAK;EAEb,WAAW;EACX;EACA,aAAa;EACb;;;;;;;;;;;;;;;;;;;;;;;;;;;cAmCW,2BAA2B;;WAC7B;EAoCT,YAAY,SAAQ;;;;;EAYpB,SAAS,QAAQ;;;;;;;;;EAwCjB,aAAa;;EAoBb,WAAW;;;;;;EAgCL,cAAc;;;;;;;;;EAiCd,UAAU,aAAa,QAAQ,2BAA2B;;;;;;;;;;;;;;EAmE1D,QAAQ,iBAAiB,UAAS,wBAA6B,QAAQ;;;;;;;EA6EvE,cAAc,mBAAmB,kBAAkB,QAAQ;;;;;;;;;;EAmD3D,WAAW,kBAAkB;;EAY7B,qBAAqB,QAAQ;;EAO7B,gBAAgB,kBAAkB,QAAQ;;EAMhD;;EAKA,WAAW,yBAAyB;;;;;cCtdzB;;;;;;;;;;;;;cAgCA;;mBAEQ;mBACA;EAFnB,YACmB,SAAS,iBACT;;EAMb,gBAAgB,QAAQ;;EAaxB,cAAc,KAAK,mBAAmB;;EAUtC,mBAAmB;;EAOnB,WAAW,kBAAkB,QAAQ;;EAMrC,eAAe,oBAAoB,QAAQ;;;;;;;;;;;;;;;;EAoB3C,aAAa,kBAAkB,QAAQ;;EAqBvC,KAAK,cAAc,sBAAsB;;EAKzC,QAAQ,QAAQ;;EAMhB,cAAc,kBAAkB;;;iBA6DxB,mBAAmB,SAAS,sBAAsB;;;;;;;;UC/JjD;;EAEf;;EAEA;;;;;;EAMA;;;;;;;;;;;;iBAmBoB,eACpB,kBACA,gBACA,cACA,qBACA,iBACC,QAAQ;;;;;;;iBA+BW,YACpB,kBACA,sBACC,QAAQ;;;;;;;;;;;;;;iBAyBW,iBACpB,kBACA,iBACA,sBACC;;;;;;;;iBA8EmB,sBACpB,kBACA,uBACA,iBACA,mBAAmB,kBACnB,sBACC;;;;;;;;iBAcmB,uBACpB,kBACA,uBACA,mBAAmB,kBACnB,sBACC;;UAOc;;EAEf;;EAEA;;EAEA;;;;;EAKA;;;;;;;;;iBAUc,eAAe,SAAS;;;;;;;;;cCjQ3B,2BAA2B;;;;;;;;;;iBAaxB,kBAAkB,cAAc,oCAAoC;;;;;;;iBAyBpE,aAAa,mBAAmB;;;;iBC3BhC,YAAY;;iBAKZ,QAAQ,mBAAmB;;iBAU3B,QAAQ,eAAe"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/discord-provider.ts","../src/install-store.ts","../src/discord-client.ts","../src/commands.ts","../src/crypto.ts"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -130,6 +130,37 @@ async function guildHealthCheck(botToken, guildId, apiBaseUrl = DISCORD_API_BASE
130
130
  }
131
131
  throw new Error(`Discord guild lookup failed for "${guildId}": ${await describeError(response)}`);
132
132
  }
133
+ /** Guilds-per-page for `GET /users/@me/guilds` (Discord's documented maximum). */
134
+ const GUILD_PAGE_LIMIT = 200;
135
+ /**
136
+ * Pages we're willing to walk when listing the bot's guilds. The listing feeds
137
+ * the pending-install snapshot diff, whose multiple-new-guilds guard keeps an
138
+ * *incomplete* listing safe (reconcile just stays pending) — so a hard cap is
139
+ * preferable to an unbounded walk on a bot in thousands of guilds.
140
+ */
141
+ const GUILD_PAGE_MAX_PAGES = 10;
142
+ /**
143
+ * The ids of every guild the bot is a member of, via `GET /users/@me/guilds`
144
+ * (Bot auth), following `after`-cursor pagination. Returns `null` instead of a
145
+ * partial listing when the bot is in more guilds than {@link GUILD_PAGE_MAX_PAGES}
146
+ * covers — callers diffing snapshots must not mistake a truncated page for the
147
+ * full membership. Throws on request failure.
148
+ *
149
+ * @see https://discord.com/developers/docs/resources/user#get-current-user-guilds
150
+ */
151
+ async function listBotGuildIds(botToken, apiBaseUrl = DISCORD_API_BASE_URL) {
152
+ const ids = [];
153
+ let after;
154
+ for (let page = 0; page < GUILD_PAGE_MAX_PAGES; page++) {
155
+ const response = await discordRequest(botToken, "GET", `/users/@me/guilds${`?limit=${GUILD_PAGE_LIMIT}${after ? `&after=${after}` : ""}`}`, apiBaseUrl);
156
+ if (!response.ok) throw new Error(`Discord guild listing failed: ${await describeError(response)}`);
157
+ const batch = await response.json();
158
+ for (const guild of batch) ids.push(guild.id);
159
+ if (batch.length < GUILD_PAGE_LIMIT) return ids;
160
+ after = batch[batch.length - 1].id;
161
+ }
162
+ return null;
163
+ }
133
164
  /** Map normalized commands to the Discord bulk-overwrite `CHAT_INPUT` payload. */
134
165
  function toCommandPayload(commands) {
135
166
  return commands.map((c) => ({
@@ -430,7 +461,11 @@ var DiscordInstallStore = class {
430
461
  guildIds: install.guildIds,
431
462
  displayName: install.displayName,
432
463
  commands: install.commands,
433
- commandVersions: install.commandVersions
464
+ commandVersions: install.commandVersions,
465
+ guildSnapshot: install.guildSnapshot,
466
+ snapshotAt: install.snapshotAt?.toISOString(),
467
+ snapshotApplicationId: install.snapshotApplicationId,
468
+ targetGuildId: install.targetGuildId
434
469
  };
435
470
  return {
436
471
  id: install.id,
@@ -454,7 +489,11 @@ var DiscordInstallStore = class {
454
489
  displayName: data.displayName,
455
490
  commands: data.commands,
456
491
  commandVersions: data.commandVersions,
457
- installedAt: record.createdAt
492
+ installedAt: record.createdAt,
493
+ guildSnapshot: data.guildSnapshot,
494
+ snapshotAt: data.snapshotAt ? new Date(data.snapshotAt) : void 0,
495
+ snapshotApplicationId: data.snapshotApplicationId,
496
+ targetGuildId: data.targetGuildId
458
497
  };
459
498
  }
460
499
  };
@@ -469,6 +508,78 @@ function toInstallationInfo(install) {
469
508
  installedAt: install.installedAt
470
509
  };
471
510
  }
511
+ /**
512
+ * Whether an install's invite flow is still *live*: started against the
513
+ * current application and within {@link SNAPSHOT_TTL_MS}. Deliberately
514
+ * independent of whether the baseline fetch succeeded — a live flow with a
515
+ * missing baseline still poisons attribution (see {@link planReconcile}),
516
+ * while rows from before these fields existed (no `snapshotAt`) are treated
517
+ * as not live and fall back to first-interaction activation.
518
+ */
519
+ function isInviteFlowLive(installation, applicationId, now) {
520
+ return installation.status === "pending" && installation.snapshotApplicationId === applicationId && installation.snapshotAt != null && now.getTime() - installation.snapshotAt.getTime() <= 18e5;
521
+ }
522
+ /**
523
+ * Whether a pending install can reconcile at all: a live invite flow (see
524
+ * {@link isInviteFlowLive}) *with* a membership baseline to diff against.
525
+ * Used as a cheap pre-check before the membership fetch.
526
+ */
527
+ function hasReconcilableSnapshot(installation, applicationId, now) {
528
+ return installation.guildSnapshot != null && isInviteFlowLive(installation, applicationId, now);
529
+ }
530
+ /**
531
+ * Decide which pending installs the completed-invite reconcile pass may
532
+ * activate, and on which guild. Pure claim analysis over plain data — all
533
+ * store reads, membership fetches, and activations happen in the caller
534
+ * ({@link DiscordProvider.reconcileInstallation}).
535
+ *
536
+ * Rules, in order:
537
+ * - Only installs with a live invite flow ({@link isInviteFlowLive}) take part.
538
+ * Expired/foreign-app baselines are ignored (first interaction remains their
539
+ * activation path), as are legacy rows without snapshot metadata.
540
+ * - A live flow whose baseline fetch failed at connect time poisons the whole
541
+ * pass: any new guild might be ITS invite landing, so nothing is attributed.
542
+ * - A guild any install already lists is owned and never claimed — it can
543
+ * still look "new" relative to a stale baseline.
544
+ * - An install with an explicit {@link DiscordInstallation.targetGuildId}
545
+ * claims *only* that guild. The narrowing happens before claims are tallied,
546
+ * so a targeted install never contests a guild it would refuse anyway — but
547
+ * its target, if new, still blocks other installs from taking it.
548
+ * - An install activates only on exactly one new guild, claimed by no other
549
+ * live install. Zero or multiple candidates, or a contested guild, stay
550
+ * pending and retry on the next pass.
551
+ */
552
+ function planReconcile(input) {
553
+ const { installations, currentGuildIds, applicationId, now } = input;
554
+ const live = installations.filter((i) => isInviteFlowLive(i, applicationId, now));
555
+ const claimants = live.filter((i) => i.guildSnapshot != null);
556
+ if (claimants.length === 0) return [];
557
+ if (claimants.length !== live.length) return [];
558
+ const owned = new Set(installations.flatMap((i) => i.guildIds));
559
+ const diffs = claimants.map((installation) => {
560
+ const snapshot = new Set(installation.guildSnapshot);
561
+ let newGuilds = currentGuildIds.filter((id) => !snapshot.has(id) && !owned.has(id));
562
+ if (installation.targetGuildId != null) newGuilds = newGuilds.includes(installation.targetGuildId) ? [installation.targetGuildId] : [];
563
+ return {
564
+ installation,
565
+ newGuilds
566
+ };
567
+ });
568
+ const claims = /* @__PURE__ */ new Map();
569
+ for (const { newGuilds } of diffs) for (const id of newGuilds) claims.set(id, (claims.get(id) ?? 0) + 1);
570
+ const activations = [];
571
+ for (const { installation, newGuilds } of diffs) {
572
+ const guildId = newGuilds.length === 1 ? newGuilds[0] : void 0;
573
+ if (guildId == null || claims.get(guildId) > 1) continue;
574
+ activations.push({
575
+ installationId: installation.id,
576
+ agentId: installation.agentId,
577
+ webhookId: installation.webhookId,
578
+ guildId
579
+ });
580
+ }
581
+ return activations;
582
+ }
472
583
  //#endregion
473
584
  //#region src/discord-provider.ts
474
585
  /**
@@ -726,39 +837,69 @@ var DiscordProvider = class {
726
837
  const installedAt = existing?.installedAt ?? /* @__PURE__ */ new Date();
727
838
  const commands = normalizeCommands(options.commands ?? this.#config.commands ?? DEFAULT_COMMANDS);
728
839
  if (options.guildId && await guildHealthCheck(app.botToken, options.guildId, this.#apiBaseUrl())) {
729
- const installation = {
840
+ const guildId = options.guildId;
841
+ return await this.#enqueueWebhookTask(webhookId, async () => {
842
+ const fresh = existing ? await store.getByWebhookId(webhookId) : null;
843
+ if (fresh?.status === "active") return {
844
+ type: "immediate",
845
+ installationId: fresh.id
846
+ };
847
+ const installation = {
848
+ id: installationId,
849
+ agentId,
850
+ webhookId,
851
+ status: "active",
852
+ guildIds: [guildId],
853
+ displayName,
854
+ commands: commands.length ? commands : void 0,
855
+ commandVersions: fresh?.commandVersions ?? existing?.commandVersions,
856
+ installedAt
857
+ };
858
+ await store.save(installation);
859
+ await this.#registerCommands(app, installation, guildId);
860
+ await this.#activateInstallation(installation);
861
+ this.#configured = true;
862
+ await this.#config.onInstall?.(installation);
863
+ return {
864
+ type: "immediate",
865
+ installationId
866
+ };
867
+ });
868
+ }
869
+ const outcome = await this.#enqueueWebhookTask(webhookId, async () => {
870
+ const fresh = existing ? await store.getByWebhookId(webhookId) : null;
871
+ if (fresh?.status === "active") return {
872
+ type: "immediate",
873
+ installationId: fresh.id
874
+ };
875
+ const base = fresh ?? existing;
876
+ const now = /* @__PURE__ */ new Date();
877
+ let guildSnapshot = base?.guildSnapshot != null && base.snapshotApplicationId === app.applicationId && base.snapshotAt != null && now.getTime() - base.snapshotAt.getTime() <= 18e5 ? base.guildSnapshot : void 0;
878
+ if (!guildSnapshot) try {
879
+ guildSnapshot = await listBotGuildIds(app.botToken, this.#apiBaseUrl()) ?? void 0;
880
+ } catch (err) {
881
+ console.warn("[Discord] Could not snapshot guild membership for pending install:", err);
882
+ }
883
+ const pending = {
730
884
  id: installationId,
731
885
  agentId,
732
886
  webhookId,
733
- status: "active",
734
- guildIds: [options.guildId],
887
+ status: "pending",
888
+ guildIds: base?.guildIds ?? [],
735
889
  displayName,
736
890
  commands: commands.length ? commands : void 0,
737
- commandVersions: existing?.commandVersions,
738
- installedAt
891
+ commandVersions: base?.commandVersions,
892
+ installedAt,
893
+ guildSnapshot,
894
+ snapshotAt: now,
895
+ snapshotApplicationId: app.applicationId,
896
+ targetGuildId: options.guildId ?? base?.targetGuildId
739
897
  };
740
- await store.save(installation);
741
- await this.#registerCommands(app, installation, options.guildId);
742
- await this.#activateInstallation(installation);
743
- this.#configured = true;
744
- await this.#config.onInstall?.(installation);
745
- return {
746
- type: "immediate",
747
- installationId
748
- };
749
- }
750
- const pending = {
751
- id: installationId,
752
- agentId,
753
- webhookId,
754
- status: "pending",
755
- guildIds: existing?.guildIds ?? [],
756
- displayName,
757
- commands: commands.length ? commands : void 0,
758
- commandVersions: existing?.commandVersions,
759
- installedAt
760
- };
761
- await store.save(pending);
898
+ await store.save(pending);
899
+ return pending;
900
+ });
901
+ if ("type" in outcome) return outcome;
902
+ const pending = outcome;
762
903
  await this.#registerCommands(app, pending);
763
904
  this.#configured = true;
764
905
  return {
@@ -779,7 +920,22 @@ var DiscordProvider = class {
779
920
  * `webhookId` is unknown.
780
921
  */
781
922
  async activateGuild(webhookId, guildId) {
782
- const next = (this.#activationChains.get(webhookId) ?? Promise.resolve()).catch(() => {}).then(() => this.#activateGuildNow(webhookId, guildId));
923
+ return this.#enqueueWebhookTask(webhookId, () => this.#activateGuildNow(webhookId, guildId));
924
+ }
925
+ #activationChains = /* @__PURE__ */ new Map();
926
+ /**
927
+ * Serialize every mutation of one webhook's installation row — activations,
928
+ * pending-row saves, runtime bring-up, disconnects — so each task's read
929
+ * happens *after* the previous task's save. Without the chain, two
930
+ * concurrent first-interactions can lose a guildId (both read the same row,
931
+ * second save overwrites the first) or fire onInstall twice; a connect's
932
+ * pending save can clobber a row a racing interaction just activated; and a
933
+ * disconnect can land mid-activation and be resurrected by its post-HTTP
934
+ * persistence. The chain entry is dropped once the last queued task settles
935
+ * so keys don't accumulate forever.
936
+ */
937
+ async #enqueueWebhookTask(webhookId, task) {
938
+ const next = (this.#activationChains.get(webhookId) ?? Promise.resolve()).catch(() => {}).then(task);
783
939
  this.#activationChains.set(webhookId, next);
784
940
  try {
785
941
  return await next;
@@ -787,7 +943,6 @@ var DiscordProvider = class {
787
943
  if (this.#activationChains.get(webhookId) === next) this.#activationChains.delete(webhookId);
788
944
  }
789
945
  }
790
- #activationChains = /* @__PURE__ */ new Map();
791
946
  async #activateGuildNow(webhookId, guildId) {
792
947
  const store = await this.#getStore();
793
948
  const installation = await store.getByWebhookId(webhookId);
@@ -797,6 +952,10 @@ var DiscordProvider = class {
797
952
  if (wasActive && known) return installation;
798
953
  if (!known) installation.guildIds.push(guildId);
799
954
  installation.status = "active";
955
+ delete installation.guildSnapshot;
956
+ delete installation.snapshotAt;
957
+ delete installation.snapshotApplicationId;
958
+ delete installation.targetGuildId;
800
959
  await store.save(installation);
801
960
  const app = await store.getAppConfig();
802
961
  if (app) await this.#registerCommands(app, installation, guildId);
@@ -817,14 +976,98 @@ var DiscordProvider = class {
817
976
  const store = await this.#getStore();
818
977
  const existing = await store.getByAgent(agentId);
819
978
  if (!existing) throw new Error(`No Discord installation found for agent "${agentId}"`);
820
- this.#adapters.delete(existing.id);
821
- await store.deleteByAgent(agentId);
979
+ await this.#enqueueWebhookTask(existing.webhookId, async () => {
980
+ this.#adapters.delete(existing.id);
981
+ await store.deleteByAgent(agentId);
982
+ });
822
983
  this.#configured = await store.getAppConfig() != null || (await store.list()).some((i) => i.status === "active");
823
984
  }
824
- /** List installations (public info only — no secrets). */
985
+ /**
986
+ * List installations (public info only — no secrets). A pure read: pending
987
+ * installs are confirmed through the write-authorized
988
+ * {@link reconcileInstallation} path, never as a listing side effect.
989
+ */
825
990
  async listInstallations() {
826
991
  return (await (await this.#getStore()).list()).map(toInstallationInfo);
827
992
  }
993
+ /**
994
+ * Activate an agent's pending install if its invite has since been completed.
995
+ *
996
+ * The invite URL carries no `redirect_uri` (the app would have to allowlist
997
+ * every operator origin), so nothing calls back when the operator authorizes
998
+ * the bot — and a pending install has no adapter, so no interaction or
999
+ * gateway event can reach it either. Instead, each pending install carries a
1000
+ * {@link DiscordInstallation.guildSnapshot} taken when the invite was issued;
1001
+ * this diffs it against the bot's current membership and activates the
1002
+ * install when exactly one new, uncontested guild has appeared (the full
1003
+ * claim rules live in {@link planReconcile}). The claim analysis spans every
1004
+ * install — other pending invites contest the diff — but only **this**
1005
+ * agent's install can activate: the caller holds write access for this agent
1006
+ * alone. Anything ambiguous stays pending and retries on the next call, with
1007
+ * the first interaction as the authoritative fallback. Best-effort: failures
1008
+ * log and resolve rather than throw.
1009
+ *
1010
+ * Called by the server's reconcile route (write-authorized); Studio invokes
1011
+ * it on window focus while a connect is in flight. Returns the (possibly
1012
+ * just-activated) installation's public info, or `null` when the agent has
1013
+ * no installation.
1014
+ *
1015
+ * Limitation: the bot-invite URL is per-app, not per-install, so when the
1016
+ * operator invites the bot to a new server *for an already-active install*
1017
+ * (as `connect()`'s already-connected error suggests) while an invite for
1018
+ * another agent is outstanding, nothing distinguishes the two intents — the
1019
+ * new guild can be attributed to the pending install. Reconciliation is only
1020
+ * reliable while a single invite flow is in progress.
1021
+ */
1022
+ async reconcileInstallation(agentId) {
1023
+ const store = await this.#getStore();
1024
+ const existing = await store.getByAgent(agentId);
1025
+ if (!existing) return null;
1026
+ if (existing.status === "pending") {
1027
+ const inflight = this.#reconcileInflight.get(agentId);
1028
+ if (inflight) await inflight;
1029
+ else {
1030
+ const run = this.#reconcilePendingInstallation(store, agentId).finally(() => {
1031
+ this.#reconcileInflight.delete(agentId);
1032
+ });
1033
+ this.#reconcileInflight.set(agentId, run);
1034
+ await run;
1035
+ }
1036
+ }
1037
+ const fresh = await store.getByAgent(agentId);
1038
+ return fresh ? toInstallationInfo(fresh) : null;
1039
+ }
1040
+ #reconcileInflight = /* @__PURE__ */ new Map();
1041
+ async #reconcilePendingInstallation(store, agentId) {
1042
+ try {
1043
+ const app = await store.getAppConfig();
1044
+ if (!app) return;
1045
+ const target = await store.getByAgent(agentId);
1046
+ if (!target || target.status !== "pending") return;
1047
+ if (!hasReconcilableSnapshot(target, app.applicationId, /* @__PURE__ */ new Date())) return;
1048
+ const current = await listBotGuildIds(app.botToken, this.#apiBaseUrl());
1049
+ if (!current) return;
1050
+ const activation = planReconcile({
1051
+ installations: await store.list(),
1052
+ currentGuildIds: current,
1053
+ applicationId: app.applicationId,
1054
+ now: /* @__PURE__ */ new Date()
1055
+ }).find((a) => a.agentId === agentId);
1056
+ if (!activation) return;
1057
+ await this.#enqueueWebhookTask(activation.webhookId, async () => {
1058
+ const activated = await this.#activateGuildNow(activation.webhookId, activation.guildId);
1059
+ if (!activated || this.#adapters.has(activated.id)) return;
1060
+ try {
1061
+ await this.#activateInstallation(activated);
1062
+ } catch (err) {
1063
+ this.#adapters.delete(activated.id);
1064
+ throw err;
1065
+ }
1066
+ });
1067
+ } catch (err) {
1068
+ console.warn(`[Discord] Pending-install reconcile for agent "${agentId}" failed (will retry):`, err);
1069
+ }
1070
+ }
828
1071
  /** The full installation for an agent (no secrets live on it), or `null`. */
829
1072
  async getInstallation(agentId) {
830
1073
  return await (await this.#getStore()).getByAgent(agentId) ?? null;
@@ -908,7 +1151,14 @@ var DiscordProvider = class {
908
1151
  ...versions,
909
1152
  [key]: hash
910
1153
  };
911
- await (await this.#getStore()).save(installation);
1154
+ const store = await this.#getStore();
1155
+ const fresh = await store.getByWebhookId(installation.webhookId);
1156
+ if (!fresh) return;
1157
+ fresh.commandVersions = {
1158
+ ...fresh.commandVersions,
1159
+ [key]: hash
1160
+ };
1161
+ await store.save(fresh);
912
1162
  } catch (err) {
913
1163
  console.warn(`[Discord] command registration failed (${scope}${guildId ? `, guild ${guildId}` : ""}):`, err);
914
1164
  }