feat: thread-bound subagents on Discord (#21805)

* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Onur
2026-02-21 16:14:55 +01:00
committed by GitHub
parent 166068dfbe
commit 8178ea472d
114 changed files with 12214 additions and 1659 deletions

View File

@@ -11,6 +11,7 @@ import {
upsertPairingRequestMock,
} from "./monitor.tool-result.test-harness.js";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
const loadConfigMock = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
@@ -91,6 +92,7 @@ async function createHandler(cfg: LoadedConfig) {
dmEnabled: true,
groupDmEnabled: false,
guildEntries: cfg.channels?.discord?.guilds,
threadBindings: createNoopThreadBindingManager("default"),
});
}
@@ -291,6 +293,7 @@ describe("discord tool result dispatch", () => {
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const reply = vi.fn().mockResolvedValue(undefined);

View File

@@ -10,6 +10,7 @@ import {
} from "./monitor.tool-result.test-harness.js";
import { createDiscordMessageHandler } from "./monitor/message-handler.js";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
type Config = ReturnType<typeof import("../config/config.js").loadConfig>;
@@ -71,6 +72,7 @@ async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
threadBindings: createNoopThreadBindingManager("default"),
});
}
@@ -107,6 +109,7 @@ async function createCategoryGuildHandler() {
guildEntries: {
"*": { requireMention: false, channels: { c1: { allow: true } } },
},
threadBindings: createNoopThreadBindingManager("default"),
});
}

View File

@@ -0,0 +1,209 @@
import { ChannelType } from "@buape/carbon";
import { beforeEach, describe, expect, it } from "vitest";
import {
preflightDiscordMessage,
resolvePreflightMentionRequirement,
shouldIgnoreBoundThreadWebhookMessage,
} from "./message-handler.preflight.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
function createThreadBinding(
overrides?: Partial<import("./thread-bindings.js").ThreadBindingRecord>,
) {
return {
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-1",
agentId: "main",
boundBy: "test",
boundAt: 1,
webhookId: "wh-1",
webhookToken: "tok-1",
...overrides,
} satisfies import("./thread-bindings.js").ThreadBindingRecord;
}
describe("resolvePreflightMentionRequirement", () => {
it("requires mention when config requires mention and thread is not bound", () => {
expect(
resolvePreflightMentionRequirement({
shouldRequireMention: true,
isBoundThreadSession: false,
}),
).toBe(true);
});
it("disables mention requirement for bound thread sessions", () => {
expect(
resolvePreflightMentionRequirement({
shouldRequireMention: true,
isBoundThreadSession: true,
}),
).toBe(false);
});
it("keeps mention requirement disabled when config already disables it", () => {
expect(
resolvePreflightMentionRequirement({
shouldRequireMention: false,
isBoundThreadSession: false,
}),
).toBe(false);
});
});
describe("preflightDiscordMessage", () => {
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
const threadBinding = createThreadBinding();
const threadId = "thread-bot-focus";
const parentId = "channel-parent-focus";
const client = {
fetchChannel: async (channelId: string) => {
if (channelId === threadId) {
return {
id: threadId,
type: ChannelType.PublicThread,
name: "focus",
parentId,
ownerId: "owner-1",
};
}
if (channelId === parentId) {
return {
id: parentId,
type: ChannelType.GuildText,
name: "general",
};
}
return null;
},
} as unknown as import("@buape/carbon").Client;
const message = {
id: "m-bot-1",
content: "relay message without mention",
timestamp: new Date().toISOString(),
channelId: threadId,
attachments: [],
mentionedUsers: [],
mentionedRoles: [],
mentionedEveryone: false,
author: {
id: "relay-bot-1",
bot: true,
username: "Relay",
},
} as unknown as import("@buape/carbon").Message;
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {
allowBots: true,
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: {
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
} as import("./thread-bindings.js").ThreadBindingManager,
data: {
channel_id: threadId,
guild_id: "guild-1",
guild: {
id: "guild-1",
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
expect(result).not.toBeNull();
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
expect(result?.shouldRequireMention).toBe(false);
});
});
describe("shouldIgnoreBoundThreadWebhookMessage", () => {
beforeEach(() => {
threadBindingTesting.resetThreadBindingsForTests();
});
it("returns true when inbound webhook id matches the bound thread webhook", () => {
expect(
shouldIgnoreBoundThreadWebhookMessage({
webhookId: "wh-1",
threadBinding: createThreadBinding(),
}),
).toBe(true);
});
it("returns false when webhook ids differ", () => {
expect(
shouldIgnoreBoundThreadWebhookMessage({
webhookId: "wh-other",
threadBinding: createThreadBinding(),
}),
).toBe(false);
});
it("returns false when there is no bound thread webhook", () => {
expect(
shouldIgnoreBoundThreadWebhookMessage({
webhookId: "wh-1",
threadBinding: createThreadBinding({ webhookId: undefined }),
}),
).toBe(false);
});
it("returns true for recently unbound thread webhook echoes", async () => {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
const binding = await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-1",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
});
expect(binding).not.toBeNull();
manager.unbindThread({
threadId: "thread-1",
sendFarewell: false,
});
expect(
shouldIgnoreBoundThreadWebhookMessage({
accountId: "default",
threadId: "thread-1",
webhookId: "wh-1",
}),
).toBe(true);
});
});

View File

