From e9939be6babffa64f09dba5018f95d453306f206 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:49:02 -0500 Subject: [PATCH] feat(feishu): add current-conversation session binding --- extensions/feishu/index.test.ts | 68 ++ extensions/feishu/index.ts | 2 + extensions/feishu/src/subagent-hooks.test.ts | 623 ++++++++++++++++++ extensions/feishu/src/subagent-hooks.ts | 341 ++++++++++ extensions/feishu/src/thread-bindings.ts | 12 + src/config/config.acp-binding-cutover.test.ts | 18 + src/config/zod-schema.agents.ts | 4 +- 7 files changed, 1066 insertions(+), 2 deletions(-) create mode 100644 extensions/feishu/index.test.ts create mode 100644 extensions/feishu/src/subagent-hooks.test.ts create mode 100644 extensions/feishu/src/subagent-hooks.ts diff --git a/extensions/feishu/index.test.ts b/extensions/feishu/index.test.ts new file mode 100644 index 00000000000..5236e4bb542 --- /dev/null +++ b/extensions/feishu/index.test.ts @@ -0,0 +1,68 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import { describe, expect, it, vi } from "vitest"; + +const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn()); +const setFeishuRuntimeMock = vi.hoisted(() => vi.fn()); +const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn()); + +vi.mock("./src/docx.js", () => ({ + registerFeishuDocTools: registerFeishuDocToolsMock, +})); + +vi.mock("./src/chat.js", () => ({ + registerFeishuChatTools: registerFeishuChatToolsMock, +})); + +vi.mock("./src/wiki.js", () => ({ + registerFeishuWikiTools: registerFeishuWikiToolsMock, +})); + +vi.mock("./src/drive.js", () => ({ + registerFeishuDriveTools: registerFeishuDriveToolsMock, +})); + +vi.mock("./src/perm.js", () => ({ + registerFeishuPermTools: registerFeishuPermToolsMock, +})); + +vi.mock("./src/bitable.js", () => ({ + registerFeishuBitableTools: registerFeishuBitableToolsMock, +})); + +vi.mock("./src/runtime.js", () => ({ + setFeishuRuntime: setFeishuRuntimeMock, +})); + +vi.mock("./src/subagent-hooks.js", () => ({ + registerFeishuSubagentHooks: registerFeishuSubagentHooksMock, +})); + +describe("feishu plugin register", () => { + it("registers the Feishu channel, tools, and subagent hooks", async () => { + const { default: plugin } = await import("./index.js"); + const registerChannel = vi.fn(); + const api = { + runtime: { log: vi.fn() }, + registerChannel, + on: vi.fn(), + config: {}, + } as unknown as OpenClawPluginApi; + + plugin.register(api); + + expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime); + expect(registerChannel).toHaveBeenCalledTimes(1); + expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api); + expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api); + }); +}); diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index bd26346c8ec..e01a975615a 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -7,6 +7,7 @@ import { registerFeishuDocTools } from "./src/docx.js"; import { registerFeishuDriveTools } from "./src/drive.js"; import { registerFeishuPermTools } from "./src/perm.js"; import { setFeishuRuntime } from "./src/runtime.js"; +import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js"; import { registerFeishuWikiTools } from "./src/wiki.js"; export { monitorFeishuProvider } from "./src/monitor.js"; @@ -53,6 +54,7 @@ const plugin = { register(api: OpenClawPluginApi) { setFeishuRuntime(api.runtime); api.registerChannel({ plugin: feishuPlugin }); + registerFeishuSubagentHooks(api); registerFeishuDocTools(api); registerFeishuChatTools(api); registerFeishuWikiTools(api); diff --git a/extensions/feishu/src/subagent-hooks.test.ts b/extensions/feishu/src/subagent-hooks.test.ts new file mode 100644 index 00000000000..a86e8996f35 --- /dev/null +++ b/extensions/feishu/src/subagent-hooks.test.ts @@ -0,0 +1,623 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerFeishuSubagentHooks } from "./subagent-hooks.js"; +import { + __testing as threadBindingTesting, + createFeishuThreadBindingManager, +} from "./thread-bindings.js"; + +const baseConfig = { + session: { mainKey: "main", scope: "per-sender" }, + channels: { feishu: {} }, +}; + +function registerHandlersForTest(config: Record = baseConfig) { + const handlers = new Map unknown>(); + const api = { + config, + on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => { + handlers.set(hookName, handler); + }, + } as unknown as OpenClawPluginApi; + registerFeishuSubagentHooks(api); + return handlers; +} + +function getRequiredHandler( + handlers: Map unknown>, + hookName: string, +): (event: unknown, ctx: unknown) => unknown { + const handler = handlers.get(hookName); + if (!handler) { + throw new Error(`expected ${hookName} hook handler`); + } + return handler; +} + +describe("feishu subagent hook handlers", () => { + beforeEach(() => { + threadBindingTesting.resetFeishuThreadBindingsForTests(); + }); + + it("registers Feishu subagent hooks", () => { + const handlers = registerHandlersForTest(); + expect(handlers.has("subagent_spawning")).toBe(true); + expect(handlers.has("subagent_delivery_target")).toBe(true); + expect(handlers.has("subagent_ended")).toBe(true); + expect(handlers.has("subagent_spawned")).toBe(false); + }); + + it("binds a Feishu DM conversation on subagent_spawning", async () => { + const handlers = registerHandlersForTest(); + const handler = getRequiredHandler(handlers, "subagent_spawning"); + createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + const result = await handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "codex", + label: "banana", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + threadRequested: true, + }, + {}, + ); + + expect(result).toEqual({ status: "ok", threadBindingReady: true }); + + const deliveryTargetHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + expect( + deliveryTargetHandler( + { + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toEqual({ + origin: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + }); + }); + + it("preserves the original Feishu DM delivery target", async () => { + const handlers = registerHandlersForTest(); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + manager.bindConversation({ + conversationId: "ou_sender_1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:chat-dm-child", + metadata: { + deliveryTo: "chat:oc_dm_chat_1", + boundBy: "system", + }, + }); + + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:chat-dm-child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_dm_chat_1", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toEqual({ + origin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_dm_chat_1", + }, + }); + }); + + it("binds a Feishu topic conversation and preserves parent context", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + const result = await spawnHandler( + { + childSessionKey: "agent:main:subagent:topic-child", + agentId: "codex", + label: "topic-child", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + threadRequested: true, + }, + {}, + ); + + expect(result).toEqual({ status: "ok", threadBindingReady: true }); + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:topic-child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toEqual({ + origin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + }); + }); + + it("uses the requester session binding to preserve sender-scoped topic conversations", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + manager.bindConversation({ + conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1", + parentConversationId: "oc_group_chat", + targetKind: "subagent", + targetSessionKey: "agent:main:parent", + metadata: { + agentId: "codex", + label: "parent", + boundBy: "system", + }, + }); + + const reboundResult = await spawnHandler( + { + childSessionKey: "agent:main:subagent:sender-child", + agentId: "codex", + label: "sender-child", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + threadRequested: true, + }, + { + requesterSessionKey: "agent:main:parent", + }, + ); + + expect(reboundResult).toEqual({ status: "ok", threadBindingReady: true }); + expect(manager.listBySessionKey("agent:main:subagent:sender-child")).toMatchObject([ + { + conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1", + parentConversationId: "oc_group_chat", + }, + ]); + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:sender-child", + requesterSessionKey: "agent:main:parent", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toEqual({ + origin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + }); + }); + + it("prefers requester-matching bindings when multiple child bindings exist", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + await spawnHandler( + { + childSessionKey: "agent:main:subagent:shared", + agentId: "codex", + label: "shared", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + threadRequested: true, + }, + {}, + ); + await spawnHandler( + { + childSessionKey: "agent:main:subagent:shared", + agentId: "codex", + label: "shared", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_2", + }, + threadRequested: true, + }, + {}, + ); + + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:shared", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_2", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toEqual({ + origin: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_2", + }, + }); + }); + + it("fails closed when requester-session bindings remain ambiguous for the same topic", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + manager.bindConversation({ + conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1", + parentConversationId: "oc_group_chat", + targetKind: "subagent", + targetSessionKey: "agent:main:parent", + metadata: { boundBy: "system" }, + }); + manager.bindConversation({ + conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_2", + parentConversationId: "oc_group_chat", + targetKind: "subagent", + targetSessionKey: "agent:main:parent", + metadata: { boundBy: "system" }, + }); + + await expect( + spawnHandler( + { + childSessionKey: "agent:main:subagent:ambiguous-child", + agentId: "codex", + label: "ambiguous-child", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + threadRequested: true, + }, + { + requesterSessionKey: "agent:main:parent", + }, + ), + ).resolves.toMatchObject({ + status: "error", + error: expect.stringContaining("direct messages or topic conversations"), + }); + + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:ambiguous-child", + requesterSessionKey: "agent:main:parent", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toBeUndefined(); + }); + + it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + manager.bindConversation({ + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + targetKind: "subagent", + targetSessionKey: "agent:main:parent", + metadata: { boundBy: "system" }, + }); + manager.bindConversation({ + conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1", + parentConversationId: "oc_group_chat", + targetKind: "subagent", + targetSessionKey: "agent:main:parent", + metadata: { boundBy: "system" }, + }); + + await expect( + spawnHandler( + { + childSessionKey: "agent:main:subagent:mixed-topic-child", + agentId: "codex", + label: "mixed-topic-child", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + threadRequested: true, + }, + { + requesterSessionKey: "agent:main:parent", + }, + ), + ).resolves.toMatchObject({ + status: "error", + error: expect.stringContaining("direct messages or topic conversations"), + }); + + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:mixed-topic-child", + requesterSessionKey: "agent:main:parent", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + threadId: "om_topic_root", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toBeUndefined(); + }); + + it("no-ops for non-Feishu channels and non-threaded spawns", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const endedHandler = getRequiredHandler(handlers, "subagent_ended"); + + await expect( + spawnHandler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "codex", + mode: "run", + requester: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + threadRequested: true, + }, + {}, + ), + ).resolves.toBeUndefined(); + + await expect( + spawnHandler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "codex", + mode: "run", + requester: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + threadRequested: false, + }, + {}, + ), + ).resolves.toBeUndefined(); + + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "discord", + accountId: "work", + to: "channel:123", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toBeUndefined(); + + expect( + endedHandler( + { + targetSessionKey: "agent:main:subagent:child", + targetKind: "subagent", + reason: "done", + accountId: "work", + }, + {}, + ), + ).toBeUndefined(); + }); + + it("returns an error for unsupported non-topic Feishu group conversations", async () => { + const handler = getRequiredHandler(registerHandlersForTest(), "subagent_spawning"); + createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + await expect( + handler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "codex", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "chat:oc_group_chat", + }, + threadRequested: true, + }, + {}, + ), + ).resolves.toMatchObject({ + status: "error", + error: expect.stringContaining("direct messages or topic conversations"), + }); + }); + + it("unbinds Feishu bindings on subagent_ended", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + const endedHandler = getRequiredHandler(handlers, "subagent_ended"); + createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" }); + + await spawnHandler( + { + childSessionKey: "agent:main:subagent:child", + agentId: "codex", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + threadRequested: true, + }, + {}, + ); + + endedHandler( + { + targetSessionKey: "agent:main:subagent:child", + targetKind: "subagent", + reason: "done", + accountId: "work", + }, + {}, + ); + + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toBeUndefined(); + }); + + it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => { + const handlers = registerHandlersForTest(); + const spawnHandler = getRequiredHandler(handlers, "subagent_spawning"); + const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target"); + + await expect( + spawnHandler( + { + childSessionKey: "agent:main:subagent:no-manager", + agentId: "codex", + mode: "session", + requester: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + threadRequested: true, + }, + {}, + ), + ).resolves.toMatchObject({ + status: "error", + error: expect.stringContaining("monitor is not active"), + }); + + expect( + deliveryHandler( + { + childSessionKey: "agent:main:subagent:no-manager", + requesterSessionKey: "agent:main:main", + requesterOrigin: { + channel: "feishu", + accountId: "work", + to: "user:ou_sender_1", + }, + expectsCompletionMessage: true, + }, + {}, + ), + ).toBeUndefined(); + }); +}); diff --git a/extensions/feishu/src/subagent-hooks.ts b/extensions/feishu/src/subagent-hooks.ts new file mode 100644 index 00000000000..6b048f8fbcf --- /dev/null +++ b/extensions/feishu/src/subagent-hooks.ts @@ -0,0 +1,341 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu"; +import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js"; +import { normalizeFeishuTarget } from "./targets.js"; +import { getFeishuThreadBindingManager } from "./thread-bindings.js"; + +function summarizeError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + return "error"; +} + +function stripProviderPrefix(raw: string): string { + return raw.replace(/^(feishu|lark):/i, "").trim(); +} + +function resolveFeishuRequesterConversation(params: { + accountId?: string; + to?: string; + threadId?: string | number; + requesterSessionKey?: string; +}): { + accountId: string; + conversationId: string; + parentConversationId?: string; +} | null { + const manager = getFeishuThreadBindingManager(params.accountId); + if (!manager) { + return null; + } + const rawTo = params.to?.trim(); + const withoutProviderPrefix = rawTo ? stripProviderPrefix(rawTo) : ""; + const normalizedTarget = rawTo ? normalizeFeishuTarget(rawTo) : null; + const threadId = + params.threadId != null && params.threadId !== "" ? String(params.threadId).trim() : ""; + const isChatTarget = /^(chat|group|channel):/i.test(withoutProviderPrefix); + const parsedRequesterTopic = + normalizedTarget && threadId && isChatTarget + ? parseFeishuConversationId({ + conversationId: buildFeishuConversationId({ + chatId: normalizedTarget, + scope: "group_topic", + topicId: threadId, + }), + parentConversationId: normalizedTarget, + }) + : null; + const requesterSessionKey = params.requesterSessionKey?.trim(); + if (requesterSessionKey) { + const existingBindings = manager.listBySessionKey(requesterSessionKey); + if (existingBindings.length === 1) { + const existing = existingBindings[0]; + return { + accountId: existing.accountId, + conversationId: existing.conversationId, + parentConversationId: existing.parentConversationId, + }; + } + if (existingBindings.length > 1) { + if (rawTo && normalizedTarget && !threadId && !isChatTarget) { + const directMatches = existingBindings.filter( + (entry) => + entry.accountId === manager.accountId && + entry.conversationId === normalizedTarget && + !entry.parentConversationId, + ); + if (directMatches.length === 1) { + const existing = directMatches[0]; + return { + accountId: existing.accountId, + conversationId: existing.conversationId, + parentConversationId: existing.parentConversationId, + }; + } + return null; + } + if (parsedRequesterTopic) { + const matchingTopicBindings = existingBindings.filter((entry) => { + const parsed = parseFeishuConversationId({ + conversationId: entry.conversationId, + parentConversationId: entry.parentConversationId, + }); + return ( + parsed?.chatId === parsedRequesterTopic.chatId && + parsed?.topicId === parsedRequesterTopic.topicId + ); + }); + if (matchingTopicBindings.length === 1) { + const existing = matchingTopicBindings[0]; + return { + accountId: existing.accountId, + conversationId: existing.conversationId, + parentConversationId: existing.parentConversationId, + }; + } + const senderScopedTopicBindings = matchingTopicBindings.filter((entry) => { + const parsed = parseFeishuConversationId({ + conversationId: entry.conversationId, + parentConversationId: entry.parentConversationId, + }); + return parsed?.scope === "group_topic_sender"; + }); + if ( + senderScopedTopicBindings.length === 1 && + matchingTopicBindings.length === senderScopedTopicBindings.length + ) { + const existing = senderScopedTopicBindings[0]; + return { + accountId: existing.accountId, + conversationId: existing.conversationId, + parentConversationId: existing.parentConversationId, + }; + } + return null; + } + } + } + + if (!rawTo) { + return null; + } + if (!normalizedTarget) { + return null; + } + + if (threadId) { + if (!isChatTarget) { + return null; + } + return { + accountId: manager.accountId, + conversationId: buildFeishuConversationId({ + chatId: normalizedTarget, + scope: "group_topic", + topicId: threadId, + }), + parentConversationId: normalizedTarget, + }; + } + + if (isChatTarget) { + return null; + } + + return { + accountId: manager.accountId, + conversationId: normalizedTarget, + }; +} + +function resolveFeishuDeliveryOrigin(params: { + conversationId: string; + parentConversationId?: string; + accountId: string; + deliveryTo?: string; + deliveryThreadId?: string; +}): { + channel: "feishu"; + accountId: string; + to: string; + threadId?: string; +} { + const deliveryTo = params.deliveryTo?.trim(); + const deliveryThreadId = params.deliveryThreadId?.trim(); + if (deliveryTo) { + return { + channel: "feishu", + accountId: params.accountId, + to: deliveryTo, + ...(deliveryThreadId ? { threadId: deliveryThreadId } : {}), + }; + } + const parsed = parseFeishuConversationId({ + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if (parsed?.topicId) { + return { + channel: "feishu", + accountId: params.accountId, + to: `chat:${params.parentConversationId?.trim() || parsed.chatId}`, + threadId: parsed.topicId, + }; + } + return { + channel: "feishu", + accountId: params.accountId, + to: `user:${params.conversationId}`, + }; +} + +function resolveMatchingChildBinding(params: { + accountId?: string; + childSessionKey: string; + requesterSessionKey?: string; + requesterOrigin?: { + to?: string; + threadId?: string | number; + }; +}) { + const manager = getFeishuThreadBindingManager(params.accountId); + if (!manager) { + return null; + } + const childBindings = manager.listBySessionKey(params.childSessionKey.trim()); + if (childBindings.length === 0) { + return null; + } + + const requesterConversation = resolveFeishuRequesterConversation({ + accountId: manager.accountId, + to: params.requesterOrigin?.to, + threadId: params.requesterOrigin?.threadId, + requesterSessionKey: params.requesterSessionKey, + }); + if (requesterConversation) { + const matched = childBindings.find( + (entry) => + entry.accountId === requesterConversation.accountId && + entry.conversationId === requesterConversation.conversationId && + (entry.parentConversationId?.trim() || undefined) === + (requesterConversation.parentConversationId?.trim() || undefined), + ); + if (matched) { + return matched; + } + } + + return childBindings.length === 1 ? childBindings[0] : null; +} + +export function registerFeishuSubagentHooks(api: OpenClawPluginApi) { + api.on("subagent_spawning", async (event, ctx) => { + if (!event.threadRequested) { + return; + } + const requesterChannel = event.requester?.channel?.trim().toLowerCase(); + if (requesterChannel !== "feishu") { + return; + } + + const manager = getFeishuThreadBindingManager(event.requester?.accountId); + if (!manager) { + return { + status: "error" as const, + error: + "Feishu current-conversation binding is unavailable because the Feishu account monitor is not active.", + }; + } + + const conversation = resolveFeishuRequesterConversation({ + accountId: event.requester?.accountId, + to: event.requester?.to, + threadId: event.requester?.threadId, + requesterSessionKey: ctx.requesterSessionKey, + }); + if (!conversation) { + return { + status: "error" as const, + error: + "Feishu current-conversation binding is only available in direct messages or topic conversations.", + }; + } + + try { + const binding = manager.bindConversation({ + conversationId: conversation.conversationId, + parentConversationId: conversation.parentConversationId, + targetKind: "subagent", + targetSessionKey: event.childSessionKey, + metadata: { + agentId: event.agentId, + label: event.label, + boundBy: "system", + deliveryTo: event.requester?.to, + deliveryThreadId: + event.requester?.threadId != null && event.requester.threadId !== "" + ? String(event.requester.threadId) + : undefined, + }, + }); + if (!binding) { + return { + status: "error" as const, + error: + "Unable to bind this Feishu conversation to the spawned subagent session. Session mode is unavailable for this target.", + }; + } + return { + status: "ok" as const, + threadBindingReady: true, + }; + } catch (err) { + return { + status: "error" as const, + error: `Feishu conversation bind failed: ${summarizeError(err)}`, + }; + } + }); + + api.on("subagent_delivery_target", (event) => { + if (!event.expectsCompletionMessage) { + return; + } + const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase(); + if (requesterChannel !== "feishu") { + return; + } + + const binding = resolveMatchingChildBinding({ + accountId: event.requesterOrigin?.accountId, + childSessionKey: event.childSessionKey, + requesterSessionKey: event.requesterSessionKey, + requesterOrigin: { + to: event.requesterOrigin?.to, + threadId: event.requesterOrigin?.threadId, + }, + }); + if (!binding) { + return; + } + + return { + origin: resolveFeishuDeliveryOrigin({ + conversationId: binding.conversationId, + parentConversationId: binding.parentConversationId, + accountId: binding.accountId, + deliveryTo: binding.deliveryTo, + deliveryThreadId: binding.deliveryThreadId, + }), + }; + }); + + api.on("subagent_ended", (event) => { + const manager = getFeishuThreadBindingManager(event.accountId); + manager?.unbindBySessionKey(event.targetSessionKey); + }); +} diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts index 88cb955fc08..b2ab72467c3 100644 --- a/extensions/feishu/src/thread-bindings.ts +++ b/extensions/feishu/src/thread-bindings.ts @@ -22,6 +22,8 @@ type FeishuThreadBindingRecord = { accountId: string; conversationId: string; parentConversationId?: string; + deliveryTo?: string; + deliveryThreadId?: string; targetKind: FeishuBindingTargetKind; targetSessionKey: string; agentId?: string; @@ -108,6 +110,8 @@ function toSessionBindingRecord( agentId: record.agentId, label: record.label, boundBy: record.boundBy, + deliveryTo: record.deliveryTo, + deliveryThreadId: record.deliveryThreadId, lastActivityAt: record.lastActivityAt, idleTimeoutMs: defaults.idleTimeoutMs, maxAgeMs: defaults.maxAgeMs, @@ -160,6 +164,14 @@ export function createFeishuThreadBindingManager(params: { accountId, conversationId: normalizedConversationId, parentConversationId: parentConversationId?.trim() || undefined, + deliveryTo: + typeof metadata?.deliveryTo === "string" && metadata.deliveryTo.trim() + ? metadata.deliveryTo.trim() + : undefined, + deliveryThreadId: + typeof metadata?.deliveryThreadId === "string" && metadata.deliveryThreadId.trim() + ? metadata.deliveryThreadId.trim() + : undefined, targetKind: toFeishuTargetKind(targetKind), targetSessionKey: targetSessionKey.trim(), agentId: diff --git a/src/config/config.acp-binding-cutover.test.ts b/src/config/config.acp-binding-cutover.test.ts index fbcf8054730..040e5f1c502 100644 --- a/src/config/config.acp-binding-cutover.test.ts +++ b/src/config/config.acp-binding-cutover.test.ts @@ -190,6 +190,24 @@ describe("ACP binding cutover schema", () => { expect(parsed.success).toBe(false); }); + it("rejects Feishu ACP DM peer IDs keyed by union id", () => { + const parsed = OpenClawSchema.safeParse({ + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "direct", id: "on_union_user_123" }, + }, + }, + ], + }); + + expect(parsed.success).toBe(false); + }); + it("rejects bare Feishu group chat ACP peer IDs", () => { const parsed = OpenClawSchema.safeParse({ bindings: [ diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 66bd586fa04..80246610b5b 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -90,13 +90,13 @@ const AcpBindingSchema = z } if ( channel === "feishu" && - !/^(ou_[^:]+|on_[^:]+|oc_[^:]+:topic:[^:]+(?::sender:[^:]+)?)$/.test(peerId) + !/^(ou_[^:]+|oc_[^:]+:topic:[^:]+(?::sender:[^:]+)?)$/.test(peerId) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["match", "peer", "id"], message: - "Feishu ACP bindings require canonical DM IDs (ou_xxx/on_xxx) or topic IDs in the form oc_group:topic:om_root[:sender:ou_xxx].", + "Feishu ACP bindings require canonical DM IDs (ou_xxx) or topic IDs in the form oc_group:topic:om_root[:sender:ou_xxx].", }); } });