@mastra/discord 1.3.0-alpha.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @mastra/discord
2
2
 
3
+ ## 1.3.0-alpha.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed a Discord Gateway reconnect storm that could trip Discord's connection abuse limit (>1000 connects in a short window) and get the bot token force-reset. The provider now owns the Gateway reconnection loop: failed connects (invalid token, Message Content privileged intent not enabled) back off exponentially instead of retrying instantly, a revoked token parks reconnection until new credentials arrive, and exactly one loop runs per installation — credential rotations and `disconnect()` now stop the previous loop instead of leaking it. ([#26095](https://github.com/mastra-ai/mastra/pull/26095))
8
+
9
+ - Updated dependencies [[`97644a7`](https://github.com/mastra-ai/mastra/commit/97644a78cafe8276026509c56a70108075e950b7), [`4cf860a`](https://github.com/mastra-ai/mastra/commit/4cf860a5a550a21fabce43010e6f1c95710e155c), [`dac82ea`](https://github.com/mastra-ai/mastra/commit/dac82eaa324b66acad38d468799fa4e66594107f), [`97644a7`](https://github.com/mastra-ai/mastra/commit/97644a78cafe8276026509c56a70108075e950b7), [`0a37598`](https://github.com/mastra-ai/mastra/commit/0a375986869049865023d765337db427b6e27436), [`018ae9d`](https://github.com/mastra-ai/mastra/commit/018ae9d2f4ebfd3bd6f267d0010171a546cb3abf), [`c498e24`](https://github.com/mastra-ai/mastra/commit/c498e249038d08a2e2fc31eed7ba4ca5e7fa1aa8), [`045d583`](https://github.com/mastra-ai/mastra/commit/045d583852e55d0c1c518d2f5f9c33b48243cf7d)]:
10
+ - @mastra/core@1.75.0-alpha.7
11
+
3
12
  ## 1.3.0-alpha.0
4
13
 
5
14
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -109038,7 +109038,7 @@ var require_Shard = /* @__PURE__ */ __commonJSMin(((exports, module) => {
109038
109038
  const path$2 = require("path");
109039
109039
  const process$7 = require("process");
109040
109040
  const { setTimeout: setTimeout$1, clearTimeout: clearTimeout$1 } = require("timers");
109041
- const { setTimeout: sleep$1 } = require("timers/promises");
109041
+ const { setTimeout: sleep$2 } = require("timers/promises");
109042
109042
  const { SHARE_ENV } = require("worker_threads");
109043
109043
  const { DiscordjsError, ErrorCodes } = require_errors();
109044
109044
  const ShardEvents = require_ShardEvents();
@@ -109226,7 +109226,7 @@ var require_Shard = /* @__PURE__ */ __commonJSMin(((exports, module) => {
109226
109226
  */
109227
109227
  async respawn({ delay = 500, timeout = 3e4 } = {}) {
109228
109228
  this.kill();
109229
- if (delay > 0) await sleep$1(delay);
109229
+ if (delay > 0) await sleep$2(delay);
109230
109230
  return this.spawn(timeout);
109231
109231
  }
109232
109232
  /**
@@ -109449,7 +109449,7 @@ var require_ShardingManager = /* @__PURE__ */ __commonJSMin(((exports, module) =
109449
109449
  const fs = require("fs");
109450
109450
  const path$1 = require("path");
109451
109451
  const process$6 = require("process");
109452
- const { setTimeout: sleep } = require("timers/promises");
109452
+ const { setTimeout: sleep$1 } = require("timers/promises");
109453
109453
  const { Collection } = require_dist$4();
109454
109454
  const Shard = require_Shard();
109455
109455
  const { DiscordjsError, DiscordjsTypeError, DiscordjsRangeError, ErrorCodes } = require_errors();
@@ -109613,7 +109613,7 @@ var require_ShardingManager = /* @__PURE__ */ __commonJSMin(((exports, module) =
109613
109613
  const promises = [];
109614
109614
  const shard = this.createShard(shardId);
109615
109615
  promises.push(shard.spawn(timeout));
109616
- if (delay > 0 && this.shards.size !== this.shardList.length) promises.push(sleep(delay));
109616
+ if (delay > 0 && this.shards.size !== this.shardList.length) promises.push(sleep$1(delay));
109617
109617
  await Promise.all(promises);
109618
109618
  }
109619
109619
  return this.shards;
@@ -109697,7 +109697,7 @@ var require_ShardingManager = /* @__PURE__ */ __commonJSMin(((exports, module) =
109697
109697
  delay: respawnDelay,
109698
109698
  timeout
109699
109699
  })];
109700
- if (++s < this.shards.size && shardDelay > 0) promises.push(sleep(shardDelay));
109700
+ if (++s < this.shards.size && shardDelay > 0) promises.push(sleep$1(shardDelay));
109701
109701
  await Promise.all(promises);
109702
109702
  }
109703
109703
  return this.shards;
@@ -123635,6 +123635,120 @@ function hashCommands(commands) {
123635
123635
  return (0, crypto$1.createHash)("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
123636
123636
  }
123637
123637
  //#endregion
123638
+ //#region src/gateway-loop.ts
123639
+ /**
123640
+ * Provider-owned Discord Gateway reconnection loop.
123641
+ *
123642
+ * Core's `AgentChannels` has a built-in gateway loop, but the provider opts out
123643
+ * of it (`gateway: false` on the adapter entry) and runs this one instead,
123644
+ * because the core loop has two failure modes that combine into a connect
123645
+ * storm — severe enough that Discord's abuse tripwire (>1000 connects in a
123646
+ * short window) force-resets the bot token:
123647
+ *
123648
+ * - `@chat-adapter/discord` **swallows `client.login()` errors**: a session
123649
+ * that fails IDENTIFY (invalid/reset token, the MessageContent privileged
123650
+ * intent not enabled on the app) resolves "cleanly" within milliseconds.
123651
+ * - The core loop reconnects **immediately** on a clean session end and keeps
123652
+ * **no abort handle**, so a failing session becomes a zero-delay IDENTIFY
123653
+ * loop that nothing can stop, and every credential rotation leaks another
123654
+ * immortal loop still hammering with the stale token.
123655
+ *
123656
+ * This loop defends against both: a session that ends before
123657
+ * {@link HEALTHY_SESSION_MS} is treated as a failed connect and backed off
123658
+ * exponentially; a REST token check distinguishes a revoked token (park the
123659
+ * loop until `configure()` delivers new credentials and restarts it) from a
123660
+ * transient failure (keep backing off); and the `AbortSignal` lets the
123661
+ * provider kill the loop on disconnect, credential rotation, or re-attach.
123662
+ * This mirrors how `@mastra/telegram` owns its polling loop (`stopPolling()`
123663
+ * on reconfigure/disconnect, capped backoff on errors).
123664
+ */
123665
+ /** How long each Gateway session runs before a scheduled reconnect. */
123666
+ const GATEWAY_SESSION_MS = 1440 * 60 * 1e3;
123667
+ /**
123668
+ * A session that ends sooner than this is treated as a failed connect. The
123669
+ * adapter resolves login failures as if the session ended cleanly, so elapsed
123670
+ * time is the only signal that distinguishes "ran fine until the duration
123671
+ * elapsed / Discord closed us" from "IDENTIFY was rejected instantly".
123672
+ */
123673
+ const HEALTHY_SESSION_MS = 60 * 1e3;
123674
+ /** First retry delay after a failed connect; doubles up to {@link MAX_BACKOFF_MS}. */
123675
+ const INITIAL_BACKOFF_MS = 5 * 1e3;
123676
+ /** Retry delay ceiling. */
123677
+ const MAX_BACKOFF_MS = 900 * 1e3;
123678
+ /** Pause between healthy sessions — a scheduled rollover, not a failure. */
123679
+ const RECONNECT_DELAY_MS = 1e3;
123680
+ /** After this many consecutive failed connects, log a diagnostic hint. */
123681
+ const SHORT_SESSION_HINT_THRESHOLD = 3;
123682
+ /**
123683
+ * Run Gateway sessions back-to-back until `signal` aborts or the bot token is
123684
+ * confirmed revoked. Resolves when the loop stops; never rejects.
123685
+ */
123686
+ async function runGatewayLoop(deps, signal) {
123687
+ const sessionMs = deps.sessionMs ?? 864e5;
123688
+ let backoffMs = INITIAL_BACKOFF_MS;
123689
+ let consecutiveFailures = 0;
123690
+ while (!signal.aborted) {
123691
+ const startedAt = Date.now();
123692
+ try {
123693
+ let settle;
123694
+ let fail;
123695
+ const done = new Promise((resolve, reject) => {
123696
+ settle = resolve;
123697
+ fail = reject;
123698
+ });
123699
+ let sessionDelivered = false;
123700
+ const response = await deps.startSession({ waitUntil: (promise) => {
123701
+ sessionDelivered = true;
123702
+ promise.then(() => settle(), (err) => fail(err));
123703
+ } }, sessionMs, signal);
123704
+ if (!response.ok || !sessionDelivered) throw new Error(`Gateway session refused (status ${response.status})`);
123705
+ await done;
123706
+ } catch (err) {
123707
+ deps.log("error", `Gateway session error: ${err instanceof Error ? err.message : String(err)}`);
123708
+ }
123709
+ if (signal.aborted) return;
123710
+ const elapsedMs = Date.now() - startedAt;
123711
+ if (elapsedMs >= 6e4) {
123712
+ backoffMs = INITIAL_BACKOFF_MS;
123713
+ consecutiveFailures = 0;
123714
+ deps.log("info", "Gateway session ended; reconnecting");
123715
+ await sleep(RECONNECT_DELAY_MS, signal);
123716
+ continue;
123717
+ }
123718
+ consecutiveFailures += 1;
123719
+ let check;
123720
+ try {
123721
+ check = await deps.checkToken();
123722
+ } catch {
123723
+ check = "unreachable";
123724
+ }
123725
+ if (signal.aborted) return;
123726
+ if (check === "invalid") {
123727
+ 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.");
123728
+ return;
123729
+ }
123730
+ 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.`);
123731
+ deps.log("warn", `Gateway session ended after ${Math.round(elapsedMs / 1e3)}s; retrying in ${Math.round(backoffMs / 1e3)}s`);
123732
+ await sleep(backoffMs, signal);
123733
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
123734
+ }
123735
+ }
123736
+ /** Abortable sleep: resolves early (never rejects) when the signal fires. */
123737
+ function sleep(ms, signal) {
123738
+ if (signal.aborted) return Promise.resolve();
123739
+ return new Promise((resolve) => {
123740
+ const onAbort = () => {
123741
+ clearTimeout(timer);
123742
+ resolve();
123743
+ };
123744
+ const timer = setTimeout(() => {
123745
+ signal.removeEventListener("abort", onAbort);
123746
+ resolve();
123747
+ }, ms);
123748
+ signal.addEventListener("abort", onAbort, { once: true });
123749
+ });
123750
+ }
123751
+ //#endregion
123638
123752
  //#region src/crypto.ts
123639
123753
  /**
123640
123754
  * Opt-in AES-256-GCM encryption for the app's bot token at rest, with
@@ -123931,8 +124045,10 @@ function planReconcile(input) {
123931
124045
  * Resolve the per-adapter config the provider applies to the Discord entry in
123932
124046
  * `AgentChannels.adapters`. Discord has native embeds + action-row buttons, so
123933
124047
  * `toolDisplay` defaults to `'cards'` (unlike Telegram's `'text'`); `streaming`
123934
- * post-and-edits the interaction followup; `gateway` (default `true`) makes core
123935
- * own the DM/mention Gateway reconnection loop.
124048
+ * post-and-edits the interaction followup; `gateway` (default `true`) makes the
124049
+ * **provider** run the DM/mention Gateway reconnection loop. (The adapter entry
124050
+ * handed to `AgentChannels` always carries `gateway: false` — core's loop must
124051
+ * never start; see `#createAgentChannels`.)
123936
124052
  */
123937
124053
  function resolveDiscordAdapterConfig(config) {
123938
124054
  return {
@@ -123956,11 +124072,15 @@ function resolveDiscordAdapterConfig(config) {
123956
124072
  * member) or lazily off the first inbound interaction's authoritative `guild_id`
123957
124073
  * (see {@link activateGuild}).
123958
124074
  *
123959
- * **Gateway is core's job.** The mounted interactions route only ever receives
123960
- * HTTP Interactions (PING, slash commands, buttons). DMs / @mentions / reactions
123961
- * arrive over the Gateway WebSocket, whose reconnection loop core owns: setting
123962
- * `gateway: true` (default) on the adapter entry is enough — the wrapper never
123963
- * calls `startGatewayListener` or runs a reconnect loop.
124075
+ * **Gateway is the provider's job.** The mounted interactions route only ever
124076
+ * receives HTTP Interactions (PING, slash commands, buttons). DMs / @mentions /
124077
+ * reactions arrive over the Gateway WebSocket, whose reconnection loop the
124078
+ * provider owns ({@link #startGatewayLoop} → `./gateway-loop`): one abortable
124079
+ * loop per installation, exponential backoff on failed connects, parked on a
124080
+ * revoked token. Core's `AgentChannels` loop is explicitly disabled
124081
+ * (`gateway: false` on the adapter entry) — it reconnects with no backoff and
124082
+ * cannot be stopped, which is how a failing IDENTIFY once tripped Discord's
124083
+ * 1000-connect abuse limit and got the bot token force-reset.
123964
124084
  *
123965
124085
  * Implemented (issues `mastra-discord-13x.2` + `.3`): the app-config +
123966
124086
  * guild-keyed install store, `connect()`/`disconnect()`, OAuth2 invite-URL
@@ -123974,6 +124094,15 @@ var DiscordProvider = class {
123974
124094
  #store;
123975
124095
  /** Live adapters, keyed by installation id (one per agent; all share the app's bot token). */
123976
124096
  #adapters = /* @__PURE__ */ new Map();
124097
+ /**
124098
+ * Live Gateway reconnect loops, keyed by installation id. Aborting a
124099
+ * controller ends the loop and destroys its discord.js client. Exactly one
124100
+ * loop may exist per installation — {@link #startGatewayLoop} aborts any
124101
+ * predecessor, so credential rotations and adapter rebuilds can't leak loops
124102
+ * that keep IDENTIFYing with stale tokens (Discord force-resets the bot
124103
+ * token past ~1000 connects in a short window).
124104
+ */
124105
+ #gatewayLoops = /* @__PURE__ */ new Map();
123977
124106
  /** Cached sync view of whether the app is configured (for {@link getInfo}). */
123978
124107
  #configured = false;
123979
124108
  #initPromise = null;
@@ -124020,6 +124149,7 @@ var DiscordProvider = class {
124020
124149
  this.#storeIsFallback = false;
124021
124150
  }
124022
124151
  if (isNewInstance) {
124152
+ this.#stopAllGatewayLoops();
124023
124153
  this.#adapters.clear();
124024
124154
  this.#configured = this.#hasSuppliedCredentials();
124025
124155
  }
@@ -124117,6 +124247,7 @@ var DiscordProvider = class {
124117
124247
  app: void 0
124118
124248
  };
124119
124249
  this.#configured = false;
124250
+ this.#stopAllGatewayLoops();
124120
124251
  this.#adapters.clear();
124121
124252
  this.#initPromise = null;
124122
124253
  return;
@@ -124152,6 +124283,7 @@ var DiscordProvider = class {
124152
124283
  if (merged.botToken && merged.publicKey && merged.applicationId) await store.saveAppConfig(merged);
124153
124284
  }
124154
124285
  const wasInitialized = this.#initPromise !== null;
124286
+ this.#stopAllGatewayLoops();
124155
124287
  this.#adapters.clear();
124156
124288
  this.#initPromise = null;
124157
124289
  if (wasInitialized) await this.initialize();
@@ -124309,19 +124441,15 @@ var DiscordProvider = class {
124309
124441
  return installation;
124310
124442
  }
124311
124443
  /**
124312
- * Disconnect an agent from Discord: remove its installation row and drop the
124313
- * adapter entry.
124314
- *
124315
- * **Limitation:** the Gateway loop is owned by core (it calls
124316
- * `startGatewayListener` itself; there is no `stopGatewayListener`), so
124317
- * disconnect cannot kill an in-flight gateway window — it lapses at the next
124318
- * duration boundary. Contrast Telegram's clean `stopPolling()`.
124444
+ * Disconnect an agent from Discord: abort its Gateway loop, remove its
124445
+ * installation row, and drop the adapter entry.
124319
124446
  */
124320
124447
  async disconnect(agentId) {
124321
124448
  const store = await this.#getStore();
124322
124449
  const existing = await store.getByAgent(agentId);
124323
124450
  if (!existing) throw new Error(`No Discord installation found for agent "${agentId}"`);
124324
124451
  await this.#enqueueWebhookTask(existing.webhookId, async () => {
124452
+ this.#stopGatewayLoop(existing.id);
124325
124453
  this.#adapters.delete(existing.id);
124326
124454
  await store.deleteByAgent(agentId);
124327
124455
  });
@@ -124455,6 +124583,7 @@ var DiscordProvider = class {
124455
124583
  if (!channels || channels.adapters["discord"] !== adapter) {
124456
124584
  channels = this.#createAgentChannels(agent, adapter);
124457
124585
  await channels.initialize(this.#mastra);
124586
+ this.#startGatewayLoop(installation, adapter, app);
124458
124587
  }
124459
124588
  try {
124460
124589
  return scheduleActivation(await channels.handleWebhookEvent(PLATFORM, c.req.raw, waitUntil ? { waitUntil } : void 0));
@@ -124531,13 +124660,64 @@ var DiscordProvider = class {
124531
124660
  if (!app) return;
124532
124661
  const agent = this.#resolveAgent(installation.agentId);
124533
124662
  const adapter = this.#getOrCreateAdapter(installation, app);
124534
- if (agent && this.#mastra) await this.#createAgentChannels(agent, adapter).initialize(this.#mastra);
124663
+ if (agent && this.#mastra) {
124664
+ await this.#createAgentChannels(agent, adapter).initialize(this.#mastra);
124665
+ this.#startGatewayLoop(installation, adapter, app);
124666
+ }
124667
+ }
124668
+ /**
124669
+ * Start (or restart) the Gateway reconnect loop for an installation. Any
124670
+ * previous loop for the same installation is aborted first, so adapter
124671
+ * rebuilds and credential rotations replace the loop instead of stacking
124672
+ * another one. No-op when the provider is configured `gateway: false`.
124673
+ *
124674
+ * The loop itself lives in {@link runGatewayLoop}; see that module for why
124675
+ * the provider owns this instead of core's `AgentChannels` loop.
124676
+ */
124677
+ #startGatewayLoop(installation, adapter, app) {
124678
+ if (!resolveDiscordAdapterConfig(this.#config).gateway) return;
124679
+ this.#stopGatewayLoop(installation.id);
124680
+ const controller = new AbortController();
124681
+ this.#gatewayLoops.set(installation.id, controller);
124682
+ const apiBaseUrl = this.#apiBaseUrl();
124683
+ runGatewayLoop({
124684
+ startSession: (options, durationMs, signal) => adapter.startGatewayListener(options, durationMs, signal),
124685
+ checkToken: async () => {
124686
+ try {
124687
+ const response = await discordRequest(app.botToken, "GET", "/applications/@me", apiBaseUrl);
124688
+ return response.status === 401 ? "invalid" : response.ok ? "valid" : "unreachable";
124689
+ } catch {
124690
+ return "unreachable";
124691
+ }
124692
+ },
124693
+ log: (level, message) => {
124694
+ const line = `[Discord] [gateway:${installation.agentId}] ${message}`;
124695
+ if (level === "error") console.error(line);
124696
+ else if (level === "warn") console.warn(line);
124697
+ else console.info(line);
124698
+ }
124699
+ }, controller.signal).finally(() => {
124700
+ if (this.#gatewayLoops.get(installation.id) === controller) this.#gatewayLoops.delete(installation.id);
124701
+ });
124702
+ }
124703
+ /** Abort an installation's Gateway loop (ends the session, destroys the client). */
124704
+ #stopGatewayLoop(installationId) {
124705
+ const controller = this.#gatewayLoops.get(installationId);
124706
+ if (!controller) return;
124707
+ this.#gatewayLoops.delete(installationId);
124708
+ controller.abort();
124709
+ }
124710
+ /** Abort every Gateway loop — credential revocation or Mastra re-attach. */
124711
+ #stopAllGatewayLoops() {
124712
+ for (const installationId of [...this.#gatewayLoops.keys()]) this.#stopGatewayLoop(installationId);
124535
124713
  }
124536
124714
  /**
124537
124715
  * Create AgentChannels for an agent with the Discord adapter, preserving any
124538
124716
  * adapters/config the agent author already configured (mirrors `@mastra/slack`
124539
- * / `@mastra/telegram`). `gateway: true` (default) makes core start the Gateway
124540
- * reconnection loop for DMs/mentions.
124717
+ * / `@mastra/telegram`). The entry always carries `gateway: false`: core's
124718
+ * built-in gateway loop reconnects with no backoff and no abort handle, so
124719
+ * the provider owns the Gateway lifecycle instead ({@link #startGatewayLoop},
124720
+ * gated on the provider-level `gateway` config).
124541
124721
  */
124542
124722
  #createAgentChannels(agent, adapter) {
124543
124723
  const existingConfig = agent.getChannels()?.channelConfig;
@@ -124545,6 +124725,7 @@ var DiscordProvider = class {
124545
124725
  const entry = {
124546
124726
  adapter,
124547
124727
  ...resolveDiscordAdapterConfig(cfg),
124728
+ gateway: false,
124548
124729
  ...cfg.cors !== void 0 ? { cors: cfg.cors } : {},
124549
124730
  ...cfg.formatError !== void 0 ? { formatError: cfg.formatError } : {}
124550
124731
  };