@@ -25,6 +25,7 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
import { sendMessageDiscord } from "../send.js";
import {
@@ -55,6 +56,10 @@ import {
} from "./message-utils.js";
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { resolveDiscordSystemEvent } from "./system-events.js";
import {
isRecentlyUnboundThreadWebhookMessage,
type ThreadBindingRecord,
} from "./thread-bindings.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
export type {
@@ -62,6 +67,41 @@ export type {
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean;
isBoundThreadSession: boolean;
}): boolean {
if (!params.shouldRequireMention) {
return false;
}
return !params.isBoundThreadSession;
}
export function shouldIgnoreBoundThreadWebhookMessage(params: {
accountId?: string;
threadId?: string;
webhookId?: string | null;
threadBinding?: ThreadBindingRecord;
}): boolean {
const webhookId = params.webhookId?.trim() || "";
if (!webhookId) {
return false;
}
const boundWebhookId = params.threadBinding?.webhookId?.trim() || "";
if (!boundWebhookId) {
const threadId = params.threadId?.trim() || "";
if (!threadId) {
return false;
}
return isRecentlyUnboundThreadWebhookMessage({
accountId: params.accountId,
threadId,
webhookId,
});
}
return webhookId === boundWebhookId;
}
export async function preflightDiscordMessage(
params: DiscordMessagePreflightParams,
): Promise<DiscordMessagePreflightContext | null> {
@@ -253,7 +293,30 @@ export async function preflightDiscordMessage(
// Pass parent peer for thread binding inheritance
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
});
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
const threadBinding = earlyThreadChannel
? params.threadBindings.getByThreadId(messageChannelId)
: undefined;
if (
shouldIgnoreBoundThreadWebhookMessage({
accountId: params.accountId,
threadId: messageChannelId,
webhookId,
threadBinding,
})
) {
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
return null;
}
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
const effectiveRoute = boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
const explicitlyMentioned = Boolean(
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
);
@@ -314,7 +377,7 @@ export async function preflightDiscordMessage(
const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const baseSessionKey = route.sessionKey;
const baseSessionKey = effectiveRoute.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfigWithFallback({
guildInfo,
@@ -408,7 +471,7 @@ export async function preflightDiscordMessage(
: undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const shouldRequireMention = resolveDiscordShouldRequireMention({
const shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),
botId,
@@ -416,6 +479,11 @@ export async function preflightDiscordMessage(
channelConfig,
guildInfo,
});
const isBoundThreadSession = Boolean(boundSessionKey && threadChannel);
const shouldRequireMention = resolvePreflightMentionRequirement({
shouldRequireMention: shouldRequireMentionByConfig,
isBoundThreadSession,
});
// Preflight audio transcription for mention detection in guilds
// This allows voice notes to be checked for mentions before being dropped
@@ -547,7 +615,7 @@ export async function preflightDiscordMessage(
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
logDebug(
`[discord-preflight] shouldRequireMention=${shouldRequireMention} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
);
if (isGuildMessage && shouldRequireMention) {
if (botId && mentionGate.shouldSkip) {
@@ -586,7 +654,7 @@ export async function preflightDiscordMessage(
if (systemText) {
logDebug(`[discord-preflight] drop: system event`);
enqueueSystemEvent(systemText, {
sessionKey: route.sessionKey,
sessionKey: effectiveRoute.sessionKey,
contextKey: `discord:system:${messageChannelId}:${message.id}`,
});
return null;
@@ -598,7 +666,9 @@ export async function preflightDiscordMessage(
return null;
}
logDebug(`[discord-preflight] success: route=${route.agentId} sessionKey=${route.sessionKey}`);
logDebug(
`[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`,
);
return {
cfg: params.cfg,
discordConfig: params.discordConfig,
@@ -628,7 +698,10 @@ export async function preflightDiscordMessage(
baseText,
messageText,
wasMentioned,
route,
route: effectiveRoute,
threadBinding,
boundSessionKey: boundSessionKey || undefined,
boundAgentId,
guildInfo,
guildSlug,
threadChannel,
@@ -651,5 +724,6 @@ export async function preflightDiscordMessage(
effectiveWasMentioned,
canDetectMention,
historyEntry,
threadBindings: params.threadBindings,
};
}

View File

@@ -5,6 +5,7 @@ import type { resolveAgentRoute } from "../../routing/resolve-route.js";
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
import type { DiscordChannelInfo } from "./message-utils.js";
import type { DiscordSenderIdentity } from "./sender-identity.js";
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
export type { DiscordSenderIdentity } from "./sender-identity.js";
import type { DiscordThreadChannel } from "./threading.js";
@@ -51,6 +52,9 @@ export type DiscordMessagePreflightContext = {
wasMentioned: boolean;
route: ReturnType<typeof resolveAgentRoute>;
threadBinding?: ThreadBindingRecord;
boundSessionKey?: string;
boundAgentId?: string;
guildInfo: DiscordGuildEntryResolved | null;
guildSlug: string;
@@ -79,6 +83,7 @@ export type DiscordMessagePreflightContext = {
canDetectMention: boolean;
historyEntry?: HistoryEntry;
threadBindings: ThreadBindingManager;
};
export type DiscordMessagePreflightParams = {
@@ -100,6 +105,7 @@ export type DiscordMessagePreflightParams = {
guildEntries?: Record<string, DiscordGuildEntryResolved>;
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
threadBindings: ThreadBindingManager;
data: DiscordMessageEvent;
client: Client;
};

View File

@@ -1,20 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_EMOJIS } from "../../channels/status-reactions.js";
import { createBaseDiscordMessageContext } from "./message-handler.test-harness.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
const reactMessageDiscord = vi.fn(async () => {});
const removeReactionDiscord = vi.fn(async () => {});
const editMessageDiscord = vi.fn(async () => ({}));
const deliverDiscordReply = vi.fn(async () => {});
const createDiscordDraftStream = vi.fn(() => ({
update: vi.fn<(text: string) => void>(() => {}),
flush: vi.fn(async () => {}),
messageId: vi.fn(() => "preview-1"),
clear: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
forceNewMessage: vi.fn(() => {}),
const sendMocks = vi.hoisted(() => ({
reactMessageDiscord: vi.fn(async () => {}),
removeReactionDiscord: vi.fn(async () => {}),
}));
const deliveryMocks = vi.hoisted(() => ({
editMessageDiscord: vi.fn(async () => ({})),
deliverDiscordReply: vi.fn(async () => {}),
createDiscordDraftStream: vi.fn(() => ({
update: vi.fn<(text: string) => void>(() => {}),
flush: vi.fn(async () => {}),
messageId: vi.fn(() => "preview-1"),
clear: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
forceNewMessage: vi.fn(() => {}),
})),
}));
const editMessageDiscord = deliveryMocks.editMessageDiscord;
const deliverDiscordReply = deliveryMocks.deliverDiscordReply;
const createDiscordDraftStream = deliveryMocks.createDiscordDraftStream;
type DispatchInboundParams = {
dispatcher: {
sendFinalReply: (payload: { text?: string }) => boolean | Promise<boolean>;
@@ -36,20 +46,20 @@ const readSessionUpdatedAt = vi.fn(() => undefined);
const resolveStorePath = vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json");
vi.mock("../send.js", () => ({
reactMessageDiscord,
removeReactionDiscord,
reactMessageDiscord: sendMocks.reactMessageDiscord,
removeReactionDiscord: sendMocks.removeReactionDiscord,
}));
vi.mock("../send.messages.js", () => ({
editMessageDiscord,
editMessageDiscord: deliveryMocks.editMessageDiscord,
}));
vi.mock("../draft-stream.js", () => ({
createDiscordDraftStream,
createDiscordDraftStream: deliveryMocks.createDiscordDraftStream,
}));
vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply,
deliverDiscordReply: deliveryMocks.deliverDiscordReply,
}));
vi.mock("../../auto-reply/dispatch.js", () => ({
@@ -91,8 +101,8 @@ const createBaseContext = createBaseDiscordMessageContext;
beforeEach(() => {
vi.useRealTimers();
reactMessageDiscord.mockClear();
removeReactionDiscord.mockClear();
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
editMessageDiscord.mockClear();
deliverDiscordReply.mockClear();
createDiscordDraftStream.mockClear();
@@ -107,6 +117,7 @@ beforeEach(() => {
recordInboundSession.mockResolvedValue(undefined);
readSessionUpdatedAt.mockReturnValue(undefined);
resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json");
threadBindingTesting.resetThreadBindingsForTests();
});
function getLastRouteUpdate():
@@ -126,6 +137,16 @@ function getLastRouteUpdate():
return params?.updateLastRoute;
}
function getLastDispatchCtx():
| { SessionKey?: string; MessageThreadId?: string | number }
| undefined {
const callArgs = dispatchInboundMessage.mock.calls.at(-1) as unknown[] | undefined;
const params = callArgs?.[0] as
| { ctx?: { SessionKey?: string; MessageThreadId?: string | number } }
| undefined;
return params?.ctx;
}
describe("processDiscordMessage ack reactions", () => {
it("skips ack reactions for group-mentions when mentions are not required", async () => {
const ctx = await createBaseContext({
@@ -136,7 +157,7 @@ describe("processDiscordMessage ack reactions", () => {
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).not.toHaveBeenCalled();
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
});
it("sends ack reactions for mention-gated guild messages when mentioned", async () => {
@@ -148,7 +169,7 @@ describe("processDiscordMessage ack reactions", () => {
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord.mock.calls[0]).toEqual(["c1", "m1", "👀", { rest: {} }]);
expect(sendMocks.reactMessageDiscord.mock.calls[0]).toEqual(["c1", "m1", "👀", { rest: {} }]);
});
it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => {
@@ -166,7 +187,7 @@ describe("processDiscordMessage ack reactions", () => {
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord.mock.calls[0]).toEqual([
expect(sendMocks.reactMessageDiscord.mock.calls[0]).toEqual([
"fallback-channel",
"m1",
"👀",
@@ -187,7 +208,7 @@ describe("processDiscordMessage ack reactions", () => {
await processDiscordMessage(ctx as any);
const emojis = (
reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
).map((call) => call[2]);
expect(emojis).toContain("👀");
expect(emojis).toContain(DEFAULT_EMOJIS.done);
@@ -216,7 +237,7 @@ describe("processDiscordMessage ack reactions", () => {
await runPromise;
const emojis = (
reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
).map((call) => call[2]);
expect(emojis).toContain(DEFAULT_EMOJIS.stallSoft);
expect(emojis).toContain(DEFAULT_EMOJIS.stallHard);
@@ -289,6 +310,52 @@ describe("processDiscordMessage session routing", () => {
accountId: "default",
});
});
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {
const threadBindings = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "c-parent",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
const ctx = await createBaseContext({
messageChannelId: "thread-1",
threadChannel: { id: "thread-1", name: "subagent-thread" },
boundSessionKey: "agent:main:subagent:child",
threadBindings,
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:discord:channel:c1",
mainSessionKey: "agent:main:main",
},
});
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
expect(getLastDispatchCtx()).toMatchObject({
SessionKey: "agent:main:subagent:child",
MessageThreadId: "thread-1",
});
expect(getLastRouteUpdate()).toEqual({
sessionKey: "agent:main:subagent:child",
channel: "discord",
to: "channel:thread-1",
accountId: "default",
});
});
});
describe("processDiscordMessage draft streaming", () => {

View File

@@ -94,6 +94,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
guildSlug,
channelConfig,
baseSessionKey,
boundSessionKey,
threadBindings,
route,
commandAuthorized,
} = ctx;
@@ -324,7 +326,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
CommandBody: baseText,
From: effectiveFrom,
To: effectiveTo,
SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
SessionKey: boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: fromLabel,
@@ -346,6 +348,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
ReplyToBody: replyContext?.body,
ReplyToSender: replyContext?.sender,
ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
@@ -633,6 +636,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode,
chunkMode,
sessionKey: ctxPayload.SessionKey,
threadBindings,
});
replyReference.markSent();
},

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
export async function createBaseDiscordMessageContext(
overrides: Record<string, unknown> = {},
@@ -67,6 +68,7 @@ export async function createBaseDiscordMessageContext(
sessionKey: "agent:main:discord:guild:g1",
mainSessionKey: "agent:main:main",
},
threadBindings: createNoopThreadBindingManager("default"),
...overrides,
} as unknown as DiscordMessagePreflightContext;
}

View File

@@ -6,6 +6,7 @@ import { normalizeProviderId } from "../../agents/model-selection.js";
import { resolveStateDir } from "../../config/paths.js";
import { withFileLock } from "../../infra/file-lock.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { normalizeAccountId as normalizeSharedAccountId } from "../../routing/account-id.js";
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
retries: {
@@ -41,11 +42,6 @@ function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): stri
return path.join(stateDir, "discord", "model-picker-preferences.json");
}
function normalizeAccountId(value?: string): string {
const normalized = value?.trim().toLowerCase();
return normalized || "default";
}
function normalizeId(value?: string): string {
return value?.trim() ?? "";
}
@@ -57,7 +53,7 @@ export function buildDiscordModelPickerPreferenceKey(
if (!userId) {
return null;
}
const accountId = normalizeAccountId(scope.accountId);
const accountId = normalizeSharedAccountId(scope.accountId);
const guildId = normalizeId(scope.guildId);
if (guildId) {
return `discord:${accountId}:guild:${guildId}:user:${userId}`;

View File

@@ -8,6 +8,7 @@ import type {
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as globalsModule from "../../globals.js";
import * as timeoutModule from "../../utils/with-timeout.js";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
@@ -15,6 +16,7 @@ import {
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
} from "./native-command.js";
import { createNoopThreadBindingManager, type ThreadBindingManager } from "./thread-bindings.js";
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
const byProvider = new Map<string, Set<string>>();
@@ -70,6 +72,7 @@ function createModelPickerContext(): ModelPickerContext {
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
threadBindings: createNoopThreadBindingManager("default"),
};
}
@@ -99,6 +102,38 @@ function createInteraction(params?: { userId?: string; values?: string[] }): Moc
};
}
function createBoundThreadBindingManager(params: {
accountId: string;
threadId: string;
targetSessionKey: string;
agentId: string;
}): ThreadBindingManager {
return {
accountId: params.accountId,
getSessionTtlMs: () => 24 * 60 * 60 * 1000,
getByThreadId: (threadId: string) =>
threadId === params.threadId
? {
accountId: params.accountId,
channelId: "parent-1",
threadId: params.threadId,
targetKind: "subagent",
targetSessionKey: params.targetSessionKey,
agentId: params.agentId,
boundBy: "system",
boundAt: Date.now(),
}
: undefined,
getBySessionKey: () => undefined,
listBySessionKey: () => [],
listBindings: () => [],
bindTarget: async () => null,
unbindThread: () => null,
unbindBySessionKey: () => [],
stop: () => {},
};
}
describe("Discord model picker interactions", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -375,4 +410,78 @@ describe("Discord model picker interactions", () => {
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
});
it("verifies model state against the bound thread session", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const pickerData = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
const modelCommand: ChatCommandDefinition = {
key: "model",
nativeName: "model",
description: "Switch model",
textAliases: ["/model"],
acceptsArgs: true,
argsParsing: "none" as CommandArgsParsing,
scope: "native",
};
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
name === "model" ? modelCommand : undefined,
);
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({} as never);
const verboseSpy = vi.spyOn(globalsModule, "logVerbose").mockImplementation(() => {});
const select = createDiscordModelPickerFallbackSelect(context);
const selectInteraction = createInteraction({
userId: "owner",
values: ["gpt-4o"],
});
selectInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
const selectData: PickerSelectData = {
cmd: "model",
act: "model",
view: "models",
u: "owner",
p: "openai",
pg: "1",
};
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
const button = createDiscordModelPickerFallbackButton(context);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
const submitData: PickerButtonData = {
cmd: "model",
act: "submit",
view: "models",
u: "owner",
p: "openai",
pg: "1",
mi: "2",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
const mismatchLog = verboseSpy.mock.calls.find((call) =>
String(call[0] ?? "").includes("model picker override mismatch"),
)?.[0];
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
});
});

View File

@@ -48,6 +48,7 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
import { chunkItems } from "../../utils/chunk-items.js";
import { withTimeout } from "../../utils/with-timeout.js";
@@ -80,6 +81,7 @@ import {
type DiscordModelPickerCommandContext,
} from "./model-picker.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
import type { ThreadBindingManager } from "./thread-bindings.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
@@ -268,6 +270,7 @@ type DiscordCommandArgContext = {
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
threadBindings: ThreadBindingManager;
};
type DiscordModelPickerContext = DiscordCommandArgContext;
@@ -353,6 +356,7 @@ async function resolveDiscordModelPickerRoute(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
threadBindings: ThreadBindingManager;
}) {
const { interaction, cfg, accountId } = params;
const channel = interaction.channel;
@@ -383,7 +387,7 @@ async function resolveDiscordModelPickerRoute(params: {
threadParentId = parentInfo.id;
}
return resolveAgentRoute({
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId,
@@ -395,6 +399,19 @@ async function resolveDiscordModelPickerRoute(params: {
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
});
const threadBinding = isThreadChannel
? params.threadBindings.getByThreadId(rawChannelId)
: undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
return boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
}
function resolveDiscordModelPickerCurrentModel(params: {
@@ -436,6 +453,7 @@ async function replyWithDiscordModelPickerProviders(params: {
command: DiscordModelPickerCommandContext;
userId: string;
accountId: string;
threadBindings: ThreadBindingManager;
preferFollowUp: boolean;
}) {
const data = await loadDiscordModelPickerData(params.cfg);
@@ -443,6 +461,7 @@ async function replyWithDiscordModelPickerProviders(params: {
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
threadBindings: params.threadBindings,
});
const currentModel = resolveDiscordModelPickerCurrentModel({
cfg: params.cfg,
@@ -603,6 +622,7 @@ async function handleDiscordModelPickerInteraction(
interaction,
cfg: ctx.cfg,
accountId: ctx.accountId,
threadBindings: ctx.threadBindings,
});
const currentModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
@@ -827,6 +847,7 @@ async function handleDiscordModelPickerInteraction(
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
threadBindings: ctx.threadBindings,
suppressReplies: true,
}),
12000,
@@ -957,6 +978,7 @@ async function handleDiscordCommandArgInteraction(
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
threadBindings: ctx.threadBindings,
});
}
@@ -968,6 +990,7 @@ class DiscordCommandArgButton extends Button {
private discordConfig: DiscordConfig;
private accountId: string;
private sessionPrefix: string;
private threadBindings: ThreadBindingManager;
constructor(params: {
label: string;
@@ -976,6 +999,7 @@ class DiscordCommandArgButton extends Button {
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
threadBindings: ThreadBindingManager;
}) {
super();
this.label = params.label;
@@ -984,6 +1008,7 @@ class DiscordCommandArgButton extends Button {
this.discordConfig = params.discordConfig;
this.accountId = params.accountId;
this.sessionPrefix = params.sessionPrefix;
this.threadBindings = params.threadBindings;
}
async run(interaction: ButtonInteraction, data: ComponentData) {
@@ -992,6 +1017,7 @@ class DiscordCommandArgButton extends Button {
discordConfig: this.discordConfig,
accountId: this.accountId,
sessionPrefix: this.sessionPrefix,
threadBindings: this.threadBindings,
});
}
}
@@ -1067,6 +1093,7 @@ function buildDiscordCommandArgMenu(params: {
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
threadBindings: ThreadBindingManager;
}): { content: string; components: Row<Button>[] } {
const { command, menu, interaction } = params;
const commandLabel = command.nativeName ?? command.key;
@@ -1086,6 +1113,7 @@ function buildDiscordCommandArgMenu(params: {
discordConfig: params.discordConfig,
accountId: params.accountId,
sessionPrefix: params.sessionPrefix,
threadBindings: params.threadBindings,
}),
);
return new Row(buttons);
@@ -1102,8 +1130,17 @@ export function createDiscordNativeCommand(params: {
accountId: string;
sessionPrefix: string;
ephemeralDefault: boolean;
threadBindings: ThreadBindingManager;
}): Command {
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
const {
command,
cfg,
discordConfig,
accountId,
sessionPrefix,
ephemeralDefault,
threadBindings,
} = params;
const commandDefinition =
findCommandByNativeName(command.name, "discord") ??
({
@@ -1164,6 +1201,7 @@ export function createDiscordNativeCommand(params: {
accountId,
sessionPrefix,
preferFollowUp: false,
threadBindings,
});
}
})();
@@ -1179,6 +1217,7 @@ async function dispatchDiscordCommandInteraction(params: {
accountId: string;
sessionPrefix: string;
preferFollowUp: boolean;
threadBindings: ThreadBindingManager;
suppressReplies?: boolean;
}) {
const {
@@ -1191,6 +1230,7 @@ async function dispatchDiscordCommandInteraction(params: {
accountId,
sessionPrefix,
preferFollowUp,
threadBindings,
suppressReplies,
} = params;
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
@@ -1391,6 +1431,7 @@ async function dispatchDiscordCommandInteraction(params: {
discordConfig,
accountId,
sessionPrefix,
threadBindings,
});
if (preferFollowUp) {
await safeDiscordInteractionCall("interaction follow-up", () =>
@@ -1423,6 +1464,7 @@ async function dispatchDiscordCommandInteraction(params: {
command: pickerCommandContext,
userId: user.id,
accountId,
threadBindings,
preferFollowUp,
});
return;
@@ -1443,6 +1485,16 @@ async function dispatchDiscordCommandInteraction(params: {
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
});
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
const effectiveRoute = boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig,
@@ -1461,9 +1513,9 @@ async function dispatchDiscordCommandInteraction(params: {
? `discord:group:${channelId}`
: `discord:channel:${channelId}`,
To: `slash:${user.id}`,
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: route.sessionKey,
AccountId: route.accountId,
SessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey,
AccountId: effectiveRoute.accountId,
ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
ConversationLabel: conversationLabel,
GroupSubject: isGuild ? interaction.guild?.name : undefined,
@@ -1496,6 +1548,7 @@ async function dispatchDiscordCommandInteraction(params: {
Surface: "discord" as const,
WasMentioned: true,
MessageSid: interactionId,
MessageThreadId: isThreadChannel ? channelId : undefined,
Timestamp: Date.now(),
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
@@ -1508,11 +1561,11 @@ async function dispatchDiscordCommandInteraction(params: {
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
agentId: effectiveRoute.agentId,
channel: "discord",
accountId: route.accountId,
accountId: effectiveRoute.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
let didReply = false;
await dispatchReplyWithDispatcher({
@@ -1520,7 +1573,7 @@ async function dispatchDiscordCommandInteraction(params: {
cfg,
dispatcherOptions: {
...prefixOptions,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
humanDelay: resolveHumanDelayConfig(cfg, effectiveRoute.agentId),
deliver: async (payload) => {
if (suppressReplies) {
return;

View File

@@ -0,0 +1,207 @@
import {
addAllowlistUserEntriesFromConfigEntry,
buildAllowlistResolutionSummary,
mergeAllowlist,
patchAllowlistUsersInConfigEntries,
resolveAllowlistIdAdditions,
summarizeMapping,
} from "../../channels/allowlists/resolve-utils.js";
import type { DiscordGuildEntry } from "../../config/types.discord.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
type GuildEntries = Record<string, DiscordGuildEntry>;
function toGuildEntries(value: unknown): GuildEntries {
if (!value || typeof value !== "object") {
return {};
}
const out: GuildEntries = {};
for (const [key, entry] of Object.entries(value)) {
if (!entry || typeof entry !== "object") {
continue;
}
out[key] = entry as DiscordGuildEntry;
}
return out;
}
function toAllowlistEntries(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
return value.map((entry) => String(entry).trim()).filter((entry) => Boolean(entry));
}
export async function resolveDiscordAllowlistConfig(params: {
token: string;
guildEntries: unknown;
allowFrom: unknown;
fetcher: typeof fetch;
runtime: RuntimeEnv;
}): Promise<{ guildEntries: GuildEntries | undefined; allowFrom: string[] | undefined }> {
let guildEntries = toGuildEntries(params.guildEntries);
let allowFrom = toAllowlistEntries(params.allowFrom);
if (Object.keys(guildEntries).length > 0) {
try {
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
if (guildKey === "*") {
continue;
}
const channels = guildCfg?.channels ?? {};
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
if (channelKeys.length === 0) {
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
entries.push({ input, guildKey });
continue;
}
for (const channelKey of channelKeys) {
entries.push({
input: `${guildKey}/${channelKey}`,
guildKey,
channelKey,
});
}
}
if (entries.length > 0) {
const resolved = await resolveDiscordChannelAllowlist({
token: params.token,
entries: entries.map((entry) => entry.input),
fetcher: params.fetcher,
});
const nextGuilds = { ...guildEntries };
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of resolved) {
const source = entries.find((item) => item.input === entry.input);
if (!source) {
continue;
}
const sourceGuild = guildEntries[source.guildKey] ?? {};
if (!entry.resolved || !entry.guildId) {
unresolved.push(entry.input);
continue;
}
mapping.push(
entry.channelId
? `${entry.input}${entry.guildId}/${entry.channelId}`
: `${entry.input}${entry.guildId}`,
);
const existing = nextGuilds[entry.guildId] ?? {};
const mergedChannels = {
...sourceGuild.channels,
...existing.channels,
};
const mergedGuild: DiscordGuildEntry = {
...sourceGuild,
...existing,
channels: mergedChannels,
};
nextGuilds[entry.guildId] = mergedGuild;
if (source.channelKey && entry.channelId) {
const sourceChannel = sourceGuild.channels?.[source.channelKey];
if (sourceChannel) {
nextGuilds[entry.guildId] = {
...mergedGuild,
channels: {
...mergedChannels,
[entry.channelId]: {
...sourceChannel,
...mergedChannels[entry.channelId],
},
},
};
}
}
}
guildEntries = nextGuilds;
summarizeMapping("discord channels", mapping, unresolved, params.runtime);
}
} catch (err) {
params.runtime.log?.(
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
const allowEntries =
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
if (allowEntries.length > 0) {
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token: params.token,
entries: allowEntries.map((entry) => String(entry)),
fetcher: params.fetcher,
});
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers);
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("discord users", mapping, unresolved, params.runtime);
} catch (err) {
params.runtime.log?.(
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
if (Object.keys(guildEntries).length > 0) {
const userEntries = new Set<string>();
for (const guild of Object.values(guildEntries)) {
if (!guild || typeof guild !== "object") {
continue;
}
addAllowlistUserEntriesFromConfigEntry(userEntries, guild);
const channels = (guild as { channels?: Record<string, unknown> }).channels ?? {};
for (const channel of Object.values(channels)) {
addAllowlistUserEntriesFromConfigEntry(userEntries, channel);
}
}
if (userEntries.size > 0) {
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token: params.token,
entries: Array.from(userEntries),
fetcher: params.fetcher,
});
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers);
const nextGuilds = { ...guildEntries };
for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) {
if (!guildConfig || typeof guildConfig !== "object") {
continue;
}
const nextGuild = { ...guildConfig } as Record<string, unknown>;
const users = (guildConfig as { users?: string[] }).users;
if (Array.isArray(users) && users.length > 0) {
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
nextGuild.users = mergeAllowlist({ existing: users, additions });
}
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
if (channels && typeof channels === "object") {
nextGuild.channels = patchAllowlistUsersInConfigEntries({
entries: channels,
resolvedMap,
});
}
nextGuilds[guildKey] = nextGuild as DiscordGuildEntry;
}
guildEntries = nextGuilds;
summarizeMapping("discord channel users", mapping, unresolved, params.runtime);
} catch (err) {
params.runtime.log?.(
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
}
return {
guildEntries: Object.keys(guildEntries).length > 0 ? guildEntries : undefined,
allowFrom,
};
}

View File

@@ -0,0 +1,106 @@
import type { Client } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime.js";
const {
attachDiscordGatewayLoggingMock,
getDiscordGatewayEmitterMock,
registerGatewayMock,
stopGatewayLoggingMock,
unregisterGatewayMock,
waitForDiscordGatewayStopMock,
} = vi.hoisted(() => {
const stopGatewayLoggingMock = vi.fn();
return {
attachDiscordGatewayLoggingMock: vi.fn(() => stopGatewayLoggingMock),
getDiscordGatewayEmitterMock: vi.fn(() => undefined),
waitForDiscordGatewayStopMock: vi.fn(() => Promise.resolve()),
registerGatewayMock: vi.fn(),
unregisterGatewayMock: vi.fn(),
stopGatewayLoggingMock,
};
});
vi.mock("../gateway-logging.js", () => ({
attachDiscordGatewayLogging: attachDiscordGatewayLoggingMock,
}));
vi.mock("../monitor.gateway.js", () => ({
getDiscordGatewayEmitter: getDiscordGatewayEmitterMock,
waitForDiscordGatewayStop: waitForDiscordGatewayStopMock,
}));
vi.mock("./gateway-registry.js", () => ({
registerGateway: registerGatewayMock,
unregisterGateway: unregisterGatewayMock,
}));
describe("runDiscordGatewayLifecycle", () => {
beforeEach(() => {
attachDiscordGatewayLoggingMock.mockClear();
getDiscordGatewayEmitterMock.mockClear();
waitForDiscordGatewayStopMock.mockClear();
registerGatewayMock.mockClear();
unregisterGatewayMock.mockClear();
stopGatewayLoggingMock.mockClear();
});
it("cleans up thread bindings when exec approvals startup fails", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const start = vi.fn(async () => {
throw new Error("startup failed");
});
const stop = vi.fn(async () => undefined);
const threadStop = vi.fn();
await expect(
runDiscordGatewayLifecycle({
accountId: "default",
client: { getPlugin: vi.fn(() => undefined) } as unknown as Client,
runtime: {} as RuntimeEnv,
isDisallowedIntentsError: () => false,
voiceManager: null,
voiceManagerRef: { current: null },
execApprovalsHandler: { start, stop },
threadBindings: { stop: threadStop },
}),
).rejects.toThrow("startup failed");
expect(start).toHaveBeenCalledTimes(1);
expect(stop).toHaveBeenCalledTimes(1);
expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled();
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(threadStop).toHaveBeenCalledTimes(1);
});
it("cleans up when gateway wait fails after startup", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
const start = vi.fn(async () => undefined);
const stop = vi.fn(async () => undefined);
const threadStop = vi.fn();
await expect(
runDiscordGatewayLifecycle({
accountId: "default",
client: { getPlugin: vi.fn(() => undefined) } as unknown as Client,
runtime: {} as RuntimeEnv,
isDisallowedIntentsError: () => false,
voiceManager: null,
voiceManagerRef: { current: null },
execApprovalsHandler: { start, stop },
threadBindings: { stop: threadStop },
}),
).rejects.toThrow("gateway wait failed");
expect(start).toHaveBeenCalledTimes(1);
expect(stop).toHaveBeenCalledTimes(1);
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1);
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(threadStop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,132 @@
import type { Client } from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { danger } from "../../globals.js";
import type { RuntimeEnv } from "../../runtime.js";
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
import type { DiscordVoiceManager } from "../voice/manager.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
type ExecApprovalsHandler = {
start: () => Promise<void>;
stop: () => Promise<void>;
};
export async function runDiscordGatewayLifecycle(params: {
accountId: string;
client: Client;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
isDisallowedIntentsError: (err: unknown) => boolean;
voiceManager: DiscordVoiceManager | null;
voiceManagerRef: { current: DiscordVoiceManager | null };
execApprovalsHandler: ExecApprovalsHandler | null;
threadBindings: { stop: () => void };
}) {
const gateway = params.client.getPlugin<GatewayPlugin>("gateway");
if (gateway) {
registerGateway(params.accountId, gateway);
}
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({
emitter: gatewayEmitter,
runtime: params.runtime,
});
const onAbort = () => {
if (!gateway) {
return;
}
gatewayEmitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
};
if (params.abortSignal?.aborted) {
onAbort();
} else {
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
}
const HELLO_TIMEOUT_MS = 30000;
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
const onGatewayDebug = (msg: unknown) => {
const message = String(msg);
if (!message.includes("WebSocket connection opened")) {
return;
}
if (helloTimeoutId) {
clearTimeout(helloTimeoutId);
}
helloTimeoutId = setTimeout(() => {
if (!gateway?.isConnected) {
params.runtime.log?.(
danger(
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
),
);
gateway?.disconnect();
gateway?.connect(false);
}
helloTimeoutId = undefined;
}, HELLO_TIMEOUT_MS);
};
gatewayEmitter?.on("debug", onGatewayDebug);
let sawDisallowedIntents = false;
try {
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.start();
}
await waitForDiscordGatewayStop({
gateway: gateway
? {
emitter: gatewayEmitter,
disconnect: () => gateway.disconnect(),
}
: undefined,
abortSignal: params.abortSignal,
onGatewayError: (err) => {
if (params.isDisallowedIntentsError(err)) {
sawDisallowedIntents = true;
params.runtime.error?.(
danger(
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
),
);
return;
}
params.runtime.error?.(danger(`discord gateway error: ${String(err)}`));
},
shouldStopOnError: (err) => {
const message = String(err);
return (
message.includes("Max reconnect attempts") ||
message.includes("Fatal Gateway error") ||
params.isDisallowedIntentsError(err)
);
},
});
} catch (err) {
if (!sawDisallowedIntents && !params.isDisallowedIntentsError(err)) {
throw err;
}
} finally {
unregisterGateway(params.accountId);
stopGatewayLogging();
if (helloTimeoutId) {
clearTimeout(helloTimeoutId);
}
gatewayEmitter?.removeListener("debug", onGatewayDebug);
params.abortSignal?.removeEventListener("abort", onAbort);
if (params.voiceManager) {
await params.voiceManager.destroy();
params.voiceManagerRef.current = null;
}
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.stop();
}
params.threadBindings.stop();
}
}

View File

@@ -24,3 +24,38 @@ describe("dedupeSkillCommandsForDiscord", () => {
expect(output[0]?.name).toBe("ClawHub");
});
});
describe("resolveThreadBindingsEnabled", () => {
it("defaults to enabled when unset", () => {
expect(
__testing.resolveThreadBindingsEnabled({
channelEnabledRaw: undefined,
sessionEnabledRaw: undefined,
}),
).toBe(true);
});
it("uses global session default when channel value is unset", () => {
expect(
__testing.resolveThreadBindingsEnabled({
channelEnabledRaw: undefined,
sessionEnabledRaw: false,
}),
).toBe(false);
});
it("uses channel value to override global session default", () => {
expect(
__testing.resolveThreadBindingsEnabled({
channelEnabledRaw: true,
sessionEnabledRaw: false,
}),
).toBe(true);
expect(
__testing.resolveThreadBindingsEnabled({
channelEnabledRaw: false,
sessionEnabledRaw: true,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,293 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
const {
createDiscordNativeCommandMock,
createNoopThreadBindingManagerMock,
createThreadBindingManagerMock,
createdBindingManagers,
listNativeCommandSpecsForConfigMock,
listSkillCommandsForAgentsMock,
monitorLifecycleMock,
resolveDiscordAccountMock,
resolveDiscordAllowlistConfigMock,
resolveNativeCommandsEnabledMock,
resolveNativeSkillsEnabledMock,
} = vi.hoisted(() => {
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
return {
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
createNoopThreadBindingManagerMock: vi.fn(() => {
const manager = { stop: vi.fn() };
createdBindingManagers.push(manager);
return manager;
}),
createThreadBindingManagerMock: vi.fn(() => {
const manager = { stop: vi.fn() };
createdBindingManagers.push(manager);
return manager;
}),
createdBindingManagers,
listNativeCommandSpecsForConfigMock: vi.fn(() => [{ name: "cmd" }]),
listSkillCommandsForAgentsMock: vi.fn(() => []),
monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => {
params.threadBindings.stop();
}),
resolveDiscordAccountMock: vi.fn(() => ({
accountId: "default",
token: "cfg-token",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
},
})),
resolveDiscordAllowlistConfigMock: vi.fn(async () => ({
guildEntries: undefined,
allowFrom: undefined,
})),
resolveNativeCommandsEnabledMock: vi.fn(() => true),
resolveNativeSkillsEnabledMock: vi.fn(() => false),
};
});
vi.mock("@buape/carbon", () => {
class ReadyListener {}
class Client {
listeners: unknown[];
rest: { put: ReturnType<typeof vi.fn> };
constructor(_options: unknown, handlers: { listeners?: unknown[] }) {
this.listeners = handlers.listeners ?? [];
this.rest = { put: vi.fn(async () => undefined) };
}
async handleDeployRequest() {
return undefined;
}
async fetchUser(_target: string) {
return { id: "bot-1" };
}
getPlugin(_name: string) {
return undefined;
}
}
return { Client, ReadyListener };
});
vi.mock("@buape/carbon/gateway", () => ({
GatewayCloseCodes: { DisallowedIntents: 4014 },
}));
vi.mock("@buape/carbon/voice", () => ({
VoicePlugin: class VoicePlugin {},
}));
vi.mock("../../auto-reply/chunk.js", () => ({
resolveTextChunkLimit: () => 2000,
}));
vi.mock("../../auto-reply/commands-registry.js", () => ({
listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock,
}));
vi.mock("../../auto-reply/skill-commands.js", () => ({
listSkillCommandsForAgents: listSkillCommandsForAgentsMock,
}));
vi.mock("../../config/commands.js", () => ({
isNativeCommandsExplicitlyDisabled: () => false,
resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock,
resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock,
}));
vi.mock("../../config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("../../globals.js", () => ({
danger: (v: string) => v,
logVerbose: vi.fn(),
shouldLogVerbose: () => false,
warn: (v: string) => v,
}));
vi.mock("../../infra/errors.js", () => ({
formatErrorMessage: (err: unknown) => String(err),
}));
vi.mock("../../infra/retry-policy.js", () => ({
createDiscordRetryRunner: () => async (run: () => Promise<unknown>) => run(),
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }),
}));
vi.mock("../../runtime.js", () => ({
createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }),
}));
vi.mock("../accounts.js", () => ({
resolveDiscordAccount: resolveDiscordAccountMock,
}));
vi.mock("../probe.js", () => ({
fetchDiscordApplicationId: async () => "app-1",
}));
vi.mock("../token.js", () => ({
normalizeDiscordToken: (value?: string) => value,
}));
vi.mock("../voice/command.js", () => ({
createDiscordVoiceCommand: () => ({ name: "voice-command" }),
}));
vi.mock("../voice/manager.js", () => ({
DiscordVoiceManager: class DiscordVoiceManager {},
DiscordVoiceReadyListener: class DiscordVoiceReadyListener {},
}));
vi.mock("./agent-components.js", () => ({
createAgentComponentButton: () => ({ id: "btn" }),
createAgentSelectMenu: () => ({ id: "menu" }),
createDiscordComponentButton: () => ({ id: "btn2" }),
createDiscordComponentChannelSelect: () => ({ id: "channel" }),
createDiscordComponentMentionableSelect: () => ({ id: "mentionable" }),
createDiscordComponentModal: () => ({ id: "modal" }),
createDiscordComponentRoleSelect: () => ({ id: "role" }),
createDiscordComponentStringSelect: () => ({ id: "string" }),
createDiscordComponentUserSelect: () => ({ id: "user" }),
}));
vi.mock("./commands.js", () => ({
resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }),
}));
vi.mock("./exec-approvals.js", () => ({
createExecApprovalButton: () => ({ id: "exec-approval" }),
DiscordExecApprovalHandler: class DiscordExecApprovalHandler {
async start() {
return undefined;
}
async stop() {
return undefined;
}
},
}));
vi.mock("./gateway-plugin.js", () => ({
createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }),
}));
vi.mock("./listeners.js", () => ({
DiscordMessageListener: class DiscordMessageListener {},
DiscordPresenceListener: class DiscordPresenceListener {},
DiscordReactionListener: class DiscordReactionListener {},
DiscordReactionRemoveListener: class DiscordReactionRemoveListener {},
registerDiscordListener: vi.fn(),
}));
vi.mock("./message-handler.js", () => ({
createDiscordMessageHandler: () => ({ handle: vi.fn() }),
}));
vi.mock("./native-command.js", () => ({
createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }),
createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }),
createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }),
createDiscordNativeCommand: createDiscordNativeCommandMock,
}));
vi.mock("./presence.js", () => ({
resolveDiscordPresenceUpdate: () => undefined,
}));
vi.mock("./provider.allowlist.js", () => ({
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
}));
vi.mock("./provider.lifecycle.js", () => ({
runDiscordGatewayLifecycle: monitorLifecycleMock,
}));
vi.mock("./rest-fetch.js", () => ({
resolveDiscordRestFetch: () => async () => undefined,
}));
vi.mock("./thread-bindings.js", () => ({
createNoopThreadBindingManager: createNoopThreadBindingManagerMock,
createThreadBindingManager: createThreadBindingManagerMock,
}));
describe("monitorDiscordProvider", () => {
const baseRuntime = (): RuntimeEnv => {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
};
const baseConfig = (): OpenClawConfig =>
({
channels: {
discord: {
accounts: {
default: {},
},
},
},
}) as OpenClawConfig;
beforeEach(() => {
createDiscordNativeCommandMock.mockReset().mockReturnValue({ name: "mock-command" });
createNoopThreadBindingManagerMock.mockClear();
createThreadBindingManagerMock.mockClear();
createdBindingManagers.length = 0;
listNativeCommandSpecsForConfigMock.mockReset().mockReturnValue([{ name: "cmd" }]);
listSkillCommandsForAgentsMock.mockReset().mockReturnValue([]);
monitorLifecycleMock.mockReset().mockImplementation(async (params) => {
params.threadBindings.stop();
});
resolveDiscordAccountMock.mockClear();
resolveDiscordAllowlistConfigMock.mockReset().mockResolvedValue({
guildEntries: undefined,
allowFrom: undefined,
});
resolveNativeCommandsEnabledMock.mockReset().mockReturnValue(true);
resolveNativeSkillsEnabledMock.mockReset().mockReturnValue(false);
});
it("stops thread bindings when startup fails before lifecycle begins", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
createDiscordNativeCommandMock.mockImplementation(() => {
throw new Error("native command boom");
});
await expect(
monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
}),
).rejects.toThrow("native command boom");
expect(monitorLifecycleMock).not.toHaveBeenCalled();
expect(createdBindingManagers).toHaveLength(1);
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
});
it("does not double-stop thread bindings when lifecycle performs cleanup", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
});
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
expect(createdBindingManagers).toHaveLength(1);
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -14,14 +14,6 @@ import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
import {
addAllowlistUserEntriesFromConfigEntry,
buildAllowlistResolutionSummary,
mergeAllowlist,
resolveAllowlistIdAdditions,
patchAllowlistUsersInConfigEntries,
summarizeMapping,
} from "../../channels/allowlists/resolve-utils.js";
import {
isNativeCommandsExplicitlyDisabled,
resolveNativeCommandsEnabled,
@@ -35,11 +27,7 @@ import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
import { resolveDiscordAccount } from "../accounts.js";
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
import { fetchDiscordApplicationId } from "../probe.js";
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
import { normalizeDiscordToken } from "../token.js";
import { createDiscordVoiceCommand } from "../voice/command.js";
import { DiscordVoiceManager, DiscordVoiceReadyListener } from "../voice/manager.js";
@@ -57,7 +45,6 @@ import {
import { resolveDiscordSlashCommandConfig } from "./commands.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
import {
DiscordMessageListener,
DiscordPresenceListener,
@@ -73,7 +60,10 @@ import {
createDiscordNativeCommand,
} from "./native-command.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
import { resolveDiscordRestFetch } from "./rest-fetch.js";
import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
export type MonitorDiscordOpts = {
token?: string;
@@ -105,6 +95,61 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
return `${sample.join(", ")}${suffix}`;
}
const DEFAULT_THREAD_BINDING_TTL_HOURS = 24;
function normalizeThreadBindingTtlHours(raw: unknown): number | undefined {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
if (raw < 0) {
return undefined;
}
return raw;
}
function resolveThreadBindingSessionTtlMs(params: {
channelTtlHoursRaw: unknown;
sessionTtlHoursRaw: unknown;
}): number {
const ttlHours =
normalizeThreadBindingTtlHours(params.channelTtlHoursRaw) ??
normalizeThreadBindingTtlHours(params.sessionTtlHoursRaw) ??
DEFAULT_THREAD_BINDING_TTL_HOURS;
return Math.floor(ttlHours * 60 * 60 * 1000);
}
function normalizeThreadBindingsEnabled(raw: unknown): boolean | undefined {
if (typeof raw !== "boolean") {
return undefined;
}
return raw;
}
function resolveThreadBindingsEnabled(params: {
channelEnabledRaw: unknown;
sessionEnabledRaw: unknown;
}): boolean {
return (
normalizeThreadBindingsEnabled(params.channelEnabledRaw) ??
normalizeThreadBindingsEnabled(params.sessionEnabledRaw) ??
true
);
}
function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
if (ttlMs <= 0) {
return "off";
}
if (ttlMs < 60_000) {
return "<1m";
}
const totalMinutes = Math.floor(ttlMs / 60_000);
if (totalMinutes % 60 === 0) {
return `${Math.floor(totalMinutes / 60)}h`;
}
return `${totalMinutes}m`;
}
function dedupeSkillCommandsForDiscord(
skillCommands: ReturnType<typeof listSkillCommandsForAgents>,
) {
@@ -201,6 +246,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
const discordCfg = account.config;
const discordRootThreadBindings = cfg.channels?.discord?.threadBindings;
const discordAccountThreadBindings =
cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime);
const dmConfig = discordCfg.dm;
let guildEntries = discordCfg.guilds;
@@ -230,6 +278,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing";
const threadBindingSessionTtlMs = resolveThreadBindingSessionTtlMs({
channelTtlHoursRaw:
discordAccountThreadBindings?.ttlHours ?? discordRootThreadBindings?.ttlHours,
sessionTtlHoursRaw: cfg.session?.threadBindings?.ttlHours,
});
const threadBindingsEnabled = resolveThreadBindingsEnabled({
channelEnabledRaw: discordAccountThreadBindings?.enabled ?? discordRootThreadBindings?.enabled,
sessionEnabledRaw: cfg.session?.threadBindings?.enabled,
});
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
const nativeEnabled = resolveNativeCommandsEnabled({
@@ -252,159 +309,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const ephemeralDefault = slashCommand.ephemeral;
const voiceEnabled = discordCfg.voice?.enabled !== false;
if (token) {
if (guildEntries && Object.keys(guildEntries).length > 0) {
try {
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
if (guildKey === "*") {
continue;
}
const channels = guildCfg?.channels ?? {};
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
if (channelKeys.length === 0) {
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
entries.push({ input, guildKey });
continue;
}
for (const channelKey of channelKeys) {
entries.push({
input: `${guildKey}/${channelKey}`,
guildKey,
channelKey,
});
}
}
if (entries.length > 0) {
const resolved = await resolveDiscordChannelAllowlist({
token,
entries: entries.map((entry) => entry.input),
fetcher: discordRestFetch,
});
const nextGuilds = { ...guildEntries };
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of resolved) {
const source = entries.find((item) => item.input === entry.input);
if (!source) {
continue;
}
const sourceGuild = guildEntries?.[source.guildKey] ?? {};
if (!entry.resolved || !entry.guildId) {
unresolved.push(entry.input);
continue;
}
mapping.push(
entry.channelId
? `${entry.input}${entry.guildId}/${entry.channelId}`
: `${entry.input}${entry.guildId}`,
);
const existing = nextGuilds[entry.guildId] ?? {};
const mergedChannels = { ...sourceGuild.channels, ...existing.channels };
const mergedGuild = { ...sourceGuild, ...existing, channels: mergedChannels };
nextGuilds[entry.guildId] = mergedGuild;
if (source.channelKey && entry.channelId) {
const sourceChannel = sourceGuild.channels?.[source.channelKey];
if (sourceChannel) {
nextGuilds[entry.guildId] = {
...mergedGuild,
channels: {
...mergedChannels,
[entry.channelId]: {
...sourceChannel,
...mergedChannels?.[entry.channelId],
},
},
};
}
}
}
guildEntries = nextGuilds;
summarizeMapping("discord channels", mapping, unresolved, runtime);
}
} catch (err) {
runtime.log?.(
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
const allowEntries =
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
if (allowEntries.length > 0) {
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token,
entries: allowEntries.map((entry) => String(entry)),
fetcher: discordRestFetch,
});
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers);
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("discord users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
if (guildEntries && Object.keys(guildEntries).length > 0) {
const userEntries = new Set<string>();
for (const guild of Object.values(guildEntries)) {
if (!guild || typeof guild !== "object") {
continue;
}
addAllowlistUserEntriesFromConfigEntry(userEntries, guild);
const channels = (guild as { channels?: Record<string, unknown> }).channels ?? {};
for (const channel of Object.values(channels)) {
addAllowlistUserEntriesFromConfigEntry(userEntries, channel);
}
}
if (userEntries.size > 0) {
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token,
entries: Array.from(userEntries),
fetcher: discordRestFetch,
});
const { resolvedMap, mapping, unresolved } =
buildAllowlistResolutionSummary(resolvedUsers);
const nextGuilds = { ...guildEntries };
for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) {
if (!guildConfig || typeof guildConfig !== "object") {
continue;
}
const nextGuild = { ...guildConfig } as Record<string, unknown>;
const users = (guildConfig as { users?: string[] }).users;
if (Array.isArray(users) && users.length > 0) {
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
nextGuild.users = mergeAllowlist({ existing: users, additions });
}
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
if (channels && typeof channels === "object") {
nextGuild.channels = patchAllowlistUsersInConfigEntries({
entries: channels,
resolvedMap,
});
}
nextGuilds[guildKey] = nextGuild;
}
guildEntries = nextGuilds;
summarizeMapping("discord channel users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
}
}
const allowlistResolved = await resolveDiscordAllowlistConfig({
token,
guildEntries,
allowFrom,
fetcher: discordRestFetch,
runtime,
});
guildEntries = allowlistResolved.guildEntries;
allowFrom = allowlistResolved.allowFrom;
if (shouldLogVerbose()) {
logVerbose(
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadSessionTtl=${formatThreadBindingSessionTtlLabel(threadBindingSessionTtlMs)}`,
);
}
@@ -439,326 +356,250 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
);
}
const voiceManagerRef: { current: DiscordVoiceManager | null } = { current: null };
const commands: BaseCommand[] = commandSpecs.map((spec) =>
createDiscordNativeCommand({
command: spec,
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
ephemeralDefault,
}),
);
if (nativeEnabled && voiceEnabled) {
commands.push(
createDiscordVoiceCommand({
const threadBindings = threadBindingsEnabled
? createThreadBindingManager({
accountId: account.accountId,
token,
sessionTtlMs: threadBindingSessionTtlMs,
})
: createNoopThreadBindingManager(account.accountId);
let lifecycleStarted = false;
try {
const commands: BaseCommand[] = commandSpecs.map((spec) =>
createDiscordNativeCommand({
command: spec,
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
groupPolicy,
useAccessGroups,
getManager: () => voiceManagerRef.current,
sessionPrefix,
ephemeralDefault,
threadBindings,
}),
);
}
if (nativeEnabled && voiceEnabled) {
commands.push(
createDiscordVoiceCommand({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
groupPolicy,
useAccessGroups,
getManager: () => voiceManagerRef.current,
ephemeralDefault,
}),
);
}
// Initialize exec approvals handler if enabled
const execApprovalsConfig = discordCfg.execApprovals ?? {};
const execApprovalsHandler = execApprovalsConfig.enabled
? new DiscordExecApprovalHandler({
token,
accountId: account.accountId,
config: execApprovalsConfig,
// Initialize exec approvals handler if enabled
const execApprovalsConfig = discordCfg.execApprovals ?? {};
const execApprovalsHandler = execApprovalsConfig.enabled
? new DiscordExecApprovalHandler({
token,
accountId: account.accountId,
config: execApprovalsConfig,
cfg,
runtime,
})
: null;
const agentComponentsConfig = discordCfg.agentComponents ?? {};
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
const components: BaseMessageInteractiveComponent[] = [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
threadBindings,
}),
createDiscordModelPickerFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
threadBindings,
}),
createDiscordModelPickerFallbackSelect({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
threadBindings,
}),
];
const modals: Modal[] = [];
if (execApprovalsHandler) {
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
}
if (agentComponentsEnabled) {
const componentContext = {
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
guildEntries,
allowFrom,
dmPolicy,
runtime,
})
: null;
const agentComponentsConfig = discordCfg.agentComponents ?? {};
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
const components: BaseMessageInteractiveComponent[] = [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
createDiscordModelPickerFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
createDiscordModelPickerFallbackSelect({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
];
const modals: Modal[] = [];
if (execApprovalsHandler) {
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
}
if (agentComponentsEnabled) {
const componentContext = {
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
guildEntries,
allowFrom,
dmPolicy,
runtime,
token,
};
components.push(createAgentComponentButton(componentContext));
components.push(createAgentSelectMenu(componentContext));
components.push(createDiscordComponentButton(componentContext));
components.push(createDiscordComponentStringSelect(componentContext));
components.push(createDiscordComponentUserSelect(componentContext));
components.push(createDiscordComponentRoleSelect(componentContext));
components.push(createDiscordComponentMentionableSelect(componentContext));
components.push(createDiscordComponentChannelSelect(componentContext));
modals.push(createDiscordComponentModal(componentContext));
}
class DiscordStatusReadyListener extends ReadyListener {
async handle(_data: unknown, client: Client) {
const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (!gateway) {
return;
}
const presence = resolveDiscordPresenceUpdate(discordCfg);
if (!presence) {
return;
}
gateway.updatePresence(presence);
token,
};
components.push(createAgentComponentButton(componentContext));
components.push(createAgentSelectMenu(componentContext));
components.push(createDiscordComponentButton(componentContext));
components.push(createDiscordComponentStringSelect(componentContext));
components.push(createDiscordComponentUserSelect(componentContext));
components.push(createDiscordComponentRoleSelect(componentContext));
components.push(createDiscordComponentMentionableSelect(componentContext));
components.push(createDiscordComponentChannelSelect(componentContext));
modals.push(createDiscordComponentModal(componentContext));
}
}
const clientPlugins: Plugin[] = [
createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }),
];
if (voiceEnabled) {
clientPlugins.push(new VoicePlugin());
}
const client = new Client(
{
baseUrl: "http://localhost",
deploySecret: "a",
clientId: applicationId,
publicKey: "a",
token,
autoDeploy: false,
},
{
commands,
listeners: [new DiscordStatusReadyListener()],
components,
modals,
},
clientPlugins,
);
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
const logger = createSubsystemLogger("discord/monitor");
const guildHistories = new Map<string, HistoryEntry[]>();
let botUserId: string | undefined;
let voiceManager: DiscordVoiceManager | null = null;
if (nativeDisabledExplicit) {
await clearDiscordNativeCommands({
client,
applicationId,
runtime,
});
}
try {
const botUser = await client.fetchUser("@me");
botUserId = botUser?.id;
} catch (err) {
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
}
if (voiceEnabled) {
voiceManager = new DiscordVoiceManager({
client,
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
runtime,
botUserId,
});
voiceManagerRef.current = voiceManager;
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
}
const messageHandler = createDiscordMessageHandler({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
token,
runtime,
botUserId,
guildHistories,
historyLimit,
mediaMaxBytes,
textLimit,
replyToMode,
dmEnabled,
groupDmEnabled,
groupDmChannels,
allowFrom,
guildEntries,
});
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
registerDiscordListener(
client.listeners,
new DiscordReactionListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
logger,
}),
);
registerDiscordListener(
client.listeners,
new DiscordReactionRemoveListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
logger,
}),
);
if (discordCfg.intents?.presence) {
registerDiscordListener(
client.listeners,
new DiscordPresenceListener({ logger, accountId: account.accountId }),
);
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
}
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
// Start exec approvals handler after client is ready
if (execApprovalsHandler) {
await execApprovalsHandler.start();
}
const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (gateway) {
registerGateway(account.accountId, gateway);
}
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({
emitter: gatewayEmitter,
runtime,
});
const abortSignal = opts.abortSignal;
const onAbort = () => {
if (!gateway) {
return;
}
// Carbon emits an error when maxAttempts is 0; keep a one-shot listener to avoid
// an unhandled error after we tear down listeners during abort.
gatewayEmitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
};
if (abortSignal?.aborted) {
onAbort();
} else {
abortSignal?.addEventListener("abort", onAbort, { once: true });
}
// Timeout to detect zombie connections where HELLO is never received.
const HELLO_TIMEOUT_MS = 30000;
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
const onGatewayDebug = (msg: unknown) => {
const message = String(msg);
if (!message.includes("WebSocket connection opened")) {
return;
}
if (helloTimeoutId) {
clearTimeout(helloTimeoutId);
}
helloTimeoutId = setTimeout(() => {
if (!gateway?.isConnected) {
runtime.log?.(
danger(
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
),
);
gateway?.disconnect();
gateway?.connect(false);
}
helloTimeoutId = undefined;
}, HELLO_TIMEOUT_MS);
};
gatewayEmitter?.on("debug", onGatewayDebug);
// Disallowed intents (4014) should stop the provider without crashing the gateway.
let sawDisallowedIntents = false;
try {
await waitForDiscordGatewayStop({
gateway: gateway
? {
emitter: gatewayEmitter,
disconnect: () => gateway.disconnect(),
}
: undefined,
abortSignal,
onGatewayError: (err) => {
if (isDiscordDisallowedIntentsError(err)) {
sawDisallowedIntents = true;
runtime.error?.(
danger(
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
),
);
class DiscordStatusReadyListener extends ReadyListener {
async handle(_data: unknown, client: Client) {
const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (!gateway) {
return;
}
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
const presence = resolveDiscordPresenceUpdate(discordCfg);
if (!presence) {
return;
}
gateway.updatePresence(presence);
}
}
const clientPlugins: Plugin[] = [
createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }),
];
if (voiceEnabled) {
clientPlugins.push(new VoicePlugin());
}
const client = new Client(
{
baseUrl: "http://localhost",
deploySecret: "a",
clientId: applicationId,
publicKey: "a",
token,
autoDeploy: false,
},
shouldStopOnError: (err) => {
const message = String(err);
return (
message.includes("Max reconnect attempts") ||
message.includes("Fatal Gateway error") ||
isDiscordDisallowedIntentsError(err)
);
{
commands,
listeners: [new DiscordStatusReadyListener()],
components,
modals,
},
clientPlugins,
);
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
const logger = createSubsystemLogger("discord/monitor");
const guildHistories = new Map<string, HistoryEntry[]>();
let botUserId: string | undefined;
let voiceManager: DiscordVoiceManager | null = null;
if (nativeDisabledExplicit) {
await clearDiscordNativeCommands({
client,
applicationId,
runtime,
});
}
try {
const botUser = await client.fetchUser("@me");
botUserId = botUser?.id;
} catch (err) {
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
}
if (voiceEnabled) {
voiceManager = new DiscordVoiceManager({
client,
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
runtime,
botUserId,
});
voiceManagerRef.current = voiceManager;
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
}
const messageHandler = createDiscordMessageHandler({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
token,
runtime,
botUserId,
guildHistories,
historyLimit,
mediaMaxBytes,
textLimit,
replyToMode,
dmEnabled,
groupDmEnabled,
groupDmChannels,
allowFrom,
guildEntries,
threadBindings,
});
} catch (err) {
if (!sawDisallowedIntents && !isDiscordDisallowedIntentsError(err)) {
throw err;
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
registerDiscordListener(
client.listeners,
new DiscordReactionListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
logger,
}),
);
registerDiscordListener(
client.listeners,
new DiscordReactionRemoveListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
logger,
}),
);
if (discordCfg.intents?.presence) {
registerDiscordListener(
client.listeners,
new DiscordPresenceListener({ logger, accountId: account.accountId }),
);
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
}
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
lifecycleStarted = true;
await runDiscordGatewayLifecycle({
accountId: account.accountId,
client,
runtime,
abortSignal: opts.abortSignal,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
voiceManager,
voiceManagerRef,
execApprovalsHandler,
threadBindings,
});
} finally {
unregisterGateway(account.accountId);
stopGatewayLogging();
if (helloTimeoutId) {
clearTimeout(helloTimeoutId);
}
gatewayEmitter?.removeListener("debug", onGatewayDebug);
abortSignal?.removeEventListener("abort", onAbort);
if (voiceManager) {
await voiceManager.destroy();
voiceManagerRef.current = null;
}
if (execApprovalsHandler) {
await execApprovalsHandler.stop();
if (!lifecycleStarted) {
threadBindings.stop();
}
}
}
@@ -782,4 +623,5 @@ export const __testing = {
createDiscordGatewayPlugin,
dedupeSkillCommandsForDiscord,
resolveDiscordRestFetch,
resolveThreadBindingsEnabled,
};

View File

@@ -1,13 +1,19 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
vi.mock("../send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args),
sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args),
}));
describe("deliverDiscordReply", () => {
@@ -22,6 +28,11 @@ describe("deliverDiscordReply", () => {
messageId: "voice-1",
channelId: "channel-1",
});
sendWebhookMessageDiscordMock.mockReset().mockResolvedValue({
messageId: "webhook-1",
channelId: "thread-1",
});
threadBindingTesting.resetThreadBindingsForTests();
});
it("routes audioAsVoice payloads through the voice API and sends text separately", async () => {
@@ -104,4 +115,141 @@ describe("deliverDiscordReply", () => {
expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.replyTo).toBe("reply-1");
expect(sendMessageDiscordMock.mock.calls[1]?.[2]?.replyTo).toBeUndefined();
});
it("does not consume replyToId for replyToMode=first on whitespace-only payloads", async () => {
await deliverDiscordReply({
replies: [{ text: " " }, { text: "actual reply" }],
target: "channel:789",
token: "token",
runtime,
textLimit: 2000,
replyToId: "reply-1",
replyToMode: "first",
});
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:789",
"actual reply",
expect.objectContaining({ token: "token", replyTo: "reply-1" }),
);
});
it("sends bound-session text replies through webhook delivery", async () => {
const threadBindings = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "codex-refactor",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
await deliverDiscordReply({
replies: [{ text: "Hello from subagent" }],
target: "channel:thread-1",
token: "token",
runtime,
textLimit: 2000,
replyToId: "reply-1",
sessionKey: "agent:main:subagent:child",
threadBindings,
});
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith(
"Hello from subagent",
expect.objectContaining({
webhookId: "wh_1",
webhookToken: "tok_1",
accountId: "default",
threadId: "thread-1",
replyTo: "reply-1",
}),
);
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
});
it("falls back to bot send when webhook delivery fails", async () => {
const threadBindings = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
await deliverDiscordReply({
replies: [{ text: "Fallback path" }],
target: "channel:thread-1",
token: "token",
accountId: "default",
runtime,
textLimit: 2000,
sessionKey: "agent:main:subagent:child",
threadBindings,
});
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:thread-1",
"Fallback path",
expect.objectContaining({ token: "token", accountId: "default" }),
);
});
it("does not use thread webhook when outbound target is not a bound thread", async () => {
const threadBindings = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await threadBindings.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh_1",
webhookToken: "tok_1",
introText: "",
});
await deliverDiscordReply({
replies: [{ text: "Parent channel delivery" }],
target: "channel:parent-1",
token: "token",
accountId: "default",
runtime,
textLimit: 2000,
sessionKey: "agent:main:subagent:child",
threadBindings,
});
expect(sendWebhookMessageDiscordMock).not.toHaveBeenCalled();
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:parent-1",
"Parent channel delivery",
expect.objectContaining({ token: "token", accountId: "default" }),
);
});
});

