@fased/discord 0.1.36

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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "discord",
3
+ "channels": ["discord"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { emptyPluginConfigSchema, type FasedAgentPluginApi } from "fased/plugin-sdk/discord";
2
+ import { discordPlugin } from "./src/channel.js";
3
+ import { setDiscordRuntime } from "./src/runtime.js";
4
+ import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
5
+
6
+ const plugin = {
7
+ id: "discord",
8
+ name: "Discord",
9
+ description: "Discord channel plugin",
10
+ configSchema: emptyPluginConfigSchema(),
11
+ register(api: FasedAgentPluginApi) {
12
+ setDiscordRuntime(api.runtime);
13
+ api.registerChannel({ plugin: discordPlugin });
14
+ registerDiscordSubagentHooks(api);
15
+ },
16
+ };
17
+
18
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@fased/discord",
3
+ "version": "0.1.36",
4
+ "description": "FasedAgent Discord channel plugin",
5
+ "files": [
6
+ "index.ts",
7
+ "src",
8
+ "fased.plugin.json",
9
+ "!src/**/*.test.ts",
10
+ "!src/**/*.test.tsx"
11
+ ],
12
+ "type": "module",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "dependencies": {
17
+ "@buape/carbon": "0.0.0-beta-20260216184201",
18
+ "@discordjs/voice": "^0.19.0",
19
+ "@snazzah/davey": "^0.1.9",
20
+ "discord-api-types": "^0.38.40",
21
+ "opusscript": "^0.1.1"
22
+ },
23
+ "peerDependencies": {
24
+ "@fased/fased": "^0.1.36"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "@fased/fased": {
28
+ "optional": true
29
+ }
30
+ },
31
+ "optionalDependencies": {
32
+ "@discordjs/opus": "^0.10.0"
33
+ },
34
+ "fased": {
35
+ "extensions": [
36
+ "./index.ts"
37
+ ],
38
+ "channel": {
39
+ "id": "discord",
40
+ "label": "Discord",
41
+ "selectionLabel": "Discord (Bot API)",
42
+ "docsPath": "/channels/discord",
43
+ "docsLabel": "discord",
44
+ "blurb": "Bot API channel with DMs, servers, threads, reactions, and moderation workflows.",
45
+ "order": 30,
46
+ "quickstartAllowFrom": true
47
+ },
48
+ "install": {
49
+ "npmSpec": "@fased/discord",
50
+ "localPath": "extensions/discord",
51
+ "defaultChoice": "npm"
52
+ }
53
+ }
54
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,457 @@
1
+ import {
2
+ applyAccountNameToChannelSection,
3
+ buildChannelConfigSchema,
4
+ buildTokenChannelStatusSummary,
5
+ collectDiscordAuditChannelIds,
6
+ collectDiscordStatusIssues,
7
+ DEFAULT_ACCOUNT_ID,
8
+ deleteAccountFromConfigSection,
9
+ discordOnboardingAdapter,
10
+ DiscordConfigSchema,
11
+ formatPairingApproveHint,
12
+ getChatChannelMeta,
13
+ listDiscordAccountIds,
14
+ listDiscordDirectoryGroupsFromConfig,
15
+ listDiscordDirectoryPeersFromConfig,
16
+ looksLikeDiscordTargetId,
17
+ migrateBaseNameToDefaultAccount,
18
+ normalizeAccountId,
19
+ normalizeDiscordMessagingTarget,
20
+ normalizeDiscordOutboundTarget,
21
+ PAIRING_APPROVED_MESSAGE,
22
+ resolveDiscordAccount,
23
+ resolveDefaultDiscordAccountId,
24
+ resolveDiscordGroupRequireMention,
25
+ resolveDiscordGroupToolPolicy,
26
+ resolveOpenProviderRuntimeGroupPolicy,
27
+ resolveDefaultGroupPolicy,
28
+ setAccountEnabledInConfigSection,
29
+ type ChannelMessageActionAdapter,
30
+ type ChannelPlugin,
31
+ type ResolvedDiscordAccount,
32
+ } from "fased/plugin-sdk/discord";
33
+ import { getDiscordRuntime } from "./runtime.js";
34
+
35
+ const meta = getChatChannelMeta("discord");
36
+
37
+ const discordMessageActions: ChannelMessageActionAdapter = {
38
+ listActions: (ctx) =>
39
+ getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
40
+ extractToolSend: (ctx) =>
41
+ getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null,
42
+ handleAction: async (ctx) => {
43
+ const ma = getDiscordRuntime().channel.discord.messageActions;
44
+ if (!ma?.handleAction) {
45
+ throw new Error("Discord message actions not available");
46
+ }
47
+ return ma.handleAction(ctx);
48
+ },
49
+ };
50
+
51
+ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
52
+ id: "discord",
53
+ meta: {
54
+ ...meta,
55
+ },
56
+ onboarding: discordOnboardingAdapter,
57
+ pairing: {
58
+ idLabel: "discordUserId",
59
+ normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
60
+ notifyApproval: async ({ id }) => {
61
+ await getDiscordRuntime().channel.discord.sendMessageDiscord(
62
+ `user:${id}`,
63
+ PAIRING_APPROVED_MESSAGE,
64
+ );
65
+ },
66
+ },
67
+ capabilities: {
68
+ chatTypes: ["direct", "channel", "thread"],
69
+ polls: true,
70
+ reactions: true,
71
+ threads: true,
72
+ media: true,
73
+ nativeCommands: true,
74
+ },
75
+ streaming: {
76
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
77
+ },
78
+ reload: { configPrefixes: ["channels.discord"] },
79
+ configSchema: buildChannelConfigSchema(DiscordConfigSchema),
80
+ config: {
81
+ listAccountIds: (cfg) => listDiscordAccountIds(cfg),
82
+ resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
83
+ defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
84
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
85
+ setAccountEnabledInConfigSection({
86
+ cfg,
87
+ sectionKey: "discord",
88
+ accountId,
89
+ enabled,
90
+ allowTopLevel: true,
91
+ }),
92
+ deleteAccount: ({ cfg, accountId }) =>
93
+ deleteAccountFromConfigSection({
94
+ cfg,
95
+ sectionKey: "discord",
96
+ accountId,
97
+ clearBaseFields: ["token", "name"],
98
+ }),
99
+ isConfigured: (account) => Boolean(account.token?.trim()),
100
+ describeAccount: (account) => ({
101
+ accountId: account.accountId,
102
+ name: account.name,
103
+ enabled: account.enabled,
104
+ configured: Boolean(account.token?.trim()),
105
+ tokenSource: account.tokenSource,
106
+ }),
107
+ resolveAllowFrom: ({ cfg, accountId }) =>
108
+ (resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
109
+ String(entry),
110
+ ),
111
+ formatAllowFrom: ({ allowFrom }) =>
112
+ allowFrom
113
+ .map((entry) => String(entry).trim())
114
+ .filter(Boolean)
115
+ .map((entry) => entry.toLowerCase()),
116
+ resolveDefaultTo: ({ cfg, accountId }) =>
117
+ resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
118
+ },
119
+ security: {
120
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
121
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
122
+ const useAccountPath = Boolean(cfg.channels?.discord?.accounts?.[resolvedAccountId]);
123
+ const allowFromPath = useAccountPath
124
+ ? `channels.discord.accounts.${resolvedAccountId}.dm.`
125
+ : "channels.discord.dm.";
126
+ return {
127
+ policy: account.config.dm?.policy ?? "pairing",
128
+ allowFrom: account.config.dm?.allowFrom ?? [],
129
+ allowFromPath,
130
+ approveHint: formatPairingApproveHint("discord"),
131
+ normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
132
+ };
133
+ },
134
+ collectWarnings: ({ account, cfg }) => {
135
+ const warnings: string[] = [];
136
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
137
+ const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
138
+ providerConfigPresent: cfg.channels?.discord !== undefined,
139
+ groupPolicy: account.config.groupPolicy,
140
+ defaultGroupPolicy,
141
+ });
142
+ const guildEntries = account.config.guilds ?? {};
143
+ const guildsConfigured = Object.keys(guildEntries).length > 0;
144
+ const channelAllowlistConfigured = guildsConfigured;
145
+
146
+ if (groupPolicy === "open") {
147
+ if (channelAllowlistConfigured) {
148
+ warnings.push(
149
+ `- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
150
+ );
151
+ } else {
152
+ warnings.push(
153
+ `- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
154
+ );
155
+ }
156
+ }
157
+
158
+ return warnings;
159
+ },
160
+ },
161
+ groups: {
162
+ resolveRequireMention: resolveDiscordGroupRequireMention,
163
+ resolveToolPolicy: resolveDiscordGroupToolPolicy,
164
+ },
165
+ mentions: {
166
+ stripPatterns: () => ["<@!?\\d+>"],
167
+ },
168
+ threading: {
169
+ resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
170
+ },
171
+ agentPrompt: {
172
+ messageToolHints: () => [
173
+ "- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.",
174
+ "- Forms: add `components.modal` (title, fields). FasedAgent adds a trigger button and routes submissions as new messages.",
175
+ ],
176
+ },
177
+ messaging: {
178
+ normalizeTarget: normalizeDiscordMessagingTarget,
179
+ targetResolver: {
180
+ looksLikeId: looksLikeDiscordTargetId,
181
+ hint: "<channelId|user:ID|channel:ID>",
182
+ },
183
+ },
184
+ directory: {
185
+ self: async () => null,
186
+ listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
187
+ listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
188
+ listPeersLive: async (params) =>
189
+ getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
190
+ listGroupsLive: async (params) =>
191
+ getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
192
+ },
193
+ resolver: {
194
+ resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
195
+ const account = resolveDiscordAccount({ cfg, accountId });
196
+ const token = account.token?.trim();
197
+ if (!token) {
198
+ return inputs.map((input) => ({
199
+ input,
200
+ resolved: false,
201
+ note: "missing Discord token",
202
+ }));
203
+ }
204
+ if (kind === "group") {
205
+ const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
206
+ token,
207
+ entries: inputs,
208
+ });
209
+ return resolved.map((entry) => ({
210
+ input: entry.input,
211
+ resolved: entry.resolved,
212
+ id: entry.channelId ?? entry.guildId,
213
+ name:
214
+ entry.channelName ??
215
+ entry.guildName ??
216
+ (entry.guildId && !entry.channelId ? entry.guildId : undefined),
217
+ note: entry.note,
218
+ }));
219
+ }
220
+ const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
221
+ token,
222
+ entries: inputs,
223
+ });
224
+ return resolved.map((entry) => ({
225
+ input: entry.input,
226
+ resolved: entry.resolved,
227
+ id: entry.id,
228
+ name: entry.name,
229
+ note: entry.note,
230
+ }));
231
+ },
232
+ },
233
+ actions: discordMessageActions,
234
+ setup: {
235
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
236
+ applyAccountName: ({ cfg, accountId, name }) =>
237
+ applyAccountNameToChannelSection({
238
+ cfg,
239
+ channelKey: "discord",
240
+ accountId,
241
+ name,
242
+ }),
243
+ validateInput: ({ accountId, input }) => {
244
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
245
+ return "DISCORD_BOT_TOKEN can only be used for the default account.";
246
+ }
247
+ if (!input.useEnv && !input.token) {
248
+ return "Discord requires token (or --use-env).";
249
+ }
250
+ return null;
251
+ },
252
+ applyAccountConfig: ({ cfg, accountId, input }) => {
253
+ const namedConfig = applyAccountNameToChannelSection({
254
+ cfg,
255
+ channelKey: "discord",
256
+ accountId,
257
+ name: input.name,
258
+ });
259
+ const next =
260
+ accountId !== DEFAULT_ACCOUNT_ID
261
+ ? migrateBaseNameToDefaultAccount({
262
+ cfg: namedConfig,
263
+ channelKey: "discord",
264
+ })
265
+ : namedConfig;
266
+ if (accountId === DEFAULT_ACCOUNT_ID) {
267
+ return {
268
+ ...next,
269
+ channels: {
270
+ ...next.channels,
271
+ discord: {
272
+ ...next.channels?.discord,
273
+ enabled: true,
274
+ ...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
275
+ },
276
+ },
277
+ };
278
+ }
279
+ return {
280
+ ...next,
281
+ channels: {
282
+ ...next.channels,
283
+ discord: {
284
+ ...next.channels?.discord,
285
+ enabled: true,
286
+ accounts: {
287
+ ...next.channels?.discord?.accounts,
288
+ [accountId]: {
289
+ ...next.channels?.discord?.accounts?.[accountId],
290
+ enabled: true,
291
+ ...(input.token ? { token: input.token } : {}),
292
+ },
293
+ },
294
+ },
295
+ },
296
+ };
297
+ },
298
+ },
299
+ outbound: {
300
+ deliveryMode: "direct",
301
+ chunker: null,
302
+ textChunkLimit: 2000,
303
+ pollMaxOptions: 10,
304
+ resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
305
+ sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
306
+ const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
307
+ const result = await send(to, text, {
308
+ verbose: false,
309
+ replyTo: replyToId ?? undefined,
310
+ accountId: accountId ?? undefined,
311
+ silent: silent ?? undefined,
312
+ });
313
+ return { channel: "discord", ...result };
314
+ },
315
+ sendMedia: async ({
316
+ to,
317
+ text,
318
+ mediaUrl,
319
+ mediaLocalRoots,
320
+ accountId,
321
+ deps,
322
+ replyToId,
323
+ silent,
324
+ }) => {
325
+ const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
326
+ const result = await send(to, text, {
327
+ verbose: false,
328
+ mediaUrl,
329
+ mediaLocalRoots,
330
+ replyTo: replyToId ?? undefined,
331
+ accountId: accountId ?? undefined,
332
+ silent: silent ?? undefined,
333
+ });
334
+ return { channel: "discord", ...result };
335
+ },
336
+ sendPoll: async ({ to, poll, accountId, silent }) =>
337
+ await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
338
+ accountId: accountId ?? undefined,
339
+ silent: silent ?? undefined,
340
+ }),
341
+ },
342
+ status: {
343
+ defaultRuntime: {
344
+ accountId: DEFAULT_ACCOUNT_ID,
345
+ running: false,
346
+ lastStartAt: null,
347
+ lastStopAt: null,
348
+ lastError: null,
349
+ },
350
+ collectStatusIssues: collectDiscordStatusIssues,
351
+ buildChannelSummary: ({ snapshot }) =>
352
+ buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
353
+ probeAccount: async ({ account, timeoutMs }) =>
354
+ getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
355
+ includeApplication: true,
356
+ }),
357
+ auditAccount: async ({ account, timeoutMs, cfg }) => {
358
+ const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
359
+ cfg,
360
+ accountId: account.accountId,
361
+ });
362
+ if (!channelIds.length && unresolvedChannels === 0) {
363
+ return undefined;
364
+ }
365
+ const botToken = account.token?.trim();
366
+ if (!botToken) {
367
+ return {
368
+ ok: unresolvedChannels === 0,
369
+ checkedChannels: 0,
370
+ unresolvedChannels,
371
+ channels: [],
372
+ elapsedMs: 0,
373
+ };
374
+ }
375
+ const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
376
+ token: botToken,
377
+ accountId: account.accountId,
378
+ channelIds,
379
+ timeoutMs,
380
+ });
381
+ return { ...audit, unresolvedChannels };
382
+ },
383
+ buildAccountSnapshot: ({ account, runtime, probe, audit }) => {
384
+ const configured = Boolean(account.token?.trim());
385
+ const app = runtime?.application ?? (probe as { application?: unknown })?.application;
386
+ const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
387
+ return {
388
+ accountId: account.accountId,
389
+ name: account.name,
390
+ enabled: account.enabled,
391
+ configured,
392
+ tokenSource: account.tokenSource,
393
+ running: runtime?.running ?? false,
394
+ connected: runtime?.connected,
395
+ reconnectAttempts: runtime?.reconnectAttempts,
396
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
397
+ lastDisconnect: runtime?.lastDisconnect ?? null,
398
+ lastStartAt: runtime?.lastStartAt ?? null,
399
+ lastStopAt: runtime?.lastStopAt ?? null,
400
+ lastError: runtime?.lastError ?? null,
401
+ application: app ?? undefined,
402
+ bot: bot ?? undefined,
403
+ probe,
404
+ audit,
405
+ lastInboundAt: runtime?.lastInboundAt ?? null,
406
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
407
+ };
408
+ },
409
+ },
410
+ gateway: {
411
+ startAccount: async (ctx) => {
412
+ const account = ctx.account;
413
+ const token = account.token.trim();
414
+ let discordBotLabel = "";
415
+ try {
416
+ const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
417
+ includeApplication: true,
418
+ });
419
+ const username = probe.ok ? probe.bot?.username?.trim() : null;
420
+ if (username) {
421
+ discordBotLabel = ` (@${username})`;
422
+ }
423
+ ctx.setStatus({
424
+ accountId: account.accountId,
425
+ bot: probe.bot,
426
+ application: probe.application,
427
+ });
428
+ const messageContent = probe.application?.intents?.messageContent;
429
+ if (messageContent === "disabled") {
430
+ ctx.log?.warn(
431
+ `[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
432
+ );
433
+ } else if (messageContent === "limited") {
434
+ ctx.log?.info(
435
+ `[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
436
+ );
437
+ }
438
+ } catch (err) {
439
+ if (getDiscordRuntime().logging.shouldLogVerbose()) {
440
+ ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
441
+ }
442
+ }
443
+ ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
444
+ return getDiscordRuntime().channel.discord.monitorDiscordProvider({
445
+ token,
446
+ accountId: account.accountId,
447
+ config: ctx.cfg,
448
+ runtime: ctx.runtime,
449
+ abortSignal: ctx.abortSignal,
450
+ mediaMaxMb: account.config.mediaMaxMb,
451
+ historyLimit: account.config.historyLimit,
452
+ getStatus: ctx.getStatus,
453
+ setStatus: ctx.setStatus,
454
+ });
455
+ },
456
+ },
457
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "fased/plugin-sdk/discord";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setDiscordRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getDiscordRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("Discord runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
@@ -0,0 +1,152 @@
1
+ import {
2
+ autoBindSpawnedDiscordSubagent,
3
+ type FasedAgentPluginApi,
4
+ listThreadBindingsBySessionKey,
5
+ resolveDiscordAccount,
6
+ unbindThreadBindingsBySessionKey,
7
+ } from "fased/plugin-sdk/discord";
8
+
9
+ function summarizeError(err: unknown): string {
10
+ if (err instanceof Error) {
11
+ return err.message;
12
+ }
13
+ if (typeof err === "string") {
14
+ return err;
15
+ }
16
+ return "error";
17
+ }
18
+
19
+ export function registerDiscordSubagentHooks(api: FasedAgentPluginApi) {
20
+ const resolveThreadBindingFlags = (accountId?: string) => {
21
+ const account = resolveDiscordAccount({
22
+ cfg: api.config,
23
+ accountId,
24
+ });
25
+ const baseThreadBindings = api.config.channels?.discord?.threadBindings;
26
+ const accountThreadBindings =
27
+ api.config.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
28
+ return {
29
+ enabled:
30
+ accountThreadBindings?.enabled ??
31
+ baseThreadBindings?.enabled ??
32
+ api.config.session?.threadBindings?.enabled ??
33
+ true,
34
+ spawnSubagentSessions:
35
+ accountThreadBindings?.spawnSubagentSessions ??
36
+ baseThreadBindings?.spawnSubagentSessions ??
37
+ false,
38
+ };
39
+ };
40
+
41
+ api.on("subagent_spawning", async (event) => {
42
+ if (!event.threadRequested) {
43
+ return;
44
+ }
45
+ const channel = event.requester?.channel?.trim().toLowerCase();
46
+ if (channel !== "discord") {
47
+ // Ignore non-Discord channels so channel-specific plugins can handle
48
+ // their own thread/session provisioning without Discord blocking them.
49
+ return;
50
+ }
51
+ const threadBindingFlags = resolveThreadBindingFlags(event.requester?.accountId);
52
+ if (!threadBindingFlags.enabled) {
53
+ return {
54
+ status: "error" as const,
55
+ error:
56
+ "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).",
57
+ };
58
+ }
59
+ if (!threadBindingFlags.spawnSubagentSessions) {
60
+ return {
61
+ status: "error" as const,
62
+ error:
63
+ "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).",
64
+ };
65
+ }
66
+ try {
67
+ const binding = await autoBindSpawnedDiscordSubagent({
68
+ accountId: event.requester?.accountId,
69
+ channel: event.requester?.channel,
70
+ to: event.requester?.to,
71
+ threadId: event.requester?.threadId,
72
+ childSessionKey: event.childSessionKey,
73
+ agentId: event.agentId,
74
+ label: event.label,
75
+ boundBy: "system",
76
+ });
77
+ if (!binding) {
78
+ return {
79
+ status: "error" as const,
80
+ error:
81
+ "Unable to create or bind a Discord thread for this subagent session. Session mode is unavailable for this target.",
82
+ };
83
+ }
84
+ return { status: "ok" as const, threadBindingReady: true };
85
+ } catch (err) {
86
+ return {
87
+ status: "error" as const,
88
+ error: `Discord thread bind failed: ${summarizeError(err)}`,
89
+ };
90
+ }
91
+ });
92
+
93
+ api.on("subagent_ended", (event) => {
94
+ unbindThreadBindingsBySessionKey({
95
+ targetSessionKey: event.targetSessionKey,
96
+ accountId: event.accountId,
97
+ targetKind: event.targetKind,
98
+ reason: event.reason,
99
+ sendFarewell: event.sendFarewell,
100
+ });
101
+ });
102
+
103
+ api.on("subagent_delivery_target", (event) => {
104
+ if (!event.expectsCompletionMessage) {
105
+ return;
106
+ }
107
+ const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
108
+ if (requesterChannel !== "discord") {
109
+ return;
110
+ }
111
+ const requesterAccountId = event.requesterOrigin?.accountId?.trim();
112
+ const requesterThreadId =
113
+ event.requesterOrigin?.threadId != null && event.requesterOrigin.threadId !== ""
114
+ ? String(event.requesterOrigin.threadId).trim()
115
+ : "";
116
+ const bindings = listThreadBindingsBySessionKey({
117
+ targetSessionKey: event.childSessionKey,
118
+ ...(requesterAccountId ? { accountId: requesterAccountId } : {}),
119
+ targetKind: "subagent",
120
+ });
121
+ if (bindings.length === 0) {
122
+ return;
123
+ }
124
+
125
+ let binding: (typeof bindings)[number] | undefined;
126
+ if (requesterThreadId) {
127
+ binding = bindings.find((entry) => {
128
+ if (entry.threadId !== requesterThreadId) {
129
+ return false;
130
+ }
131
+ if (requesterAccountId && entry.accountId !== requesterAccountId) {
132
+ return false;
133
+ }
134
+ return true;
135
+ });
136
+ }
137
+ if (!binding && bindings.length === 1) {
138
+ binding = bindings[0];
139
+ }
140
+ if (!binding) {
141
+ return;
142
+ }
143
+ return {
144
+ origin: {
145
+ channel: "discord",
146
+ accountId: binding.accountId,
147
+ to: `channel:${binding.threadId}`,
148
+ threadId: binding.threadId,
149
+ },
150
+ };
151
+ });
152
+ }