@mastra/discord 1.2.1 → 1.3.0-alpha.1

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.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) => ({
@@ -269,6 +300,88 @@ function hashCommands(commands) {
269
300
  }));
270
301
  return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
271
302
  }
303
+ /**
304
+ * A session that ends sooner than this is treated as a failed connect. The
305
+ * adapter resolves login failures as if the session ended cleanly, so elapsed
306
+ * time is the only signal that distinguishes "ran fine until the duration
307
+ * elapsed / Discord closed us" from "IDENTIFY was rejected instantly".
308
+ */
309
+ const HEALTHY_SESSION_MS = 60 * 1e3;
310
+ /** First retry delay after a failed connect; doubles up to {@link MAX_BACKOFF_MS}. */
311
+ const INITIAL_BACKOFF_MS = 5 * 1e3;
312
+ /** Retry delay ceiling. */
313
+ const MAX_BACKOFF_MS = 900 * 1e3;
314
+ /** Pause between healthy sessions — a scheduled rollover, not a failure. */
315
+ const RECONNECT_DELAY_MS = 1e3;
316
+ /**
317
+ * Run Gateway sessions back-to-back until `signal` aborts or the bot token is
318
+ * confirmed revoked. Resolves when the loop stops; never rejects.
319
+ */
320
+ async function runGatewayLoop(deps, signal) {
321
+ const sessionMs = deps.sessionMs ?? 864e5;
322
+ let backoffMs = INITIAL_BACKOFF_MS;
323
+ let consecutiveFailures = 0;
324
+ while (!signal.aborted) {
325
+ const startedAt = Date.now();
326
+ try {
327
+ let settle;
328
+ let fail;
329
+ const done = new Promise((resolve, reject) => {
330
+ settle = resolve;
331
+ fail = reject;
332
+ });
333
+ let sessionDelivered = false;
334
+ const response = await deps.startSession({ waitUntil: (promise) => {
335
+ sessionDelivered = true;
336
+ promise.then(() => settle(), (err) => fail(err));
337
+ } }, sessionMs, signal);
338
+ if (!response.ok || !sessionDelivered) throw new Error(`Gateway session refused (status ${response.status})`);
339
+ await done;
340
+ } catch (err) {
341
+ deps.log("error", `Gateway session error: ${err instanceof Error ? err.message : String(err)}`);
342
+ }
343
+ if (signal.aborted) return;
344
+ const elapsedMs = Date.now() - startedAt;
345
+ if (elapsedMs >= 6e4) {
346
+ backoffMs = INITIAL_BACKOFF_MS;
347
+ consecutiveFailures = 0;
348
+ deps.log("info", "Gateway session ended; reconnecting");
349
+ await sleep(RECONNECT_DELAY_MS, signal);
350
+ continue;
351
+ }
352
+ consecutiveFailures += 1;
353
+ let check;
354
+ try {
355
+ check = await deps.checkToken();
356
+ } catch {
357
+ check = "unreachable";
358
+ }
359
+ if (signal.aborted) return;
360
+ if (check === "invalid") {
361
+ deps.log("error", "Discord rejected the bot token (401); Gateway reconnection is parked until configure() supplies new credentials. Reset tokens must be rotated in the Discord Developer Portal and updated wherever this provider gets its credentials.");
362
+ return;
363
+ }
364
+ if (consecutiveFailures === 3) deps.log("error", `Gateway sessions keep ending within ${HEALTHY_SESSION_MS / 1e3}s of starting. The token checks out over REST, so the usual cause is a rejected IDENTIFY: enable the "Message Content Intent" (a privileged intent) for this app in the Discord Developer Portal, or set gateway: false for an interactions-only deployment.`);
365
+ deps.log("warn", `Gateway session ended after ${Math.round(elapsedMs / 1e3)}s; retrying in ${Math.round(backoffMs / 1e3)}s`);
366
+ await sleep(backoffMs, signal);
367
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
368
+ }
369
+ }
370
+ /** Abortable sleep: resolves early (never rejects) when the signal fires. */
371
+ function sleep(ms, signal) {
372
+ if (signal.aborted) return Promise.resolve();
373
+ return new Promise((resolve) => {
374
+ const onAbort = () => {
375
+ clearTimeout(timer);
376
+ resolve();
377
+ };
378
+ const timer = setTimeout(() => {
379
+ signal.removeEventListener("abort", onAbort);
380
+ resolve();
381
+ }, ms);
382
+ signal.addEventListener("abort", onAbort, { once: true });
383
+ });
384
+ }
272
385
  //#endregion