View File

@@ -1,11 +1,104 @@
import type { RequestClient } from "@buape/carbon";
import { resolveAgentAvatar } from "../../agents/identity-avatar.js";
import type { ChunkMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { loadConfig } from "../../config/config.js";
import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { sendMessageDiscord, sendVoiceMessageDiscord } from "../send.js";
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
function resolveTargetChannelId(target: string): string | undefined {
if (!target.startsWith("channel:")) {
return undefined;
}
const channelId = target.slice("channel:".length).trim();
return channelId || undefined;
}
function resolveBoundThreadBinding(params: {
threadBindings?: ThreadBindingManager;
sessionKey?: string;
target: string;
}): ThreadBindingRecord | undefined {
const sessionKey = params.sessionKey?.trim();
if (!params.threadBindings || !sessionKey) {
return undefined;
}
const bindings = params.threadBindings.listBySessionKey(sessionKey);
if (bindings.length === 0) {
return undefined;
}
const targetChannelId = resolveTargetChannelId(params.target);
if (!targetChannelId) {
return undefined;
}
return bindings.find((entry) => entry.threadId === targetChannelId);
}
function resolveBindingPersona(binding: ThreadBindingRecord | undefined): {
username?: string;
avatarUrl?: string;
} {
if (!binding) {
return {};
}
const baseLabel = binding.label?.trim() || binding.agentId;
const username = (`🤖 ${baseLabel}`.trim() || "🤖 agent").slice(0, 80);
let avatarUrl: string | undefined;
try {
const avatar = resolveAgentAvatar(loadConfig(), binding.agentId);
if (avatar.kind === "remote") {
avatarUrl = avatar.url;
}
} catch {
avatarUrl = undefined;
}
return { username, avatarUrl };
}
async function sendDiscordChunkWithFallback(params: {
target: string;
text: string;
token: string;
accountId?: string;
rest?: RequestClient;
replyTo?: string;
binding?: ThreadBindingRecord;
username?: string;
avatarUrl?: string;
}) {
const text = params.text.trim();
if (!text) {
return;
}
const binding = params.binding;
if (binding?.webhookId && binding?.webhookToken) {
try {
await sendWebhookMessageDiscord(text, {
webhookId: binding.webhookId,
webhookToken: binding.webhookToken,
accountId: binding.accountId,
threadId: binding.threadId,
replyTo: params.replyTo,
username: params.username,
avatarUrl: params.avatarUrl,
});
return;
} catch {
// Fall through to the standard bot sender path.
}
}
await sendMessageDiscord(params.target, text, {
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo: params.replyTo,
});
}
export async function deliverDiscordReply(params: {
replies: ReplyPayload[];
@@ -20,6 +113,8 @@ export async function deliverDiscordReply(params: {
replyToMode?: ReplyToMode;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
sessionKey?: string;
threadBindings?: ThreadBindingManager;
}) {
const chunkLimit = Math.min(params.textLimit, 2000);
const replyTo = params.replyToId?.trim() || undefined;
@@ -40,6 +135,12 @@ export async function deliverDiscordReply(params: {
replyUsed = true;
return replyTo;
};
const binding = resolveBoundThreadBinding({
threadBindings: params.threadBindings,
sessionKey: params.sessionKey,
target: params.target,
});
const persona = resolveBindingPersona(binding);
for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? "";
@@ -59,16 +160,20 @@ export async function deliverDiscordReply(params: {
chunks.push(text);
}
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) {
if (!chunk.trim()) {
continue;
}
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, trimmed, {
await sendDiscordChunkWithFallback({
target: params.target,
text: chunk,
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo,
binding,
username: persona.username,
avatarUrl: persona.avatarUrl,
});
}
continue;
@@ -79,7 +184,7 @@ export async function deliverDiscordReply(params: {
continue;
}
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord.
if (payload.audioAsVoice) {
const replyTo = resolveReplyTo();
await sendVoiceMessageDiscord(params.target, firstMedia, {
@@ -88,17 +193,19 @@ export async function deliverDiscordReply(params: {
accountId: params.accountId,
replyTo,
});
// Voice messages cannot include text; send remaining text separately if present
if (text.trim()) {
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, text, {
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo,
});
}
// Additional media items are sent as regular attachments (voice is single-file only)
// Voice messages cannot include text; send remaining text separately if present.
await sendDiscordChunkWithFallback({
target: params.target,
text,
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo: resolveReplyTo(),
binding,
username: persona.username,
avatarUrl: persona.avatarUrl,
});
// Additional media items are sent as regular attachments (voice is single-file only).
for (const extra of mediaList.slice(1)) {
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, "", {

View File

@@ -0,0 +1,85 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {
const restGet = vi.fn();
const createDiscordRestClient = vi.fn(() => ({
rest: {
get: restGet,
},
}));
return {
restGet,
createDiscordRestClient,
};
});
vi.mock("../client.js", () => ({
createDiscordRestClient: hoisted.createDiscordRestClient,
}));
const { resolveChannelIdForBinding } = await import("./thread-bindings.discord-api.js");
describe("resolveChannelIdForBinding", () => {
beforeEach(() => {
hoisted.restGet.mockReset();
hoisted.createDiscordRestClient.mockClear();
});
it("returns explicit channelId without resolving route", async () => {
const resolved = await resolveChannelIdForBinding({
accountId: "default",
threadId: "thread-1",
channelId: "channel-explicit",
});
expect(resolved).toBe("channel-explicit");
expect(hoisted.createDiscordRestClient).not.toHaveBeenCalled();
expect(hoisted.restGet).not.toHaveBeenCalled();
});
it("returns parent channel for thread channels", async () => {
hoisted.restGet.mockResolvedValueOnce({
id: "thread-1",
type: ChannelType.PublicThread,
parent_id: "channel-parent",
});
const resolved = await resolveChannelIdForBinding({
accountId: "default",
threadId: "thread-1",
});
expect(resolved).toBe("channel-parent");
});
it("keeps non-thread channel id even when parent_id exists", async () => {
hoisted.restGet.mockResolvedValueOnce({
id: "channel-text",
type: ChannelType.GuildText,
parent_id: "category-1",
});
const resolved = await resolveChannelIdForBinding({
accountId: "default",
threadId: "channel-text",
});
expect(resolved).toBe("channel-text");
});
it("keeps forum channel id instead of parent category", async () => {
hoisted.restGet.mockResolvedValueOnce({
id: "forum-1",
type: ChannelType.GuildForum,
parent_id: "category-1",
});
const resolved = await resolveChannelIdForBinding({
accountId: "default",
threadId: "forum-1",
});
expect(resolved).toBe("forum-1");
});
});

View File

@@ -0,0 +1,289 @@
import { ChannelType, Routes } from "discord-api-types/v10";
import { logVerbose } from "../../globals.js";
import { createDiscordRestClient } from "../client.js";
import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
import { createThreadDiscord } from "../send.messages.js";
import { summarizeBindingPersona } from "./thread-bindings.messages.js";
import {
BINDINGS_BY_THREAD_ID,
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL,
rememberReusableWebhook,
toReusableWebhookKey,
} from "./thread-bindings.state.js";
import {
DISCORD_UNKNOWN_CHANNEL_ERROR_CODE,
type ThreadBindingRecord,
} from "./thread-bindings.types.js";
function buildThreadTarget(threadId: string): string {
return `channel:${threadId}`;
}
export function isThreadArchived(raw: unknown): boolean {
if (!raw || typeof raw !== "object") {
return false;
}
const asRecord = raw as {
archived?: unknown;
thread_metadata?: { archived?: unknown };
threadMetadata?: { archived?: unknown };
};
if (asRecord.archived === true) {
return true;
}
if (asRecord.thread_metadata?.archived === true) {
return true;
}
if (asRecord.threadMetadata?.archived === true) {
return true;
}
return false;
}
function isThreadChannelType(type: unknown): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
export function summarizeDiscordError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
if (
typeof err === "number" ||
typeof err === "boolean" ||
typeof err === "bigint" ||
typeof err === "symbol"
) {
return String(err);
}
return "error";
}
function extractNumericDiscordErrorValue(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
return Number(value);
}
return undefined;
}
function extractDiscordErrorStatus(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const candidate = err as {
status?: unknown;
statusCode?: unknown;
response?: { status?: unknown };
};
return (
extractNumericDiscordErrorValue(candidate.status) ??
extractNumericDiscordErrorValue(candidate.statusCode) ??
extractNumericDiscordErrorValue(candidate.response?.status)
);
}
function extractDiscordErrorCode(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const candidate = err as {
code?: unknown;
rawError?: { code?: unknown };
body?: { code?: unknown };
response?: { body?: { code?: unknown }; data?: { code?: unknown } };
};
return (
extractNumericDiscordErrorValue(candidate.code) ??
extractNumericDiscordErrorValue(candidate.rawError?.code) ??
extractNumericDiscordErrorValue(candidate.body?.code) ??
extractNumericDiscordErrorValue(candidate.response?.body?.code) ??
extractNumericDiscordErrorValue(candidate.response?.data?.code)
);
}
export function isDiscordThreadGoneError(err: unknown): boolean {
const code = extractDiscordErrorCode(err);
if (code === DISCORD_UNKNOWN_CHANNEL_ERROR_CODE) {
return true;
}
const status = extractDiscordErrorStatus(err);
// 404: deleted/unknown channel. 403: bot no longer has access.
return status === 404 || status === 403;
}
export async function maybeSendBindingMessage(params: {
record: ThreadBindingRecord;
text: string;
preferWebhook?: boolean;
}) {
const text = params.text.trim();
if (!text) {
return;
}
const record = params.record;
if (params.preferWebhook !== false && record.webhookId && record.webhookToken) {
try {
await sendWebhookMessageDiscord(text, {
webhookId: record.webhookId,
webhookToken: record.webhookToken,
accountId: record.accountId,
threadId: record.threadId,
username: summarizeBindingPersona(record),
});
return;
} catch (err) {
logVerbose(`discord thread binding webhook send failed: ${summarizeDiscordError(err)}`);
}
}
try {
await sendMessageDiscord(buildThreadTarget(record.threadId), text, {
accountId: record.accountId,
});
} catch (err) {
logVerbose(`discord thread binding fallback send failed: ${summarizeDiscordError(err)}`);
}
}
export async function createWebhookForChannel(params: {
accountId: string;
token?: string;
channelId: string;
}): Promise<{ webhookId?: string; webhookToken?: string }> {
try {
const rest = createDiscordRestClient({
accountId: params.accountId,
token: params.token,
}).rest;
const created = (await rest.post(Routes.channelWebhooks(params.channelId), {
body: {
name: "OpenClaw Agents",
},
})) as { id?: string; token?: string };
const webhookId = typeof created?.id === "string" ? created.id.trim() : "";
const webhookToken = typeof created?.token === "string" ? created.token.trim() : "";
if (!webhookId || !webhookToken) {
return {};
}
return { webhookId, webhookToken };
} catch (err) {
logVerbose(
`discord thread binding webhook create failed for ${params.channelId}: ${summarizeDiscordError(err)}`,
);
return {};
}
}
export function findReusableWebhook(params: { accountId: string; channelId: string }): {
webhookId?: string;
webhookToken?: string;
} {
const reusableKey = toReusableWebhookKey({
accountId: params.accountId,
channelId: params.channelId,
});
const cached = REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.get(reusableKey);
if (cached) {
return {
webhookId: cached.webhookId,
webhookToken: cached.webhookToken,
};
}
for (const record of BINDINGS_BY_THREAD_ID.values()) {
if (record.accountId !== params.accountId) {
continue;
}
if (record.channelId !== params.channelId) {
continue;
}
if (!record.webhookId || !record.webhookToken) {
continue;
}
rememberReusableWebhook(record);
return {
webhookId: record.webhookId,
webhookToken: record.webhookToken,
};
}
return {};
}
export async function resolveChannelIdForBinding(params: {
accountId: string;
token?: string;
threadId: string;
channelId?: string;
}): Promise<string | null> {
const explicit = params.channelId?.trim();
if (explicit) {
return explicit;
}
try {
const rest = createDiscordRestClient({
accountId: params.accountId,
token: params.token,
}).rest;
const channel = (await rest.get(Routes.channel(params.threadId))) as {
id?: string;
type?: number;
parent_id?: string;
parentId?: string;
};
const channelId = typeof channel?.id === "string" ? channel.id.trim() : "";
const type = channel?.type;
const parentId =
typeof channel?.parent_id === "string"
? channel.parent_id.trim()
: typeof channel?.parentId === "string"
? channel.parentId.trim()
: "";
// Only thread channels should resolve to their parent channel.
// Non-thread channels (text/forum/media) must keep their own ID.
if (parentId && isThreadChannelType(type)) {
return parentId;
}
return channelId || null;
} catch (err) {
logVerbose(
`discord thread binding channel resolve failed for ${params.threadId}: ${summarizeDiscordError(err)}`,
);
return null;
}
}
export async function createThreadForBinding(params: {
accountId: string;
token?: string;
channelId: string;
threadName: string;
}): Promise<string | null> {
try {
const created = await createThreadDiscord(
params.channelId,
{
name: params.threadName,
autoArchiveMinutes: 60,
},
{
accountId: params.accountId,
token: params.token,
},
);
const createdId = typeof created?.id === "string" ? created.id.trim() : "";
return createdId || null;
} catch (err) {
logVerbose(
`discord thread binding auto-thread create failed for ${params.channelId}: ${summarizeDiscordError(err)}`,
);
return null;
}
}

View File

@@ -0,0 +1,225 @@
import { normalizeAccountId } from "../../routing/session-key.js";
import { parseDiscordTarget } from "../targets.js";
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
import { getThreadBindingManager } from "./thread-bindings.manager.js";
import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "./thread-bindings.messages.js";
import {
BINDINGS_BY_THREAD_ID,
MANAGERS_BY_ACCOUNT_ID,
ensureBindingsLoaded,
getThreadBindingToken,
normalizeThreadBindingTtlMs,
normalizeThreadId,
rememberRecentUnboundWebhookEcho,
removeBindingRecord,
resolveBindingIdsForSession,
saveBindingsToDisk,
setBindingRecord,
shouldPersistBindingMutations,
} from "./thread-bindings.state.js";
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] {
const manager = getThreadBindingManager(accountId);
if (!manager) {
return [];
}
return manager.listBindings();
}
export function listThreadBindingsBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
}): ThreadBindingRecord[] {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
return ids
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
}
export async function autoBindSpawnedDiscordSubagent(params: {
accountId?: string;
channel?: string;
to?: string;
threadId?: string | number;
childSessionKey: string;
agentId: string;
label?: string;
boundBy?: string;
}): Promise<ThreadBindingRecord | null> {
const channel = params.channel?.trim().toLowerCase();
if (channel !== "discord") {
return null;
}
const manager = getThreadBindingManager(params.accountId);
if (!manager) {
return null;
}
const managerToken = getThreadBindingToken(manager.accountId);
const requesterThreadId = normalizeThreadId(params.threadId);
let channelId = "";
if (requesterThreadId) {
const existing = manager.getByThreadId(requesterThreadId);
if (existing?.channelId?.trim()) {
channelId = existing.channelId.trim();
} else {
channelId =
(await resolveChannelIdForBinding({
accountId: manager.accountId,
token: managerToken,
threadId: requesterThreadId,
})) ?? "";
}
}
if (!channelId) {
const to = params.to?.trim() || "";
if (!to) {
return null;
}
try {
const target = parseDiscordTarget(to, { defaultKind: "channel" });
if (!target || target.kind !== "channel") {
return null;
}
channelId =
(await resolveChannelIdForBinding({
accountId: manager.accountId,
token: managerToken,
threadId: target.id,
})) ?? "";
} catch {
return null;
}
}
return await manager.bindTarget({
threadId: undefined,
channelId,
createThread: true,
threadName: resolveThreadBindingThreadName({
agentId: params.agentId,
label: params.label,
}),
targetKind: "subagent",
targetSessionKey: params.childSessionKey,
agentId: params.agentId,
label: params.label,
boundBy: params.boundBy ?? "system",
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label: params.label,
sessionTtlMs: manager.getSessionTtlMs(),
}),
});
}
export function unbindThreadBindingsBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
reason?: string;
sendFarewell?: boolean;
farewellText?: string;
}): ThreadBindingRecord[] {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
if (ids.length === 0) {
return [];
}
const removed: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const record = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!record) {
continue;
}
const manager = MANAGERS_BY_ACCOUNT_ID.get(record.accountId);
if (manager) {
const unbound = manager.unbindThread({
threadId: record.threadId,
reason: params.reason,
sendFarewell: params.sendFarewell,
farewellText: params.farewellText,
});
if (unbound) {
removed.push(unbound);
}
continue;
}
const unbound = removeBindingRecord(bindingKey);
if (unbound) {
rememberRecentUnboundWebhookEcho(unbound);
removed.push(unbound);
}
}
if (removed.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return removed;
}
export function setThreadBindingTtlBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
ttlMs: number;
}): ThreadBindingRecord[] {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
});
if (ids.length === 0) {
return [];
}
const ttlMs = normalizeThreadBindingTtlMs(params.ttlMs);
const now = Date.now();
const expiresAt = ttlMs > 0 ? now + ttlMs : 0;
const updated: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!existing) {
continue;
}
const nextRecord: ThreadBindingRecord = {
...existing,
boundAt: now,
expiresAt,
};
setBindingRecord(nextRecord);
updated.push(nextRecord);
}
if (updated.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return updated;
}

