@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/CHANGELOG.md +39 -0
- package/LICENSE.md +32 -0
- package/README.md +175 -0
- package/dist/index.cjs +116012 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +554 -0
- package/dist/index.d.ts +554 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1110 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -50
- package/src/Discord.test.ts +0 -55
- package/src/assets/discord.png +0 -0
- package/src/index.ts +0 -201
- package/src/openapi-components.ts +0 -31769
- package/src/openapi-paths.ts +0 -15861
- package/src/openapi.ts +0 -23784
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
|