@mastra/discord 1.2.1 → 1.3.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 +18 -0
- package/dist/index.cjs +14413 -5808
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -1
- package/dist/index.d.ts +71 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +285 -35
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.cts
CHANGED
|
@@ -265,6 +265,42 @@ interface DiscordInstallation {
|
|
|
265
265
|
commandVersions?: Record<string, string>;
|
|
266
266
|
/** When the installation was created. */
|
|
267
267
|
installedAt: Date;
|
|
268
|
+
/**
|
|
269
|
+
* The bot's guild ids at the moment a pending invite was issued. Lets
|
|
270
|
+
* {@link DiscordProvider.reconcileInstallation} confirm the install after the
|
|
271
|
+
* operator completes the invite in another tab: exactly one guild appearing
|
|
272
|
+
* since the snapshot is the authorized guild, so the install activates
|
|
273
|
+
* without waiting for a first interaction. `undefined` when the snapshot
|
|
274
|
+
* couldn't be taken (or the bot is in too many guilds to page) — the install
|
|
275
|
+
* then activates only via the first interaction, as before. Cleared on
|
|
276
|
+
* activation.
|
|
277
|
+
*/
|
|
278
|
+
guildSnapshot?: string[];
|
|
279
|
+
/**
|
|
280
|
+
* When the invite flow that owns {@link guildSnapshot} was last (re)started.
|
|
281
|
+
* The snapshot is only trusted for a bounded claim window after this moment
|
|
282
|
+
* (see `SNAPSHOT_TTL_MS` in `reconcile.ts`) — an abandoned Connect click must
|
|
283
|
+
* not claim a guild the bot joins days later. Set on every invite-flow
|
|
284
|
+
* `connect()`, including when the fetch that should have produced
|
|
285
|
+
* {@link guildSnapshot} failed. Cleared on activation.
|
|
286
|
+
*/
|
|
287
|
+
snapshotAt?: Date;
|
|
288
|
+
/**
|
|
289
|
+
* The Discord application {@link guildSnapshot} was taken against. A baseline
|
|
290
|
+
* from app A says nothing about app B's membership — after `configure()`
|
|
291
|
+
* swaps the bot token, every guild B was already in would look newly
|
|
292
|
+
* authorized. Reconciliation and snapshot reuse require it to match the
|
|
293
|
+
* current app. Cleared on activation.
|
|
294
|
+
*/
|
|
295
|
+
snapshotApplicationId?: string;
|
|
296
|
+
/**
|
|
297
|
+
* The guild `connect()` explicitly targeted (`options.guildId`) when the
|
|
298
|
+
* pending invite was issued, if any. Reconciliation only auto-activates this
|
|
299
|
+
* install on that guild — a different guild appearing in the bot's
|
|
300
|
+
* membership contradicts the caller's intent and waits for an authoritative
|
|
301
|
+
* first interaction instead. Cleared on activation.
|
|
302
|
+
*/
|
|
303
|
+
targetGuildId?: string;
|
|
268
304
|
}
|
|
269
305
|
//#endregion
|
|
270
306
|
//#region src/discord-provider.d.ts
|
|
@@ -372,8 +408,42 @@ declare class DiscordProvider implements ChannelProvider {
|
|
|
372
408
|
* duration boundary. Contrast Telegram's clean `stopPolling()`.
|
|
373
409
|
*/
|
|
374
410
|
disconnect(agentId: string): Promise<void>;
|
|
375
|
-
/**
|
|
411
|
+
/**
|
|
412
|
+
* List installations (public info only — no secrets). A pure read: pending
|
|
413
|
+
* installs are confirmed through the write-authorized
|
|
414
|
+
* {@link reconcileInstallation} path, never as a listing side effect.
|
|
415
|
+
*/
|
|
376
416
|
listInstallations(): Promise<ChannelInstallationInfo[]>;
|
|
417
|
+
/**
|
|
418
|
+
* Activate an agent's pending install if its invite has since been completed.
|
|
419
|
+
*
|
|
420
|
+
* The invite URL carries no `redirect_uri` (the app would have to allowlist
|
|
421
|
+
* every operator origin), so nothing calls back when the operator authorizes
|
|
422
|
+
* the bot — and a pending install has no adapter, so no interaction or
|
|
423
|
+
* gateway event can reach it either. Instead, each pending install carries a
|
|
424
|
+
* {@link DiscordInstallation.guildSnapshot} taken when the invite was issued;
|
|
425
|
+
* this diffs it against the bot's current membership and activates the
|
|
426
|
+
* install when exactly one new, uncontested guild has appeared (the full
|
|
427
|
+
* claim rules live in {@link planReconcile}). The claim analysis spans every
|
|
428
|
+
* install — other pending invites contest the diff — but only **this**
|
|
429
|
+
* agent's install can activate: the caller holds write access for this agent
|
|
430
|
+
* alone. Anything ambiguous stays pending and retries on the next call, with
|
|
431
|
+
* the first interaction as the authoritative fallback. Best-effort: failures
|
|
432
|
+
* log and resolve rather than throw.
|
|
433
|
+
*
|
|
434
|
+
* Called by the server's reconcile route (write-authorized); Studio invokes
|
|
435
|
+
* it on window focus while a connect is in flight. Returns the (possibly
|
|
436
|
+
* just-activated) installation's public info, or `null` when the agent has
|
|
437
|
+
* no installation.
|
|
438
|
+
*
|
|
439
|
+
* Limitation: the bot-invite URL is per-app, not per-install, so when the
|
|
440
|
+
* operator invites the bot to a new server *for an already-active install*
|
|
441
|
+
* (as `connect()`'s already-connected error suggests) while an invite for
|
|
442
|
+
* another agent is outstanding, nothing distinguishes the two intents — the
|
|
443
|
+
* new guild can be attributed to the pending install. Reconciliation is only
|
|
444
|
+
* reliable while a single invite flow is in progress.
|
|
445
|
+
*/
|
|
446
|
+
reconcileInstallation(agentId: string): Promise<ChannelInstallationInfo | null>;
|
|
377
447
|
/** The full installation for an agent (no secrets live on it), or `null`. */
|
|
378
448
|
getInstallation(agentId: string): Promise<DiscordInstallation | null>;
|
|
379
449
|
/** Whether the Discord app is configured (credentials resolvable). */
|
package/dist/index.d.ts
CHANGED
|
@@ -265,6 +265,42 @@ interface DiscordInstallation {
|
|
|
265
265
|
commandVersions?: Record<string, string>;
|
|
266
266
|
/** When the installation was created. */
|
|
267
267
|
installedAt: Date;
|
|
268
|
+
/**
|
|
269
|
+
* The bot's guild ids at the moment a pending invite was issued. Lets
|
|
270
|
+
* {@link DiscordProvider.reconcileInstallation} confirm the install after the
|
|
271
|
+
* operator completes the invite in another tab: exactly one guild appearing
|
|
272
|
+
* since the snapshot is the authorized guild, so the install activates
|
|
273
|
+
* without waiting for a first interaction. `undefined` when the snapshot
|
|
274
|
+
* couldn't be taken (or the bot is in too many guilds to page) — the install
|
|
275
|
+
* then activates only via the first interaction, as before. Cleared on
|
|
276
|
+
* activation.
|
|
277
|
+
*/
|
|
278
|
+
guildSnapshot?: string[];
|
|
279
|
+
/**
|
|
280
|
+
* When the invite flow that owns {@link guildSnapshot} was last (re)started.
|
|
281
|
+
* The snapshot is only trusted for a bounded claim window after this moment
|
|
282
|
+
* (see `SNAPSHOT_TTL_MS` in `reconcile.ts`) — an abandoned Connect click must
|
|
283
|
+
* not claim a guild the bot joins days later. Set on every invite-flow
|
|
284
|
+
* `connect()`, including when the fetch that should have produced
|
|
285
|
+
* {@link guildSnapshot} failed. Cleared on activation.
|
|
286
|
+
*/
|
|
287
|
+
snapshotAt?: Date;
|
|
288
|
+
/**
|
|
289
|
+
* The Discord application {@link guildSnapshot} was taken against. A baseline
|
|
290
|
+
* from app A says nothing about app B's membership — after `configure()`
|
|
291
|
+
* swaps the bot token, every guild B was already in would look newly
|
|
292
|
+
* authorized. Reconciliation and snapshot reuse require it to match the
|
|
293
|
+
* current app. Cleared on activation.
|
|
294
|
+
*/
|
|
295
|
+
snapshotApplicationId?: string;
|
|
296
|
+
/**
|
|
297
|
+
* The guild `connect()` explicitly targeted (`options.guildId`) when the
|
|
298
|
+
* pending invite was issued, if any. Reconciliation only auto-activates this
|
|
299
|
+
* install on that guild — a different guild appearing in the bot's
|
|
300
|
+
* membership contradicts the caller's intent and waits for an authoritative
|
|
301
|
+
* first interaction instead. Cleared on activation.
|
|
302
|
+
*/
|
|
303
|
+
targetGuildId?: string;
|
|
268
304
|
}
|
|
269
305
|
//#endregion
|
|
270
306
|
//#region src/discord-provider.d.ts
|
|
@@ -372,8 +408,42 @@ declare class DiscordProvider implements ChannelProvider {
|
|
|
372
408
|
* duration boundary. Contrast Telegram's clean `stopPolling()`.
|
|
373
409
|
*/
|
|
374
410
|
disconnect(agentId: string): Promise<void>;
|
|
375
|
-
/**
|
|
411
|
+
/**
|
|
412
|
+
* List installations (public info only — no secrets). A pure read: pending
|
|
413
|
+
* installs are confirmed through the write-authorized
|
|
414
|
+
* {@link reconcileInstallation} path, never as a listing side effect.
|
|
415
|
+
*/
|
|
376
416
|
listInstallations(): Promise<ChannelInstallationInfo[]>;
|
|
417
|
+
/**
|
|
418
|
+
* Activate an agent's pending install if its invite has since been completed.
|
|
419
|
+
*
|
|
420
|
+
* The invite URL carries no `redirect_uri` (the app would have to allowlist
|
|
421
|
+
* every operator origin), so nothing calls back when the operator authorizes
|
|
422
|
+
* the bot — and a pending install has no adapter, so no interaction or
|
|
423
|
+
* gateway event can reach it either. Instead, each pending install carries a
|
|
424
|
+
* {@link DiscordInstallation.guildSnapshot} taken when the invite was issued;
|
|
425
|
+
* this diffs it against the bot's current membership and activates the
|
|
426
|
+
* install when exactly one new, uncontested guild has appeared (the full
|
|
427
|
+
* claim rules live in {@link planReconcile}). The claim analysis spans every
|
|
428
|
+
* install — other pending invites contest the diff — but only **this**
|
|
429
|
+
* agent's install can activate: the caller holds write access for this agent
|
|
430
|
+
* alone. Anything ambiguous stays pending and retries on the next call, with
|
|
431
|
+
* the first interaction as the authoritative fallback. Best-effort: failures
|
|
432
|
+
* log and resolve rather than throw.
|
|
433
|
+
*
|
|
434
|
+
* Called by the server's reconcile route (write-authorized); Studio invokes
|
|
435
|
+
* it on window focus while a connect is in flight. Returns the (possibly
|
|
436
|
+
* just-activated) installation's public info, or `null` when the agent has
|
|
437
|
+
* no installation.
|
|
438
|
+
*
|
|
439
|
+
* Limitation: the bot-invite URL is per-app, not per-install, so when the
|
|
440
|
+
* operator invites the bot to a new server *for an already-active install*
|
|
441
|
+
* (as `connect()`'s already-connected error suggests) while an invite for
|
|
442
|
+
* another agent is outstanding, nothing distinguishes the two intents — the
|
|
443
|
+
* new guild can be attributed to the pending install. Reconciliation is only
|
|
444
|
+
* reliable while a single invite flow is in progress.
|
|
445
|
+
*/
|
|
446
|
+
reconcileInstallation(agentId: string): Promise<ChannelInstallationInfo | null>;
|
|
377
447
|
/** The full installation for an agent (no secrets live on it), or `null`. */
|
|
378
448
|
getInstallation(agentId: string): Promise<DiscordInstallation | null>;
|
|
379
449
|
/** Whether the Discord app is configured (credentials resolvable). */
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/discord-provider.ts","../src/install-store.ts","../src/discord-client.ts","../src/commands.ts","../src/crypto.ts"],"mappings":";;;;;;;;;;;;cAgBa;;cAGA;;;;;;;;cASA;WACX;WACA;WACA;WACA;WACA;WACA;WACA;WACA;;;;;;;;;cAUW;;cAWA;;;;;;UAOI;;EAEf;;EAEA;;;KAIU;EAAiC;EAAc;;;;;;;;UAQ1C;;EAEf;;EAEA;;EAEA;;;;;;;;;;UAWe;;;;;;;;;EASf,MAAM,QAAQ;;;;;EAKd;;;;;;EAMA,UAAU;;;;;EAKV;;;;;;;EAOA;;;;;EAKA;;;;;;EAMA,WAAW;;;;;;;;;;EAUX;;;;;EAKA,YAAY;;;;;;;;EAQZ;;;;;EAKA;;;;;;EAMA,mBAAmB;;EAEnB,SAAS;;;;;;;;EAQT,YAAY;;;;;;EAMZ;;EASA,WAAW;;EAEX,cAAc;;EAEd,cAAc;;EAEd,QAAQ;;EAER,gBAAgB;;EAEhB,cAAc;;EAEd,oBAAoB;;EAEpB,mBAAmB;;EAEnB,OAAO;;EAEP,cAAc;;;;;;;;EAQd,cAAc;;;;;;;EAOd,QAAQ;;EAER,aAAa,cAAc,+BAA+B;;;UAI3C;;;;;;;EAOf;;EAEA;;;;;EAKA,WAAW;;EAEX;;;;;;;;;UAUe;;EAEf;;EAEA;;;;;EAKA;;;;;EAKA;;EAEA;;EAEA;;EAEA,WAAW;;;;;;EAMX,kBAAkB;;EAElB,aAAa;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/discord-provider.ts","../src/install-store.ts","../src/discord-client.ts","../src/commands.ts","../src/crypto.ts"],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -130,6 +130,37 @@ async function guildHealthCheck(botToken, guildId, apiBaseUrl = DISCORD_API_BASE
|
|
|
130
130
|
}
|
|
131
131
|
throw new Error(`Discord guild lookup failed for "${guildId}": ${await describeError(response)}`);
|
|
132
132
|
}
|
|
133
|
+
/** Guilds-per-page for `GET /users/@me/guilds` (Discord's documented maximum). */
|
|
134
|
+
const GUILD_PAGE_LIMIT = 200;
|
|
135
|
+
/**
|
|
136
|
+
* Pages we're willing to walk when listing the bot's guilds. The listing feeds
|
|
137
|
+
* the pending-install snapshot diff, whose multiple-new-guilds guard keeps an
|
|
138
|
+
* *incomplete* listing safe (reconcile just stays pending) — so a hard cap is
|
|
139
|
+
* preferable to an unbounded walk on a bot in thousands of guilds.
|
|
140
|
+
*/
|
|
141
|
+
const GUILD_PAGE_MAX_PAGES = 10;
|
|
142
|
+
/**
|
|
143
|
+
* The ids of every guild the bot is a member of, via `GET /users/@me/guilds`
|
|
144
|
+
* (Bot auth), following `after`-cursor pagination. Returns `null` instead of a
|
|
145
|
+
* partial listing when the bot is in more guilds than {@link GUILD_PAGE_MAX_PAGES}
|
|
146
|
+
* covers — callers diffing snapshots must not mistake a truncated page for the
|
|
147
|
+
* full membership. Throws on request failure.
|
|
148
|
+
*
|
|
149
|
+
* @see https://discord.com/developers/docs/resources/user#get-current-user-guilds
|
|
150
|
+
*/
|
|
151
|
+
async function listBotGuildIds(botToken, apiBaseUrl = DISCORD_API_BASE_URL) {
|
|
152
|
+
const ids = [];
|
|
153
|
+
let after;
|
|
154
|
+
for (let page = 0; page < GUILD_PAGE_MAX_PAGES; page++) {
|
|
155
|
+
const response = await discordRequest(botToken, "GET", `/users/@me/guilds${`?limit=${GUILD_PAGE_LIMIT}${after ? `&after=${after}` : ""}`}`, apiBaseUrl);
|
|
156
|
+
if (!response.ok) throw new Error(`Discord guild listing failed: ${await describeError(response)}`);
|
|
157
|
+
const batch = await response.json();
|
|
158
|
+
for (const guild of batch) ids.push(guild.id);
|
|
159
|
+
if (batch.length < GUILD_PAGE_LIMIT) return ids;
|
|
160
|
+
after = batch[batch.length - 1].id;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
133
164
|
/** Map normalized commands to the Discord bulk-overwrite `CHAT_INPUT` payload. */
|
|
134
165
|
function toCommandPayload(commands) {
|
|
135
166
|
return commands.map((c) => ({
|
|
@@ -430,7 +461,11 @@ var DiscordInstallStore = class {
|
|
|
430
461
|
guildIds: install.guildIds,
|
|
431
462
|
displayName: install.displayName,
|
|
432
463
|
commands: install.commands,
|
|
433
|
-
commandVersions: install.commandVersions
|
|
464
|
+
commandVersions: install.commandVersions,
|
|
465
|
+
guildSnapshot: install.guildSnapshot,
|
|
466
|
+
snapshotAt: install.snapshotAt?.toISOString(),
|
|
467
|
+
snapshotApplicationId: install.snapshotApplicationId,
|
|
468
|
+
targetGuildId: install.targetGuildId
|
|
434
469
|
};
|
|
435
470
|
return {
|
|
436
471
|
id: install.id,
|
|
@@ -454,7 +489,11 @@ var DiscordInstallStore = class {
|
|
|
454
489
|
displayName: data.displayName,
|
|
455
490
|
commands: data.commands,
|
|
456
491
|
commandVersions: data.commandVersions,
|
|
457
|
-
installedAt: record.createdAt
|
|
492
|
+
installedAt: record.createdAt,
|
|
493
|
+
guildSnapshot: data.guildSnapshot,
|
|
494
|
+
snapshotAt: data.snapshotAt ? new Date(data.snapshotAt) : void 0,
|
|
495
|
+
snapshotApplicationId: data.snapshotApplicationId,
|
|
496
|
+
targetGuildId: data.targetGuildId
|
|
458
497
|
};
|
|
459
498
|
}
|
|
460
499
|
};
|
|
@@ -469,6 +508,78 @@ function toInstallationInfo(install) {
|
|
|
469
508
|
installedAt: install.installedAt
|
|
470
509
|
};
|
|
471
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* Whether an install's invite flow is still *live*: started against the
|
|
513
|
+
* current application and within {@link SNAPSHOT_TTL_MS}. Deliberately
|
|
514
|
+
* independent of whether the baseline fetch succeeded — a live flow with a
|
|
515
|
+
* missing baseline still poisons attribution (see {@link planReconcile}),
|
|
516
|
+
* while rows from before these fields existed (no `snapshotAt`) are treated
|
|
517
|
+
* as not live and fall back to first-interaction activation.
|
|
518
|
+
*/
|
|
519
|
+
function isInviteFlowLive(installation, applicationId, now) {
|
|
520
|
+
return installation.status === "pending" && installation.snapshotApplicationId === applicationId && installation.snapshotAt != null && now.getTime() - installation.snapshotAt.getTime() <= 18e5;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Whether a pending install can reconcile at all: a live invite flow (see
|
|
524
|
+
* {@link isInviteFlowLive}) *with* a membership baseline to diff against.
|
|
525
|
+
* Used as a cheap pre-check before the membership fetch.
|
|
526
|
+
*/
|
|
527
|
+
function hasReconcilableSnapshot(installation, applicationId, now) {
|
|
528
|
+
return installation.guildSnapshot != null && isInviteFlowLive(installation, applicationId, now);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Decide which pending installs the completed-invite reconcile pass may
|
|
532
|
+
* activate, and on which guild. Pure claim analysis over plain data — all
|
|
533
|
+
* store reads, membership fetches, and activations happen in the caller
|
|
534
|
+
* ({@link DiscordProvider.reconcileInstallation}).
|
|
535
|
+
*
|
|
536
|
+
* Rules, in order:
|
|
537
|
+
* - Only installs with a live invite flow ({@link isInviteFlowLive}) take part.
|
|
538
|
+
* Expired/foreign-app baselines are ignored (first interaction remains their
|
|
539
|
+
* activation path), as are legacy rows without snapshot metadata.
|
|
540
|
+
* - A live flow whose baseline fetch failed at connect time poisons the whole
|
|
541
|
+
* pass: any new guild might be ITS invite landing, so nothing is attributed.
|
|
542
|
+
* - A guild any install already lists is owned and never claimed — it can
|
|
543
|
+
* still look "new" relative to a stale baseline.
|
|
544
|
+
* - An install with an explicit {@link DiscordInstallation.targetGuildId}
|
|
545
|
+
* claims *only* that guild. The narrowing happens before claims are tallied,
|
|
546
|
+
* so a targeted install never contests a guild it would refuse anyway — but
|
|
547
|
+
* its target, if new, still blocks other installs from taking it.
|
|
548
|
+
* - An install activates only on exactly one new guild, claimed by no other
|
|
549
|
+
* live install. Zero or multiple candidates, or a contested guild, stay
|
|
550
|
+
* pending and retry on the next pass.
|
|
551
|
+
*/
|
|
552
|
+
function planReconcile(input) {
|
|
553
|
+
const { installations, currentGuildIds, applicationId, now } = input;
|
|
554
|
+
const live = installations.filter((i) => isInviteFlowLive(i, applicationId, now));
|
|
555
|
+
const claimants = live.filter((i) => i.guildSnapshot != null);
|
|
556
|
+
if (claimants.length === 0) return [];
|
|
557
|
+
if (claimants.length !== live.length) return [];
|
|
558
|
+
const owned = new Set(installations.flatMap((i) => i.guildIds));
|
|
559
|
+
const diffs = claimants.map((installation) => {
|
|
560
|
+
const snapshot = new Set(installation.guildSnapshot);
|
|
561
|
+
let newGuilds = currentGuildIds.filter((id) => !snapshot.has(id) && !owned.has(id));
|
|
562
|
+
if (installation.targetGuildId != null) newGuilds = newGuilds.includes(installation.targetGuildId) ? [installation.targetGuildId] : [];
|
|
563
|
+
return {
|
|
564
|
+
installation,
|
|
565
|
+
newGuilds
|
|
566
|
+
};
|
|
567
|
+
});
|
|
568
|
+
const claims = /* @__PURE__ */ new Map();
|
|
569
|
+
for (const { newGuilds } of diffs) for (const id of newGuilds) claims.set(id, (claims.get(id) ?? 0) + 1);
|
|
570
|
+
const activations = [];
|
|
571
|
+
for (const { installation, newGuilds } of diffs) {
|
|
572
|
+
const guildId = newGuilds.length === 1 ? newGuilds[0] : void 0;
|
|
573
|
+
if (guildId == null || claims.get(guildId) > 1) continue;
|
|
574
|
+
activations.push({
|
|
575
|
+
installationId: installation.id,
|
|
576
|
+
agentId: installation.agentId,
|
|
577
|
+
webhookId: installation.webhookId,
|
|
578
|
+
guildId
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return activations;
|
|
582
|
+
}
|
|
472
583
|
//#endregion
|
|
473
584
|
//#region src/discord-provider.ts
|
|
474
585
|
/**
|
|
@@ -726,39 +837,69 @@ var DiscordProvider = class {
|
|
|
726
837
|
const installedAt = existing?.installedAt ?? /* @__PURE__ */ new Date();
|
|
727
838
|
const commands = normalizeCommands(options.commands ?? this.#config.commands ?? DEFAULT_COMMANDS);
|
|
728
839
|
if (options.guildId && await guildHealthCheck(app.botToken, options.guildId, this.#apiBaseUrl())) {
|
|
729
|
-
const
|
|
840
|
+
const guildId = options.guildId;
|
|
841
|
+
return await this.#enqueueWebhookTask(webhookId, async () => {
|
|
842
|
+
const fresh = existing ? await store.getByWebhookId(webhookId) : null;
|
|
843
|
+
if (fresh?.status === "active") return {
|
|
844
|
+
type: "immediate",
|
|
845
|
+
installationId: fresh.id
|
|
846
|
+
};
|
|
847
|
+
const installation = {
|
|
848
|
+
id: installationId,
|
|
849
|
+
agentId,
|
|
850
|
+
webhookId,
|
|
851
|
+
status: "active",
|
|
852
|
+
guildIds: [guildId],
|
|
853
|
+
displayName,
|
|
854
|
+
commands: commands.length ? commands : void 0,
|
|
855
|
+
commandVersions: fresh?.commandVersions ?? existing?.commandVersions,
|
|
856
|
+
installedAt
|
|
857
|
+
};
|
|
858
|
+
await store.save(installation);
|
|
859
|
+
await this.#registerCommands(app, installation, guildId);
|
|
860
|
+
await this.#activateInstallation(installation);
|
|
861
|
+
this.#configured = true;
|
|
862
|
+
await this.#config.onInstall?.(installation);
|
|
863
|
+
return {
|
|
864
|
+
type: "immediate",
|
|
865
|
+
installationId
|
|
866
|
+
};
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
const outcome = await this.#enqueueWebhookTask(webhookId, async () => {
|
|
870
|
+
const fresh = existing ? await store.getByWebhookId(webhookId) : null;
|
|
871
|
+
if (fresh?.status === "active") return {
|
|
872
|
+
type: "immediate",
|
|
873
|
+
installationId: fresh.id
|
|
874
|
+
};
|
|
875
|
+
const base = fresh ?? existing;
|
|
876
|
+
const now = /* @__PURE__ */ new Date();
|
|
877
|
+
let guildSnapshot = base?.guildSnapshot != null && base.snapshotApplicationId === app.applicationId && base.snapshotAt != null && now.getTime() - base.snapshotAt.getTime() <= 18e5 ? base.guildSnapshot : void 0;
|
|
878
|
+
if (!guildSnapshot) try {
|
|
879
|
+
guildSnapshot = await listBotGuildIds(app.botToken, this.#apiBaseUrl()) ?? void 0;
|
|
880
|
+
} catch (err) {
|
|
881
|
+
console.warn("[Discord] Could not snapshot guild membership for pending install:", err);
|
|
882
|
+
}
|
|
883
|
+
const pending = {
|
|
730
884
|
id: installationId,
|
|
731
885
|
agentId,
|
|
732
886
|
webhookId,
|
|
733
|
-
status: "
|
|
734
|
-
guildIds: [
|
|
887
|
+
status: "pending",
|
|
888
|
+
guildIds: base?.guildIds ?? [],
|
|
735
889
|
displayName,
|
|
736
890
|
commands: commands.length ? commands : void 0,
|
|
737
|
-
commandVersions:
|
|
738
|
-
installedAt
|
|
891
|
+
commandVersions: base?.commandVersions,
|
|
892
|
+
installedAt,
|
|
893
|
+
guildSnapshot,
|
|
894
|
+
snapshotAt: now,
|
|
895
|
+
snapshotApplicationId: app.applicationId,
|
|
896
|
+
targetGuildId: options.guildId ?? base?.targetGuildId
|
|
739
897
|
};
|
|
740
|
-
await store.save(
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
return {
|
|
746
|
-
type: "immediate",
|
|
747
|
-
installationId
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
const pending = {
|
|
751
|
-
id: installationId,
|
|
752
|
-
agentId,
|
|
753
|
-
webhookId,
|
|
754
|
-
status: "pending",
|
|
755
|
-
guildIds: existing?.guildIds ?? [],
|
|
756
|
-
displayName,
|
|
757
|
-
commands: commands.length ? commands : void 0,
|
|
758
|
-
commandVersions: existing?.commandVersions,
|
|
759
|
-
installedAt
|
|
760
|
-
};
|
|
761
|
-
await store.save(pending);
|
|
898
|
+
await store.save(pending);
|
|
899
|
+
return pending;
|
|
900
|
+
});
|
|
901
|
+
if ("type" in outcome) return outcome;
|
|
902
|
+
const pending = outcome;
|
|
762
903
|
await this.#registerCommands(app, pending);
|
|
763
904
|
this.#configured = true;
|
|
764
905
|
return {
|
|
@@ -779,7 +920,22 @@ var DiscordProvider = class {
|
|
|
779
920
|
* `webhookId` is unknown.
|
|
780
921
|
*/
|
|
781
922
|
async activateGuild(webhookId, guildId) {
|
|
782
|
-
|
|
923
|
+
return this.#enqueueWebhookTask(webhookId, () => this.#activateGuildNow(webhookId, guildId));
|
|
924
|
+
}
|
|
925
|
+
#activationChains = /* @__PURE__ */ new Map();
|
|
926
|
+
/**
|
|
927
|
+
* Serialize every mutation of one webhook's installation row — activations,
|
|
928
|
+
* pending-row saves, runtime bring-up, disconnects — so each task's read
|
|
929
|
+
* happens *after* the previous task's save. Without the chain, two
|
|
930
|
+
* concurrent first-interactions can lose a guildId (both read the same row,
|
|
931
|
+
* second save overwrites the first) or fire onInstall twice; a connect's
|
|
932
|
+
* pending save can clobber a row a racing interaction just activated; and a
|
|
933
|
+
* disconnect can land mid-activation and be resurrected by its post-HTTP
|
|
934
|
+
* persistence. The chain entry is dropped once the last queued task settles
|
|
935
|
+
* so keys don't accumulate forever.
|
|
936
|
+
*/
|
|
937
|
+
async #enqueueWebhookTask(webhookId, task) {
|
|
938
|
+
const next = (this.#activationChains.get(webhookId) ?? Promise.resolve()).catch(() => {}).then(task);
|
|
783
939
|
this.#activationChains.set(webhookId, next);
|
|
784
940
|
try {
|
|
785
941
|
return await next;
|
|
@@ -787,7 +943,6 @@ var DiscordProvider = class {
|
|
|
787
943
|
if (this.#activationChains.get(webhookId) === next) this.#activationChains.delete(webhookId);
|
|
788
944
|
}
|
|
789
945
|
}
|
|
790
|
-
#activationChains = /* @__PURE__ */ new Map();
|
|
791
946
|
async #activateGuildNow(webhookId, guildId) {
|
|
792
947
|
const store = await this.#getStore();
|
|
793
948
|
const installation = await store.getByWebhookId(webhookId);
|
|
@@ -797,6 +952,10 @@ var DiscordProvider = class {
|
|
|
797
952
|
if (wasActive && known) return installation;
|
|
798
953
|
if (!known) installation.guildIds.push(guildId);
|
|
799
954
|
installation.status = "active";
|
|
955
|
+
delete installation.guildSnapshot;
|
|
956
|
+
delete installation.snapshotAt;
|
|
957
|
+
delete installation.snapshotApplicationId;
|
|
958
|
+
delete installation.targetGuildId;
|
|
800
959
|
await store.save(installation);
|
|
801
960
|
const app = await store.getAppConfig();
|
|
802
961
|
if (app) await this.#registerCommands(app, installation, guildId);
|
|
@@ -817,14 +976,98 @@ var DiscordProvider = class {
|
|
|
817
976
|
const store = await this.#getStore();
|
|
818
977
|
const existing = await store.getByAgent(agentId);
|
|
819
978
|
if (!existing) throw new Error(`No Discord installation found for agent "${agentId}"`);
|
|
820
|
-
this.#
|
|
821
|
-
|
|
979
|
+
await this.#enqueueWebhookTask(existing.webhookId, async () => {
|
|
980
|
+
this.#adapters.delete(existing.id);
|
|
981
|
+
await store.deleteByAgent(agentId);
|
|
982
|
+
});
|
|
822
983
|
this.#configured = await store.getAppConfig() != null || (await store.list()).some((i) => i.status === "active");
|
|
823
984
|
}
|
|
824
|
-
/**
|
|
985
|
+
/**
|
|
986
|
+
* List installations (public info only — no secrets). A pure read: pending
|
|
987
|
+
* installs are confirmed through the write-authorized
|
|
988
|
+
* {@link reconcileInstallation} path, never as a listing side effect.
|
|
989
|
+
*/
|
|
825
990
|
async listInstallations() {
|
|
826
991
|
return (await (await this.#getStore()).list()).map(toInstallationInfo);
|
|
827
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Activate an agent's pending install if its invite has since been completed.
|
|
995
|
+
*
|
|
996
|
+
* The invite URL carries no `redirect_uri` (the app would have to allowlist
|
|
997
|
+
* every operator origin), so nothing calls back when the operator authorizes
|
|
998
|
+
* the bot — and a pending install has no adapter, so no interaction or
|
|
999
|
+
* gateway event can reach it either. Instead, each pending install carries a
|
|
1000
|
+
* {@link DiscordInstallation.guildSnapshot} taken when the invite was issued;
|
|
1001
|
+
* this diffs it against the bot's current membership and activates the
|
|
1002
|
+
* install when exactly one new, uncontested guild has appeared (the full
|
|
1003
|
+
* claim rules live in {@link planReconcile}). The claim analysis spans every
|
|
1004
|
+
* install — other pending invites contest the diff — but only **this**
|
|
1005
|
+
* agent's install can activate: the caller holds write access for this agent
|
|
1006
|
+
* alone. Anything ambiguous stays pending and retries on the next call, with
|
|
1007
|
+
* the first interaction as the authoritative fallback. Best-effort: failures
|
|
1008
|
+
* log and resolve rather than throw.
|
|
1009
|
+
*
|
|
1010
|
+
* Called by the server's reconcile route (write-authorized); Studio invokes
|
|
1011
|
+
* it on window focus while a connect is in flight. Returns the (possibly
|
|
1012
|
+
* just-activated) installation's public info, or `null` when the agent has
|
|
1013
|
+
* no installation.
|
|
1014
|
+
*
|
|
1015
|
+
* Limitation: the bot-invite URL is per-app, not per-install, so when the
|
|
1016
|
+
* operator invites the bot to a new server *for an already-active install*
|
|
1017
|
+
* (as `connect()`'s already-connected error suggests) while an invite for
|
|
1018
|
+
* another agent is outstanding, nothing distinguishes the two intents — the
|
|
1019
|
+
* new guild can be attributed to the pending install. Reconciliation is only
|
|
1020
|
+
* reliable while a single invite flow is in progress.
|
|
1021
|
+
*/
|
|
1022
|
+
async reconcileInstallation(agentId) {
|
|
1023
|
+
const store = await this.#getStore();
|
|
1024
|
+
const existing = await store.getByAgent(agentId);
|
|
1025
|
+
if (!existing) return null;
|
|
1026
|
+
if (existing.status === "pending") {
|
|
1027
|
+
const inflight = this.#reconcileInflight.get(agentId);
|
|
1028
|
+
if (inflight) await inflight;
|
|
1029
|
+
else {
|
|
1030
|
+
const run = this.#reconcilePendingInstallation(store, agentId).finally(() => {
|
|
1031
|
+
this.#reconcileInflight.delete(agentId);
|
|
1032
|
+
});
|
|
1033
|
+
this.#reconcileInflight.set(agentId, run);
|
|
1034
|
+
await run;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
const fresh = await store.getByAgent(agentId);
|
|
1038
|
+
return fresh ? toInstallationInfo(fresh) : null;
|
|
1039
|
+
}
|
|
1040
|
+
#reconcileInflight = /* @__PURE__ */ new Map();
|
|
1041
|
+
async #reconcilePendingInstallation(store, agentId) {
|
|
1042
|
+
try {
|
|
1043
|
+
const app = await store.getAppConfig();
|
|
1044
|
+
if (!app) return;
|
|
1045
|
+
const target = await store.getByAgent(agentId);
|
|
1046
|
+
if (!target || target.status !== "pending") return;
|
|
1047
|
+
if (!hasReconcilableSnapshot(target, app.applicationId, /* @__PURE__ */ new Date())) return;
|
|
1048
|
+
const current = await listBotGuildIds(app.botToken, this.#apiBaseUrl());
|
|
1049
|
+
if (!current) return;
|
|
1050
|
+
const activation = planReconcile({
|
|
1051
|
+
installations: await store.list(),
|
|
1052
|
+
currentGuildIds: current,
|
|
1053
|
+
applicationId: app.applicationId,
|
|
1054
|
+
now: /* @__PURE__ */ new Date()
|
|
1055
|
+
}).find((a) => a.agentId === agentId);
|
|
1056
|
+
if (!activation) return;
|
|
1057
|
+
await this.#enqueueWebhookTask(activation.webhookId, async () => {
|
|
1058
|
+
const activated = await this.#activateGuildNow(activation.webhookId, activation.guildId);
|
|
1059
|
+
if (!activated || this.#adapters.has(activated.id)) return;
|
|
1060
|
+
try {
|
|
1061
|
+
await this.#activateInstallation(activated);
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
this.#adapters.delete(activated.id);
|
|
1064
|
+
throw err;
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
console.warn(`[Discord] Pending-install reconcile for agent "${agentId}" failed (will retry):`, err);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
828
1071
|
/** The full installation for an agent (no secrets live on it), or `null`. */
|
|
829
1072
|
async getInstallation(agentId) {
|
|
830
1073
|
return await (await this.#getStore()).getByAgent(agentId) ?? null;
|
|
@@ -908,7 +1151,14 @@ var DiscordProvider = class {
|
|
|
908
1151
|
...versions,
|
|
909
1152
|
[key]: hash
|
|
910
1153
|
};
|
|
911
|
-
|
|
1154
|
+
const store = await this.#getStore();
|
|
1155
|
+
const fresh = await store.getByWebhookId(installation.webhookId);
|
|
1156
|
+
if (!fresh) return;
|
|
1157
|
+
fresh.commandVersions = {
|
|
1158
|
+
...fresh.commandVersions,
|
|
1159
|
+
[key]: hash
|
|
1160
|
+
};
|
|
1161
|
+
await store.save(fresh);
|
|
912
1162
|
} catch (err) {
|
|
913
1163
|
console.warn(`[Discord] command registration failed (${scope}${guildId ? `, guild ${guildId}` : ""}):`, err);
|
|
914
1164
|
}
|