View File

@@ -0,0 +1,515 @@
import { Routes } from "discord-api-types/v10";
import { logVerbose } from "../../globals.js";
import {
registerSessionBindingAdapter,
unregisterSessionBindingAdapter,
type BindingTargetKind,
type SessionBindingRecord,
} from "../../infra/outbound/session-binding-service.js";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { createDiscordRestClient } from "../client.js";
import {
createThreadForBinding,
createWebhookForChannel,
findReusableWebhook,
isDiscordThreadGoneError,
isThreadArchived,
maybeSendBindingMessage,
resolveChannelIdForBinding,
summarizeDiscordError,
} from "./thread-bindings.discord-api.js";
import {
resolveThreadBindingFarewellText,
resolveThreadBindingThreadName,
} from "./thread-bindings.messages.js";
import {
BINDINGS_BY_THREAD_ID,
forgetThreadBindingToken,
getThreadBindingToken,
MANAGERS_BY_ACCOUNT_ID,
PERSIST_BY_ACCOUNT_ID,
ensureBindingsLoaded,
rememberThreadBindingToken,
normalizeTargetKind,
normalizeThreadBindingTtlMs,
normalizeThreadId,
rememberRecentUnboundWebhookEcho,
removeBindingRecord,
resolveBindingIdsForSession,
resolveBindingRecordKey,
resolveThreadBindingExpiresAt,
resolveThreadBindingsPath,
saveBindingsToDisk,
setBindingRecord,
shouldDefaultPersist,
resetThreadBindingsForTests,
} from "./thread-bindings.state.js";
import {
DEFAULT_THREAD_BINDING_TTL_MS,
THREAD_BINDINGS_SWEEP_INTERVAL_MS,
type ThreadBindingManager,
type ThreadBindingRecord,
} from "./thread-bindings.types.js";
function registerManager(manager: ThreadBindingManager) {
MANAGERS_BY_ACCOUNT_ID.set(manager.accountId, manager);
}
function unregisterManager(accountId: string, manager: ThreadBindingManager) {
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existing === manager) {
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
}
}
function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
const accountId = normalizeAccountId(accountIdRaw);
return {
accountId,
getSessionTtlMs: () => DEFAULT_THREAD_BINDING_TTL_MS,
getByThreadId: () => undefined,
getBySessionKey: () => undefined,
listBySessionKey: () => [],
listBindings: () => [],
bindTarget: async () => null,
unbindThread: () => null,
unbindBySessionKey: () => [],
stop: () => {},
};
}
function toSessionBindingTargetKind(raw: string): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
}
function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
return raw === "subagent" ? "subagent" : "acp";
}
function toSessionBindingRecord(record: ThreadBindingRecord): SessionBindingRecord {
const bindingId =
resolveBindingRecordKey({
accountId: record.accountId,
threadId: record.threadId,
}) ?? `${record.accountId}:${record.threadId}`;
return {
bindingId,
targetSessionKey: record.targetSessionKey,
targetKind: toSessionBindingTargetKind(record.targetKind),
conversation: {
channel: "discord",
accountId: record.accountId,
conversationId: record.threadId,
parentConversationId: record.channelId,
},
status: "active",
boundAt: record.boundAt,
expiresAt: record.expiresAt,
metadata: {
agentId: record.agentId,
label: record.label,
webhookId: record.webhookId,
webhookToken: record.webhookToken,
boundBy: record.boundBy,
},
};
}
function resolveThreadIdFromBindingId(params: {
accountId: string;
bindingId?: string;
}): string | undefined {
const bindingId = params.bindingId?.trim();
if (!bindingId) {
return undefined;
}
const prefix = `${params.accountId}:`;
if (!bindingId.startsWith(prefix)) {
return undefined;
}
const threadId = bindingId.slice(prefix.length).trim();
return threadId || undefined;
}
export function createThreadBindingManager(
params: {
accountId?: string;
token?: string;
persist?: boolean;
enableSweeper?: boolean;
sessionTtlMs?: number;
} = {},
): ThreadBindingManager {
ensureBindingsLoaded();
const accountId = normalizeAccountId(params.accountId);
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existing) {
rememberThreadBindingToken({ accountId, token: params.token });
return existing;
}
rememberThreadBindingToken({ accountId, token: params.token });
const persist = params.persist ?? shouldDefaultPersist();
PERSIST_BY_ACCOUNT_ID.set(accountId, persist);
const sessionTtlMs = normalizeThreadBindingTtlMs(params.sessionTtlMs);
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
let sweepTimer: NodeJS.Timeout | null = null;
const manager: ThreadBindingManager = {
accountId,
getSessionTtlMs: () => sessionTtlMs,
getByThreadId: (threadId) => {
const key = resolveBindingRecordKey({
accountId,
threadId,
});
if (!key) {
return undefined;
}
const entry = BINDINGS_BY_THREAD_ID.get(key);
if (!entry || entry.accountId !== accountId) {
return undefined;
}
return entry;
},
getBySessionKey: (targetSessionKey) => {
const all = manager.listBySessionKey(targetSessionKey);
return all[0];
},
listBySessionKey: (targetSessionKey) => {
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
});
return ids
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
},
listBindings: () =>
[...BINDINGS_BY_THREAD_ID.values()].filter((entry) => entry.accountId === accountId),
bindTarget: async (bindParams) => {
let threadId = normalizeThreadId(bindParams.threadId);
let channelId = bindParams.channelId?.trim() || "";
if (!threadId && bindParams.createThread) {
if (!channelId) {
return null;
}
const threadName = resolveThreadBindingThreadName({
agentId: bindParams.agentId,
label: bindParams.label,
});
threadId =
(await createThreadForBinding({
accountId,
token: resolveCurrentToken(),
channelId,
threadName: bindParams.threadName?.trim() || threadName,
})) ?? undefined;
}
if (!threadId) {
return null;
}
if (!channelId) {
channelId =
(await resolveChannelIdForBinding({
accountId,
token: resolveCurrentToken(),
threadId,
channelId: bindParams.channelId,
})) ?? "";
}
if (!channelId) {
return null;
}
const targetSessionKey = bindParams.targetSessionKey.trim();
if (!targetSessionKey) {
return null;
}
const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey);
let webhookId = bindParams.webhookId?.trim() || "";
let webhookToken = bindParams.webhookToken?.trim() || "";
if (!webhookId || !webhookToken) {
const cachedWebhook = findReusableWebhook({ accountId, channelId });
webhookId = cachedWebhook.webhookId ?? "";
webhookToken = cachedWebhook.webhookToken ?? "";
}
if (!webhookId || !webhookToken) {
const createdWebhook = await createWebhookForChannel({
accountId,
token: resolveCurrentToken(),
channelId,
});
webhookId = createdWebhook.webhookId ?? "";
webhookToken = createdWebhook.webhookToken ?? "";
}
const boundAt = Date.now();
const record: ThreadBindingRecord = {
accountId,
channelId,
threadId,
targetKind,
targetSessionKey,
agentId: bindParams.agentId?.trim() || resolveAgentIdFromSessionKey(targetSessionKey),
label: bindParams.label?.trim() || undefined,
webhookId: webhookId || undefined,
webhookToken: webhookToken || undefined,
boundBy: bindParams.boundBy?.trim() || "system",
boundAt,
expiresAt: sessionTtlMs > 0 ? boundAt + sessionTtlMs : undefined,
};
setBindingRecord(record);
if (persist) {
saveBindingsToDisk();
}
const introText = bindParams.introText?.trim();
if (introText) {
void maybeSendBindingMessage({ record, text: introText });
}
return record;
},
unbindThread: (unbindParams) => {
const bindingKey = resolveBindingRecordKey({
accountId,
threadId: unbindParams.threadId,
});
if (!bindingKey) {
return null;
}
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!existing || existing.accountId !== accountId) {
return null;
}
const removed = removeBindingRecord(bindingKey);
if (!removed) {
return null;
}
rememberRecentUnboundWebhookEcho(removed);
if (persist) {
saveBindingsToDisk();
}
if (unbindParams.sendFarewell !== false) {
const farewell = resolveThreadBindingFarewellText({
reason: unbindParams.reason,
farewellText: unbindParams.farewellText,
sessionTtlMs,
});
// Use bot send path for farewell messages so unbound threads don't process
// webhook echoes as fresh inbound turns when allowBots is enabled.
void maybeSendBindingMessage({ record: removed, text: farewell, preferWebhook: false });
}
return removed;
},
unbindBySessionKey: (unbindParams) => {
const ids = resolveBindingIdsForSession({
targetSessionKey: unbindParams.targetSessionKey,
accountId,
targetKind: unbindParams.targetKind,
});
if (ids.length === 0) {
return [];
}
const removed: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const binding = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!binding) {
continue;
}
const entry = manager.unbindThread({
threadId: binding.threadId,
reason: unbindParams.reason,
sendFarewell: unbindParams.sendFarewell,
farewellText: unbindParams.farewellText,
});
if (entry) {
removed.push(entry);
}
}
return removed;
},
stop: () => {
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
unregisterManager(accountId, manager);
unregisterSessionBindingAdapter({
channel: "discord",
accountId,
});
forgetThreadBindingToken(accountId);
},
};
if (params.enableSweeper !== false) {
sweepTimer = setInterval(() => {
void (async () => {
const bindings = manager.listBindings();
if (bindings.length === 0) {
return;
}
let rest;
try {
rest = createDiscordRestClient({
accountId,
token: resolveCurrentToken(),
}).rest;
} catch {
return;
}
for (const binding of bindings) {
const expiresAt = resolveThreadBindingExpiresAt({
record: binding,
sessionTtlMs,
});
if (expiresAt != null && Date.now() >= expiresAt) {
const ttlFromBinding = Math.max(0, expiresAt - binding.boundAt);
manager.unbindThread({
threadId: binding.threadId,
reason: "ttl-expired",
sendFarewell: true,
farewellText: resolveThreadBindingFarewellText({
reason: "ttl-expired",
sessionTtlMs: ttlFromBinding,
}),
});
continue;
}
try {
const channel = await rest.get(Routes.channel(binding.threadId));
if (!channel || typeof channel !== "object") {
logVerbose(
`discord thread binding sweep probe returned invalid payload for ${binding.threadId}`,
);
continue;
}
if (isThreadArchived(channel)) {
manager.unbindThread({
threadId: binding.threadId,
reason: "thread-archived",
sendFarewell: true,
});
}
} catch (err) {
if (isDiscordThreadGoneError(err)) {
logVerbose(
`discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`,
);
manager.unbindThread({
threadId: binding.threadId,
reason: "thread-delete",
sendFarewell: false,
});
continue;
}
logVerbose(
`discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`,
);
}
}
})();
}, THREAD_BINDINGS_SWEEP_INTERVAL_MS);
sweepTimer.unref?.();
}
registerSessionBindingAdapter({
channel: "discord",
accountId,
bind: async (input) => {
if (input.conversation.channel !== "discord") {
return null;
}
const targetSessionKey = input.targetSessionKey.trim();
if (!targetSessionKey) {
return null;
}
const conversationId = input.conversation.conversationId.trim();
const metadata = input.metadata ?? {};
const label =
typeof metadata.label === "string" ? metadata.label.trim() || undefined : undefined;
const threadName =
typeof metadata.threadName === "string"
? metadata.threadName.trim() || undefined
: undefined;
const introText =
typeof metadata.introText === "string" ? metadata.introText.trim() || undefined : undefined;
const boundBy =
typeof metadata.boundBy === "string" ? metadata.boundBy.trim() || undefined : undefined;
const agentId =
typeof metadata.agentId === "string" ? metadata.agentId.trim() || undefined : undefined;
const bound = await manager.bindTarget({
threadId: conversationId || undefined,
channelId: input.conversation.parentConversationId?.trim() || undefined,
createThread: !conversationId,
threadName,
targetKind: toThreadBindingTargetKind(input.targetKind),
targetSessionKey,
agentId,
label,
boundBy,
introText,
});
return bound ? toSessionBindingRecord(bound) : null;
},
listBySession: (targetSessionKey) =>
manager.listBySessionKey(targetSessionKey).map(toSessionBindingRecord),
resolveByConversation: (ref) => {
if (ref.channel !== "discord") {
return null;
}
const binding = manager.getByThreadId(ref.conversationId);
return binding ? toSessionBindingRecord(binding) : null;
},
touch: () => {
// Thread bindings are activity-touched by inbound/outbound message flows.
},
unbind: async (input) => {
if (input.targetSessionKey?.trim()) {
const removed = manager.unbindBySessionKey({
targetSessionKey: input.targetSessionKey,
reason: input.reason,
});
return removed.map(toSessionBindingRecord);
}
const threadId = resolveThreadIdFromBindingId({
accountId,
bindingId: input.bindingId,
});
if (!threadId) {
return [];
}
const removed = manager.unbindThread({
threadId,
reason: input.reason,
});
return removed ? [toSessionBindingRecord(removed)] : [];
},
});
registerManager(manager);
return manager;
}
export function createNoopThreadBindingManager(accountId?: string): ThreadBindingManager {
return createNoopManager(accountId);
}
export function getThreadBindingManager(accountId?: string): ThreadBindingManager | null {
const normalized = normalizeAccountId(accountId);
return MANAGERS_BY_ACCOUNT_ID.get(normalized) ?? null;
}
export const __testing = {
resolveThreadBindingsPath,
resolveThreadBindingThreadName,
resetThreadBindingsForTests,
};

