diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 467fc57c0fe..2fc16aed5d4 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -532,6 +532,75 @@ Feishu supports streaming replies via interactive cards. When enabled, the bot u Set `streaming: false` to wait for the full reply before sending. +### ACP sessions + +Feishu supports ACP for: + +- DMs +- group topic conversations + +Feishu ACP is text-command driven. There are no native slash-command menus, so use `/acp ...` messages directly in the conversation. + +#### Persistent ACP bindings + +Use top-level typed ACP bindings to pin a Feishu DM or topic conversation to a persistent ACP session. + +```json5 +{ + agents: { + list: [ + { + id: "codex", + runtime: { + type: "acp", + acp: { + agent: "codex", + backend: "acpx", + mode: "persistent", + cwd: "/workspace/openclaw", + }, + }, + }, + ], + }, + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "direct", id: "ou_1234567890" }, + }, + }, + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root" }, + }, + acp: { label: "codex-feishu-topic" }, + }, + ], +} +``` + +#### Thread-bound ACP spawn from chat + +In a Feishu DM or topic conversation, you can spawn and bind an ACP session in place: + +```text +/acp spawn codex --thread here +``` + +Notes: + +- `--thread here` works for DMs and Feishu topics. +- Follow-up messages in the bound DM/topic route directly to that ACP session. +- v1 does not target generic non-topic group chats. + ### Multi-agent routing Use `bindings` to route Feishu DMs or groups to different agents. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 4e0dd9d4fed..3e14bcdadd5 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -21,6 +21,10 @@ const { mockResolveAgentRoute, mockReadSessionUpdatedAt, mockResolveStorePath, + mockResolveConfiguredAcpRoute, + mockEnsureConfiguredAcpRouteReady, + mockResolveBoundConversation, + mockTouchBinding, } = vi.hoisted(() => ({ mockCreateFeishuReplyDispatcher: vi.fn(() => ({ dispatcher: vi.fn(), @@ -46,6 +50,13 @@ const { })), mockReadSessionUpdatedAt: vi.fn(), mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"), + mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({ + configuredBinding: null, + route, + })), + mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })), + mockResolveBoundConversation: vi.fn(() => null), + mockTouchBinding: vi.fn(), })); vi.mock("./reply-dispatcher.js", () => ({ @@ -66,6 +77,18 @@ vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, })); +vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({ + resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params), + ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params), +})); + +vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ + getSessionBindingService: () => ({ + resolveByConversation: mockResolveBoundConversation, + touch: mockTouchBinding, + }), +})); + function createRuntimeEnv(): RuntimeEnv { return { log: vi.fn(), @@ -110,6 +133,261 @@ describe("buildFeishuAgentBody", () => { }); }); +describe("handleFeishuMessage ACP routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockResolveConfiguredAcpRoute.mockReset().mockImplementation( + ({ route }) => + ({ + configuredBinding: null, + route, + }) as any, + ); + mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true }); + mockResolveBoundConversation.mockReset().mockReturnValue(null); + mockTouchBinding.mockReset(); + mockResolveAgentRoute.mockReset().mockReturnValue({ + agentId: "main", + channel: "feishu", + accountId: "default", + sessionKey: "agent:main:feishu:direct:ou_sender_1", + mainSessionKey: "agent:main:main", + matchedBy: "default", + }); + mockSendMessageFeishu + .mockReset() + .mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" }); + mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({ + dispatcher: { + sendToolResult: vi.fn(), + sendBlockReply: vi.fn(), + sendFinalReply: vi.fn(), + waitForIdle: vi.fn(), + getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), + markComplete: vi.fn(), + } as any, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }); + + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + routing: { + resolveAgentRoute: + mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + }, + session: { + readSessionUpdatedAt: + mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], + resolveStorePath: + mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn( + () => ({}), + ) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], + formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), + finalizeInboundContext: ((ctx: unknown) => + ctx) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + dispatchReplyFromConfig: vi.fn().mockResolvedValue({ + queuedFinal: false, + counts: { final: 1 }, + }), + withReplyDispatcher: vi.fn( + async ({ + run, + }: Parameters[0]) => + await run(), + ) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"], + }, + commands: { + shouldComputeCommandAuthorized: vi.fn(() => false), + resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false), + }, + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]), + upsertPairingRequest: vi.fn(), + buildPairingReply: vi.fn(), + }, + }, + }), + ); + }); + + it("ensures configured ACP routes for Feishu DMs", async () => { + mockResolveConfiguredAcpRoute.mockReturnValue({ + configuredBinding: { + spec: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:feishu:default:ou_sender_1", + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + }, + status: "active", + boundAt: 0, + metadata: { source: "config" }, + }, + }, + route: { + agentId: "codex", + channel: "feishu", + accountId: "default", + sessionKey: "agent:codex:acp:binding:feishu:default:abc123", + mainSessionKey: "agent:codex:main", + matchedBy: "binding.channel", + }, + } as any); + + await dispatchMessage({ + cfg: { + session: { mainKey: "main", scope: "per-sender" }, + channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } }, + }, + event: { + sender: { sender_id: { open_id: "ou_sender_1" } }, + message: { + message_id: "msg-1", + chat_id: "oc_dm", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }, + }); + + expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1); + expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1); + }); + + it("surfaces configured ACP initialization failures to the Feishu conversation", async () => { + mockResolveConfiguredAcpRoute.mockReturnValue({ + configuredBinding: { + spec: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:feishu:default:ou_sender_1", + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "ou_sender_1", + }, + status: "active", + boundAt: 0, + metadata: { source: "config" }, + }, + }, + route: { + agentId: "codex", + channel: "feishu", + accountId: "default", + sessionKey: "agent:codex:acp:binding:feishu:default:abc123", + mainSessionKey: "agent:codex:main", + matchedBy: "binding.channel", + }, + } as any); + mockEnsureConfiguredAcpRouteReady.mockResolvedValue({ + ok: false, + error: "runtime unavailable", + } as any); + + await dispatchMessage({ + cfg: { + session: { mainKey: "main", scope: "per-sender" }, + channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } }, + }, + event: { + sender: { sender_id: { open_id: "ou_sender_1" } }, + message: { + message_id: "msg-2", + chat_id: "oc_dm", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }, + }); + + expect(mockSendMessageFeishu).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:oc_dm", + text: expect.stringContaining("runtime unavailable"), + }), + ); + }); + + it("routes Feishu topic messages through active bound conversations", async () => { + mockResolveBoundConversation.mockReturnValue({ + bindingId: "default:oc_group_chat:topic:om_topic_root", + targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }, + status: "active", + boundAt: 0, + } as any); + + await dispatchMessage({ + cfg: { + session: { mainKey: "main", scope: "per-sender" }, + channels: { + feishu: { + enabled: true, + allowFrom: ["ou_sender_1"], + groups: { + oc_group_chat: { + allow: true, + requireMention: false, + groupSessionScope: "group_topic", + }, + }, + }, + }, + }, + event: { + sender: { sender_id: { open_id: "ou_sender_1" } }, + message: { + message_id: "msg-3", + chat_id: "oc_group_chat", + chat_type: "group", + message_type: "text", + root_id: "om_topic_root", + content: JSON.stringify({ text: "hello topic" }), + }, + }, + }); + + expect(mockResolveBoundConversation).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "feishu", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + ); + expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root"); + }); +}); + describe("handleFeishuMessage command authorization", () => { const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); const mockDispatchReplyFromConfig = vi @@ -153,6 +431,16 @@ describe("handleFeishuMessage command authorization", () => { mockListFeishuThreadMessages.mockReset().mockResolvedValue([]); mockReadSessionUpdatedAt.mockReturnValue(undefined); mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json"); + mockResolveConfiguredAcpRoute.mockReset().mockImplementation( + ({ route }) => + ({ + configuredBinding: null, + route, + }) as any, + ); + mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true }); + mockResolveBoundConversation.mockReset().mockReturnValue(null); + mockTouchBinding.mockReset(); mockResolveAgentRoute.mockReturnValue({ agentId: "main", channel: "feishu", diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index dc8326b1dba..a55872599c8 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -14,8 +14,16 @@ import { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/feishu"; +import { + ensureConfiguredAcpRouteReady, + resolveConfiguredAcpRoute, +} from "../../../src/acp/persistent-bindings.route.js"; +import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; +import { deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js"; +import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { buildFeishuConversationId } from "./conversation-id.js"; import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js"; import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; @@ -273,15 +281,34 @@ function resolveFeishuGroupSession(params: { let peerId = chatId; switch (groupSessionScope) { case "group_sender": - peerId = `${chatId}:sender:${senderOpenId}`; + peerId = buildFeishuConversationId({ + chatId, + scope: "group_sender", + senderOpenId, + }); break; case "group_topic": - peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId; + peerId = topicScope + ? buildFeishuConversationId({ + chatId, + scope: "group_topic", + topicId: topicScope, + }) + : chatId; break; case "group_topic_sender": peerId = topicScope - ? `${chatId}:topic:${topicScope}:sender:${senderOpenId}` - : `${chatId}:sender:${senderOpenId}`; + ? buildFeishuConversationId({ + chatId, + scope: "group_topic_sender", + topicId: topicScope, + senderOpenId, + }) + : buildFeishuConversationId({ + chatId, + scope: "group_sender", + senderOpenId, + }); break; case "group": default: @@ -1168,6 +1195,10 @@ export async function handleFeishuMessage(params: { const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId; const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null; const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false; + const feishuAcpConversationSupported = + !isGroup || + groupSession?.groupSessionScope === "group_topic" || + groupSession?.groupSessionScope === "group_topic_sender"; if (isGroup && groupSession) { log( @@ -1216,6 +1247,73 @@ export async function handleFeishuMessage(params: { } } + const currentConversationId = peerId; + const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined; + let configuredBinding = null; + if (feishuAcpConversationSupported) { + const configuredRoute = resolveConfiguredAcpRoute({ + cfg: effectiveCfg, + route, + channel: "feishu", + accountId: account.accountId, + conversationId: currentConversationId, + parentConversationId, + }); + configuredBinding = configuredRoute.configuredBinding; + route = configuredRoute.route; + + const threadBinding = getSessionBindingService().resolveByConversation({ + channel: "feishu", + accountId: account.accountId, + conversationId: currentConversationId, + ...(parentConversationId ? { parentConversationId } : {}), + }); + const boundSessionKey = threadBinding?.targetSessionKey?.trim(); + if (threadBinding && boundSessionKey) { + route = { + ...route, + sessionKey: boundSessionKey, + agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId, + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: boundSessionKey, + mainSessionKey: route.mainSessionKey, + }), + matchedBy: "binding.channel", + }; + configuredBinding = null; + getSessionBindingService().touch(threadBinding.bindingId); + log( + `feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${boundSessionKey}`, + ); + } + } + + if (configuredBinding) { + const ensured = await ensureConfiguredAcpRouteReady({ + cfg: effectiveCfg, + configuredBinding, + }); + if (!ensured.ok) { + const replyTargetMessageId = + isGroup && + (groupSession?.groupSessionScope === "group_topic" || + groupSession?.groupSessionScope === "group_topic_sender") + ? (ctx.rootId ?? ctx.messageId) + : ctx.messageId; + await sendMessageFeishu({ + cfg: effectiveCfg, + to: `chat:${ctx.chatId}`, + text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`, + replyToMessageId: replyTargetMessageId, + replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false, + accountId: account.accountId, + }).catch((err) => { + log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`); + }); + return; + } + } + const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160); const inboundLabel = isGroup ? `Feishu[${account.accountId}] message in group ${ctx.chatId}` diff --git a/extensions/feishu/src/conversation-id.ts b/extensions/feishu/src/conversation-id.ts new file mode 100644 index 00000000000..39cb8cc74b6 --- /dev/null +++ b/extensions/feishu/src/conversation-id.ts @@ -0,0 +1,125 @@ +export type FeishuGroupSessionScope = + | "group" + | "group_sender" + | "group_topic" + | "group_topic_sender"; + +function normalizeText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +export function buildFeishuConversationId(params: { + chatId: string; + scope: FeishuGroupSessionScope; + senderOpenId?: string; + topicId?: string; +}): string { + const chatId = normalizeText(params.chatId) ?? "unknown"; + const senderOpenId = normalizeText(params.senderOpenId); + const topicId = normalizeText(params.topicId); + + switch (params.scope) { + case "group_sender": + return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId; + case "group_topic": + return topicId ? `${chatId}:topic:${topicId}` : chatId; + case "group_topic_sender": + if (topicId && senderOpenId) { + return `${chatId}:topic:${topicId}:sender:${senderOpenId}`; + } + if (topicId) { + return `${chatId}:topic:${topicId}`; + } + return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId; + case "group": + default: + return chatId; + } +} + +export function parseFeishuConversationId(params: { + conversationId: string; + parentConversationId?: string; +}): { + canonicalConversationId: string; + chatId: string; + topicId?: string; + senderOpenId?: string; + scope: FeishuGroupSessionScope; +} | null { + const conversationId = normalizeText(params.conversationId); + const parentConversationId = normalizeText(params.parentConversationId); + if (!conversationId) { + return null; + } + + const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/); + if (topicSenderMatch) { + const [, chatId, topicId, senderOpenId] = topicSenderMatch; + return { + canonicalConversationId: buildFeishuConversationId({ + chatId, + scope: "group_topic_sender", + topicId, + senderOpenId, + }), + chatId, + topicId, + senderOpenId, + scope: "group_topic_sender", + }; + } + + const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/); + if (topicMatch) { + const [, chatId, topicId] = topicMatch; + return { + canonicalConversationId: buildFeishuConversationId({ + chatId, + scope: "group_topic", + topicId, + }), + chatId, + topicId, + scope: "group_topic", + }; + } + + const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/); + if (senderMatch) { + const [, chatId, senderOpenId] = senderMatch; + return { + canonicalConversationId: buildFeishuConversationId({ + chatId, + scope: "group_sender", + senderOpenId, + }), + chatId, + senderOpenId, + scope: "group_sender", + }; + } + + if (parentConversationId) { + return { + canonicalConversationId: buildFeishuConversationId({ + chatId: parentConversationId, + scope: "group_topic", + topicId: conversationId, + }), + chatId: parentConversationId, + topicId: conversationId, + scope: "group_topic", + }; + } + + return { + canonicalConversationId: conversationId, + chatId: conversationId, + scope: "group", + }; +} diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 6bc990a8d1e..3d761631399 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -24,6 +24,7 @@ import { botNames, botOpenIds } from "./monitor.state.js"; import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu } from "./send.js"; +import { createFeishuThreadBindingManager } from "./thread-bindings.js"; import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js"; const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500; @@ -631,19 +632,25 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams): log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`); } - const eventDispatcher = createEventDispatcher(account); - const chatHistories = new Map(); + let threadBindingManager: ReturnType | null = null; + try { + const eventDispatcher = createEventDispatcher(account); + const chatHistories = new Map(); + threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg }); - registerEventHandlers(eventDispatcher, { - cfg, - accountId, - runtime, - chatHistories, - fireAndForget: true, - }); + registerEventHandlers(eventDispatcher, { + cfg, + accountId, + runtime, + chatHistories, + fireAndForget: true, + }); - if (connectionMode === "webhook") { - return monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher }); + if (connectionMode === "webhook") { + return await monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher }); + } + return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher }); + } finally { + threadBindingManager?.stop(); } - return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher }); } diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 49da928ea3b..001b8140f80 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -17,6 +17,7 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: const createEventDispatcherMock = vi.hoisted(() => vi.fn()); const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {})); const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {})); +const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() }))); let handlers: Record Promise> = {}; @@ -37,6 +38,10 @@ vi.mock("./monitor.transport.js", () => ({ monitorWebhook: monitorWebhookMock, })); +vi.mock("./thread-bindings.js", () => ({ + createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock, +})); + const cfg = {} as ClawdbotConfig; function makeReactionEvent( @@ -419,6 +424,94 @@ describe("resolveReactionSyntheticEvent", () => { }); }); +describe("monitorSingleAccount lifecycle", () => { + beforeEach(() => { + createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({ + stop: vi.fn(), + })); + createEventDispatcherMock.mockReset().mockReturnValue({ + register: vi.fn(), + }); + }); + + it("stops the Feishu thread binding manager when the monitor exits", async () => { + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: { + resolveInboundDebounceMs, + createInboundDebouncer, + }, + text: { + hasControlCommand, + }, + }, + }), + ); + + await monitorSingleAccount({ + cfg: buildDebounceConfig(), + account: buildDebounceAccount(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as RuntimeEnv, + botOpenIdSource: { + kind: "prefetched", + botOpenId: "ou_bot", + }, + }); + + const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as + | { stop: ReturnType } + | undefined; + expect(manager?.stop).toHaveBeenCalledTimes(1); + }); + + it("stops the Feishu thread binding manager when setup fails before transport starts", async () => { + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: { + resolveInboundDebounceMs, + createInboundDebouncer, + }, + text: { + hasControlCommand, + }, + }, + }), + ); + createEventDispatcherMock.mockReturnValue({ + get register() { + throw new Error("register failed"); + }, + }); + + await expect( + monitorSingleAccount({ + cfg: buildDebounceConfig(), + account: buildDebounceAccount(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as RuntimeEnv, + botOpenIdSource: { + kind: "prefetched", + botOpenId: "ou_bot", + }, + }), + ).rejects.toThrow("register failed"); + + const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as + | { stop: ReturnType } + | undefined; + expect(manager?.stop).toHaveBeenCalledTimes(1); + }); +}); + describe("Feishu inbound debounce regressions", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/extensions/feishu/src/thread-bindings.test.ts b/extensions/feishu/src/thread-bindings.test.ts new file mode 100644 index 00000000000..a118926df57 --- /dev/null +++ b/extensions/feishu/src/thread-bindings.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; +import { __testing, createFeishuThreadBindingManager } from "./thread-bindings.js"; + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +describe("Feishu thread bindings", () => { + beforeEach(() => { + __testing.resetFeishuThreadBindingsForTests(); + }); + + it("registers current-placement adapter capabilities for Feishu", () => { + createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + + expect( + getSessionBindingService().getCapabilities({ + channel: "feishu", + accountId: "default", + }), + ).toEqual({ + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }); + }); + + it("binds and resolves a Feishu topic conversation", async () => { + createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + + const binding = await getSessionBindingService().bind({ + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }, + placement: "current", + metadata: { + agentId: "codex", + label: "codex-main", + }, + }); + + expect(binding.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root"); + expect( + getSessionBindingService().resolveByConversation({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + )?.toMatchObject({ + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + metadata: expect.objectContaining({ + agentId: "codex", + label: "codex-main", + }), + }); + }); + + it("clears account-scoped bindings when the manager stops", async () => { + const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123", + targetKind: "session", + conversation: { + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }, + placement: "current", + metadata: { + agentId: "codex", + }, + }); + + manager.stop(); + + expect( + getSessionBindingService().resolveByConversation({ + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + ).toBeNull(); + }); +}); diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts new file mode 100644 index 00000000000..88cb955fc08 --- /dev/null +++ b/extensions/feishu/src/thread-bindings.ts @@ -0,0 +1,304 @@ +import { resolveThreadBindingConversationIdFromBindingId } from "../../../src/channels/thread-binding-id.js"; +import { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, +} from "../../../src/channels/thread-bindings-policy.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + registerSessionBindingAdapter, + unregisterSessionBindingAdapter, + type BindingTargetKind, + type SessionBindingRecord, +} from "../../../src/infra/outbound/session-binding-service.js"; +import { + normalizeAccountId, + resolveAgentIdFromSessionKey, +} from "../../../src/routing/session-key.js"; +import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; + +type FeishuBindingTargetKind = "subagent" | "acp"; + +type FeishuThreadBindingRecord = { + accountId: string; + conversationId: string; + parentConversationId?: string; + targetKind: FeishuBindingTargetKind; + targetSessionKey: string; + agentId?: string; + label?: string; + boundBy?: string; + boundAt: number; + lastActivityAt: number; +}; + +type FeishuThreadBindingManager = { + accountId: string; + getByConversationId: (conversationId: string) => FeishuThreadBindingRecord | undefined; + listBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[]; + bindConversation: (params: { + conversationId: string; + parentConversationId?: string; + targetKind: BindingTargetKind; + targetSessionKey: string; + metadata?: Record; + }) => FeishuThreadBindingRecord | null; + touchConversation: (conversationId: string, at?: number) => FeishuThreadBindingRecord | null; + unbindConversation: (conversationId: string) => FeishuThreadBindingRecord | null; + unbindBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[]; + stop: () => void; +}; + +type FeishuThreadBindingsState = { + managersByAccountId: Map; + bindingsByAccountConversation: Map; +}; + +const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState"); +const state = resolveGlobalSingleton( + FEISHU_THREAD_BINDINGS_STATE_KEY, + () => ({ + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + }), +); + +const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId; +const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation; + +function resolveBindingKey(params: { accountId: string; conversationId: string }): string { + return `${params.accountId}:${params.conversationId}`; +} + +function toSessionBindingTargetKind(raw: FeishuBindingTargetKind): BindingTargetKind { + return raw === "subagent" ? "subagent" : "session"; +} + +function toFeishuTargetKind(raw: BindingTargetKind): FeishuBindingTargetKind { + return raw === "subagent" ? "subagent" : "acp"; +} + +function toSessionBindingRecord( + record: FeishuThreadBindingRecord, + defaults: { idleTimeoutMs: number; maxAgeMs: number }, +): SessionBindingRecord { + const idleExpiresAt = + defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined; + const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined; + const expiresAt = + idleExpiresAt != null && maxAgeExpiresAt != null + ? Math.min(idleExpiresAt, maxAgeExpiresAt) + : (idleExpiresAt ?? maxAgeExpiresAt); + return { + bindingId: resolveBindingKey({ + accountId: record.accountId, + conversationId: record.conversationId, + }), + targetSessionKey: record.targetSessionKey, + targetKind: toSessionBindingTargetKind(record.targetKind), + conversation: { + channel: "feishu", + accountId: record.accountId, + conversationId: record.conversationId, + parentConversationId: record.parentConversationId, + }, + status: "active", + boundAt: record.boundAt, + expiresAt, + metadata: { + agentId: record.agentId, + label: record.label, + boundBy: record.boundBy, + lastActivityAt: record.lastActivityAt, + idleTimeoutMs: defaults.idleTimeoutMs, + maxAgeMs: defaults.maxAgeMs, + }, + }; +} + +export function createFeishuThreadBindingManager(params: { + accountId?: string; + cfg: OpenClawConfig; +}): FeishuThreadBindingManager { + const accountId = normalizeAccountId(params.accountId); + const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); + if (existing) { + return existing; + } + + const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({ + cfg: params.cfg, + channel: "feishu", + accountId, + }); + const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({ + cfg: params.cfg, + channel: "feishu", + accountId, + }); + + const manager: FeishuThreadBindingManager = { + accountId, + getByConversationId: (conversationId) => + BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })), + listBySessionKey: (targetSessionKey) => + [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + (record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey, + ), + bindConversation: ({ + conversationId, + parentConversationId, + targetKind, + targetSessionKey, + metadata, + }) => { + const normalizedConversationId = conversationId.trim(); + if (!normalizedConversationId || !targetSessionKey.trim()) { + return null; + } + const now = Date.now(); + const record: FeishuThreadBindingRecord = { + accountId, + conversationId: normalizedConversationId, + parentConversationId: parentConversationId?.trim() || undefined, + targetKind: toFeishuTargetKind(targetKind), + targetSessionKey: targetSessionKey.trim(), + agentId: + typeof metadata?.agentId === "string" && metadata.agentId.trim() + ? metadata.agentId.trim() + : resolveAgentIdFromSessionKey(targetSessionKey), + label: + typeof metadata?.label === "string" && metadata.label.trim() + ? metadata.label.trim() + : undefined, + boundBy: + typeof metadata?.boundBy === "string" && metadata.boundBy.trim() + ? metadata.boundBy.trim() + : undefined, + boundAt: now, + lastActivityAt: now, + }; + BINDINGS_BY_ACCOUNT_CONVERSATION.set( + resolveBindingKey({ accountId, conversationId: normalizedConversationId }), + record, + ); + return record; + }, + touchConversation: (conversationId, at = Date.now()) => { + const key = resolveBindingKey({ accountId, conversationId }); + const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + if (!existingRecord) { + return null; + } + const updated = { ...existingRecord, lastActivityAt: at }; + BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated); + return updated; + }, + unbindConversation: (conversationId) => { + const key = resolveBindingKey({ accountId, conversationId }); + const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + if (!existingRecord) { + return null; + } + BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + return existingRecord; + }, + unbindBySessionKey: (targetSessionKey) => { + const removed: FeishuThreadBindingRecord[] = []; + for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) { + if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) { + continue; + } + BINDINGS_BY_ACCOUNT_CONVERSATION.delete( + resolveBindingKey({ accountId, conversationId: record.conversationId }), + ); + removed.push(record); + } + return removed; + }, + stop: () => { + for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) { + if (key.startsWith(`${accountId}:`)) { + BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + } + } + MANAGERS_BY_ACCOUNT_ID.delete(accountId); + unregisterSessionBindingAdapter({ channel: "feishu", accountId }); + }, + }; + + registerSessionBindingAdapter({ + channel: "feishu", + accountId, + capabilities: { + placements: ["current"], + }, + bind: async (input) => { + if (input.conversation.channel !== "feishu" || input.placement === "child") { + return null; + } + const bound = manager.bindConversation({ + conversationId: input.conversation.conversationId, + parentConversationId: input.conversation.parentConversationId, + targetKind: input.targetKind, + targetSessionKey: input.targetSessionKey, + metadata: input.metadata, + }); + return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null; + }, + listBySession: (targetSessionKey) => + manager + .listBySessionKey(targetSessionKey) + .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })), + resolveByConversation: (ref) => { + if (ref.channel !== "feishu") { + return null; + } + const found = manager.getByConversationId(ref.conversationId); + return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null; + }, + touch: (bindingId, at) => { + const conversationId = resolveThreadBindingConversationIdFromBindingId({ + accountId, + bindingId, + }); + if (conversationId) { + manager.touchConversation(conversationId, at); + } + }, + unbind: async (input) => { + if (input.targetSessionKey?.trim()) { + return manager + .unbindBySessionKey(input.targetSessionKey.trim()) + .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })); + } + const conversationId = resolveThreadBindingConversationIdFromBindingId({ + accountId, + bindingId: input.bindingId, + }); + if (!conversationId) { + return []; + } + const removed = manager.unbindConversation(conversationId); + return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : []; + }, + }); + + MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); + return manager; +} + +export function getFeishuThreadBindingManager( + accountId?: string, +): FeishuThreadBindingManager | null { + return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; +} + +export const __testing = { + resetFeishuThreadBindingsForTests() { + for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { + manager.stop(); + } + MANAGERS_BY_ACCOUNT_ID.clear(); + BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); + }, +}; diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index 84f052797ad..4af2883d90d 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -1,3 +1,4 @@ +import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; import { listAcpBindings } from "../config/bindings.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentAcpBinding } from "../config/types.js"; @@ -21,7 +22,7 @@ import { function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null { const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "discord" || normalized === "telegram") { + if (normalized === "discord" || normalized === "telegram" || normalized === "feishu") { return normalized; } return null; @@ -228,6 +229,40 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: { } continue; } + if (channel === "feishu") { + const targetParsed = parseFeishuConversationId({ + conversationId: targetConversationId, + }); + if ( + !targetParsed || + (targetParsed.scope !== "group_topic" && + targetParsed.scope !== "group_topic_sender" && + !targetParsed.canonicalConversationId.startsWith("ou_") && + !targetParsed.canonicalConversationId.startsWith("on_")) + ) { + continue; + } + const spec = toConfiguredBindingSpec({ + cfg: params.cfg, + channel: "feishu", + accountId: parsedSessionKey.accountId, + conversationId: targetParsed.canonicalConversationId, + parentConversationId: + targetParsed.scope === "group_topic" || targetParsed.scope === "group_topic_sender" + ? targetParsed.chatId + : undefined, + binding, + }); + if (buildConfiguredAcpSessionKey(spec) === sessionKey) { + if (accountMatchPriority === 2) { + return spec; + } + if (!wildcardMatch) { + wildcardMatch = spec; + } + } + continue; + } const parsedTopic = parseTelegramTopicConversation({ conversationId: targetConversationId, }); @@ -334,5 +369,64 @@ export function resolveConfiguredAcpBindingRecord(params: { }); } + if (channel === "feishu") { + const parsed = parseFeishuConversationId({ + conversationId, + parentConversationId, + }); + if ( + !parsed || + (parsed.scope !== "group_topic" && + parsed.scope !== "group_topic_sender" && + !parsed.canonicalConversationId.startsWith("ou_") && + !parsed.canonicalConversationId.startsWith("on_")) + ) { + return null; + } + return resolveConfiguredBindingRecord({ + cfg: params.cfg, + bindings: listAcpBindings(params.cfg), + channel: "feishu", + accountId, + selectConversation: (binding) => { + const targetConversationId = resolveBindingConversationId(binding); + if (!targetConversationId) { + return null; + } + const targetParsed = parseFeishuConversationId({ + conversationId: targetConversationId, + }); + if ( + !targetParsed || + (targetParsed.scope !== "group_topic" && + targetParsed.scope !== "group_topic_sender" && + !targetParsed.canonicalConversationId.startsWith("ou_") && + !targetParsed.canonicalConversationId.startsWith("on_")) + ) { + return null; + } + const matchesCanonicalConversation = + targetParsed.canonicalConversationId === parsed.canonicalConversationId; + const matchesParentTopicForSenderScopedConversation = + parsed.scope === "group_topic_sender" && + targetParsed.scope === "group_topic" && + parsed.chatId === targetParsed.chatId && + parsed.topicId === targetParsed.topicId; + if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { + return null; + } + return { + conversationId: matchesParentTopicForSenderScopedConversation + ? targetParsed.canonicalConversationId + : parsed.canonicalConversationId, + parentConversationId: + parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" + ? parsed.chatId + : undefined, + }; + }, + }); + } + return null; } diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index 30e74c05082..353ab801991 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -90,6 +90,27 @@ function createTelegramGroupBinding(params: { } as ConfiguredBinding; } +function createFeishuBinding(params: { + agentId: string; + conversationId: string; + accountId?: string; + acp?: Record; +}): ConfiguredBinding { + return { + type: "acp", + agentId: params.agentId, + match: { + channel: "feishu", + accountId: params.accountId ?? defaultDiscordAccountId, + peer: { + kind: params.conversationId.includes(":topic:") ? "group" : "direct", + id: params.conversationId, + }, + }, + ...(params.acp ? { acp: params.acp } : {}), + } as ConfiguredBinding; +} + function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial = {}) { return resolveConfiguredAcpBindingRecord({ cfg, @@ -284,6 +305,108 @@ describe("resolveConfiguredAcpBindingRecord", () => { expect(resolved).toBeNull(); }); + it("resolves Feishu DM bindings using direct peer ids", () => { + const cfg = createCfgWithBindings([ + createFeishuBinding({ + agentId: "codex", + conversationId: "ou_user_1", + }), + ]); + + const resolved = resolveConfiguredAcpBindingRecord({ + cfg, + channel: "feishu", + accountId: "default", + conversationId: "ou_user_1", + }); + + expect(resolved?.spec.channel).toBe("feishu"); + expect(resolved?.spec.conversationId).toBe("ou_user_1"); + expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:"); + }); + + it("resolves Feishu topic bindings with parent chat ids", () => { + const cfg = createCfgWithBindings([ + createFeishuBinding({ + agentId: "claude", + conversationId: "oc_group_chat:topic:om_topic_root", + acp: { backend: "acpx" }, + }), + ]); + + const resolved = resolveConfiguredAcpBindingRecord({ + cfg, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }); + + expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root"); + expect(resolved?.spec.agentId).toBe("claude"); + expect(resolved?.record.conversation.parentConversationId).toBe("oc_group_chat"); + }); + + it("inherits configured Feishu topic bindings for sender-scoped topic conversations", () => { + const cfg = createCfgWithBindings([ + createFeishuBinding({ + agentId: "claude", + conversationId: "oc_group_chat:topic:om_topic_root", + acp: { backend: "acpx" }, + }), + ]); + + const resolved = resolveConfiguredAcpBindingRecord({ + cfg, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + parentConversationId: "oc_group_chat", + }); + + expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root"); + expect(resolved?.spec.agentId).toBe("claude"); + expect(resolved?.spec.backend).toBe("acpx"); + expect(resolved?.record.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root"); + }); + + it("rejects non-matching Feishu topic roots", () => { + const cfg = createCfgWithBindings([ + createFeishuBinding({ + agentId: "claude", + conversationId: "oc_group_chat:topic:om_topic_root", + }), + ]); + + const resolved = resolveConfiguredAcpBindingRecord({ + cfg, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_other_root", + parentConversationId: "oc_group_chat", + }); + + expect(resolved).toBeNull(); + }); + + it("rejects Feishu non-topic group ACP bindings", () => { + const cfg = createCfgWithBindings([ + createFeishuBinding({ + agentId: "claude", + conversationId: "oc_group_chat", + }), + ]); + + const resolved = resolveConfiguredAcpBindingRecord({ + cfg, + channel: "feishu", + accountId: "default", + conversationId: "oc_group_chat", + }); + + expect(resolved).toBeNull(); + }); + it("applies agent runtime ACP defaults for bound conversations", () => { const cfg = createCfgWithBindings( [ diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index 715ae9c70d4..3864392c96c 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -3,7 +3,7 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser import { sanitizeAgentId } from "../routing/session-key.js"; import type { AcpRuntimeSessionMode } from "./runtime/types.js"; -export type ConfiguredAcpBindingChannel = "discord" | "telegram"; +export type ConfiguredAcpBindingChannel = "discord" | "telegram" | "feishu"; export type ConfiguredAcpBindingSpec = { channel: ConfiguredAcpBindingChannel; diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 18136b67b03..9c3dcdb84bb 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -126,4 +126,69 @@ describe("commands-acp context", () => { }); expect(resolveAcpCommandConversationId(params)).toBe("123456789"); }); + + it("builds Feishu topic conversation ids from chat target + root message id", () => { + const params = buildCommandTestParams("/acp status", baseCfg, { + Provider: "feishu", + Surface: "feishu", + OriginatingChannel: "feishu", + OriginatingTo: "chat:oc_group_chat", + MessageThreadId: "om_topic_root", + SenderId: "ou_topic_user", + AccountId: "work", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "feishu", + accountId: "work", + threadId: "om_topic_root", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + }); + expect(resolveAcpCommandConversationId(params)).toBe("oc_group_chat:topic:om_topic_root"); + }); + + it("builds sender-scoped Feishu topic conversation ids when current session is sender-scoped", () => { + const params = buildCommandTestParams("/acp status", baseCfg, { + Provider: "feishu", + Surface: "feishu", + OriginatingChannel: "feishu", + OriginatingTo: "chat:oc_group_chat", + MessageThreadId: "om_topic_root", + SenderId: "ou_topic_user", + AccountId: "work", + SessionKey: "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }); + params.sessionKey = + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user"; + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "feishu", + accountId: "work", + threadId: "om_topic_root", + conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + parentConversationId: "oc_group_chat", + }); + expect(resolveAcpCommandConversationId(params)).toBe( + "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ); + }); + + it("resolves Feishu DM conversation ids from user targets", () => { + const params = buildCommandTestParams("/acp status", baseCfg, { + Provider: "feishu", + Surface: "feishu", + OriginatingChannel: "feishu", + OriginatingTo: "user:ou_sender_1", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "feishu", + accountId: "default", + threadId: undefined, + conversationId: "ou_sender_1", + parentConversationId: "ou_sender_1", + }); + expect(resolveAcpCommandConversationId(params)).toBe("ou_sender_1"); + }); }); diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 84acb828015..33c4bdf85ba 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,3 +1,4 @@ +import { buildFeishuConversationId } from "../../../../extensions/feishu/src/conversation-id.js"; import { buildTelegramTopicConversationId, normalizeConversationText, @@ -5,10 +6,65 @@ import { } from "../../../acp/conversation-id.js"; import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js"; +import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js"; import { resolveTelegramConversationId } from "../telegram-context.js"; +function parseFeishuTargetId(raw: unknown): string | undefined { + const target = normalizeConversationText(raw); + if (!target) { + return undefined; + } + const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim(); + if (!withoutProvider) { + return undefined; + } + const lowered = withoutProvider.toLowerCase(); + for (const prefix of ["chat:", "group:", "channel:", "user:", "dm:", "open_id:"]) { + if (lowered.startsWith(prefix)) { + return normalizeConversationText(withoutProvider.slice(prefix.length)); + } + } + return withoutProvider; +} + +function parseFeishuDirectConversationId(raw: unknown): string | undefined { + const id = parseFeishuTargetId(raw); + if (!id) { + return undefined; + } + if (id.startsWith("ou_") || id.startsWith("on_")) { + return id; + } + return undefined; +} + +function resolveFeishuSenderScopedConversationId(params: { + parentConversationId?: string; + threadId?: string; + senderId?: string; + sessionKey?: string; +}): string | undefined { + const parentConversationId = normalizeConversationText(params.parentConversationId); + const threadId = normalizeConversationText(params.threadId); + const senderId = normalizeConversationText(params.senderId); + const scopedRest = parseAgentSessionKey(params.sessionKey)?.rest?.trim().toLowerCase() ?? ""; + const expectedScopePrefix = `feishu:group:${parentConversationId?.toLowerCase()}:topic:${threadId?.toLowerCase()}:sender:`; + const isSenderScopedSession = Boolean( + scopedRest && expectedScopePrefix && scopedRest.startsWith(expectedScopePrefix), + ); + if (!parentConversationId || !threadId || !senderId || !isSenderScopedSession) { + return undefined; + } + return buildFeishuConversationId({ + chatId: parentConversationId, + scope: "group_topic_sender", + topicId: threadId, + senderOpenId: senderId, + }); +} + export function resolveAcpCommandChannel(params: HandleCommandsParams): string { const raw = params.ctx.OriginatingChannel ?? @@ -58,6 +114,31 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s ); } } + if (channel === "feishu") { + const threadId = resolveAcpCommandThreadId(params); + const parentConversationId = resolveAcpCommandParentConversationId(params); + if (threadId && parentConversationId) { + const senderScopedConversationId = resolveFeishuSenderScopedConversationId({ + parentConversationId, + threadId, + senderId: params.command.senderId ?? params.ctx.SenderId, + sessionKey: params.sessionKey, + }); + return ( + senderScopedConversationId ?? + buildFeishuConversationId({ + chatId: parentConversationId, + scope: "group_topic", + topicId: threadId, + }) + ); + } + return ( + parseFeishuDirectConversationId(params.ctx.OriginatingTo) ?? + parseFeishuDirectConversationId(params.command.to) ?? + parseFeishuDirectConversationId(params.ctx.To) + ); + } return resolveConversationIdFromTargets({ threadId: params.ctx.MessageThreadId, targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To], @@ -83,6 +164,13 @@ export function resolveAcpCommandParentConversationId( parseTelegramChatIdFromTarget(params.ctx.To) ); } + if (channel === "feishu") { + return ( + parseFeishuTargetId(params.ctx.OriginatingTo) ?? + parseFeishuTargetId(params.command.to) ?? + parseFeishuTargetId(params.ctx.To) + ); + } if (channel === DISCORD_THREAD_BINDING_CHANNEL) { const threadId = resolveAcpCommandThreadId(params); if (!threadId) { diff --git a/src/config/config.acp-binding-cutover.test.ts b/src/config/config.acp-binding-cutover.test.ts index ea9f4d603ea..fbcf8054730 100644 --- a/src/config/config.acp-binding-cutover.test.ts +++ b/src/config/config.acp-binding-cutover.test.ts @@ -144,4 +144,67 @@ describe("ACP binding cutover schema", () => { expect(parsed.success).toBe(false); }); + + it("accepts canonical Feishu ACP DM and topic peer IDs", () => { + const parsed = OpenClawSchema.safeParse({ + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "direct", id: "ou_user_123" }, + }, + }, + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root:sender:ou_user_123" }, + }, + }, + ], + }); + + expect(parsed.success).toBe(true); + }); + + it("rejects non-canonical Feishu ACP peer IDs", () => { + const parsed = OpenClawSchema.safeParse({ + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "group", id: "oc_group_chat:sender:ou_user_123" }, + }, + }, + ], + }); + + expect(parsed.success).toBe(false); + }); + + it("rejects bare Feishu group chat ACP peer IDs", () => { + const parsed = OpenClawSchema.safeParse({ + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "feishu", + accountId: "default", + peer: { kind: "group", id: "oc_group_chat" }, + }, + }, + ], + }); + + expect(parsed.success).toBe(false); + }); }); diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index ed638d9b502..ebdaadfe27d 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -71,11 +71,12 @@ const AcpBindingSchema = z return; } const channel = value.match.channel.trim().toLowerCase(); - if (channel !== "discord" && channel !== "telegram") { + if (channel !== "discord" && channel !== "telegram" && channel !== "feishu") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["match", "channel"], - message: 'ACP bindings currently support only "discord" and "telegram" channels.', + message: + 'ACP bindings currently support only "discord", "telegram", and "feishu" channels.', }); return; } @@ -87,6 +88,17 @@ const AcpBindingSchema = z "Telegram ACP bindings require canonical topic IDs in the form -1001234567890:topic:42.", }); } + if ( + channel === "feishu" && + !/^(ou_[^:]+|on_[^:]+|[^:]+: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].", + }); + } }); export const BindingsSchema = z.array(z.union([RouteBindingSchema, AcpBindingSchema])).optional();