273
386
  //#region src/crypto.ts
274
387
  /**
@@ -430,7 +543,11 @@ var DiscordInstallStore = class {
430
543
  guildIds: install.guildIds,
431
544
  displayName: install.displayName,
432
545
  commands: install.commands,
433
- commandVersions: install.commandVersions
546
+ commandVersions: install.commandVersions,
547
+ guildSnapshot: install.guildSnapshot,
548
+ snapshotAt: install.snapshotAt?.toISOString(),
549
+ snapshotApplicationId: install.snapshotApplicationId,
550
+ targetGuildId: install.targetGuildId
434
551
  };
435
552
  return {
436
553
  id: install.id,
@@ -454,7 +571,11 @@ var DiscordInstallStore = class {
454
571
  displayName: data.displayName,
455
572
  commands: data.commands,
456
573
  commandVersions: data.commandVersions,
457
- installedAt: record.createdAt
574
+ installedAt: record.createdAt,
575
+ guildSnapshot: data.guildSnapshot,
576
+ snapshotAt: data.snapshotAt ? new Date(data.snapshotAt) : void 0,
577
+ snapshotApplicationId: data.snapshotApplicationId,
578
+ targetGuildId: data.targetGuildId
458
579
  };
459
580
  }
460
581
  };
@@ -469,14 +590,88 @@ function toInstallationInfo(install) {
469
590
  installedAt: install.installedAt
470
591
  };
471
592
  }
593
+ /**
594
+ * Whether an install's invite flow is still *live*: started against the
595
+ * current application and within {@link SNAPSHOT_TTL_MS}. Deliberately
596
+ * independent of whether the baseline fetch succeeded — a live flow with a
597
+ * missing baseline still poisons attribution (see {@link planReconcile}),
598
+ * while rows from before these fields existed (no `snapshotAt`) are treated
599
+ * as not live and fall back to first-interaction activation.
600
+ */
601
+ function isInviteFlowLive(installation, applicationId, now) {
602
+ return installation.status === "pending" && installation.snapshotApplicationId === applicationId && installation.snapshotAt != null && now.getTime() - installation.snapshotAt.getTime() <= 18e5;
603
+ }
604
+ /**
605
+ * Whether a pending install can reconcile at all: a live invite flow (see
606
+ * {@link isInviteFlowLive}) *with* a membership baseline to diff against.
607
+ * Used as a cheap pre-check before the membership fetch.
608
+ */
609
+ function hasReconcilableSnapshot(installation, applicationId, now) {
610
+ return installation.guildSnapshot != null && isInviteFlowLive(installation, applicationId, now);
611
+ }
612
+ /**
613
+ * Decide which pending installs the completed-invite reconcile pass may
614
+ * activate, and on which guild. Pure claim analysis over plain data — all
615
+ * store reads, membership fetches, and activations happen in the caller
616
+ * ({@link DiscordProvider.reconcileInstallation}).
617
+ *
618
+ * Rules, in order:
619
+ * - Only installs with a live invite flow ({@link isInviteFlowLive}) take part.
620
+ * Expired/foreign-app baselines are ignored (first interaction remains their
621
+ * activation path), as are legacy rows without snapshot metadata.
622
+ * - A live flow whose baseline fetch failed at connect time poisons the whole
623
+ * pass: any new guild might be ITS invite landing, so nothing is attributed.
624
+ * - A guild any install already lists is owned and never claimed — it can
625
+ * still look "new" relative to a stale baseline.
626
+ * - An install with an explicit {@link DiscordInstallation.targetGuildId}
627
+ * claims *only* that guild. The narrowing happens before claims are tallied,
628
+ * so a targeted install never contests a guild it would refuse anyway — but
629
+ * its target, if new, still blocks other installs from taking it.
630
+ * - An install activates only on exactly one new guild, claimed by no other
631
+ * live install. Zero or multiple candidates, or a contested guild, stay
632
+ * pending and retry on the next pass.
633
+ */
634
+ function planReconcile(input) {
635
+ const { installations, currentGuildIds, applicationId, now } = input;
636
+ const live = installations.filter((i) => isInviteFlowLive(i, applicationId, now));
637
+ const claimants = live.filter((i) => i.guildSnapshot != null);
638
+ if (claimants.length === 0) return [];
639
+ if (claimants.length !== live.length) return [];
640
+ const owned = new Set(installations.flatMap((i) => i.guildIds));
641
+ const diffs = claimants.map((installation) => {
642
+ const snapshot = new Set(installation.guildSnapshot);
643
+ let newGuilds = currentGuildIds.filter((id) => !snapshot.has(id) && !owned.has(id));
644
+ if (installation.targetGuildId != null) newGuilds = newGuilds.includes(installation.targetGuildId) ? [installation.targetGuildId] : [];
645
+ return {
646
+ installation,
647
+ newGuilds
648
+ };
649
+ });
650
+ const claims = /* @__PURE__ */ new Map();
651
+ for (const { newGuilds } of diffs) for (const id of newGuilds) claims.set(id, (claims.get(id) ?? 0) + 1);
652
+ const activations = [];
653
+ for (const { installation, newGuilds } of diffs) {
654
+ const guildId = newGuilds.length === 1 ? newGuilds[0] : void 0;
655
+ if (guildId == null || claims.get(guildId) > 1) continue;
656
+ activations.push({
657
+ installationId: installation.id,
658
+ agentId: installation.agentId,
659
+ webhookId: installation.webhookId,
660
+ guildId
661
+ });
662
+ }
663
+ return activations;
664
+ }
472
665
  //#endregion