View File

@@ -0,0 +1,72 @@
import { DEFAULT_FAREWELL_TEXT, type ThreadBindingRecord } from "./thread-bindings.types.js";
function normalizeThreadBindingMessageTtlMs(raw: unknown): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return 0;
}
const ttlMs = Math.floor(raw);
if (ttlMs < 0) {
return 0;
}
return ttlMs;
}
export function formatThreadBindingTtlLabel(ttlMs: number): string {
if (ttlMs <= 0) {
return "disabled";
}
if (ttlMs < 60_000) {
return "<1m";
}
const totalMinutes = Math.floor(ttlMs / 60_000);
if (totalMinutes % 60 === 0) {
return `${Math.floor(totalMinutes / 60)}h`;
}
return `${totalMinutes}m`;
}
export function resolveThreadBindingThreadName(params: {
agentId?: string;
label?: string;
}): string {
const label = params.label?.trim();
const base = label || params.agentId?.trim() || "agent";
const raw = `🤖 ${base}`.replace(/\s+/g, " ").trim();
return raw.slice(0, 100);
}
export function resolveThreadBindingIntroText(params: {
agentId?: string;
label?: string;
sessionTtlMs?: number;
}): string {
const label = params.label?.trim();
const base = label || params.agentId?.trim() || "agent";
const normalized = base.replace(/\s+/g, " ").trim().slice(0, 100) || "agent";
const ttlMs = normalizeThreadBindingMessageTtlMs(params.sessionTtlMs);
if (ttlMs > 0) {
return `🤖 ${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.`;
}
return `🤖 ${normalized} session active. Messages here go directly to this session.`;
}
export function resolveThreadBindingFarewellText(params: {
reason?: string;
farewellText?: string;
sessionTtlMs: number;
}): string {
const custom = params.farewellText?.trim();
if (custom) {
return custom;
}
if (params.reason === "ttl-expired") {
return `Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`;
}
return DEFAULT_FAREWELL_TEXT;
}
export function summarizeBindingPersona(record: ThreadBindingRecord): string {
const label = record.label?.trim();
const base = label || record.agentId;
return (`🤖 ${base}`.trim() || "🤖 agent").slice(0, 80);
}

