@mastra/discord 1.0.0 → 1.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1110 @@
1
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID } from "crypto";
2
+ import { AgentChannels, resolveWaitUntil } from "@mastra/core/channels";
3
+ import { InMemoryChannelsStorage } from "@mastra/core/storage";
4
+ import { DiscordAdapter, createDiscordAdapter, createDiscordAdapter as createDiscordAdapter$1 } from "@chat-adapter/discord";
5
+ //#region src/types.ts
6
+ /**
7
+ * Default Discord REST base — **including** the `/api/v10` version segment. The
8
+ * client concatenates this with the request path verbatim, so an override must
9
+ * carry its own version segment (`https://example.test/api/v10`); passing a bare
10
+ * origin produces unversioned request URLs.
11
+ */
12
+ const DISCORD_API_BASE_URL = "https://discord.com/api/v10";
13
+ /** Base for the OAuth2 authorize (bot-invite) URL. */
14
+ const DISCORD_OAUTH_AUTHORIZE_URL = "https://discord.com/oauth2/authorize";
15
+ /**
16
+ * Named Discord permission bits (a subset). Discord permissions are a 53+ bit
17
+ * field, so they are represented as `bigint` and serialized to a decimal string
18
+ * in the invite URL.
19
+ *
20
+ * @see https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
21
+ */
22
+ const DISCORD_PERMISSIONS = {
23
+ ADD_REACTIONS: 1n << 6n,
24
+ VIEW_CHANNEL: 1n << 10n,
25
+ SEND_MESSAGES: 1n << 11n,
26
+ EMBED_LINKS: 1n << 14n,
27
+ ATTACH_FILES: 1n << 15n,
28
+ READ_MESSAGE_HISTORY: 1n << 16n,
29
+ USE_APPLICATION_COMMANDS: 1n << 31n,
30
+ SEND_MESSAGES_IN_THREADS: 1n << 38n
31
+ };
32
+ /**
33
+ * Default permissions requested in the bot-invite URL: enough for a chat agent
34
+ * to read and reply (in channels and threads), post embeds/files, add reactions,
35
+ * and see recent history. Kept intentionally minimal — no moderation or manage
36
+ * permissions. Callers can override via {@link DiscordConnectOptions.permissions}
37
+ * or {@link DiscordProviderConfig.permissions}.
38
+ */
39
+ const DEFAULT_INVITE_PERMISSIONS = DISCORD_PERMISSIONS.VIEW_CHANNEL | DISCORD_PERMISSIONS.SEND_MESSAGES | DISCORD_PERMISSIONS.SEND_MESSAGES_IN_THREADS | DISCORD_PERMISSIONS.EMBED_LINKS | DISCORD_PERMISSIONS.ATTACH_FILES | DISCORD_PERMISSIONS.READ_MESSAGE_HISTORY | DISCORD_PERMISSIONS.ADD_REACTIONS | DISCORD_PERMISSIONS.USE_APPLICATION_COMMANDS;
40
+ /** OAuth2 scopes requested by the bot-invite URL. */
41
+ const DEFAULT_INVITE_SCOPES = ["bot", "applications.commands"];
42
+ //#endregion
43
+ //#region src/discord-client.ts
44
+ /** `CHAT_INPUT` (slash) application command type. */
45
+ const APPLICATION_COMMAND_TYPE_CHAT_INPUT = 1;
46
+ /** Per-request ceiling for control-plane calls, so a hung API can't stall the provider. */
47
+ const REQUEST_TIMEOUT_MS = 1e4;
48
+ /**
49
+ * Discord snowflake pattern (17–20 digits). Every id Discord issues — application,
50
+ * guild, channel, user — is a snowflake. Validate before interpolating any id
51
+ * into a REST path so unverified callers can't smuggle path traversal (`..`) or
52
+ * other non-snowflake values that resolve to a different endpoint (e.g., a
53
+ * `guildId` of `..` on `/applications/{id}/guilds/{guildId}/commands` collapses
54
+ * to the global-commands endpoint and would overwrite an app's global commands).
55
+ *
56
+ * @see https://discord.com/developers/docs/reference#snowflakes
57
+ */
58
+ const DISCORD_SNOWFLAKE = /^\d{17,20}$/;
59
+ function assertSnowflake(value, label) {
60
+ if (!DISCORD_SNOWFLAKE.test(value)) throw new Error(`Invalid Discord ${label}: ${JSON.stringify(value)}`);
61
+ }
62
+ /**
63
+ * Call the Discord REST API with Bot-token auth. `method` is required; supplying
64
+ * `body` adds the JSON `content-type` header and serializes the payload. Returns
65
+ * the raw {@link Response} so callers can classify the status themselves rather
66
+ * than having every non-2xx collapse into a thrown error.
67
+ *
68
+ * This is the **control plane only** — validating the app and reading guild
69
+ * membership. Sending replies uses the interaction token inside the adapter,
70
+ * never this bot-token path.
71
+ */
72
+ async function discordRequest(botToken, method, path, apiBaseUrl = DISCORD_API_BASE_URL, body) {
73
+ const init = {
74
+ method,
75
+ headers: {
76
+ authorization: `Bot ${botToken}`,
77
+ ...body !== void 0 ? { "content-type": "application/json" } : {}
78
+ },
79
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
80
+ };
81
+ try {
82
+ return await fetch(`${apiBaseUrl}${path}`, {
83
+ ...init,
84
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
85
+ });
86
+ } catch (cause) {
87
+ throw new Error(`Discord ${method} ${path} request failed`, { cause });
88
+ }
89
+ }
90
+ /** Read a Discord error description from a non-2xx response, best-effort. */
91
+ async function describeError(response) {
92
+ return (await response.json().catch(() => null))?.message ?? `HTTP ${response.status}`;
93
+ }
94
+ /**
95
+ * Validate the app's bot token and resolve the application identity via
96
+ * `GET /applications/@me` (Bot auth). Throws if the token is rejected.
97
+ *
98
+ * @see https://discord.com/developers/docs/resources/application#get-current-application
99
+ */
100
+ async function validateApp(botToken, apiBaseUrl = DISCORD_API_BASE_URL) {
101
+ const response = await discordRequest(botToken, "GET", "/applications/@me", apiBaseUrl);
102
+ if (!response.ok) throw new Error(`Discord rejected the bot token: ${await describeError(response)}`);
103
+ const app = await response.json();
104
+ if (!app?.id) throw new Error("Discord /applications/@me returned no application id");
105
+ return app;
106
+ }
107
+ /**
108
+ * Whether the bot is already a member of a guild. `GET /guilds/{id}` (Bot auth)
109
+ * returns `200` only when the bot is in the guild. Absence is `404` (`10004
110
+ * Unknown Guild`) — Discord standardized on 404 rather than 403 so the response
111
+ * can't confirm a guild exists to a caller that lacks access — with legacy
112
+ * deployments still answering `403` (`50001 Missing Access`).
113
+ *
114
+ * Any **other** failure (`401`, `429`, `5xx`) is a transient or auth problem,
115
+ * not evidence of absence, and throws. Collapsing those into `false` would send
116
+ * a caller whose bot *is* in the guild down the invite path on a rate limit.
117
+ *
118
+ * @see https://discord.com/developers/docs/resources/guild#get-guild
119
+ */
120
+ async function guildHealthCheck(botToken, guildId, apiBaseUrl = DISCORD_API_BASE_URL) {
121
+ assertSnowflake(guildId, "guild id");
122
+ const response = await discordRequest(botToken, "GET", `/guilds/${guildId}`, apiBaseUrl);
123
+ if (response.ok) {
124
+ await response.body?.cancel().catch(() => {});
125
+ return true;
126
+ }
127
+ if (response.status === 404 || response.status === 403) {
128
+ await response.body?.cancel().catch(() => {});
129
+ return false;
130
+ }
131
+ throw new Error(`Discord guild lookup failed for "${guildId}": ${await describeError(response)}`);
132
+ }
133
+ /** Map normalized commands to the Discord bulk-overwrite `CHAT_INPUT` payload. */
134
+ function toCommandPayload(commands) {
135
+ return commands.map((c) => ({
136
+ name: c.name,
137
+ description: c.description,
138
+ type: APPLICATION_COMMAND_TYPE_CHAT_INPUT
139
+ }));
140
+ }
141
+ /**
142
+ * Longest `Retry-After` we will wait out inline before giving up. Registration
143
+ * is best-effort at the call site, so a long bucket is better surfaced as an
144
+ * error than held open.
145
+ */
146
+ const MAX_RETRY_AFTER_MS = 5e3;
147
+ /**
148
+ * `PUT` a bulk command overwrite, retrying **once** if Discord rate-limits it.
149
+ *
150
+ * Discord returns the wait in the `retry_after` body field (seconds, may be
151
+ * fractional) and the `Retry-After` header. The value is dynamic, so it is read
152
+ * from the response rather than assumed.
153
+ *
154
+ * @see https://discord.com/developers/docs/topics/rate-limits
155
+ */
156
+ async function bulkOverwriteCommands(botToken, path, commands, apiBaseUrl, scopeLabel) {
157
+ const payload = toCommandPayload(commands);
158
+ for (let attempt = 0; attempt < 2; attempt++) {
159
+ const response = await discordRequest(botToken, "PUT", path, apiBaseUrl, payload);
160
+ if (response.ok) {
161
+ await response.body?.cancel().catch(() => {});
162
+ return;
163
+ }
164
+ if (response.status === 429 && attempt === 0) {
165
+ const body = await response.json().catch(() => null);
166
+ const headerSeconds = Number(response.headers.get("retry-after"));
167
+ const seconds = body?.retry_after ?? (Number.isFinite(headerSeconds) ? headerSeconds : 0);
168
+ const waitMs = Math.ceil(seconds * 1e3);
169
+ if (waitMs > 0 && waitMs <= MAX_RETRY_AFTER_MS) {
170
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
171
+ continue;
172
+ }
173
+ throw new Error(`Discord ${scopeLabel} command registration was rate-limited; retry after ${seconds}s: ${body?.message ?? `HTTP ${response.status}`}`);
174
+ }
175
+ throw new Error(`Discord ${scopeLabel} command registration failed: ${await describeError(response)}`);
176
+ }
177
+ }
178
+ /**
179
+ * Bulk-overwrite a **guild's** slash commands (`PUT …/guilds/{guildId}/commands`).
180
+ * Guild-scoped commands update **instantly**. Register on first-seen guild;
181
+ * callers skip the call when the command hash is unchanged (200 creates/day/guild).
182
+ *
183
+ * @see https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-guild-application-commands
184
+ */
185
+ async function registerGuildCommands(botToken, applicationId, guildId, commands, apiBaseUrl = DISCORD_API_BASE_URL) {
186
+ assertSnowflake(applicationId, "application id");
187
+ assertSnowflake(guildId, "guild id");
188
+ await bulkOverwriteCommands(botToken, `/applications/${applicationId}/guilds/${guildId}/commands`, commands, apiBaseUrl, "guild");
189
+ }
190
+ /**
191
+ * Bulk-overwrite the app's **global** slash commands (`PUT …/commands`).
192
+ * Eventually consistent (propagation is not instant — don't quote a fixed
193
+ * number). Opt-in via `commandScope: 'global'`.
194
+ *
195
+ * @see https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands
196
+ */
197
+ async function registerGlobalCommands(botToken, applicationId, commands, apiBaseUrl = DISCORD_API_BASE_URL) {
198
+ assertSnowflake(applicationId, "application id");
199
+ await bulkOverwriteCommands(botToken, `/applications/${applicationId}/commands`, commands, apiBaseUrl, "global");
200
+ }
201
+ /**
202
+ * Build the OAuth2 bot-invite URL. This *is* the Discord "install" flow: the
203
+ * operator opens it, picks a guild, and authorizes — there is no token exchange.
204
+ * `scope=bot applications.commands` + a `permissions` bitfield.
205
+ *
206
+ * @see https://discord.com/developers/docs/topics/oauth2#bot-authorization-flow
207
+ */
208
+ function buildInviteUrl(options) {
209
+ const permissions = (options.permissions ?? DEFAULT_INVITE_PERMISSIONS).toString();
210
+ const scopes = options.scopes ?? DEFAULT_INVITE_SCOPES;
211
+ const url = new URL(DISCORD_OAUTH_AUTHORIZE_URL);
212
+ url.searchParams.set("client_id", options.applicationId);
213
+ url.searchParams.set("scope", scopes.join(" "));
214
+ url.searchParams.set("permissions", permissions);
215
+ if (options.guildId) {
216
+ url.searchParams.set("guild_id", options.guildId);
217
+ url.searchParams.set("disable_guild_select", "true");
218
+ }
219
+ return url.toString();
220
+ }
221
+ //#endregion
222
+ //#region src/commands.ts
223
+ /**
224
+ * Conventional command seed registered when a connect provides none. Discord
225
+ * surfaces `/help` in the command picker; the built-in DM/mention handling
226
+ * covers everything else, so the seed is intentionally tiny.
227
+ * @see https://discord.com/developers/docs/interactions/application-commands
228
+ */
229
+ const DEFAULT_COMMANDS = [{
230
+ name: "help",
231
+ description: "Show what this bot can do"
232
+ }];
233
+ /**
234
+ * Map user-supplied commands to Discord `CHAT_INPUT` command shapes, enforcing
235
+ * the API constraints: `name` is lowercased, stripped of a leading slash,
236
+ * reduced to `[a-z0-9_-]`, and clamped to 1-32 chars; `description` defaults to
237
+ * `Run /<name>` and is clamped to 1-100 chars. Empty or duplicate names are
238
+ * dropped.
239
+ *
240
+ * @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming
241
+ */
242
+ function normalizeCommands(raw) {
243
+ if (!raw) return [];
244
+ const seen = /* @__PURE__ */ new Set();
245
+ const commands = [];
246
+ for (const item of raw) {
247
+ const input = typeof item === "string" ? { name: item } : item;
248
+ const name = input.name.replace(/^\//, "").toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 32);
249
+ if (!name || seen.has(name)) continue;
250
+ seen.add(name);
251
+ const description = (input.description?.trim() || `Run /${name}`).slice(0, 100);
252
+ commands.push({
253
+ name,
254
+ description
255
+ });
256
+ }
257
+ return commands;
258
+ }
259
+ /**
260
+ * Stable content hash of a normalized command list, used to skip re-`PUT`ting a
261
+ * guild/global command set that hasn't changed (Discord allows only 200
262
+ * application-command creates per day, per guild). Order-independent: commands
263
+ * are sorted by name before hashing.
264
+ */
265
+ function hashCommands(commands) {
266
+ const canonical = [...commands].sort((a, b) => a.name.localeCompare(b.name)).map((c) => ({
267
+ name: c.name,
268
+ description: c.description
269
+ }));
270
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
271
+ }
272
+ //#endregion
273
+ //#region src/crypto.ts
274
+ /**
275
+ * Opt-in AES-256-GCM encryption for the app's bot token at rest, with
276
+ * HKDF-SHA256 key derivation (mirrors `@mastra/telegram` / `@mastra/slack`'s
277
+ * `crypto.ts`). Each value gets a fresh random 16-byte salt + 12-byte IV; the
278
+ * salt travels in the ciphertext, so the same passphrase never derives the same
279
+ * key twice. The algorithm prefix lets plaintext and encrypted values coexist
280
+ * during migration, so {@link decrypt} can no-op on plaintext.
281
+ *
282
+ * Format: `aes-256-gcm-hkdf:base64(salt):base64(iv):base64(authTag):base64(ciphertext)`
283
+ */
284
+ const ALGO_PREFIX = "aes-256-gcm-hkdf";
285
+ const HKDF_INFO = "mastra-discord-encryption";
286
+ function deriveKey(passphrase, salt) {
287
+ return Buffer.from(hkdfSync("sha256", passphrase, salt, HKDF_INFO, 32));
288
+ }
289
+ /** Whether a stored value was produced by {@link encrypt}. */
290
+ function isEncrypted(value) {
291
+ return value.startsWith(`${ALGO_PREFIX}:`);
292
+ }
293
+ /** Encrypt a UTF-8 string with a per-value random salt + IV. */
294
+ function encrypt(plaintext, passphrase) {
295
+ const salt = randomBytes(16);
296
+ const iv = randomBytes(12);
297
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(passphrase, salt), iv);
298
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
299
+ const tag = cipher.getAuthTag();
300
+ return `${ALGO_PREFIX}:${salt.toString("base64")}:${iv.toString("base64")}:${tag.toString("base64")}:${enc.toString("base64")}`;
301
+ }
302
+ /** Decrypt a value from {@link encrypt}. Plaintext (unprefixed) is returned unchanged. */
303
+ function decrypt(value, passphrase) {
304
+ if (!isEncrypted(value)) return value;
305
+ const [, saltB64, ivB64, tagB64, ctB64] = value.split(":");
306
+ if (!saltB64 || !ivB64 || !tagB64 || ctB64 === void 0) throw new Error("Invalid ciphertext payload");
307
+ try {
308
+ const decipher = createDecipheriv("aes-256-gcm", deriveKey(passphrase, Buffer.from(saltB64, "base64")), Buffer.from(ivB64, "base64"));
309
+ decipher.setAuthTag(Buffer.from(tagB64, "base64"));
310
+ return Buffer.concat([decipher.update(Buffer.from(ctB64, "base64")), decipher.final()]).toString("utf8");
311
+ } catch (cause) {
312
+ throw new Error("Failed to decrypt a stored Discord secret — either the configured encryption key does not match the one it was encrypted with, or the stored value is corrupt. Check `encryptionKey` on DiscordProvider or MASTRA_ENCRYPTION_KEY.", { cause });
313
+ }
314
+ }
315
+ //#endregion
316
+ //#region src/install-store.ts
317
+ /** Platform identifier used for every stored record, config, and route. */
318
+ const PLATFORM = "discord";
319
+ /**
320
+ * Two-tier persistence for Discord, layered over the platform-agnostic
321
+ * `ChannelsStorage` (the same store `@mastra/slack` / `@mastra/telegram` use).
322
+ *
323
+ * - **App tier** (`saveConfig`/`getConfig`) — the one application's
324
+ * `botToken` + `publicKey` + `applicationId`, stored **once** (one app, many
325
+ * guilds). `botToken` is AES-256-GCM encrypted at rest when an `encryptionKey`
326
+ * is supplied.
327
+ * - **Install tier** (`getInstallationByAgent`/`…ByWebhookId`/`saveInstallation`)
328
+ * — one row per agent, keyed by agent, tracking the `guildIds` the bot is live
329
+ * in. Secrets are **not** duplicated per row.
330
+ */
331
+ var DiscordInstallStore = class {
332
+ storage;
333
+ encryptionKey;
334
+ constructor(storage, encryptionKey) {
335
+ this.storage = storage;
336
+ this.encryptionKey = encryptionKey;
337
+ }
338
+ /** The stored app config, if any (bot token decrypted). */
339
+ async getAppConfig() {
340
+ const config = await this.storage.getConfig(PLATFORM);
341
+ if (!config) return null;
342
+ const data = config.data ?? {};
343
+ if (!data.botToken || !data.publicKey || !data.applicationId) return null;
344
+ return {
345
+ botToken: this.#dec(data.botToken),
346
+ publicKey: data.publicKey,
347
+ applicationId: data.applicationId
348
+ };
349
+ }
350
+ /** Persist the app config once (bot token encrypted at rest when keyed). */
351
+ async saveAppConfig(app) {
352
+ const data = {
353
+ botToken: this.#enc(app.botToken),
354
+ publicKey: app.publicKey,
355
+ applicationId: app.applicationId
356
+ };
357
+ await this.storage.saveConfig({
358
+ platform: PLATFORM,
359
+ data,
360
+ updatedAt: /* @__PURE__ */ new Date()
361
+ });
362
+ }
363
+ /** Remove the stored app config. */
364
+ async deleteAppConfig() {
365
+ await this.storage.deleteConfig(PLATFORM);
366
+ }
367
+ /** The installation for an agent, if any. */
368
+ async getByAgent(agentId) {
369
+ const record = await this.storage.getInstallationByAgent(PLATFORM, agentId);
370
+ return record ? this.#fromRecord(record) : null;
371
+ }
372
+ /** Look up an installation by the routing id in its interactions-route path. */
373
+ async getByWebhookId(webhookId) {
374
+ const record = await this.storage.getInstallationByWebhookId(webhookId);
375
+ return record && record.platform === "discord" ? this.#fromRecord(record) : null;
376
+ }
377
+ /**
378
+ * Find the installation that owns a guild (tenancy lookup by `guildId`). Used
379
+ * when the routing key is a guild rather than a webhook id.
380
+ *
381
+ * `guildIds` is per-agent and nothing enforces exclusivity, so two agents can
382
+ * legitimately be installed into the same guild. Returning the first match
383
+ * would make routing depend on storage row order — the same interaction could
384
+ * reach a different agent on the next lookup. Instead the **oldest** install
385
+ * wins (ties broken by id), which is stable across restarts and storage
386
+ * backends, and the ambiguity is logged once so an operator can see it.
387
+ *
388
+ * `ChannelsStorage` has no query-by-data-field, so this necessarily lists the
389
+ * platform's installations; it is not on the interactions hot path, which
390
+ * routes by `webhookId`.
391
+ */
392
+ async getByGuildId(guildId) {
393
+ const matches = (await this.storage.listInstallations(PLATFORM)).filter((r) => (r.data?.guildIds ?? []).includes(guildId));
394
+ if (matches.length === 0) return null;
395
+ if (matches.length > 1) console.warn(`[Discord] Guild "${guildId}" is claimed by ${matches.length} installations (${matches.map((r) => r.agentId).join(", ")}). Routing to the oldest; disconnect the others to remove the ambiguity.`);
396
+ const winner = matches.reduce((oldest, r) => {
397
+ const a = r.createdAt?.getTime() ?? 0;
398
+ const b = oldest.createdAt?.getTime() ?? 0;
399
+ if (a !== b) return a < b ? r : oldest;
400
+ return r.id < oldest.id ? r : oldest;
401
+ });
402
+ return this.#fromRecord(winner);
403
+ }
404
+ /** Insert or replace an installation. */
405
+ async save(installation) {
406
+ await this.storage.saveInstallation(this.#toRecord(installation));
407
+ }
408
+ /** All Discord installations (active and pending). */
409
+ async list() {
410
+ return (await this.storage.listInstallations(PLATFORM)).map((r) => this.#fromRecord(r));
411
+ }
412
+ /** Remove an agent's installation, if present. */
413
+ async deleteByAgent(agentId) {
414
+ const record = await this.storage.getInstallationByAgent(PLATFORM, agentId);
415
+ if (record) await this.storage.deleteInstallation(record.id);
416
+ }
417
+ #enc(value) {
418
+ return value && this.encryptionKey ? encrypt(value, this.encryptionKey) : value;
419
+ }
420
+ #dec(value) {
421
+ if (!value) return value;
422
+ if (!this.encryptionKey) {
423
+ if (isEncrypted(value)) throw new Error("The stored Discord bot token is encrypted at rest, but no encryption key is configured. Set `encryptionKey` on DiscordProvider or MASTRA_ENCRYPTION_KEY.");
424
+ return value;
425
+ }
426
+ return decrypt(value, this.encryptionKey);
427
+ }
428
+ #toRecord(install) {
429
+ const data = {
430
+ guildIds: install.guildIds,
431
+ displayName: install.displayName,
432
+ commands: install.commands,
433
+ commandVersions: install.commandVersions
434
+ };
435
+ return {
436
+ id: install.id,
437
+ platform: PLATFORM,
438
+ agentId: install.agentId,
439
+ status: install.status,
440
+ webhookId: install.webhookId,
441
+ data,
442
+ createdAt: install.installedAt,
443
+ updatedAt: /* @__PURE__ */ new Date()
444
+ };
445
+ }
446
+ #fromRecord(record) {
447
+ const data = record.data ?? {};
448
+ return {
449
+ id: record.id,
450
+ agentId: record.agentId,
451
+ webhookId: record.webhookId ?? "",
452
+ status: record.status === "active" ? "active" : "pending",
453
+ guildIds: data.guildIds ?? [],
454
+ displayName: data.displayName,
455
+ commands: data.commands,
456
+ commandVersions: data.commandVersions,
457
+ installedAt: record.createdAt
458
+ };
459
+ }
460
+ };
461
+ /** Project an installation to its public, secret-free info for the editor UI. */
462
+ function toInstallationInfo(install) {
463
+ return {
464
+ id: install.id,
465
+ platform: PLATFORM,
466
+ agentId: install.agentId,
467
+ status: install.status,
468
+ displayName: install.displayName,
469
+ installedAt: install.installedAt
470
+ };
471
+ }
472
+ //#endregion
473
+ //#region src/discord-provider.ts
474
+ /**
475
+ * Resolve the per-adapter config the provider applies to the Discord entry in
476
+ * `AgentChannels.adapters`. Discord has native embeds + action-row buttons, so
477
+ * `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.
480
+ */
481
+ function resolveDiscordAdapterConfig(config) {
482
+ return {
483
+ streaming: config.streaming ?? true,
484
+ typingStatus: config.typingStatus ?? true,
485
+ toolDisplay: config.toolDisplay ?? "cards",
486
+ gateway: config.gateway ?? true
487
+ };
488
+ }
489
+ /**
490
+ * Discord channel provider for Mastra — a {@link ChannelProvider} over
491
+ * `@chat-adapter/discord`. The adapter handles the protocol (Ed25519 request
492
+ * verification, PING/PONG, deferrals, embeds/buttons, the Gateway bridge,
493
+ * post-and-edit streaming); this provider adds the lifecycle layer.
494
+ *
495
+ * **One app, many guilds.** Discord has no programmatic app creation, so a
496
+ * single application's credentials (bot token / public key / application id) are
497
+ * stored **once** and reused across every guild. "Installing" is a bot invite —
498
+ * an OAuth2 authorize URL — not a token exchange, so `connect()` returns the
499
+ * `oauth` variant. A guild is confirmed either eagerly (the bot is already a
500
+ * member) or lazily off the first inbound interaction's authoritative `guild_id`
501
+ * (see {@link activateGuild}).
502
+ *
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.
508
+ *
509
+ * Implemented (issues `mastra-discord-13x.2` + `.3`): the app-config +
510
+ * guild-keyed install store, `connect()`/`disconnect()`, OAuth2 invite-URL
511
+ * generation, the interactions route (raw-body delegation), and adapter/Gateway
512
+ * wiring. Command registration lands in `mastra-discord-13x.4`.
513
+ */
514
+ var DiscordProvider = class {
515
+ id = PLATFORM;
516
+ #config;
517
+ #mastra;
518
+ #store;
519
+ /** Live adapters, keyed by installation id (one per agent; all share the app's bot token). */
520
+ #adapters = /* @__PURE__ */ new Map();
521
+ /** Cached sync view of whether the app is configured (for {@link getInfo}). */
522
+ #configured = false;
523
+ #initPromise = null;
524
+ /**
525
+ * Whether {@link #store} was built on the in-memory fallback rather than real
526
+ * storage — i.e. it was resolved before a `Mastra` instance was available.
527
+ */
528
+ #storeIsFallback = false;
529
+ /**
530
+ * A fallback store dropped by {@link __attach}, held until the next store
531
+ * access so its contents can be carried into the real storage. See
532
+ * {@link #migrateFallback}.
533
+ */
534
+ #pendingMigration;
535
+ /**
536
+ * The single in-flight store resolution. Concurrent callers share it rather
537
+ * than each running {@link #resolveStore}, so the fallback migration can't be
538
+ * raced: without this, a second caller reads the already-claimed
539
+ * {@link #pendingMigration} as absent, skips the migration, and caches a store
540
+ * over storage the first caller has not finished populating.
541
+ */
542
+ #storeResolution;
543
+ /**
544
+ * Bumped by every {@link __attach}. A resolution that started before an attach
545
+ * was built against the previous storage target, so it must not cache its
546
+ * result; it reports itself superseded and the caller resolves again.
547
+ */
548
+ #storeGeneration = 0;
549
+ constructor(config = {}) {
550
+ this.#config = config;
551
+ this.#configured = this.#suppliedAppConfig() != null;
552
+ }
553
+ /**
554
+ * Called by Mastra when this channel is registered.
555
+ * @internal
556
+ */
557
+ __attach(mastra) {
558
+ const isNewInstance = this.#mastra != null && this.#mastra !== mastra;
559
+ this.#storeGeneration++;
560
+ if (isNewInstance || this.#storeIsFallback) {
561
+ if (this.#storeIsFallback) this.#pendingMigration = this.#store;
562
+ this.#initPromise = null;
563
+ this.#store = void 0;
564
+ this.#storeIsFallback = false;
565
+ }
566
+ if (isNewInstance) {
567
+ this.#adapters.clear();
568
+ this.#configured = this.#suppliedAppConfig() != null;
569
+ }
570
+ this.#mastra = mastra;
571
+ }
572
+ /**
573
+ * The interactions route: a single POST endpoint keyed by an opaque
574
+ * `webhookId`. The handler passes the **RAW** request bytes straight to
575
+ * `adapter.handleWebhook` — Ed25519 verification + PING/PONG live in the
576
+ * adapter, and any middleware that parsed/re-serialized the body would break
577
+ * every signature. `requiresAuth: false` (Discord authenticates via Ed25519,
578
+ * not a bearer token). Auto-initializes on first hit (mirrors `@mastra/slack`).
579
+ */
580
+ getRoutes() {
581
+ const self = this;
582
+ const withInit = (handler) => {
583
+ return async ({ mastra }) => {
584
+ self.#mastra = mastra;
585
+ await self.#autoInitialize();
586
+ return handler.bind(self);
587
+ };
588
+ };
589
+ return [{
590
+ path: `/${PLATFORM}/events/:webhookId`,
591
+ method: "POST",
592
+ requiresAuth: false,
593
+ createHandler: withInit(this.#handleWebhook)
594
+ }];
595
+ }
596
+ /** Discovery metadata for the editor UI. */
597
+ getInfo() {
598
+ return {
599
+ id: this.id,
600
+ name: "Discord",
601
+ isConfigured: this.#configured,
602
+ connectOptionsSchema: {
603
+ type: "object",
604
+ properties: {
605
+ guildId: {
606
+ type: "string",
607
+ description: "Guild (server) to install into. If the bot is already a member, connect binds immediately; otherwise you receive an invite URL."
608
+ },
609
+ name: {
610
+ type: "string",
611
+ description: "Display name for the installation (defaults to the application's name)."
612
+ },
613
+ commands: {
614
+ type: "array",
615
+ items: { type: "string" },
616
+ description: "Slash command names to register for this agent."
617
+ }
618
+ }
619
+ }
620
+ };
621
+ }
622
+ /**
623
+ * Restore state from storage: mark the provider configured when an app config
624
+ * or any active installation exists, and rebuild an adapter + `AgentChannels`
625
+ * per active install so its Gateway loop (core-owned) starts. Idempotent.
626
+ */
627
+ async initialize() {
628
+ if (this.#initPromise) return this.#initPromise;
629
+ this.#initPromise = this.#doInitialize();
630
+ try {
631
+ await this.#initPromise;
632
+ } catch (err) {
633
+ this.#initPromise = null;
634
+ throw err;
635
+ }
636
+ }
637
+ async #doInitialize() {
638
+ const store = await this.#getStore();
639
+ const active = (await store.list()).filter((i) => i.status === "active");
640
+ const hasApp = await store.getAppConfig() != null || this.#suppliedAppConfig() != null;
641
+ this.#configured = hasApp || active.length > 0;
642
+ for (const installation of active) try {
643
+ await this.#activateInstallation(installation);
644
+ } catch (err) {
645
+ console.error(`[Discord] Failed to restore installation "${installation.id}":`, err);
646
+ }
647
+ }
648
+ /**
649
+ * Provide or clear the app credentials at runtime. An object merges/overrides
650
+ * `botToken` / `publicKey` / `applicationId` (persisted on the next `connect`);
651
+ * `null` clears the stored app config.
652
+ */
653
+ async configure(credentials) {
654
+ if (credentials === null) {
655
+ await (await this.#getStore()).deleteAppConfig();
656
+ this.#config = {
657
+ ...this.#config,
658
+ app: void 0
659
+ };
660
+ this.#configured = false;
661
+ this.#adapters.clear();
662
+ this.#initPromise = null;
663
+ return;
664
+ }
665
+ const previous = this.#config.app;
666
+ this.#config = {
667
+ ...this.#config,
668
+ app: {
669
+ ...previous,
670
+ ...credentials
671
+ }
672
+ };
673
+ this.#configured = this.#suppliedAppConfig() != null || this.#configured;
674
+ if (![
675
+ "botToken",
676
+ "publicKey",
677
+ "applicationId"
678
+ ].some((k) => credentials[k] !== void 0 && credentials[k] !== previous?.[k])) return;
679
+ const store = await this.#getStore();
680
+ const stored = await store.getAppConfig();
681
+ if (stored) {
682
+ const merged = {
683
+ ...stored,
684
+ ...credentials
685
+ };
686
+ if (merged.botToken && merged.publicKey && merged.applicationId) await store.saveAppConfig(merged);
687
+ }
688
+ const wasInitialized = this.#initPromise !== null;
689
+ this.#adapters.clear();
690
+ this.#initPromise = null;
691
+ if (wasInitialized) await this.initialize();
692
+ }
693
+ /**
694
+ * Connect an agent to Discord.
695
+ *
696
+ * - Ensures the app config exists (stored, or supplied via provider config /
697
+ * `DISCORD_*` env and persisted once, after validation). Throws if none —
698
+ * Discord apps are created in the Developer Portal, not programmatically.
699
+ * - Validates the bot token via `GET /applications/@me`.
700
+ * - If `options.guildId` is given **and the bot is already in that guild**,
701
+ * binds immediately (`{ type: 'immediate' }`).
702
+ * - Otherwise persists a pending install and returns the OAuth2 bot-invite URL
703
+ * (`{ type: 'oauth', authorizationUrl }`); the guild activates lazily on the
704
+ * first interaction.
705
+ */
706
+ async connect(agentId, options = {}) {
707
+ const store = await this.#getStore();
708
+ const existing = await store.getByAgent(agentId);
709
+ if (existing?.status === "active") throw new Error(`Agent "${agentId}" is already connected to Discord. Disconnect first to reconnect. To add another server, invite the bot with the existing URL — new guilds activate on first use.`);
710
+ const { app, persisted } = await this.#resolveAppConfig(store);
711
+ const identity = await validateApp(app.botToken, this.#apiBaseUrl());
712
+ if (!persisted) await store.saveAppConfig(app);
713
+ const installationId = existing?.id ?? randomUUID();
714
+ const webhookId = existing?.webhookId ?? randomUUID();
715
+ const displayName = options.name ?? identity.name;
716
+ const installedAt = existing?.installedAt ?? /* @__PURE__ */ new Date();
717
+ const commands = normalizeCommands(options.commands ?? this.#config.commands ?? DEFAULT_COMMANDS);
718
+ if (options.guildId && await guildHealthCheck(app.botToken, options.guildId, this.#apiBaseUrl())) {
719
+ const installation = {
720
+ id: installationId,
721
+ agentId,
722
+ webhookId,
723
+ status: "active",
724
+ guildIds: [options.guildId],
725
+ displayName,
726
+ commands: commands.length ? commands : void 0,
727
+ commandVersions: existing?.commandVersions,
728
+ installedAt
729
+ };
730
+ await store.save(installation);
731
+ await this.#registerCommands(app, installation, options.guildId);
732
+ await this.#activateInstallation(installation);
733
+ this.#configured = true;
734
+ await this.#config.onInstall?.(installation);
735
+ return {
736
+ type: "immediate",
737
+ installationId
738
+ };
739
+ }
740
+ const pending = {
741
+ id: installationId,
742
+ agentId,
743
+ webhookId,
744
+ status: "pending",
745
+ guildIds: existing?.guildIds ?? [],
746
+ displayName,
747
+ commands: commands.length ? commands : void 0,
748
+ commandVersions: existing?.commandVersions,
749
+ installedAt
750
+ };
751
+ await store.save(pending);
752
+ await this.#registerCommands(app, pending);
753
+ this.#configured = true;
754
+ return {
755
+ type: "oauth",
756
+ authorizationUrl: buildInviteUrl({
757
+ applicationId: app.applicationId,
758
+ permissions: options.permissions ?? this.#config.permissions ?? DEFAULT_INVITE_PERMISSIONS,
759
+ scopes: DEFAULT_INVITE_SCOPES,
760
+ ...options.guildId !== void 0 ? { guildId: options.guildId } : {}
761
+ }),
762
+ installationId
763
+ };
764
+ }
765
+ /**
766
+ * Confirm a guild off an inbound interaction's authoritative `guild_id` and
767
+ * mark the installation active (lazy activation). Called by the interactions
768
+ * route on first use. Returns the updated installation, or `null` if the
769
+ * `webhookId` is unknown.
770
+ */
771
+ async activateGuild(webhookId, guildId) {
772
+ const next = (this.#activationChains.get(webhookId) ?? Promise.resolve()).catch(() => {}).then(() => this.#activateGuildNow(webhookId, guildId));
773
+ this.#activationChains.set(webhookId, next);
774
+ try {
775
+ return await next;
776
+ } finally {
777
+ if (this.#activationChains.get(webhookId) === next) this.#activationChains.delete(webhookId);
778
+ }
779
+ }
780
+ #activationChains = /* @__PURE__ */ new Map();
781
+ async #activateGuildNow(webhookId, guildId) {
782
+ const store = await this.#getStore();
783
+ const installation = await store.getByWebhookId(webhookId);
784
+ if (!installation) return null;
785
+ const wasActive = installation.status === "active";
786
+ const known = installation.guildIds.includes(guildId);
787
+ if (wasActive && known) return installation;
788
+ if (!known) installation.guildIds.push(guildId);
789
+ installation.status = "active";
790
+ await store.save(installation);
791
+ const app = await store.getAppConfig();
792
+ if (app) await this.#registerCommands(app, installation, guildId);
793
+ this.#configured = true;
794
+ if (!wasActive) await this.#config.onInstall?.(installation);
795
+ return installation;
796
+ }
797
+ /**
798
+ * Disconnect an agent from Discord: remove its installation row and drop the
799
+ * adapter entry.
800
+ *
801
+ * **Limitation:** the Gateway loop is owned by core (it calls
802
+ * `startGatewayListener` itself; there is no `stopGatewayListener`), so
803
+ * disconnect cannot kill an in-flight gateway window — it lapses at the next
804
+ * duration boundary. Contrast Telegram's clean `stopPolling()`.
805
+ */
806
+ async disconnect(agentId) {
807
+ const store = await this.#getStore();
808
+ const existing = await store.getByAgent(agentId);
809
+ if (!existing) throw new Error(`No Discord installation found for agent "${agentId}"`);
810
+ this.#adapters.delete(existing.id);
811
+ await store.deleteByAgent(agentId);
812
+ this.#configured = await store.getAppConfig() != null || (await store.list()).some((i) => i.status === "active");
813
+ }
814
+ /** List installations (public info only — no secrets). */
815
+ async listInstallations() {
816
+ return (await (await this.#getStore()).list()).map(toInstallationInfo);
817
+ }
818
+ /** The full installation for an agent (no secrets live on it), or `null`. */
819
+ async getInstallation(agentId) {
820
+ return await (await this.#getStore()).getByAgent(agentId) ?? null;
821
+ }
822
+ /** Whether the Discord app is configured (credentials resolvable). */
823
+ isConfigured() {
824
+ return this.#configured;
825
+ }
826
+ /** The live `DiscordAdapter` for an installation id, if one is active. */
827
+ getAdapter(installationId) {
828
+ return this.#adapters.get(installationId);
829
+ }
830
+ async #handleWebhook(c) {
831
+ const webhookId = c.req.param("webhookId");
832
+ if (!webhookId) return c.json({ error: "Missing webhookId" }, 400);
833
+ const store = await this.#getStore();
834
+ const installation = await store.getByWebhookId(webhookId);
835
+ if (!installation) return c.json({ error: "Unknown webhook" }, 404);
836
+ const app = await store.getAppConfig();
837
+ if (!app) return c.json({ error: "Discord app is not configured" }, 503);
838
+ const adapter = this.#getOrCreateAdapter(installation, app);
839
+ const waitUntil = this.#config.waitUntil ?? resolveWaitUntil(c);
840
+ const activationBody = c.req.raw.clone();
841
+ const scheduleActivation = (response) => {
842
+ if (response.ok) {
843
+ const activation = this.#activateFromInteraction(webhookId, activationBody);
844
+ if (waitUntil) waitUntil(activation);
845
+ else activation.catch(() => {});
846
+ }
847
+ return response;
848
+ };
849
+ const agent = this.#resolveAgent(installation.agentId);
850
+ if (!agent || !this.#mastra) try {
851
+ return scheduleActivation(await adapter.handleWebhook(c.req.raw, waitUntil ? { waitUntil } : void 0));
852
+ } catch (err) {
853
+ console.error("[Discord] adapter.handleWebhook error:", err);
854
+ return c.json({ error: "Internal error" }, 500);
855
+ }
856
+ let channels = agent.getChannels();
857
+ if (!channels || channels.adapters["discord"] !== adapter) {
858
+ channels = this.#createAgentChannels(agent, adapter);
859
+ await channels.initialize(this.#mastra);
860
+ }
861
+ try {
862
+ return scheduleActivation(await channels.handleWebhookEvent(PLATFORM, c.req.raw, waitUntil ? { waitUntil } : void 0));
863
+ } catch (err) {
864
+ console.error("[Discord] Error delegating to AgentChannels:", err);
865
+ return c.json({ error: "Internal error" }, 500);
866
+ }
867
+ }
868
+ /** Extract the guild from an interaction body (a clone) and activate it. Best-effort. */
869
+ async #activateFromInteraction(webhookId, request) {
870
+ try {
871
+ const body = await request.json();
872
+ if (body?.guild_id) await this.activateGuild(webhookId, body.guild_id);
873
+ } catch {}
874
+ }
875
+ #apiBaseUrl() {
876
+ return this.#config.apiBaseUrl ?? "https://discord.com/api/v10";
877
+ }
878
+ /**
879
+ * Register an installation's slash commands (best-effort — a failure logs and
880
+ * doesn't block connect). Skips the `PUT` when the command hash is unchanged
881
+ * for the scope key, so re-activation of a known guild costs no API call
882
+ * (200 creates/day/guild). `guildId` is required for guild scope; global scope
883
+ * ignores it and registers once under the `'global'` key.
884
+ */
885
+ async #registerCommands(app, installation, guildId) {
886
+ const commands = installation.commands ?? [];
887
+ if (!commands.length) return;
888
+ const scope = this.#config.commandScope ?? "guild";
889
+ const key = scope === "global" ? "global" : guildId;
890
+ if (!key) return;
891
+ const hash = hashCommands(commands);
892
+ const versions = installation.commandVersions ?? {};
893
+ if (versions[key] === hash) return;
894
+ try {
895
+ if (scope === "global") await registerGlobalCommands(app.botToken, app.applicationId, commands, this.#apiBaseUrl());
896
+ else await registerGuildCommands(app.botToken, app.applicationId, guildId, commands, this.#apiBaseUrl());
897
+ installation.commandVersions = {
898
+ ...versions,
899
+ [key]: hash
900
+ };
901
+ await (await this.#getStore()).save(installation);
902
+ } catch (err) {
903
+ console.warn(`[Discord] command registration failed (${scope}${guildId ? `, guild ${guildId}` : ""}):`, err);
904
+ }
905
+ }
906
+ /** Build (once) the adapter for an installation from the shared app config. */
907
+ #getOrCreateAdapter(installation, app) {
908
+ const existing = this.#adapters.get(installation.id);
909
+ if (existing) return existing;
910
+ const adapter = createDiscordAdapter$1({
911
+ botToken: app.botToken,
912
+ publicKey: app.publicKey,
913
+ applicationId: app.applicationId,
914
+ apiUrl: this.#apiBaseUrl(),
915
+ userName: installation.displayName,
916
+ ...this.#config.mentionRoleIds !== void 0 ? { mentionRoleIds: this.#config.mentionRoleIds } : {},
917
+ ...this.#config.interactionFlags !== void 0 ? { interactionFlags: this.#config.interactionFlags } : {},
918
+ ...this.#config.logger !== void 0 ? { logger: this.#config.logger } : {}
919
+ });
920
+ this.#adapters.set(installation.id, adapter);
921
+ return adapter;
922
+ }
923
+ /** Rebuild the adapter and inject AgentChannels for an active installation. */
924
+ async #activateInstallation(installation) {
925
+ const app = await (await this.#getStore()).getAppConfig();
926
+ if (!app) return;
927
+ const agent = this.#resolveAgent(installation.agentId);
928
+ const adapter = this.#getOrCreateAdapter(installation, app);
929
+ if (agent && this.#mastra) await this.#createAgentChannels(agent, adapter).initialize(this.#mastra);
930
+ }
931
+ /**
932
+ * Create AgentChannels for an agent with the Discord adapter, preserving any
933
+ * adapters/config the agent author already configured (mirrors `@mastra/slack`
934
+ * / `@mastra/telegram`). `gateway: true` (default) makes core start the Gateway
935
+ * reconnection loop for DMs/mentions.
936
+ */
937
+ #createAgentChannels(agent, adapter) {
938
+ const existingConfig = agent.getChannels()?.channelConfig;
939
+ const cfg = this.#config;
940
+ const entry = {
941
+ adapter,
942
+ ...resolveDiscordAdapterConfig(cfg),
943
+ ...cfg.cors !== void 0 ? { cors: cfg.cors } : {},
944
+ ...cfg.formatError !== void 0 ? { formatError: cfg.formatError } : {}
945
+ };
946
+ const channels = new AgentChannels({
947
+ ...existingConfig,
948
+ adapters: {
949
+ ...existingConfig?.adapters,
950
+ [PLATFORM]: entry
951
+ },
952
+ userName: agent.name,
953
+ handlers: cfg.handlers ?? existingConfig?.handlers,
954
+ inlineMedia: cfg.inlineMedia ?? existingConfig?.inlineMedia,
955
+ inlineLinks: cfg.inlineLinks ?? existingConfig?.inlineLinks,
956
+ state: cfg.state ?? existingConfig?.state,
957
+ threadContext: cfg.threadContext ?? existingConfig?.threadContext,
958
+ chatOptions: cfg.chatOptions ?? existingConfig?.chatOptions,
959
+ tools: cfg.tools ?? existingConfig?.tools,
960
+ resolveResourceId: cfg.resolveResourceId ?? existingConfig?.resolveResourceId,
961
+ waitUntil: cfg.waitUntil ?? existingConfig?.waitUntil,
962
+ resolveWaitUntil: cfg.resolveWaitUntil ?? existingConfig?.resolveWaitUntil
963
+ });
964
+ agent.setChannels(channels);
965
+ return channels;
966
+ }
967
+ async #autoInitialize() {
968
+ if (!this.#mastra) return;
969
+ await this.initialize();
970
+ }
971
+ #resolveAgent(agentId) {
972
+ try {
973
+ return this.#mastra?.getAgentById(agentId);
974
+ } catch {
975
+ return;
976
+ }
977
+ }
978
+ /** App credentials from provider config or `DISCORD_*` env, or `null` if incomplete. */
979
+ #suppliedAppConfig() {
980
+ const a = this.#config.app ?? {};
981
+ const botToken = a.botToken ?? process.env.DISCORD_BOT_TOKEN;
982
+ const publicKey = a.publicKey ?? process.env.DISCORD_PUBLIC_KEY;
983
+ const applicationId = a.applicationId ?? process.env.DISCORD_APPLICATION_ID;
984
+ if (botToken && publicKey && applicationId) return {
985
+ botToken,
986
+ publicKey,
987
+ applicationId
988
+ };
989
+ return null;
990
+ }
991
+ /**
992
+ * Resolve the app config: prefer the stored one, else the supplied credentials
993
+ * (config / `DISCORD_*` env). `persisted` says whether it's already in storage,
994
+ * so the caller can persist supplied credentials **once**, after validation.
995
+ * Throws if neither is available.
996
+ */
997
+ async #resolveAppConfig(store) {
998
+ const stored = await store.getAppConfig();
999
+ if (stored) return {
1000
+ app: stored,
1001
+ persisted: true
1002
+ };
1003
+ const supplied = this.#suppliedAppConfig();
1004
+ if (supplied) return {
1005
+ app: supplied,
1006
+ persisted: false
1007
+ };
1008
+ throw new Error("Discord app is not configured. Provide botToken, publicKey, and applicationId via the provider `app` option, the DISCORD_BOT_TOKEN / DISCORD_PUBLIC_KEY / DISCORD_APPLICATION_ID env vars, or configure() — Discord applications are created in the Developer Portal, not programmatically.");
1009
+ }
1010
+ /**
1011
+ * The single entry point to the store. Every caller either gets the cached
1012
+ * store or joins the one in-flight resolution — nobody resolves in parallel,
1013
+ * so a caller that arrives mid-migration waits for it to finish instead of
1014
+ * building a second store over storage that isn't populated yet.
1015
+ */
1016
+ async #getStore() {
1017
+ for (;;) {
1018
+ if (this.#store) return this.#store;
1019
+ const inFlight = this.#storeResolution ??= this.#resolveStore(this.#storeGeneration);
1020
+ let resolved;
1021
+ try {
1022
+ resolved = await inFlight;
1023
+ } finally {
1024
+ if (this.#storeResolution === inFlight) this.#storeResolution = void 0;
1025
+ }
1026
+ if (resolved) return resolved;
1027
+ }
1028
+ }
1029
+ /**
1030
+ * One resolution attempt, run under {@link #getStore}'s in-flight guard — so
1031
+ * the {@link #pendingMigration} claim below cannot be double-taken, and the
1032
+ * migration completes before any caller sees the store.
1033
+ *
1034
+ * Returns `undefined` when `__attach` superseded this attempt while it ran:
1035
+ * the storage target changed underneath it, so caching would pin the provider
1036
+ * to a store built for the previous one.
1037
+ */
1038
+ async #resolveStore(generation) {
1039
+ const encryptionKey = this.#config.encryptionKey ?? process.env.MASTRA_ENCRYPTION_KEY;
1040
+ const { storage, isFallback } = await this.#resolveStorage();
1041
+ if (generation !== this.#storeGeneration) return void 0;
1042
+ const pending = this.#pendingMigration;
1043
+ this.#pendingMigration = void 0;
1044
+ if (pending && isFallback) {
1045
+ this.#storeIsFallback = true;
1046
+ this.#store = pending;
1047
+ return pending;
1048
+ }
1049
+ const store = new DiscordInstallStore(storage, encryptionKey);
1050
+ if (pending) await this.#migrateFallback(pending, store);
1051
+ if (generation !== this.#storeGeneration) {
1052
+ if (pending && !this.#pendingMigration) this.#pendingMigration = pending;
1053
+ return;
1054
+ }
1055
+ this.#storeIsFallback = isFallback;
1056
+ this.#store = store;
1057
+ return store;
1058
+ }
1059
+ /**
1060
+ * Copy what a pre-registration store wrote into the real storage that has
1061
+ * since become available. Anything already persisted wins — the real store is
1062
+ * the durable record, and a stale in-memory row must not overwrite it. Both
1063
+ * stores share an encryption key, so this round-trips through plaintext.
1064
+ *
1065
+ * Best-effort: a failure here must not take down `__attach`'s caller or the
1066
+ * request that triggered the resolution. The provider still works against the
1067
+ * real storage; only the pre-registration writes are missing, which is the
1068
+ * behaviour before this migration existed.
1069
+ */
1070
+ async #migrateFallback(from, to) {
1071
+ try {
1072
+ const app = await from.getAppConfig();
1073
+ if (app && !await to.getAppConfig()) await to.saveAppConfig(app);
1074
+ for (const install of await from.list()) {
1075
+ if (await to.getByAgent(install.agentId)) continue;
1076
+ await to.save(install);
1077
+ }
1078
+ } catch (error) {
1079
+ console.warn("[Discord] Failed to carry pre-registration installations into Mastra storage; connect() again to re-create them.", error);
1080
+ }
1081
+ }
1082
+ /**
1083
+ * Resolve the backing storage, reporting whether it is the in-memory fallback
1084
+ * so {@link __attach} can re-resolve a store that was built before Mastra was
1085
+ * available, carrying its contents across.
1086
+ */
1087
+ async #resolveStorage() {
1088
+ if (this.#config.storage) return {
1089
+ storage: this.#config.storage,
1090
+ isFallback: false
1091
+ };
1092
+ const mastraStore = this.#mastra?.getStorage();
1093
+ if (mastraStore) try {
1094
+ await mastraStore.init();
1095
+ const channels = await mastraStore.getStore("channels");
1096
+ if (channels) return {
1097
+ storage: channels,
1098
+ isFallback: false
1099
+ };
1100
+ } catch {}
1101
+ return {
1102
+ storage: new InMemoryChannelsStorage(),
1103
+ isFallback: true
1104
+ };
1105
+ }
1106
+ };
1107
+ //#endregion
1108
+ export { DEFAULT_COMMANDS, DEFAULT_INVITE_PERMISSIONS, DEFAULT_INVITE_SCOPES, DISCORD_API_BASE_URL, DISCORD_OAUTH_AUTHORIZE_URL, DISCORD_PERMISSIONS, DiscordAdapter, DiscordInstallStore, DiscordProvider, PLATFORM, buildInviteUrl, createDiscordAdapter, decrypt, discordRequest, encrypt, guildHealthCheck, hashCommands, isEncrypted, normalizeCommands, registerGlobalCommands, registerGuildCommands, resolveDiscordAdapterConfig, toInstallationInfo, validateApp };
1109
+
1110
+ //# sourceMappingURL=index.js.map