473
666
  //#region src/discord-provider.ts
474
667
  /**
475
668
  * Resolve the per-adapter config the provider applies to the Discord entry in
476
669
  * `AgentChannels.adapters`. Discord has native embeds + action-row buttons, so
477
670
  * `toolDisplay` defaults to `'cards'` (unlike Telegram's `'text'`); `streaming`
478
- * post-and-edits the interaction followup; `gateway` (default `true`) makes core
479
- * own the DM/mention Gateway reconnection loop.
671
+ * post-and-edits the interaction followup; `gateway` (default `true`) makes the
672
+ * **provider** run the DM/mention Gateway reconnection loop. (The adapter entry
673
+ * handed to `AgentChannels` always carries `gateway: false` — core's loop must
674
+ * never start; see `#createAgentChannels`.)
480
675
  */
481
676
  function resolveDiscordAdapterConfig(config) {
482
677
  return {
@@ -500,11 +695,15 @@ function resolveDiscordAdapterConfig(config) {
500
695
  * member) or lazily off the first inbound interaction's authoritative `guild_id`
501
696
  * (see {@link activateGuild}).
502
697
  *
503
- * **Gateway is core's job.** The mounted interactions route only ever receives
504
- * HTTP Interactions (PING, slash commands, buttons). DMs / @mentions / reactions
505
- * arrive over the Gateway WebSocket, whose reconnection loop core owns: setting
506
- * `gateway: true` (default) on the adapter entry is enough — the wrapper never
507
- * calls `startGatewayListener` or runs a reconnect loop.
698
+ * **Gateway is the provider's job.** The mounted interactions route only ever
699
+ * receives HTTP Interactions (PING, slash commands, buttons). DMs / @mentions /
700
+ * reactions arrive over the Gateway WebSocket, whose reconnection loop the
701
+ * provider owns ({@link #startGatewayLoop} → `./gateway-loop`): one abortable
702
+ * loop per installation, exponential backoff on failed connects, parked on a
703
+ * revoked token. Core's `AgentChannels` loop is explicitly disabled
704
+ * (`gateway: false` on the adapter entry) — it reconnects with no backoff and
705
+ * cannot be stopped, which is how a failing IDENTIFY once tripped Discord's
706
+ * 1000-connect abuse limit and got the bot token force-reset.
508
707
  *
509
708
  * Implemented (issues `mastra-discord-13x.2` + `.3`): the app-config +
510
709
  * guild-keyed install store, `connect()`/`disconnect()`, OAuth2 invite-URL
@@ -518,6 +717,15 @@ var DiscordProvider = class {
518
717
  #store;
519
718
  /** Live adapters, keyed by installation id (one per agent; all share the app's bot token). */
520
719
  #adapters = /* @__PURE__ */ new Map();
720
+ /**
721
+ * Live Gateway reconnect loops, keyed by installation id. Aborting a
722
+ * controller ends the loop and destroys its discord.js client. Exactly one
723
+ * loop may exist per installation — {@link #startGatewayLoop} aborts any
724
+ * predecessor, so credential rotations and adapter rebuilds can't leak loops
725
+ * that keep IDENTIFYing with stale tokens (Discord force-resets the bot
726
+ * token past ~1000 connects in a short window).
727
+ */
728
+ #gatewayLoops = /* @__PURE__ */ new Map();
521
729
  /** Cached sync view of whether the app is configured (for {@link getInfo}). */
522
730
  #configured = false;
523
731
  #initPromise = null;
@@ -564,6 +772,7 @@ var DiscordProvider = class {
564
772
  this.#storeIsFallback = false;
565
773
  }
566
774
  if (isNewInstance) {
775
+ this.#stopAllGatewayLoops();
567
776
  this.#adapters.clear();
568
777
  this.#configured = this.#hasSuppliedCredentials();
569
778
  }
@@ -661,6 +870,7 @@ var DiscordProvider = class {
661
870
  app: void 0
662
871
  };
663
872
  this.#configured = false;
873
+ this.#stopAllGatewayLoops();
664
874
  this.#adapters.clear();
665
875
  this.#initPromise = null;
666
876
  return;
@@ -696,6 +906,7 @@ var DiscordProvider = class {
696
906
  if (merged.botToken && merged.publicKey && merged.applicationId) await store.saveAppConfig(merged);
697
907
  }
698
908
  const wasInitialized = this.#initPromise !== null;
909
+ this.#stopAllGatewayLoops();
699
910
  this.#adapters.clear();
700
911
  this.#initPromise = null;
701
912
  if (wasInitialized) await this.initialize();
@@ -726,39 +937,69 @@ var DiscordProvider = class {
726
937
  const installedAt = existing?.installedAt ?? /* @__PURE__ */ new Date();
727
938
  const commands = normalizeCommands(options.commands ?? this.#config.commands ?? DEFAULT_COMMANDS);
728
939
  if (options.guildId && await guildHealthCheck(app.botToken, options.guildId, this.#apiBaseUrl())) {
729
- const installation = {
940
+ const guildId = options.guildId;
941
+ return await this.#enqueueWebhookTask(webhookId, async () => {
942
+ const fresh = existing ? await store.getByWebhookId(webhookId) : null;
943
+ if (fresh?.status === "active") return {
944
+ type: "immediate",
945
+ installationId: fresh.id
946
+ };
947
+ const installation = {
948
+ id: installationId,
949
+ agentId,
950
+ webhookId,
951
+ status: "active",
952
+ guildIds: [guildId],
953
+ displayName,
954
+ commands: commands.length ? commands : void 0,
955
+ commandVersions: fresh?.commandVersions ?? existing?.commandVersions,
956
+ installedAt
957
+ };
958
+ await store.save(installation);
959
+ await this.#registerCommands(app, installation, guildId);
960
+ await this.#activateInstallation(installation);
961
+ this.#configured = true;
962
+ await this.#config.onInstall?.(installation);
963
+ return {
964
+ type: "immediate",
965
+ installationId
966
+ };
967
+ });
968
+ }
969
+ const outcome = await this.#enqueueWebhookTask(webhookId, async () => {
970
+ const fresh = existing ? await store.getByWebhookId(webhookId) : null;
971
+ if (fresh?.status === "active") return {
972
+ type: "immediate",
973
+ installationId: fresh.id
974
+ };
975
+ const base = fresh ?? existing;
976
+ const now = /* @__PURE__ */ new Date();
977
+ let guildSnapshot = base?.guildSnapshot != null && base.snapshotApplicationId === app.applicationId && base.snapshotAt != null && now.getTime() - base.snapshotAt.getTime() <= 18e5 ? base.guildSnapshot : void 0;
978
+ if (!guildSnapshot) try {
979
+ guildSnapshot = await listBotGuildIds(app.botToken, this.#apiBaseUrl()) ?? void 0;
980
+ } catch (err) {
981
+ console.warn("[Discord] Could not snapshot guild membership for pending install:", err);
982
+ }
983
+ const pending = {
730
984
  id: installationId,
731
985
  agentId,
732
986
  webhookId,
733
- status: "active",
734
- guildIds: [options.guildId],
987
+ status: "pending",
988
+ guildIds: base?.guildIds ?? [],
735
989
  displayName,
736
990
  commands: commands.length ? commands : void 0,
737
- commandVersions: existing?.commandVersions,
738
- installedAt
739
- };
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
991
+ commandVersions: base?.commandVersions,
992
+ installedAt,
993
+ guildSnapshot,
994
+ snapshotAt: now,
995
+ snapshotApplicationId: app.applicationId,
996
+ targetGuildId: options.guildId ?? base?.targetGuildId
748
997
  };
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);
998
+ await store.save(pending);
999
+ return pending;
1000
+ });
1001
+ if ("type" in outcome) return outcome;
1002
+ const pending = outcome;
762
1003
  await this.#registerCommands(app, pending);
763
1004
  this.#configured = true;
764
1005
  return {
@@ -779,7 +1020,22 @@ var DiscordProvider = class {
779
1020
  * `webhookId` is unknown.
780
1021
  */
781
1022
  async activateGuild(webhookId, guildId) {
782
- const next = (this.#activationChains.get(webhookId) ?? Promise.resolve()).catch(() => {}).then(() => this.#activateGuildNow(webhookId, guildId));
1023
+ return this.#enqueueWebhookTask(webhookId, () => this.#activateGuildNow(webhookId, guildId));
1024
+ }
1025
+ #activationChains = /* @__PURE__ */ new Map();
1026
+ /**
1027
+ * Serialize every mutation of one webhook's installation row — activations,
1028
+ * pending-row saves, runtime bring-up, disconnects — so each task's read
1029
+ * happens *after* the previous task's save. Without the chain, two
1030
+ * concurrent first-interactions can lose a guildId (both read the same row,
1031
+ * second save overwrites the first) or fire onInstall twice; a connect's
1032
+ * pending save can clobber a row a racing interaction just activated; and a
1033
+ * disconnect can land mid-activation and be resurrected by its post-HTTP
1034
+ * persistence. The chain entry is dropped once the last queued task settles
1035
+ * so keys don't accumulate forever.
1036
+ */
1037
+ async #enqueueWebhookTask(webhookId, task) {
1038
+ const next = (this.#activationChains.get(webhookId) ?? Promise.resolve()).catch(() => {}).then(task);
783
1039
  this.#activationChains.set(webhookId, next);
784
1040
  try {
785
1041
  return await next;
@@ -787,7 +1043,6 @@ var DiscordProvider = class {
787
1043
  if (this.#activationChains.get(webhookId) === next) this.#activationChains.delete(webhookId);
788
1044
  }
789
1045
  }
790
- #activationChains = /* @__PURE__ */ new Map();
791
1046
  async #activateGuildNow(webhookId, guildId) {
792
1047
  const store = await this.#getStore();
793
1048
  const installation = await store.getByWebhookId(webhookId);
@@ -797,6 +1052,10 @@ var DiscordProvider = class {
797
1052
  if (wasActive && known) return installation;
798
1053
  if (!known) installation.guildIds.push(guildId);
799
1054
  installation.status = "active";
1055
+ delete installation.guildSnapshot;
1056
+ delete installation.snapshotAt;
1057
+ delete installation.snapshotApplicationId;
1058
+ delete installation.targetGuildId;
800
1059
  await store.save(installation);
801
1060
  const app = await store.getAppConfig();
802
1061
  if (app) await this.#registerCommands(app, installation, guildId);
@@ -805,26 +1064,106 @@ var DiscordProvider = class {
805
1064
  return installation;
806
1065
  }
807
1066
  /**
808
- * Disconnect an agent from Discord: remove its installation row and drop the
809
- * adapter entry.
810
- *
811
- * **Limitation:** the Gateway loop is owned by core (it calls
812
- * `startGatewayListener` itself; there is no `stopGatewayListener`), so
813
- * disconnect cannot kill an in-flight gateway window — it lapses at the next
814
- * duration boundary. Contrast Telegram's clean `stopPolling()`.
1067
+ * Disconnect an agent from Discord: abort its Gateway loop, remove its
1068
+ * installation row, and drop the adapter entry.
815
1069
  */
816
1070
  async disconnect(agentId) {
817
1071
  const store = await this.#getStore();
818
1072
  const existing = await store.getByAgent(agentId);
819
1073
  if (!existing) throw new Error(`No Discord installation found for agent "${agentId}"`);
820
- this.#adapters.delete(existing.id);
821
- await store.deleteByAgent(agentId);
1074
+ await this.#enqueueWebhookTask(existing.webhookId, async () => {
1075
+ this.#stopGatewayLoop(existing.id);
1076
+ this.#adapters.delete(existing.id);
1077
+ await store.deleteByAgent(agentId);
1078
+ });
822
1079
  this.#configured = await store.getAppConfig() != null || (await store.list()).some((i) => i.status === "active");
823
1080
  }
824
- /** List installations (public info only — no secrets). */
1081
+ /**
1082
+ * List installations (public info only — no secrets). A pure read: pending
1083
+ * installs are confirmed through the write-authorized
1084
+ * {@link reconcileInstallation} path, never as a listing side effect.
1085
+ */
825
1086
  async listInstallations() {
826
1087
  return (await (await this.#getStore()).list()).map(toInstallationInfo);
827
1088
  }
1089
+ /**
1090
+ * Activate an agent's pending install if its invite has since been completed.
1091
+ *
1092
+ * The invite URL carries no `redirect_uri` (the app would have to allowlist
1093
+ * every operator origin), so nothing calls back when the operator authorizes
1094
+ * the bot — and a pending install has no adapter, so no interaction or
1095
+ * gateway event can reach it either. Instead, each pending install carries a
1096
+ * {@link DiscordInstallation.guildSnapshot} taken when the invite was issued;
1097
+ * this diffs it against the bot's current membership and activates the
1098
+ * install when exactly one new, uncontested guild has appeared (the full
1099
+ * claim rules live in {@link planReconcile}). The claim analysis spans every
1100
+ * install — other pending invites contest the diff — but only **this**
1101
+ * agent's install can activate: the caller holds write access for this agent
1102
+ * alone. Anything ambiguous stays pending and retries on the next call, with
1103
+ * the first interaction as the authoritative fallback. Best-effort: failures
1104
+ * log and resolve rather than throw.
1105
+ *
1106
+ * Called by the server's reconcile route (write-authorized); Studio invokes
1107
+ * it on window focus while a connect is in flight. Returns the (possibly
1108
+ * just-activated) installation's public info, or `null` when the agent has
1109
+ * no installation.
1110
+ *
1111
+ * Limitation: the bot-invite URL is per-app, not per-install, so when the
1112
+ * operator invites the bot to a new server *for an already-active install*
1113
+ * (as `connect()`'s already-connected error suggests) while an invite for
1114
+ * another agent is outstanding, nothing distinguishes the two intents — the
1115
+ * new guild can be attributed to the pending install. Reconciliation is only
1116
+ * reliable while a single invite flow is in progress.
1117
+ */
1118
+ async reconcileInstallation(agentId) {
1119
+ const store = await this.#getStore();
1120
+ const existing = await store.getByAgent(agentId);
1121
+ if (!existing) return null;
1122
+ if (existing.status === "pending") {
1123
+ const inflight = this.#reconcileInflight.get(agentId);
1124
+ if (inflight) await inflight;
1125
+ else {
1126
+ const run = this.#reconcilePendingInstallation(store, agentId).finally(() => {
1127
+ this.#reconcileInflight.delete(agentId);
1128
+ });
1129
+ this.#reconcileInflight.set(agentId, run);
1130
+ await run;
1131
+ }
1132
+ }
1133
+ const fresh = await store.getByAgent(agentId);
1134
+ return fresh ? toInstallationInfo(fresh) : null;
1135
+ }
1136
+ #reconcileInflight = /* @__PURE__ */ new Map();
1137
+ async #reconcilePendingInstallation(store, agentId) {
1138
+ try {
1139
+ const app = await store.getAppConfig();
1140
+ if (!app) return;
1141
+ const target = await store.getByAgent(agentId);
1142
+ if (!target || target.status !== "pending") return;
1143
+ if (!hasReconcilableSnapshot(target, app.applicationId, /* @__PURE__ */ new Date())) return;
1144
+ const current = await listBotGuildIds(app.botToken, this.#apiBaseUrl());
1145
+ if (!current) return;
1146
+ const activation = planReconcile({
1147
+ installations: await store.list(),
1148
+ currentGuildIds: current,
1149
+ applicationId: app.applicationId,
1150
+ now: /* @__PURE__ */ new Date()
1151
+ }).find((a) => a.agentId === agentId);
1152
+ if (!activation) return;
1153
+ await this.#enqueueWebhookTask(activation.webhookId, async () => {
1154
+ const activated = await this.#activateGuildNow(activation.webhookId, activation.guildId);
1155
+ if (!activated || this.#adapters.has(activated.id)) return;
1156
+ try {
1157
+ await this.#activateInstallation(activated);
1158
+ } catch (err) {
1159
+ this.#adapters.delete(activated.id);
1160
+ throw err;
1161
+ }
1162
+ });
1163
+ } catch (err) {
1164
+ console.warn(`[Discord] Pending-install reconcile for agent "${agentId}" failed (will retry):`, err);
1165
+ }
1166
+ }
828
1167
  /** The full installation for an agent (no secrets live on it), or `null`. */
829
1168
  async getInstallation(agentId) {
830
1169
  return await (await this.#getStore()).getByAgent(agentId) ?? null;
@@ -867,6 +1206,7 @@ var DiscordProvider = class {
867
1206
  if (!channels || channels.adapters["discord"] !== adapter) {
868
1207
  channels = this.#createAgentChannels(agent, adapter);
869
1208
  await channels.initialize(this.#mastra);
1209
+ this.#startGatewayLoop(installation, adapter, app);
870
1210
  }
871
1211
  try {
872
1212
  return scheduleActivation(await channels.handleWebhookEvent(PLATFORM, c.req.raw, waitUntil ? { waitUntil } : void 0));
@@ -908,7 +1248,14 @@ var DiscordProvider = class {
908
1248
  ...versions,
909
1249
  [key]: hash
910
1250
  };
911
- await (await this.#getStore()).save(installation);
1251
+ const store = await this.#getStore();
1252
+ const fresh = await store.getByWebhookId(installation.webhookId);
1253
+ if (!fresh) return;
1254
+ fresh.commandVersions = {
1255
+ ...fresh.commandVersions,
1256
+ [key]: hash
1257
+ };
1258
+ await store.save(fresh);
912
1259
  } catch (err) {
913
1260
  console.warn(`[Discord] command registration failed (${scope}${guildId ? `, guild ${guildId}` : ""}):`, err);
914
1261
  }
@@ -936,13 +1283,64 @@ var DiscordProvider = class {
936
1283
  if (!app) return;
937
1284
  const agent = this.#resolveAgent(installation.agentId);
938
1285
  const adapter = this.#getOrCreateAdapter(installation, app);
939
- if (agent && this.#mastra) await this.#createAgentChannels(agent, adapter).initialize(this.#mastra);
1286
+ if (agent && this.#mastra) {
1287
+ await this.#createAgentChannels(agent, adapter).initialize(this.#mastra);
1288
+ this.#startGatewayLoop(installation, adapter, app);
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Start (or restart) the Gateway reconnect loop for an installation. Any
1293
+ * previous loop for the same installation is aborted first, so adapter
1294
+ * rebuilds and credential rotations replace the loop instead of stacking
1295
+ * another one. No-op when the provider is configured `gateway: false`.
1296
+ *
1297
+ * The loop itself lives in {@link runGatewayLoop}; see that module for why
1298
+ * the provider owns this instead of core's `AgentChannels` loop.
1299
+ */
1300
+ #startGatewayLoop(installation, adapter, app) {
1301
+ if (!resolveDiscordAdapterConfig(this.#config).gateway) return;
1302
+ this.#stopGatewayLoop(installation.id);
1303
+ const controller = new AbortController();
1304
+ this.#gatewayLoops.set(installation.id, controller);
1305
+ const apiBaseUrl = this.#apiBaseUrl();
1306
+ runGatewayLoop({
1307
+ startSession: (options, durationMs, signal) => adapter.startGatewayListener(options, durationMs, signal),
1308
+ checkToken: async () => {
1309
+ try {
1310
+ const response = await discordRequest(app.botToken, "GET", "/applications/@me", apiBaseUrl);
1311
+ return response.status === 401 ? "invalid" : response.ok ? "valid" : "unreachable";
1312
+ } catch {
1313
+ return "unreachable";
1314
+ }
1315
+ },
1316
+ log: (level, message) => {
1317
+ const line = `[Discord] [gateway:${installation.agentId}] ${message}`;
1318
+ if (level === "error") console.error(line);
1319
+ else if (level === "warn") console.warn(line);
1320
+ else console.info(line);
1321
+ }
1322
+ }, controller.signal).finally(() => {
1323
+ if (this.#gatewayLoops.get(installation.id) === controller) this.#gatewayLoops.delete(installation.id);
1324
+ });
1325
+ }
1326
+ /** Abort an installation's Gateway loop (ends the session, destroys the client). */
1327
+ #stopGatewayLoop(installationId) {
1328
+ const controller = this.#gatewayLoops.get(installationId);
1329
+ if (!controller) return;
1330
+ this.#gatewayLoops.delete(installationId);
1331
+ controller.abort();
1332
+ }
1333
+ /** Abort every Gateway loop — credential revocation or Mastra re-attach. */
1334
+ #stopAllGatewayLoops() {
1335
+ for (const installationId of [...this.#gatewayLoops.keys()]) this.#stopGatewayLoop(installationId);
940
1336
  }
941
1337
  /**
942
1338
  * Create AgentChannels for an agent with the Discord adapter, preserving any
943
1339
  * adapters/config the agent author already configured (mirrors `@mastra/slack`
944
- * / `@mastra/telegram`). `gateway: true` (default) makes core start the Gateway
945
- * reconnection loop for DMs/mentions.
1340
+ * / `@mastra/telegram`). The entry always carries `gateway: false`: core's
1341
+ * built-in gateway loop reconnects with no backoff and no abort handle, so
1342
+ * the provider owns the Gateway lifecycle instead ({@link #startGatewayLoop},
1343
+ * gated on the provider-level `gateway` config).
946
1344
  */
947
1345
  #createAgentChannels(agent, adapter) {
948
1346
  const existingConfig = agent.getChannels()?.channelConfig;
@@ -950,6 +1348,7 @@ var DiscordProvider = class {
950
1348
  const entry = {
951
1349
  adapter,
952
1350
  ...resolveDiscordAdapterConfig(cfg),
1351
+ gateway: false,
953
1352
  ...cfg.cors !== void 0 ? { cors: cfg.cors } : {},
954
1353
  ...cfg.formatError !== void 0 ? { formatError: cfg.formatError } : {}
955
1354
  };