@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/CHANGELOG.md +27 -0
- package/dist/index.cjs +14616 -5830
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -18
- package/dist/index.d.ts +89 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +451 -52
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
|
479
|
-
*
|
|
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
|
|
504
|
-
* HTTP Interactions (PING, slash commands, buttons). DMs / @mentions /
|
|
505
|
-
* arrive over the Gateway WebSocket, whose reconnection loop
|
|
506
|
-
*
|
|
507
|
-
*
|
|
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
|
|
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: "
|
|
734
|
-
guildIds: [
|
|
987
|
+
status: "pending",
|
|
988
|
+
guildIds: base?.guildIds ?? [],
|
|
735
989
|
displayName,
|
|
736
990
|
commands: commands.length ? commands : void 0,
|
|
737
|
-
commandVersions:
|
|
738
|
-
installedAt
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
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:
|
|
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.#
|
|
821
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
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:
|
|
945
|
-
*
|
|
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
|
};
|