diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 7b2375acf54..e3ce8f21ea2 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -6,6 +6,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 { resolveFeishuAccountForToolContext } from "./src/tool-context.js"; import { registerFeishuWikiTools } from "./src/wiki.js"; export { monitorFeishuProvider } from "./src/monitor.js"; @@ -52,6 +53,13 @@ const plugin = { register(api: OpenClawPluginApi) { setFeishuRuntime(api.runtime); api.registerChannel({ plugin: feishuPlugin }); + + // Ensure Feishu tool registration uses the calling agent's account / outbound identity. + api.registerHook(["before_tool_call"], resolveFeishuAccountForToolContext, { + name: "feishu:resolve-account", + description: "Resolve Feishu accountId for Feishu tools based on the calling agent", + }); + registerFeishuDocTools(api); registerFeishuWikiTools(api); registerFeishuDriveTools(api); diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts new file mode 100644 index 00000000000..b5eb940b630 --- /dev/null +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test, vi } from "vitest"; + +const createFeishuClientMock = vi.fn((creds: any) => ({ __appId: creds?.appId })); + +vi.mock("./client.js", () => { + return { + createFeishuClient: (creds: any) => createFeishuClientMock(creds), + }; +}); + +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { registerFeishuDocTools } from "./docx.js"; + +// Patch the specific API calls we need so tool execution doesn't hit network. +vi.mock("@larksuiteoapi/node-sdk", () => { + return { + default: {}, + }; +}); + +function fakeApi(cfg: any) { + const tools: Array<{ name: string; execute: (id: string, params: any) => any }> = []; + const api: Partial = { + config: cfg, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + registerTool: (tool: any) => { + tools.push({ name: tool.name, execute: tool.execute }); + return undefined as any; + }, + }; + return { api: api as OpenClawPluginApi, tools }; +} + +describe("feishu_doc account selection", () => { + test("uses accountId param to pick correct account when multiple accounts configured", async () => { + const cfg = { + channels: { + feishu: { + enabled: true, + accounts: { + a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, + b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, + }, + }, + }, + }; + + const { api, tools } = fakeApi(cfg); + registerFeishuDocTools(api); + + const tool = tools.find((t) => t.name === "feishu_doc"); + expect(tool).toBeTruthy(); + + // Trigger a lightweight action (list_blocks) that will immediately attempt client creation. + // It will still fail later due to missing SDK mocks, but we only care which account's creds were used. + await tool!.execute("call-a", { action: "list_blocks", doc_token: "d", accountId: "a" }); + await tool!.execute("call-b", { action: "list_blocks", doc_token: "d", accountId: "b" }); + + expect(createFeishuClientMock).toHaveBeenCalledTimes(2); + expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a"); + expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-b"); + }); + + test("single-account setup still registers tool and uses that account", async () => { + const cfg = { + channels: { + feishu: { + enabled: true, + accounts: { + default: { appId: "app-d", appSecret: "sec-d", tools: { doc: true } }, + }, + }, + }, + }; + + const { api, tools } = fakeApi(cfg); + registerFeishuDocTools(api); + + const tool = tools.find((t) => t.name === "feishu_doc"); + expect(tool).toBeTruthy(); + + await tool!.execute("call-d", { action: "list_blocks", doc_token: "d" }); + expect(createFeishuClientMock).toHaveBeenCalled(); + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-d"); + }); +}); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 195cc8c81e7..aed1d2ec8e6 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -3,6 +3,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; +import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; import { getFeishuRuntime } from "./runtime.js"; @@ -454,15 +455,20 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { return; } - // Use first account's config for tools configuration + // Use first account's config for tools configuration (registration-time defaults only) const firstAccount = accounts[0]; const toolsCfg = resolveToolsConfig(firstAccount.config.tools); - const mediaMaxBytes = (firstAccount.config?.mediaMaxMb ?? 30) * 1024 * 1024; - // Helper to get client for the default account - const getClient = () => createFeishuClient(firstAccount); const registered: string[] = []; + const resolveAccount = (params: FeishuDocParams) => + resolveFeishuAccount({ cfg: api.config!, accountId: (params as any).accountId }); + + const getClient = (params: FeishuDocParams) => createFeishuClient(resolveAccount(params)); + + const getMediaMaxBytes = (params: FeishuDocParams) => + (resolveAccount(params).config?.mediaMaxMb ?? 30) * 1024 * 1024; + // Main document tool with action-based dispatch if (toolsCfg.doc) { api.registerTool( @@ -475,14 +481,14 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { async execute(_toolCallId, params) { const p = params as FeishuDocParams; try { - const client = getClient(); + const client = getClient(p); switch (p.action) { case "read": return json(await readDoc(client, p.doc_token)); case "write": - return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes)); + return json(await writeDoc(client, p.doc_token, p.content, getMediaMaxBytes(p))); case "append": - return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes)); + return json(await appendDoc(client, p.doc_token, p.content, getMediaMaxBytes(p))); case "create": return json(await createDoc(client, p.title, p.folder_token)); case "list_blocks": diff --git a/extensions/feishu/src/tool-context.ts b/extensions/feishu/src/tool-context.ts new file mode 100644 index 00000000000..8f7a29033b3 --- /dev/null +++ b/extensions/feishu/src/tool-context.ts @@ -0,0 +1,38 @@ +import type { BeforeToolCallHook } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; + +/** + * If the calling agent has an account id, copy it into tool params as accountId + * (unless the caller already provided one). + * + * This allows Feishu tools that are registered at startup (and therefore can't + * capture a per-agent client) to select the right Feishu account at execution + * time. + */ +export const resolveFeishuAccountForToolContext: BeforeToolCallHook = async (ctx) => { + const toolName = ctx.toolName; + if (typeof toolName !== "string" || !toolName.startsWith("feishu_")) { + return { blocked: false, params: ctx.params }; + } + + // If caller already specified an accountId, keep it. + const existing = (ctx.params as Record | undefined)?.accountId; + if (typeof existing === "string" && existing.trim()) { + return { blocked: false, params: ctx.params }; + } + + const agentAccountId = ctx.agentAccountId; + if (!agentAccountId) { + // Backward-compatible: no agent account context => default account. + return { + blocked: false, + params: { ...(ctx.params ?? {}), accountId: DEFAULT_ACCOUNT_ID }, + }; + } + + const accountId = normalizeAccountId(agentAccountId); + return { + blocked: false, + params: { ...(ctx.params ?? {}), accountId }, + }; +};