View File

@@ -0,0 +1,31 @@
import { createJiti } from "jiti";
import { beforeEach, describe, expect, it } from "vitest";
import {
__testing as threadBindingsTesting,
createThreadBindingManager,
getThreadBindingManager,
} from "./thread-bindings.js";
describe("thread binding manager state", () => {
beforeEach(() => {
threadBindingsTesting.resetThreadBindingsForTests();
});
it("shares managers between ESM and Jiti-loaded module instances", () => {
const jiti = createJiti(import.meta.url, {
interopDefault: true,
});
const viaJiti = jiti("./thread-bindings.ts") as {
getThreadBindingManager: typeof getThreadBindingManager;
};
createThreadBindingManager({
accountId: "work",
persist: false,
enableSweeper: false,
});
expect(getThreadBindingManager("work")).not.toBeNull();
expect(viaJiti.getThreadBindingManager("work")).not.toBeNull();
});
});

View File

@@ -0,0 +1,444 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import {
DEFAULT_THREAD_BINDING_TTL_MS,
RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS,
THREAD_BINDINGS_VERSION,
type PersistedThreadBindingRecord,
type PersistedThreadBindingsPayload,
type ThreadBindingManager,
type ThreadBindingRecord,
type ThreadBindingTargetKind,
} from "./thread-bindings.types.js";
type ThreadBindingsGlobalState = {
managersByAccountId: Map<string, ThreadBindingManager>;
bindingsByThreadId: Map<string, ThreadBindingRecord>;
bindingsBySessionKey: Map<string, Set<string>>;
tokensByAccountId: Map<string, string>;
recentUnboundWebhookEchoesByBindingKey: Map<string, { webhookId: string; expiresAt: number }>;
reusableWebhooksByAccountChannel: Map<string, { webhookId: string; webhookToken: string }>;
persistByAccountId: Map<string, boolean>;
loadedBindings: boolean;
};
// Plugin hooks can load this module via Jiti while core imports it via ESM.
// Store mutable state on globalThis so both loader paths share one registry.
const THREAD_BINDINGS_STATE_KEY = "__openclawDiscordThreadBindingsState";
function createThreadBindingsGlobalState(): ThreadBindingsGlobalState {
return {
managersByAccountId: new Map<string, ThreadBindingManager>(),
bindingsByThreadId: new Map<string, ThreadBindingRecord>(),
bindingsBySessionKey: new Map<string, Set<string>>(),
tokensByAccountId: new Map<string, string>(),
recentUnboundWebhookEchoesByBindingKey: new Map<
string,
{ webhookId: string; expiresAt: number }
>(),
reusableWebhooksByAccountChannel: new Map<
string,
{ webhookId: string; webhookToken: string }
>(),
persistByAccountId: new Map<string, boolean>(),
loadedBindings: false,
};
}
function resolveThreadBindingsGlobalState(): ThreadBindingsGlobalState {
const runtimeGlobal = globalThis as typeof globalThis & {
[THREAD_BINDINGS_STATE_KEY]?: ThreadBindingsGlobalState;
};
if (!runtimeGlobal[THREAD_BINDINGS_STATE_KEY]) {
runtimeGlobal[THREAD_BINDINGS_STATE_KEY] = createThreadBindingsGlobalState();
}
return runtimeGlobal[THREAD_BINDINGS_STATE_KEY];
}
const THREAD_BINDINGS_STATE = resolveThreadBindingsGlobalState();
export const MANAGERS_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.managersByAccountId;
export const BINDINGS_BY_THREAD_ID = THREAD_BINDINGS_STATE.bindingsByThreadId;
export const BINDINGS_BY_SESSION_KEY = THREAD_BINDINGS_STATE.bindingsBySessionKey;
export const TOKENS_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.tokensByAccountId;
export const RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY =
THREAD_BINDINGS_STATE.recentUnboundWebhookEchoesByBindingKey;
export const REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL =
THREAD_BINDINGS_STATE.reusableWebhooksByAccountChannel;
export const PERSIST_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.persistByAccountId;
export function rememberThreadBindingToken(params: { accountId?: string; token?: string }) {
const normalizedAccountId = normalizeAccountId(params.accountId);
const token = params.token?.trim();
if (!token) {
return;
}
TOKENS_BY_ACCOUNT_ID.set(normalizedAccountId, token);
}
export function forgetThreadBindingToken(accountId?: string) {
TOKENS_BY_ACCOUNT_ID.delete(normalizeAccountId(accountId));
}
export function getThreadBindingToken(accountId?: string): string | undefined {
return TOKENS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId));
}
export function shouldDefaultPersist(): boolean {
return !(process.env.VITEST || process.env.NODE_ENV === "test");
}
export function resolveThreadBindingsPath(): string {
return path.join(resolveStateDir(process.env), "discord", "thread-bindings.json");
}
export function normalizeTargetKind(
raw: unknown,
targetSessionKey: string,
): ThreadBindingTargetKind {
if (raw === "subagent" || raw === "acp") {
return raw;
}
return targetSessionKey.includes(":subagent:") ? "subagent" : "acp";
}
export function normalizeThreadId(raw: unknown): string | undefined {
if (typeof raw === "number" && Number.isFinite(raw)) {
return String(Math.floor(raw));
}
if (typeof raw !== "string") {
return undefined;
}
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
export function toBindingRecordKey(params: { accountId: string; threadId: string }): string {
return `${normalizeAccountId(params.accountId)}:${params.threadId.trim()}`;
}
export function resolveBindingRecordKey(params: {
accountId?: string;
threadId: string;
}): string | undefined {
const threadId = normalizeThreadId(params.threadId);
if (!threadId) {
return undefined;
}
return toBindingRecordKey({
accountId: normalizeAccountId(params.accountId),
threadId,
});
}
function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBindingRecord | null {
if (!raw || typeof raw !== "object") {
return null;
}
const value = raw as Partial<PersistedThreadBindingRecord>;
const threadId = normalizeThreadId(value.threadId ?? threadIdKey);
const channelId = typeof value.channelId === "string" ? value.channelId.trim() : "";
const targetSessionKey =
typeof value.targetSessionKey === "string"
? value.targetSessionKey.trim()
: typeof value.sessionKey === "string"
? value.sessionKey.trim()
: "";
if (!threadId || !channelId || !targetSessionKey) {
return null;
}
const accountId = normalizeAccountId(value.accountId);
const targetKind = normalizeTargetKind(value.targetKind, targetSessionKey);
const agentIdRaw = typeof value.agentId === "string" ? value.agentId.trim() : "";
const agentId = agentIdRaw || resolveAgentIdFromSessionKey(targetSessionKey);
const label = typeof value.label === "string" ? value.label.trim() || undefined : undefined;
const webhookId =
typeof value.webhookId === "string" ? value.webhookId.trim() || undefined : undefined;
const webhookToken =
typeof value.webhookToken === "string" ? value.webhookToken.trim() || undefined : undefined;
const boundBy = typeof value.boundBy === "string" ? value.boundBy.trim() || "system" : "system";
const boundAt =
typeof value.boundAt === "number" && Number.isFinite(value.boundAt)
? Math.floor(value.boundAt)
: Date.now();
const expiresAt =
typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt)
? Math.max(0, Math.floor(value.expiresAt))
: undefined;
return {
accountId,
channelId,
threadId,
targetKind,
targetSessionKey,
agentId,
label,
webhookId,
webhookToken,
boundBy,
boundAt,
expiresAt,
};
}
export function normalizeThreadBindingTtlMs(raw: unknown): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return DEFAULT_THREAD_BINDING_TTL_MS;
}
const ttlMs = Math.floor(raw);
if (ttlMs < 0) {
return DEFAULT_THREAD_BINDING_TTL_MS;
}
return ttlMs;
}
export function resolveThreadBindingExpiresAt(params: {
record: Pick<ThreadBindingRecord, "boundAt" | "expiresAt">;
sessionTtlMs: number;
}): number | undefined {
if (typeof params.record.expiresAt === "number" && Number.isFinite(params.record.expiresAt)) {
const explicitExpiresAt = Math.floor(params.record.expiresAt);
if (explicitExpiresAt <= 0) {
// 0 is an explicit per-binding TTL disable sentinel.
return undefined;
}
return explicitExpiresAt;
}
if (params.sessionTtlMs <= 0) {
return undefined;
}
const boundAt = Math.floor(params.record.boundAt);
if (!Number.isFinite(boundAt) || boundAt <= 0) {
return undefined;
}
return boundAt + params.sessionTtlMs;
}
function linkSessionBinding(targetSessionKey: string, bindingKey: string) {
const key = targetSessionKey.trim();
if (!key) {
return;
}
const threads = BINDINGS_BY_SESSION_KEY.get(key) ?? new Set<string>();
threads.add(bindingKey);
BINDINGS_BY_SESSION_KEY.set(key, threads);
}
function unlinkSessionBinding(targetSessionKey: string, bindingKey: string) {
const key = targetSessionKey.trim();
if (!key) {
return;
}
const threads = BINDINGS_BY_SESSION_KEY.get(key);
if (!threads) {
return;
}
threads.delete(bindingKey);
if (threads.size === 0) {
BINDINGS_BY_SESSION_KEY.delete(key);
}
}
export function toReusableWebhookKey(params: { accountId: string; channelId: string }): string {
return `${params.accountId.trim().toLowerCase()}:${params.channelId.trim()}`;
}
export function rememberReusableWebhook(record: ThreadBindingRecord) {
const webhookId = record.webhookId?.trim();
const webhookToken = record.webhookToken?.trim();
if (!webhookId || !webhookToken) {
return;
}
const key = toReusableWebhookKey({
accountId: record.accountId,
channelId: record.channelId,
});
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.set(key, { webhookId, webhookToken });
}
export function rememberRecentUnboundWebhookEcho(record: ThreadBindingRecord) {
const webhookId = record.webhookId?.trim();
if (!webhookId) {
return;
}
const bindingKey = resolveBindingRecordKey({
accountId: record.accountId,
threadId: record.threadId,
});
if (!bindingKey) {
return;
}
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.set(bindingKey, {
webhookId,
expiresAt: Date.now() + RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS,
});
}
function clearRecentUnboundWebhookEcho(bindingKeyRaw: string) {
const key = bindingKeyRaw.trim();
if (!key) {
return;
}
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(key);
}
export function setBindingRecord(record: ThreadBindingRecord) {
const bindingKey = toBindingRecordKey({
accountId: record.accountId,
threadId: record.threadId,
});
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (existing) {
unlinkSessionBinding(existing.targetSessionKey, bindingKey);
}
BINDINGS_BY_THREAD_ID.set(bindingKey, record);
linkSessionBinding(record.targetSessionKey, bindingKey);
clearRecentUnboundWebhookEcho(bindingKey);
rememberReusableWebhook(record);
}
export function removeBindingRecord(bindingKeyRaw: string): ThreadBindingRecord | null {
const key = bindingKeyRaw.trim();
if (!key) {
return null;
}
const existing = BINDINGS_BY_THREAD_ID.get(key);
if (!existing) {
return null;
}
BINDINGS_BY_THREAD_ID.delete(key);
unlinkSessionBinding(existing.targetSessionKey, key);
return existing;
}
export function isRecentlyUnboundThreadWebhookMessage(params: {
accountId?: string;
threadId: string;
webhookId?: string | null;
}): boolean {
const webhookId = params.webhookId?.trim() || "";
if (!webhookId) {
return false;
}
const bindingKey = resolveBindingRecordKey({
accountId: params.accountId,
threadId: params.threadId,
});
if (!bindingKey) {
return false;
}
const suppressed = RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.get(bindingKey);
if (!suppressed) {
return false;
}
if (suppressed.expiresAt <= Date.now()) {
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(bindingKey);
return false;
}
return suppressed.webhookId === webhookId;
}
function shouldPersistAnyBindingState(): boolean {
for (const value of PERSIST_BY_ACCOUNT_ID.values()) {
if (value) {
return true;
}
}
return false;
}
export function shouldPersistBindingMutations(): boolean {
if (shouldPersistAnyBindingState()) {
return true;
}
return fs.existsSync(resolveThreadBindingsPath());
}
export function saveBindingsToDisk(params: { force?: boolean } = {}) {
if (!params.force && !shouldPersistAnyBindingState()) {
return;
}
const bindings: Record<string, PersistedThreadBindingRecord> = {};
for (const [bindingKey, record] of BINDINGS_BY_THREAD_ID.entries()) {
bindings[bindingKey] = { ...record };
}
const payload: PersistedThreadBindingsPayload = {
version: THREAD_BINDINGS_VERSION,
bindings,
};
saveJsonFile(resolveThreadBindingsPath(), payload);
}
export function ensureBindingsLoaded() {
if (THREAD_BINDINGS_STATE.loadedBindings) {
return;
}
THREAD_BINDINGS_STATE.loadedBindings = true;
BINDINGS_BY_THREAD_ID.clear();
BINDINGS_BY_SESSION_KEY.clear();
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.clear();
const raw = loadJsonFile(resolveThreadBindingsPath());
if (!raw || typeof raw !== "object") {
return;
}
const payload = raw as Partial<PersistedThreadBindingsPayload>;
if (payload.version !== 1 || !payload.bindings || typeof payload.bindings !== "object") {
return;
}
for (const [threadId, entry] of Object.entries(payload.bindings)) {
const normalized = normalizePersistedBinding(threadId, entry);
if (!normalized) {
continue;
}
setBindingRecord(normalized);
}
}
export function resolveBindingIdsForSession(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
}): string[] {
const key = params.targetSessionKey.trim();
if (!key) {
return [];
}
const ids = BINDINGS_BY_SESSION_KEY.get(key);
if (!ids) {
return [];
}
const out: string[] = [];
for (const bindingKey of ids.values()) {
const record = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!record) {
continue;
}
if (params.accountId && record.accountId !== params.accountId) {
continue;
}
if (params.targetKind && record.targetKind !== params.targetKind) {
continue;
}
out.push(bindingKey);
}
return out;
}
export function resetThreadBindingsForTests() {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();
}
MANAGERS_BY_ACCOUNT_ID.clear();
BINDINGS_BY_THREAD_ID.clear();
BINDINGS_BY_SESSION_KEY.clear();
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.clear();
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.clear();
TOKENS_BY_ACCOUNT_ID.clear();
PERSIST_BY_ACCOUNT_ID.clear();
THREAD_BINDINGS_STATE.loadedBindings = false;
}

