@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/dist/index.d.cts CHANGED
@@ -138,9 +138,9 @@ interface DiscordProviderConfig {
138
138
  */
139
139
  waitUntil?: WaitUntilFn;
140
140
  /**
141
- * Start the Gateway WebSocket (core-owned) so the bot receives DMs, @mentions,
142
- * and reactions in addition to slash commands. Set `false` for interactions-
143
- * only serverless deployments. Forwarded to the adapter entry as `gateway`.
141
+ * Start the Gateway WebSocket (provider-owned reconnect loop with backoff)
142
+ * so the bot receives DMs, @mentions, and reactions in addition to slash
143
+ * commands. Set `false` for interactions-only serverless deployments.
144
144
  *
145
145
  * @default true
146
146
  */
@@ -308,8 +308,10 @@ interface DiscordInstallation {
308
308
  * Resolve the per-adapter config the provider applies to the Discord entry in
309
309
  * `AgentChannels.adapters`. Discord has native embeds + action-row buttons, so
310
310
  * `toolDisplay` defaults to `'cards'` (unlike Telegram's `'text'`); `streaming`
311
- * post-and-edits the interaction followup; `gateway` (default `true`) makes core
312
- * own the DM/mention Gateway reconnection loop.
311
+ * post-and-edits the interaction followup; `gateway` (default `true`) makes the
312
+ * **provider** run the DM/mention Gateway reconnection loop. (The adapter entry
313
+ * handed to `AgentChannels` always carries `gateway: false` — core's loop must
314
+ * never start; see `#createAgentChannels`.)
313
315
  */
314
316
  declare function resolveDiscordAdapterConfig(config: Pick<DiscordProviderConfig, 'streaming' | 'typingStatus' | 'toolDisplay' | 'gateway'>): {
315
317
  streaming: StreamingConfig;
@@ -331,11 +333,15 @@ declare function resolveDiscordAdapterConfig(config: Pick<DiscordProviderConfig,
331
333
  * member) or lazily off the first inbound interaction's authoritative `guild_id`
332
334
  * (see {@link activateGuild}).
333
335
  *
334
- * **Gateway is core's job.** The mounted interactions route only ever receives
335
- * HTTP Interactions (PING, slash commands, buttons). DMs / @mentions / reactions
336
- * arrive over the Gateway WebSocket, whose reconnection loop core owns: setting
337
- * `gateway: true` (default) on the adapter entry is enough — the wrapper never
338
- * calls `startGatewayListener` or runs a reconnect loop.
336
+ * **Gateway is the provider's job.** The mounted interactions route only ever
337
+ * receives HTTP Interactions (PING, slash commands, buttons). DMs / @mentions /
338
+ * reactions arrive over the Gateway WebSocket, whose reconnection loop the
339
+ * provider owns ({@link #startGatewayLoop} → `./gateway-loop`): one abortable
340
+ * loop per installation, exponential backoff on failed connects, parked on a
341
+ * revoked token. Core's `AgentChannels` loop is explicitly disabled
342
+ * (`gateway: false` on the adapter entry) — it reconnects with no backoff and
343
+ * cannot be stopped, which is how a failing IDENTIFY once tripped Discord's
344
+ * 1000-connect abuse limit and got the bot token force-reset.
339
345
  *
340
346
  * Implemented (issues `mastra-discord-13x.2` + `.3`): the app-config +
341
347
  * guild-keyed install store, `connect()`/`disconnect()`, OAuth2 invite-URL
@@ -399,13 +405,8 @@ declare class DiscordProvider implements ChannelProvider {
399
405
  */
400
406
  activateGuild(webhookId: string, guildId: string): Promise<DiscordInstallation | null>;
401
407
  /**
402
- * Disconnect an agent from Discord: remove its installation row and drop the
403
- * adapter entry.
404
- *
405
- * **Limitation:** the Gateway loop is owned by core (it calls
406
- * `startGatewayListener` itself; there is no `stopGatewayListener`), so
407
- * disconnect cannot kill an in-flight gateway window — it lapses at the next
408
- * duration boundary. Contrast Telegram's clean `stopPolling()`.
408
+ * Disconnect an agent from Discord: abort its Gateway loop, remove its
409
+ * installation row, and drop the adapter entry.
409
410
  */
410
411
  disconnect(agentId: string): Promise<void>;
411
412
  /**
package/dist/index.d.ts CHANGED
@@ -138,9 +138,9 @@ interface DiscordProviderConfig {
138
138
  */
139
139
  waitUntil?: WaitUntilFn;
140
140
  /**
141
- * Start the Gateway WebSocket (core-owned) so the bot receives DMs, @mentions,
142
- * and reactions in addition to slash commands. Set `false` for interactions-
143
- * only serverless deployments. Forwarded to the adapter entry as `gateway`.
141
+ * Start the Gateway WebSocket (provider-owned reconnect loop with backoff)
142
+ * so the bot receives DMs, @mentions, and reactions in addition to slash
143
+ * commands. Set `false` for interactions-only serverless deployments.
144
144
  *
145
145
  * @default true
146
146
  */
@@ -308,8 +308,10 @@ interface DiscordInstallation {
308
308
  * Resolve the per-adapter config the provider applies to the Discord entry in
309
309
  * `AgentChannels.adapters`. Discord has native embeds + action-row buttons, so
310
310
  * `toolDisplay` defaults to `'cards'` (unlike Telegram's `'text'`); `streaming`
311
- * post-and-edits the interaction followup; `gateway` (default `true`) makes core
312
- * own the DM/mention Gateway reconnection loop.
311
+ * post-and-edits the interaction followup; `gateway` (default `true`) makes the
312
+ * **provider** run the DM/mention Gateway reconnection loop. (The adapter entry
313
+ * handed to `AgentChannels` always carries `gateway: false` — core's loop must
314
+ * never start; see `#createAgentChannels`.)
313
315
  */
314
316
  declare function resolveDiscordAdapterConfig(config: Pick<DiscordProviderConfig, 'streaming' | 'typingStatus' | 'toolDisplay' | 'gateway'>): {
315
317
  streaming: StreamingConfig;
@@ -331,11 +333,15 @@ declare function resolveDiscordAdapterConfig(config: Pick<DiscordProviderConfig,
331
333
  * member) or lazily off the first inbound interaction's authoritative `guild_id`
332
334
  * (see {@link activateGuild}).
333
335
  *
334
- * **Gateway is core's job.** The mounted interactions route only ever receives
335
- * HTTP Interactions (PING, slash commands, buttons). DMs / @mentions / reactions
336
- * arrive over the Gateway WebSocket, whose reconnection loop core owns: setting
337
- * `gateway: true` (default) on the adapter entry is enough — the wrapper never
338
- * calls `startGatewayListener` or runs a reconnect loop.
336
+ * **Gateway is the provider's job.** The mounted interactions route only ever
337
+ * receives HTTP Interactions (PING, slash commands, buttons). DMs / @mentions /
338
+ * reactions arrive over the Gateway WebSocket, whose reconnection loop the
339
+ * provider owns ({@link #startGatewayLoop} → `./gateway-loop`): one abortable
340
+ * loop per installation, exponential backoff on failed connects, parked on a
341
+ * revoked token. Core's `AgentChannels` loop is explicitly disabled
342
+ * (`gateway: false` on the adapter entry) — it reconnects with no backoff and
343
+ * cannot be stopped, which is how a failing IDENTIFY once tripped Discord's
344
+ * 1000-connect abuse limit and got the bot token force-reset.
339
345
  *
340
346
  * Implemented (issues `mastra-discord-13x.2` + `.3`): the app-config +
341
347
  * guild-keyed install store, `connect()`/`disconnect()`, OAuth2 invite-URL
@@ -399,13 +405,8 @@ declare class DiscordProvider implements ChannelProvider {
399
405
  */
400
406
  activateGuild(webhookId: string, guildId: string): Promise<DiscordInstallation | null>;
401
407
  /**
402
- * Disconnect an agent from Discord: remove its installation row and drop the
403
- * adapter entry.
404
- *
405
- * **Limitation:** the Gateway loop is owned by core (it calls
406
- * `startGatewayListener` itself; there is no `stopGatewayListener`), so
407
- * disconnect cannot kill an in-flight gateway window — it lapses at the next
408
- * duration boundary. Contrast Telegram's clean `stopPolling()`.
408
+ * Disconnect an agent from Discord: abort its Gateway loop, remove its
409
+ * installation row, and drop the adapter entry.
409
410
  */
410
411
  disconnect(agentId: string): Promise<void>;
411
412
  /**
@@ -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":"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"}
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;;;;;;;;;;;EAWb;;;;;;;;;EASA,aAAa;;;;;;;;EAQb;;;;;;;;EAQA;;;;;;;;;;;;;iBC9Rc,4BACd,QAAQ,KAAK;EAEb,WAAW;EACX;EACA,aAAa;EACb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAuCW,2BAA2B;;WAC7B;EA6CT,YAAY,SAAQ;;;;;EAYpB,SAAS,QAAQ;;;;;;;;;EA0CjB,aAAa;;EAoBb,WAAW;;;;;;EAgCL,cAAc;;;;;;;;;EAiCd,UAAU,aAAa,QAAQ,2BAA2B;;;;;;;;;;;;;;EAyE1D,QAAQ,iBAAiB,UAAS,wBAA6B,QAAQ;;;;;;;EA6IvE,cAAc,mBAAmB,kBAAkB,QAAQ;;;;;EAyD3D,WAAW,kBAAkB;;;;;;EAuB7B,qBAAqB,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmC7B,sBAAsB,kBAAkB,QAAQ;;EAyEhD,gBAAgB,kBAAkB,QAAQ;;EAMhD;;EAKA,WAAW,yBAAyB;;;;;cCvqBzB;;;;;;;;;;;;;cAwCA;;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;;;iBAqExB,mBAAmB,SAAS,sBAAsB;;;;;;;;UC/KjD;;EAEf;;EAEA;;;;;;EAMA;;;;;;;;;;;;iBAmBoB,eACpB,kBACA,gBACA,cACA,qBACA,iBACC,QAAQ;;;;;;;iBA+BW,YACpB,kBACA,sBACC,QAAQ;;;;;;;;;;;;;;iBAyBW,iBACpB,kBACA,iBACA,sBACC;;;;;;;;iBAsHmB,sBACpB,kBACA,uBACA,iBACA,mBAAmB,kBACnB,sBACC;;;;;;;;iBAcmB,uBACpB,kBACA,uBACA,mBAAmB,kBACnB,sBACC;;UAOc;;EAEf;;EAEA;;EAEA;;;;;EAKA;;;;;;;;;iBAUc,eAAe,SAAS;;;;;;;;;cCzS3B,2BAA2B;;;;;;;;;;iBAaxB,kBAAkB,cAAc,oCAAoC;;;;;;;iBAyBpE,aAAa,mBAAmB;;;;iBC3BhC,YAAY;;iBAKZ,QAAQ,mBAAmB;;iBAU3B,QAAQ,eAAe"}
package/dist/index.js CHANGED
@@ -300,6 +300,88 @@ function hashCommands(commands) {
300
300
  }));
301
301
  return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
302
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
+ }
303
385
  //#endregion
304
386
  //#region src/crypto.ts
305
387
  /**
@@ -586,8 +668,10 @@ function planReconcile(input) {
586
668
  * Resolve the per-adapter config the provider applies to the Discord entry in
587
669
  * `AgentChannels.adapters`. Discord has native embeds + action-row buttons, so
588
670
  * `toolDisplay` defaults to `'cards'` (unlike Telegram's `'text'`); `streaming`
589
- * post-and-edits the interaction followup; `gateway` (default `true`) makes core
590
- * 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`.)
591
675
  */
592
676
  function resolveDiscordAdapterConfig(config) {
593
677
  return {
@@ -611,11 +695,15 @@ function resolveDiscordAdapterConfig(config) {
611
695
  * member) or lazily off the first inbound interaction's authoritative `guild_id`
612
696
  * (see {@link activateGuild}).
613
697
  *
614
- * **Gateway is core's job.** The mounted interactions route only ever receives
615
- * HTTP Interactions (PING, slash commands, buttons). DMs / @mentions / reactions
616
- * arrive over the Gateway WebSocket, whose reconnection loop core owns: setting
617
- * `gateway: true` (default) on the adapter entry is enough — the wrapper never
618
- * 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.
619
707
  *
620
708
  * Implemented (issues `mastra-discord-13x.2` + `.3`): the app-config +
621
709
  * guild-keyed install store, `connect()`/`disconnect()`, OAuth2 invite-URL
@@ -629,6 +717,15 @@ var DiscordProvider = class {
629
717
  #store;
630
718
  /** Live adapters, keyed by installation id (one per agent; all share the app's bot token). */
631
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();
632
729
  /** Cached sync view of whether the app is configured (for {@link getInfo}). */
633
730
  #configured = false;
634
731
  #initPromise = null;
@@ -675,6 +772,7 @@ var DiscordProvider = class {
675
772
  this.#storeIsFallback = false;
676
773
  }
677
774
  if (isNewInstance) {
775
+ this.#stopAllGatewayLoops();
678
776
  this.#adapters.clear();
679
777
  this.#configured = this.#hasSuppliedCredentials();
680
778
  }
@@ -772,6 +870,7 @@ var DiscordProvider = class {
772
870
  app: void 0
773
871
  };
774
872
  this.#configured = false;
873
+ this.#stopAllGatewayLoops();
775
874
  this.#adapters.clear();
776
875
  this.#initPromise = null;
777
876
  return;
@@ -807,6 +906,7 @@ var DiscordProvider = class {
807
906
  if (merged.botToken && merged.publicKey && merged.applicationId) await store.saveAppConfig(merged);
808
907
  }
809
908
  const wasInitialized = this.#initPromise !== null;
909
+ this.#stopAllGatewayLoops();
810
910
  this.#adapters.clear();
811
911
  this.#initPromise = null;
812
912
  if (wasInitialized) await this.initialize();
@@ -964,19 +1064,15 @@ var DiscordProvider = class {
964
1064
  return installation;
965
1065
  }
966
1066
  /**
967
- * Disconnect an agent from Discord: remove its installation row and drop the
968
- * adapter entry.
969
- *
970
- * **Limitation:** the Gateway loop is owned by core (it calls
971
- * `startGatewayListener` itself; there is no `stopGatewayListener`), so
972
- * disconnect cannot kill an in-flight gateway window — it lapses at the next
973
- * 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.
974
1069
  */
975
1070
  async disconnect(agentId) {
976
1071
  const store = await this.#getStore();
977
1072
  const existing = await store.getByAgent(agentId);
978
1073
  if (!existing) throw new Error(`No Discord installation found for agent "${agentId}"`);
979
1074
  await this.#enqueueWebhookTask(existing.webhookId, async () => {
1075
+ this.#stopGatewayLoop(existing.id);
980
1076
  this.#adapters.delete(existing.id);
981
1077
  await store.deleteByAgent(agentId);
982
1078
  });
@@ -1110,6 +1206,7 @@ var DiscordProvider = class {
1110
1206
  if (!channels || channels.adapters["discord"] !== adapter) {
1111
1207
  channels = this.#createAgentChannels(agent, adapter);
1112
1208
  await channels.initialize(this.#mastra);
1209
+ this.#startGatewayLoop(installation, adapter, app);
1113
1210
  }
1114
1211
  try {
1115
1212
  return scheduleActivation(await channels.handleWebhookEvent(PLATFORM, c.req.raw, waitUntil ? { waitUntil } : void 0));
@@ -1186,13 +1283,64 @@ var DiscordProvider = class {
1186
1283
  if (!app) return;
1187
1284
  const agent = this.#resolveAgent(installation.agentId);
1188
1285
  const adapter = this.#getOrCreateAdapter(installation, app);
1189
- 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);
1190
1336
  }
1191
1337
  /**
1192
1338
  * Create AgentChannels for an agent with the Discord adapter, preserving any
1193
1339
  * adapters/config the agent author already configured (mirrors `@mastra/slack`
1194
- * / `@mastra/telegram`). `gateway: true` (default) makes core start the Gateway
1195
- * 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).
1196
1344
  */
1197
1345
  #createAgentChannels(agent, adapter) {
1198
1346
  const existingConfig = agent.getChannels()?.channelConfig;
@@ -1200,6 +1348,7 @@ var DiscordProvider = class {
1200
1348
  const entry = {
1201
1349
  adapter,
1202
1350
  ...resolveDiscordAdapterConfig(cfg),
1351
+ gateway: false,
1203
1352
  ...cfg.cors !== void 0 ? { cors: cfg.cors } : {},
1204
1353
  ...cfg.formatError !== void 0 ? { formatError: cfg.formatError } : {}
1205
1354
  };