View File

@@ -0,0 +1,28 @@
export type {
ThreadBindingManager,
ThreadBindingRecord,
ThreadBindingTargetKind,
} from "./thread-bindings.types.js";
export {
formatThreadBindingTtlLabel,
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "./thread-bindings.messages.js";
export { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.state.js";
export {
autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey,
listThreadBindingsForAccount,
setThreadBindingTtlBySessionKey,
unbindThreadBindingsBySessionKey,
} from "./thread-bindings.lifecycle.js";
export {
__testing,
createNoopThreadBindingManager,
createThreadBindingManager,
getThreadBindingManager,
} from "./thread-bindings.manager.js";

View File

@@ -0,0 +1,541 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
const restGet = vi.fn(async () => ({
id: "thread-1",
type: 11,
parent_id: "parent-1",
}));
const restPost = vi.fn(async () => ({
id: "wh-created",
token: "tok-created",
}));
const createDiscordRestClient = vi.fn((..._args: unknown[]) => ({
rest: {
get: restGet,
post: restPost,
},
}));
const createThreadDiscord = vi.fn(async (..._args: unknown[]) => ({ id: "thread-created" }));
return {
sendMessageDiscord,
sendWebhookMessageDiscord,
restGet,
restPost,
createDiscordRestClient,
createThreadDiscord,
};
});
vi.mock("../send.js", () => ({
sendMessageDiscord: hoisted.sendMessageDiscord,
sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
}));
vi.mock("../client.js", () => ({
createDiscordRestClient: hoisted.createDiscordRestClient,
}));
vi.mock("../send.messages.js", () => ({
createThreadDiscord: hoisted.createThreadDiscord,
}));
const {
__testing,
autoBindSpawnedDiscordSubagent,
createThreadBindingManager,
resolveThreadBindingIntroText,
setThreadBindingTtlBySessionKey,
unbindThreadBindingsBySessionKey,
} = await import("./thread-bindings.js");
describe("thread binding ttl", () => {
beforeEach(() => {
__testing.resetThreadBindingsForTests();
hoisted.sendMessageDiscord.mockClear();
hoisted.sendWebhookMessageDiscord.mockClear();
hoisted.restGet.mockClear();
hoisted.restPost.mockClear();
hoisted.createDiscordRestClient.mockClear();
hoisted.createThreadDiscord.mockClear();
vi.useRealTimers();
});
it("includes ttl in intro text", () => {
const intro = resolveThreadBindingIntroText({
agentId: "main",
label: "worker",
sessionTtlMs: 24 * 60 * 60 * 1000,
});
expect(intro).toContain("auto-unfocus in 24h");
});
it("auto-unfocuses expired bindings and sends a ttl-expired message", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: true,
sessionTtlMs: 60_000,
});
const binding = await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
introText: "intro",
});
expect(binding).not.toBeNull();
hoisted.sendMessageDiscord.mockClear();
hoisted.sendWebhookMessageDiscord.mockClear();
await vi.advanceTimersByTimeAsync(120_000);
expect(manager.getByThreadId("thread-1")).toBeUndefined();
expect(hoisted.restGet).not.toHaveBeenCalled();
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
const farewell = hoisted.sendMessageDiscord.mock.calls[0]?.[1] as string | undefined;
expect(farewell).toContain("Session ended automatically after 1m");
} finally {
vi.useRealTimers();
}
});
it("keeps binding when thread sweep probe fails transiently", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: true,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
});
hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
await vi.advanceTimersByTimeAsync(120_000);
expect(manager.getByThreadId("thread-1")).toBeDefined();
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("unbinds when thread sweep probe reports unknown channel", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: true,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
});
hoisted.restGet.mockRejectedValueOnce({
status: 404,
rawError: { code: 10003, message: "Unknown Channel" },
});
await vi.advanceTimersByTimeAsync(120_000);
expect(manager.getByThreadId("thread-1")).toBeUndefined();
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("updates ttl by target session key", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z"));
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
});
vi.setSystemTime(new Date("2026-02-20T23:15:00.000Z"));
const updated = setThreadBindingTtlBySessionKey({
accountId: "default",
targetSessionKey: "agent:main:subagent:child",
ttlMs: 2 * 60 * 60 * 1000,
});
expect(updated).toHaveLength(1);
expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime());
expect(updated[0]?.expiresAt).toBe(new Date("2026-02-21T01:15:00.000Z").getTime());
expect(manager.getByThreadId("thread-1")?.expiresAt).toBe(
new Date("2026-02-21T01:15:00.000Z").getTime(),
);
} finally {
vi.useRealTimers();
}
});
it("keeps binding when ttl is disabled per session key", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: true,
sessionTtlMs: 60_000,
});
await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
});
const updated = setThreadBindingTtlBySessionKey({
accountId: "default",
targetSessionKey: "agent:main:subagent:child",
ttlMs: 0,
});
expect(updated).toHaveLength(1);
expect(updated[0]?.expiresAt).toBe(0);
hoisted.sendWebhookMessageDiscord.mockClear();
await vi.advanceTimersByTimeAsync(240_000);
expect(manager.getByThreadId("thread-1")).toBeDefined();
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("reuses webhook credentials after unbind when rebinding in the same channel", async () => {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
const first = await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-1",
agentId: "main",
});
expect(first).not.toBeNull();
expect(hoisted.restPost).toHaveBeenCalledTimes(1);
manager.unbindThread({
threadId: "thread-1",
sendFarewell: false,
});
const second = await manager.bindTarget({
threadId: "thread-2",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-2",
agentId: "main",
});
expect(second).not.toBeNull();
expect(second?.webhookId).toBe("wh-created");
expect(second?.webhookToken).toBe("tok-created");
expect(hoisted.restPost).toHaveBeenCalledTimes(1);
});
it("creates a new thread when spawning from an already bound thread", async () => {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:parent",
agentId: "main",
});
hoisted.createThreadDiscord.mockClear();
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-2" });
const childBinding = await autoBindSpawnedDiscordSubagent({
accountId: "default",
channel: "discord",
to: "channel:thread-1",
threadId: "thread-1",
childSessionKey: "agent:main:subagent:child-2",
agentId: "main",
});
expect(childBinding).not.toBeNull();
expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1);
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"parent-1",
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "default" }),
);
expect(manager.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:parent");
expect(manager.getByThreadId("thread-created-2")?.targetSessionKey).toBe(
"agent:main:subagent:child-2",
);
});
it("resolves parent channel when thread target is passed via to without threadId", async () => {
createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
hoisted.restGet.mockClear();
hoisted.restGet.mockResolvedValueOnce({
id: "thread-lookup",
type: 11,
parent_id: "parent-1",
});
hoisted.createThreadDiscord.mockClear();
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-lookup" });
const childBinding = await autoBindSpawnedDiscordSubagent({
accountId: "default",
channel: "discord",
to: "channel:thread-lookup",
childSessionKey: "agent:main:subagent:child-lookup",
agentId: "main",
});
expect(childBinding).not.toBeNull();
expect(childBinding?.channelId).toBe("parent-1");
expect(hoisted.restGet).toHaveBeenCalledTimes(1);
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"parent-1",
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "default" }),
);
});
it("passes manager token when resolving parent channels for auto-bind", async () => {
createThreadBindingManager({
accountId: "runtime",
token: "runtime-token",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
hoisted.createDiscordRestClient.mockClear();
hoisted.restGet.mockClear();
hoisted.restGet.mockResolvedValueOnce({
id: "thread-runtime",
type: 11,
parent_id: "parent-runtime",
});
hoisted.createThreadDiscord.mockClear();
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" });
const childBinding = await autoBindSpawnedDiscordSubagent({
accountId: "runtime",
channel: "discord",
to: "channel:thread-runtime",
childSessionKey: "agent:main:subagent:child-runtime",
agentId: "main",
});
expect(childBinding).not.toBeNull();
const firstClientArgs = hoisted.createDiscordRestClient.mock.calls[0]?.[0] as
| { accountId?: string; token?: string }
| undefined;
expect(firstClientArgs).toMatchObject({
accountId: "runtime",
token: "runtime-token",
});
});
it("refreshes manager token when an existing manager is reused", async () => {
createThreadBindingManager({
accountId: "runtime",
token: "token-old",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
const manager = createThreadBindingManager({
accountId: "runtime",
token: "token-new",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
hoisted.createThreadDiscord.mockClear();
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-token-refresh" });
hoisted.createDiscordRestClient.mockClear();
const bound = await manager.bindTarget({
createThread: true,
channelId: "parent-runtime",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:token-refresh",
agentId: "main",
});
expect(bound).not.toBeNull();
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"parent-runtime",
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "runtime", token: "token-new" }),
);
const usedTokenNew = hoisted.createDiscordRestClient.mock.calls.some(
(call) => (call?.[0] as { token?: string } | undefined)?.token === "token-new",
);
expect(usedTokenNew).toBe(true);
});
it("keeps overlapping thread ids isolated per account", async () => {
const a = createThreadBindingManager({
accountId: "a",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
const b = createThreadBindingManager({
accountId: "b",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
});
const aBinding = await a.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:a",
agentId: "main",
});
const bBinding = await b.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:b",
agentId: "main",
});
expect(aBinding?.accountId).toBe("a");
expect(bBinding?.accountId).toBe("b");
expect(a.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:a");
expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
const removedA = a.unbindBySessionKey({
targetSessionKey: "agent:main:subagent:a",
sendFarewell: false,
});
expect(removedA).toHaveLength(1);
expect(a.getByThreadId("thread-1")).toBeUndefined();
expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
});
it("persists unbinds even when no manager is active", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
__testing.resetThreadBindingsForTests();
const bindingsPath = __testing.resolveThreadBindingsPath();
fs.mkdirSync(path.dirname(bindingsPath), { recursive: true });
const now = Date.now();
fs.writeFileSync(
bindingsPath,
JSON.stringify(
{
version: 1,
bindings: {
"thread-1": {
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
boundBy: "system",
boundAt: now,
expiresAt: now + 60_000,
},
},
},
null,
2,
),
"utf-8",
);
const removed = unbindThreadBindingsBySessionKey({
targetSessionKey: "agent:main:subagent:child",
});
expect(removed).toHaveLength(1);
const payload = JSON.parse(fs.readFileSync(bindingsPath, "utf-8")) as {
bindings?: Record<string, unknown>;
};
expect(Object.keys(payload.bindings ?? {})).toEqual([]);
} finally {
__testing.resetThreadBindingsForTests();
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,69 @@
export type ThreadBindingTargetKind = "subagent" | "acp";
export type ThreadBindingRecord = {
accountId: string;
channelId: string;
threadId: string;
targetKind: ThreadBindingTargetKind;
targetSessionKey: string;
agentId: string;
label?: string;
webhookId?: string;
webhookToken?: string;
boundBy: string;
boundAt: number;
expiresAt?: number;
};
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
sessionKey?: string;
};
export type PersistedThreadBindingsPayload = {
version: 1;
bindings: Record<string, PersistedThreadBindingRecord>;
};
export type ThreadBindingManager = {
accountId: string;
getSessionTtlMs: () => number;
getByThreadId: (threadId: string) => ThreadBindingRecord | undefined;
getBySessionKey: (targetSessionKey: string) => ThreadBindingRecord | undefined;
listBySessionKey: (targetSessionKey: string) => ThreadBindingRecord[];
listBindings: () => ThreadBindingRecord[];
bindTarget: (params: {
threadId?: string | number;
channelId?: string;
createThread?: boolean;
threadName?: string;
targetKind: ThreadBindingTargetKind;
targetSessionKey: string;
agentId?: string;
label?: string;
boundBy?: string;
introText?: string;
webhookId?: string;
webhookToken?: string;
}) => Promise<ThreadBindingRecord | null>;
unbindThread: (params: {
threadId: string;
reason?: string;
sendFarewell?: boolean;
farewellText?: string;
}) => ThreadBindingRecord | null;
unbindBySessionKey: (params: {
targetSessionKey: string;
targetKind?: ThreadBindingTargetKind;
reason?: string;
sendFarewell?: boolean;
farewellText?: string;
}) => ThreadBindingRecord[];
stop: () => void;
};
export const THREAD_BINDINGS_VERSION = 1 as const;
export const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 120_000;
export const DEFAULT_THREAD_BINDING_TTL_MS = 24 * 60 * 60 * 1000; // 24h
export const DEFAULT_FAREWELL_TEXT = "Session ended. Messages here will no longer be routed.";
export const DISCORD_UNKNOWN_CHANNEL_ERROR_CODE = 10_003;
export const RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS = 30_000;

View File

@@ -10,7 +10,7 @@ vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: (...args: Parameters<typeof actual.loadConfig>) => loadConfigMock(...args),
loadConfig: (..._args: unknown[]) => loadConfigMock(),
};
});

View File

@@ -295,6 +295,100 @@ export async function sendMessageDiscord(
return toDiscordSendResult(result, channelId);
}
type DiscordWebhookSendOpts = {
webhookId: string;
webhookToken: string;
accountId?: string;
threadId?: string | number;
replyTo?: string;
username?: string;
avatarUrl?: string;
wait?: boolean;
};
function resolveWebhookExecutionUrl(params: {
webhookId: string;
webhookToken: string;
threadId?: string | number;
wait?: boolean;
}) {
const baseUrl = new URL(
`https://discord.com/api/v10/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}`,
);
baseUrl.searchParams.set("wait", params.wait === false ? "false" : "true");
if (params.threadId !== undefined && params.threadId !== null && params.threadId !== "") {
baseUrl.searchParams.set("thread_id", String(params.threadId));
}
return baseUrl.toString();
}
export async function sendWebhookMessageDiscord(
text: string,
opts: DiscordWebhookSendOpts,
): Promise<DiscordSendResult> {
const webhookId = opts.webhookId.trim();
const webhookToken = opts.webhookToken.trim();
if (!webhookId || !webhookToken) {
throw new Error("Discord webhook id/token are required");
}
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const response = await fetch(
resolveWebhookExecutionUrl({
webhookId,
webhookToken,
threadId: opts.threadId,
wait: opts.wait,
}),
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
content: text,
username: opts.username?.trim() || undefined,
avatar_url: opts.avatarUrl?.trim() || undefined,
...(messageReference ? { message_reference: messageReference } : {}),
}),
},
);
if (!response.ok) {
const raw = await response.text().catch(() => "");
throw new Error(
`Discord webhook send failed (${response.status}${raw ? `: ${raw.slice(0, 200)}` : ""})`,
);
}
const payload = (await response.json().catch(() => ({}))) as {
id?: string;
channel_id?: string;
};
try {
const account = resolveDiscordAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
recordChannelActivity({
channel: "discord",
accountId: account.accountId,
direction: "outbound",
});
} catch {
// Best-effort telemetry only.
}
return {
messageId: payload.id ? String(payload.id) : "unknown",
channelId: payload.channel_id
? String(payload.channel_id)
: opts.threadId
? String(opts.threadId)
: "",
};
}
export async function sendStickerDiscord(
to: string,
stickerIds: string[],

View File

@@ -41,6 +41,7 @@ export {
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
sendWebhookMessageDiscord,
sendVoiceMessageDiscord,
} from "./send.outbound.js";
export { sendDiscordComponentMessage } from "./send.components.js";

View File

@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendWebhookMessageDiscord } from "./send.js";
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
vi.mock("../infra/channel-activity.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/channel-activity.js")>();
return {
...actual,
recordChannelActivity: (...args: unknown[]) => recordChannelActivityMock(...args),
};
});
describe("sendWebhookMessageDiscord activity", () => {
beforeEach(() => {
recordChannelActivityMock.mockReset();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(JSON.stringify({ id: "msg-1", channel_id: "thread-1" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("records outbound channel activity for webhook sends", async () => {
const result = await sendWebhookMessageDiscord("hello world", {
webhookId: "wh-1",
webhookToken: "tok-1",
accountId: "runtime",
threadId: "thread-1",
});
expect(result).toEqual({
messageId: "msg-1",
channelId: "thread-1",
});
expect(recordChannelActivityMock).toHaveBeenCalledWith({
channel: "discord",
accountId: "runtime",
direction: "outbound",
});
});
});