From 125dc322f5c5aa09576442916b5b6a64437cbc55 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 13:19:17 +0100 Subject: [PATCH] refactor(feishu): unify account-aware tool routing and message body --- extensions/feishu/src/bitable.ts | 380 ++++++++---------- extensions/feishu/src/bot.test.ts | 26 +- extensions/feishu/src/bot.ts | 67 +-- .../feishu/src/docx.account-selection.test.ts | 53 +-- extensions/feishu/src/docx.ts | 32 +- extensions/feishu/src/drive.ts | 71 ++-- extensions/feishu/src/perm.ts | 67 +-- .../feishu/src/tool-account-routing.test.ts | 111 +++++ extensions/feishu/src/tool-account.ts | 58 +++ .../feishu/src/tool-factory-test-harness.ts | 76 ++++ extensions/feishu/src/wiki.ts | 105 ++--- 11 files changed, 630 insertions(+), 416 deletions(-) create mode 100644 extensions/feishu/src/tool-account-routing.test.ts create mode 100644 extensions/feishu/src/tool-account.ts create mode 100644 extensions/feishu/src/tool-factory-test-harness.ts diff --git a/extensions/feishu/src/bitable.ts b/extensions/feishu/src/bitable.ts index 03ac7f46e..5e0575bba 100644 --- a/extensions/feishu/src/bitable.ts +++ b/extensions/feishu/src/bitable.ts @@ -1,7 +1,8 @@ +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 { createFeishuClient } from "./client.js"; +import { createFeishuToolClient } from "./tool-account.js"; // ============ Helpers ============ @@ -64,10 +65,7 @@ function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki } /** Get app_token from wiki node_token */ -async function getAppTokenFromWiki( - client: ReturnType, - nodeToken: string, -): Promise { +async function getAppTokenFromWiki(client: Lark.Client, nodeToken: string): Promise { const res = await client.wiki.space.getNode({ params: { token: nodeToken }, }); @@ -87,7 +85,7 @@ async function getAppTokenFromWiki( } /** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */ -async function getBitableMeta(client: ReturnType, url: string) { +async function getBitableMeta(client: Lark.Client, url: string) { const parsed = parseBitableUrl(url); if (!parsed) { throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL"); @@ -134,11 +132,7 @@ async function getBitableMeta(client: ReturnType, url }; } -async function listFields( - client: ReturnType, - appToken: string, - tableId: string, -) { +async function listFields(client: Lark.Client, appToken: string, tableId: string) { const res = await client.bitable.appTableField.list({ path: { app_token: appToken, table_id: tableId }, }); @@ -161,7 +155,7 @@ async function listFields( } async function listRecords( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, pageSize?: number, @@ -186,12 +180,7 @@ async function listRecords( }; } -async function getRecord( - client: ReturnType, - appToken: string, - tableId: string, - recordId: string, -) { +async function getRecord(client: Lark.Client, appToken: string, tableId: string, recordId: string) { const res = await client.bitable.appTableRecord.get({ path: { app_token: appToken, table_id: tableId, record_id: recordId }, }); @@ -205,7 +194,7 @@ async function getRecord( } async function createRecord( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, fields: Record, @@ -235,7 +224,7 @@ const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTi /** Clean up default placeholder rows and fields in a newly created Bitable table */ async function cleanupNewBitable( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, tableName: string, @@ -334,7 +323,7 @@ async function cleanupNewBitable( } async function createApp( - client: ReturnType, + client: Lark.Client, name: string, folderToken?: string, logger?: CleanupLogger, @@ -389,7 +378,7 @@ async function createApp( } async function createField( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, fieldName: string, @@ -417,7 +406,7 @@ async function createField( } async function updateRecord( - client: ReturnType, + client: Lark.Client, appToken: string, tableId: string, recordId: string, @@ -543,203 +532,182 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) { return; } - const firstAccount = accounts[0]; - const getClient = () => createFeishuClient(firstAccount); + type AccountAwareParams = { accountId?: string }; - // Tool 0: feishu_bitable_get_meta (helper to parse URLs) - api.registerTool( - { - name: "feishu_bitable_get_meta", - label: "Feishu Bitable Get Meta", - description: - "Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.", - parameters: GetMetaSchema, - async execute(_toolCallId, params) { - const { url } = params as { url: string }; - try { - const result = await getBitableMeta(getClient(), url); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, - }, - { name: "feishu_bitable_get_meta" }, - ); + const getClient = (params: AccountAwareParams | undefined, defaultAccountId?: string) => + createFeishuToolClient({ api, executeParams: params, defaultAccountId }); - // Tool 1: feishu_bitable_list_fields - api.registerTool( - { - name: "feishu_bitable_list_fields", - label: "Feishu Bitable List Fields", - description: "List all fields (columns) in a Bitable table with their types and properties", - parameters: ListFieldsSchema, - async execute(_toolCallId, params) { - const { app_token, table_id } = params as { app_token: string; table_id: string }; - try { - const result = await listFields(getClient(), app_token, table_id); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, - }, - { name: "feishu_bitable_list_fields" }, - ); + const registerBitableTool = (params: { + name: string; + label: string; + description: string; + parameters: unknown; + execute: (args: { params: TParams; defaultAccountId?: string }) => Promise; + }) => { + api.registerTool( + (ctx) => ({ + name: params.name, + label: params.label, + description: params.description, + parameters: params.parameters, + async execute(_toolCallId, rawParams) { + try { + return json( + await params.execute({ + params: rawParams as TParams, + defaultAccountId: ctx.agentAccountId, + }), + ); + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }), + { name: params.name }, + ); + }; - // Tool 2: feishu_bitable_list_records - api.registerTool( - { - name: "feishu_bitable_list_records", - label: "Feishu Bitable List Records", - description: "List records (rows) from a Bitable table with pagination support", - parameters: ListRecordsSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, page_size, page_token } = params as { - app_token: string; - table_id: string; - page_size?: number; - page_token?: string; - }; - try { - const result = await listRecords(getClient(), app_token, table_id, page_size, page_token); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + registerBitableTool<{ url: string; accountId?: string }>({ + name: "feishu_bitable_get_meta", + label: "Feishu Bitable Get Meta", + description: + "Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.", + parameters: GetMetaSchema, + async execute({ params, defaultAccountId }) { + return getBitableMeta(getClient(params, defaultAccountId), params.url); }, - { name: "feishu_bitable_list_records" }, - ); + }); - // Tool 3: feishu_bitable_get_record - api.registerTool( - { - name: "feishu_bitable_get_record", - label: "Feishu Bitable Get Record", - description: "Get a single record by ID from a Bitable table", - parameters: GetRecordSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, record_id } = params as { - app_token: string; - table_id: string; - record_id: string; - }; - try { - const result = await getRecord(getClient(), app_token, table_id, record_id); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + registerBitableTool<{ app_token: string; table_id: string; accountId?: string }>({ + name: "feishu_bitable_list_fields", + label: "Feishu Bitable List Fields", + description: "List all fields (columns) in a Bitable table with their types and properties", + parameters: ListFieldsSchema, + async execute({ params, defaultAccountId }) { + return listFields(getClient(params, defaultAccountId), params.app_token, params.table_id); }, - { name: "feishu_bitable_get_record" }, - ); + }); - // Tool 4: feishu_bitable_create_record - api.registerTool( - { - name: "feishu_bitable_create_record", - label: "Feishu Bitable Create Record", - description: "Create a new record (row) in a Bitable table", - parameters: CreateRecordSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, fields } = params as { - app_token: string; - table_id: string; - fields: Record; - }; - try { - const result = await createRecord(getClient(), app_token, table_id, fields); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + registerBitableTool<{ + app_token: string; + table_id: string; + page_size?: number; + page_token?: string; + accountId?: string; + }>({ + name: "feishu_bitable_list_records", + label: "Feishu Bitable List Records", + description: "List records (rows) from a Bitable table with pagination support", + parameters: ListRecordsSchema, + async execute({ params, defaultAccountId }) { + return listRecords( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.page_size, + params.page_token, + ); }, - { name: "feishu_bitable_create_record" }, - ); + }); - // Tool 5: feishu_bitable_update_record - api.registerTool( - { - name: "feishu_bitable_update_record", - label: "Feishu Bitable Update Record", - description: "Update an existing record (row) in a Bitable table", - parameters: UpdateRecordSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, record_id, fields } = params as { - app_token: string; - table_id: string; - record_id: string; - fields: Record; - }; - try { - const result = await updateRecord(getClient(), app_token, table_id, record_id, fields); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + registerBitableTool<{ + app_token: string; + table_id: string; + record_id: string; + accountId?: string; + }>({ + name: "feishu_bitable_get_record", + label: "Feishu Bitable Get Record", + description: "Get a single record by ID from a Bitable table", + parameters: GetRecordSchema, + async execute({ params, defaultAccountId }) { + return getRecord( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.record_id, + ); }, - { name: "feishu_bitable_update_record" }, - ); + }); - // Tool 6: feishu_bitable_create_app - api.registerTool( - { - name: "feishu_bitable_create_app", - label: "Feishu Bitable Create App", - description: "Create a new Bitable (multidimensional table) application", - parameters: CreateAppSchema, - async execute(_toolCallId, params) { - const { name, folder_token } = params as { name: string; folder_token?: string }; - try { - const result = await createApp(getClient(), name, folder_token, { - debug: (msg) => api.logger.debug?.(msg), - warn: (msg) => api.logger.warn?.(msg), - }); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + registerBitableTool<{ + app_token: string; + table_id: string; + fields: Record; + accountId?: string; + }>({ + name: "feishu_bitable_create_record", + label: "Feishu Bitable Create Record", + description: "Create a new record (row) in a Bitable table", + parameters: CreateRecordSchema, + async execute({ params, defaultAccountId }) { + return createRecord( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.fields, + ); }, - { name: "feishu_bitable_create_app" }, - ); + }); - // Tool 7: feishu_bitable_create_field - api.registerTool( - { - name: "feishu_bitable_create_field", - label: "Feishu Bitable Create Field", - description: "Create a new field (column) in a Bitable table", - parameters: CreateFieldSchema, - async execute(_toolCallId, params) { - const { app_token, table_id, field_name, field_type, property } = params as { - app_token: string; - table_id: string; - field_name: string; - field_type: number; - property?: Record; - }; - try { - const result = await createField( - getClient(), - app_token, - table_id, - field_name, - field_type, - property, - ); - return json(result); - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + registerBitableTool<{ + app_token: string; + table_id: string; + record_id: string; + fields: Record; + accountId?: string; + }>({ + name: "feishu_bitable_update_record", + label: "Feishu Bitable Update Record", + description: "Update an existing record (row) in a Bitable table", + parameters: UpdateRecordSchema, + async execute({ params, defaultAccountId }) { + return updateRecord( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.record_id, + params.fields, + ); }, - { name: "feishu_bitable_create_field" }, - ); + }); + + registerBitableTool<{ name: string; folder_token?: string; accountId?: string }>({ + name: "feishu_bitable_create_app", + label: "Feishu Bitable Create App", + description: "Create a new Bitable (multidimensional table) application", + parameters: CreateAppSchema, + async execute({ params, defaultAccountId }) { + return createApp(getClient(params, defaultAccountId), params.name, params.folder_token, { + debug: (msg) => api.logger.debug?.(msg), + warn: (msg) => api.logger.warn?.(msg), + }); + }, + }); + + registerBitableTool<{ + app_token: string; + table_id: string; + field_name: string; + field_type: number; + property?: Record; + accountId?: string; + }>({ + name: "feishu_bitable_create_field", + label: "Feishu Bitable Create Field", + description: "Create a new field (column) in a Bitable table", + parameters: CreateFieldSchema, + async execute({ params, defaultAccountId }) { + return createField( + getClient(params, defaultAccountId), + params.app_token, + params.table_id, + params.field_name, + params.field_type, + params.property, + ); + }, + }); api.logger.info?.("feishu_bitable: Registered bitable tools"); } diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 4f3490389..7e56c36c4 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1,7 +1,7 @@ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuMessageEvent } from "./bot.js"; -import { handleFeishuMessage } from "./bot.js"; +import { buildFeishuAgentBody, handleFeishuMessage } from "./bot.js"; import { setFeishuRuntime } from "./runtime.js"; const { @@ -61,6 +61,30 @@ async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessa }); } +describe("buildFeishuAgentBody", () => { + it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => { + const body = buildFeishuAgentBody({ + ctx: { + content: "hello world", + senderName: "Sender Name", + senderOpenId: "ou-sender", + messageId: "msg-42", + mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }], + }, + quotedContent: "previous message", + permissionErrorForAgent: { + code: 99991672, + message: "permission denied", + grantUrl: "https://open.feishu.cn/app/cli_test", + }, + }); + + expect(body).toBe( + '[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]', + ); + }); +}); + describe("handleFeishuMessage command authorization", () => { const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); const mockDispatchReplyFromConfig = vi diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 94d16257e..31172cb5c 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -496,6 +496,40 @@ export function parseFeishuMessageEvent( return ctx; } +export function buildFeishuAgentBody(params: { + ctx: Pick< + FeishuMessageContext, + "content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" + >; + quotedContent?: string; + permissionErrorForAgent?: PermissionError; +}): string { + const { ctx, quotedContent, permissionErrorForAgent } = params; + let messageBody = ctx.content; + if (quotedContent) { + messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; + } + + // DMs already have per-sender sessions, but this label still improves attribution. + const speaker = ctx.senderName ?? ctx.senderOpenId; + messageBody = `${speaker}: ${messageBody}`; + + if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { + const targetNames = ctx.mentionTargets.map((t) => t.name).join(", "); + messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`; + } + + // Keep message_id on its own line so shared message-id hint stripping can parse it reliably. + messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`; + + if (permissionErrorForAgent) { + const grantUrl = permissionErrorForAgent.grantUrl ?? ""; + messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`; + } + + return messageBody; +} + export async function handleFeishuMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent; @@ -823,35 +857,14 @@ export async function handleFeishuMessage(params: { } const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); - - // Build message body with quoted content if available - let messageBody = ctx.content; - if (quotedContent) { - messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`; - } - - // Include a readable speaker label so the model can attribute instructions. - // (DMs already have per-sender sessions, but the prefix is still useful for clarity.) - const speaker = ctx.senderName ?? ctx.senderOpenId; - messageBody = `${speaker}: ${messageBody}`; - - // If there are mention targets, inform the agent that replies will auto-mention them - if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { - const targetNames = ctx.mentionTargets.map((t) => t.name).join(", "); - messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`; - } - - // Keep message_id on its own line so shared message-id hint stripping can parse it reliably. - messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`; - + const messageBody = buildFeishuAgentBody({ + ctx, + quotedContent, + permissionErrorForAgent, + }); const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId; - - // Append permission error notice to the main message body instead of dispatching - // a separate agent turn. A separate dispatch caused the bot to reply twice — once - // for the permission notification and once for the actual user message (#27372). if (permissionErrorForAgent) { - const grantUrl = permissionErrorForAgent.grantUrl ?? ""; - messageBody += `\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`; + // Keep the notice in a single dispatch to avoid duplicate replies (#27372). log(`feishu[${account.accountId}]: appending permission error notice to message body`); } diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts index d292b6f0c..6471192b6 100644 --- a/extensions/feishu/src/docx.account-selection.test.ts +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -1,6 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { describe, expect, test, vi } from "vitest"; import { registerFeishuDocTools } from "./docx.js"; +import { createToolFactoryHarness } from "./tool-factory-test-harness.js"; const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({ __appId: creds?.appId, @@ -19,54 +20,6 @@ vi.mock("@larksuiteoapi/node-sdk", () => { }; }); -type ToolLike = { - name: string; - execute: (toolCallId: string, params: unknown) => Promise; -}; - -type ToolContextLike = { - agentAccountId?: string; -}; - -type ToolFactoryLike = (ctx: ToolContextLike) => ToolLike | ToolLike[] | null | undefined; - -function createApi(cfg: OpenClawPluginApi["config"]) { - const registered: Array<{ - tool: ToolLike | ToolFactoryLike; - opts?: { name?: string }; - }> = []; - - const api: Partial = { - config: cfg, - logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }, - registerTool: (tool, opts) => { - registered.push({ tool, opts }); - }, - }; - - const resolveTool = (name: string, ctx: ToolContextLike): ToolLike => { - const entry = registered.find((item) => item.opts?.name === name); - if (!entry) { - throw new Error(`Tool not registered: ${name}`); - } - if (typeof entry.tool === "function") { - const built = entry.tool(ctx); - if (!built || Array.isArray(built)) { - throw new Error(`Unexpected tool factory output for ${name}`); - } - return built as ToolLike; - } - return entry.tool as ToolLike; - }; - - return { api: api as OpenClawPluginApi, resolveTool }; -} - describe("feishu_doc account selection", () => { test("uses agentAccountId context when params omit accountId", async () => { const cfg = { @@ -81,7 +34,7 @@ describe("feishu_doc account selection", () => { }, } as OpenClawPluginApi["config"]; - const { api, resolveTool } = createApi(cfg); + const { api, resolveTool } = createToolFactoryHarness(cfg); registerFeishuDocTools(api); const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" }); @@ -108,7 +61,7 @@ describe("feishu_doc account selection", () => { }, } as OpenClawPluginApi["config"]; - const { api, resolveTool } = createApi(cfg); + const { api, resolveTool } = createToolFactoryHarness(cfg); registerFeishuDocTools(api); const docTool = resolveTool("feishu_doc", { agentAccountId: "b" }); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index b6c5c7f4a..33cfe924d 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -3,11 +3,13 @@ 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"; -import { resolveToolsConfig } from "./tools-config.js"; +import { + createFeishuToolClient, + resolveAnyEnabledFeishuToolsConfig, + resolveFeishuToolAccount, +} from "./tool-account.js"; // ============ Helpers ============ @@ -455,31 +457,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { return; } - // Use first account's config for tools configuration (registration-time defaults only) - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + // Register if enabled on any account; account routing is resolved per execution. + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); const registered: string[] = []; type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string }; - const resolveAccount = ( - params: { accountId?: string } | undefined, - defaultAccountId: string | undefined, - ) => { - const accountId = - typeof params?.accountId === "string" && params.accountId.trim().length > 0 - ? params.accountId.trim() - : defaultAccountId; - return resolveFeishuAccount({ cfg: api.config!, accountId }); - }; - const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) => - createFeishuClient(resolveAccount(params, defaultAccountId)); + createFeishuToolClient({ api, executeParams: params, defaultAccountId }); const getMediaMaxBytes = ( params: { accountId?: string } | undefined, defaultAccountId?: string, - ) => (resolveAccount(params, defaultAccountId).config?.mediaMaxMb ?? 30) * 1024 * 1024; + ) => + (resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config + ?.mediaMaxMb ?? 30) * + 1024 * + 1024; // Main document tool with action-based dispatch if (toolsCfg.doc) { diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index beefceba3..d4bde43af 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -1,9 +1,8 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; // ============ Helpers ============ @@ -180,45 +179,51 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.drive) { api.logger.debug?.("feishu_drive: drive tool disabled in config"); return; } - const getClient = () => createFeishuClient(firstAccount); + type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string }; api.registerTool( - { - name: "feishu_drive", - label: "Feishu Drive", - description: - "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete", - parameters: FeishuDriveSchema, - async execute(_toolCallId, params) { - const p = params as FeishuDriveParams; - try { - const client = getClient(); - switch (p.action) { - case "list": - return json(await listFolder(client, p.folder_token)); - case "info": - return json(await getFileInfo(client, p.file_token)); - case "create_folder": - return json(await createFolder(client, p.name, p.folder_token)); - case "move": - return json(await moveFile(client, p.file_token, p.type, p.folder_token)); - case "delete": - return json(await deleteFile(client, p.file_token, p.type)); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_drive", + label: "Feishu Drive", + description: + "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete", + parameters: FeishuDriveSchema, + async execute(_toolCallId, params) { + const p = params as FeishuDriveExecuteParams; + try { + const client = createFeishuToolClient({ + api, + executeParams: p, + defaultAccountId, + }); + switch (p.action) { + case "list": + return json(await listFolder(client, p.folder_token)); + case "info": + return json(await getFileInfo(client, p.file_token)); + case "create_folder": + return json(await createFolder(client, p.name, p.folder_token)); + case "move": + return json(await moveFile(client, p.file_token, p.type, p.folder_token)); + case "delete": + return json(await deleteFile(client, p.file_token, p.type)); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); } - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }, + }; }, { name: "feishu_drive" }, ); diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index f11fb9882..92c3bb8cd 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -1,9 +1,8 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; // ============ Helpers ============ @@ -129,42 +128,50 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) { return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.perm) { api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)"); return; } - const getClient = () => createFeishuClient(firstAccount); + type FeishuPermExecuteParams = FeishuPermParams & { accountId?: string }; api.registerTool( - { - name: "feishu_perm", - label: "Feishu Perm", - description: "Feishu permission management. Actions: list, add, remove", - parameters: FeishuPermSchema, - async execute(_toolCallId, params) { - const p = params as FeishuPermParams; - try { - const client = getClient(); - switch (p.action) { - case "list": - return json(await listMembers(client, p.token, p.type)); - case "add": - return json( - await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm), - ); - case "remove": - return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id)); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_perm", + label: "Feishu Perm", + description: "Feishu permission management. Actions: list, add, remove", + parameters: FeishuPermSchema, + async execute(_toolCallId, params) { + const p = params as FeishuPermExecuteParams; + try { + const client = createFeishuToolClient({ + api, + executeParams: p, + defaultAccountId, + }); + switch (p.action) { + case "list": + return json(await listMembers(client, p.token, p.type)); + case "add": + return json( + await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm), + ); + case "remove": + return json( + await removeMember(client, p.token, p.type, p.member_type, p.member_id), + ); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); } - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }, + }; }, { name: "feishu_perm" }, ); diff --git a/extensions/feishu/src/tool-account-routing.test.ts b/extensions/feishu/src/tool-account-routing.test.ts new file mode 100644 index 000000000..4baa66711 --- /dev/null +++ b/extensions/feishu/src/tool-account-routing.test.ts @@ -0,0 +1,111 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { registerFeishuBitableTools } from "./bitable.js"; +import { registerFeishuDriveTools } from "./drive.js"; +import { registerFeishuPermTools } from "./perm.js"; +import { createToolFactoryHarness } from "./tool-factory-test-harness.js"; +import { registerFeishuWikiTools } from "./wiki.js"; + +const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({ + __appId: account?.appId, +})); + +vi.mock("./client.js", () => ({ + createFeishuClient: (account: { appId?: string } | undefined) => createFeishuClientMock(account), +})); + +function createConfig(params: { + toolsA?: { + wiki?: boolean; + drive?: boolean; + perm?: boolean; + }; + toolsB?: { + wiki?: boolean; + drive?: boolean; + perm?: boolean; + }; +}): OpenClawPluginApi["config"] { + return { + channels: { + feishu: { + enabled: true, + accounts: { + a: { + appId: "app-a", + appSecret: "sec-a", + tools: params.toolsA, + }, + b: { + appId: "app-b", + appSecret: "sec-b", + tools: params.toolsB, + }, + }, + }, + }, + } as OpenClawPluginApi["config"]; +} + +describe("feishu tool account routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("wiki tool registers when first account disables it and routes to agentAccountId", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { wiki: false }, + toolsB: { wiki: true }, + }), + ); + registerFeishuWikiTools(api); + + const tool = resolveTool("feishu_wiki", { agentAccountId: "b" }); + await tool.execute("call", { action: "search" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("drive tool registers when first account disables it and routes to agentAccountId", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { drive: false }, + toolsB: { drive: true }, + }), + ); + registerFeishuDriveTools(api); + + const tool = resolveTool("feishu_drive", { agentAccountId: "b" }); + await tool.execute("call", { action: "unknown_action" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("perm tool registers when only second account enables it and routes to agentAccountId", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { perm: false }, + toolsB: { perm: true }, + }), + ); + registerFeishuPermTools(api); + + const tool = resolveTool("feishu_perm", { agentAccountId: "b" }); + await tool.execute("call", { action: "unknown_action" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("bitable tool routes to agentAccountId and allows explicit accountId override", async () => { + const { api, resolveTool } = createToolFactoryHarness(createConfig({})); + registerFeishuBitableTools(api); + + const tool = resolveTool("feishu_bitable_get_meta", { agentAccountId: "b" }); + await tool.execute("call-ctx", { url: "invalid-url" }); + await tool.execute("call-override", { url: "invalid-url", accountId: "a" }); + + expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-b"); + expect(createFeishuClientMock.mock.calls[1]?.[0]?.appId).toBe("app-a"); + }); +}); diff --git a/extensions/feishu/src/tool-account.ts b/extensions/feishu/src/tool-account.ts new file mode 100644 index 000000000..72b5db9b7 --- /dev/null +++ b/extensions/feishu/src/tool-account.ts @@ -0,0 +1,58 @@ +import type * as Lark from "@larksuiteoapi/node-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { resolveToolsConfig } from "./tools-config.js"; +import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js"; + +type AccountAwareParams = { accountId?: string }; + +function normalizeOptionalAccountId(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveFeishuToolAccount(params: { + api: Pick; + executeParams?: AccountAwareParams; + defaultAccountId?: string; +}): ResolvedFeishuAccount { + if (!params.api.config) { + throw new Error("Feishu config unavailable"); + } + return resolveFeishuAccount({ + cfg: params.api.config, + accountId: + normalizeOptionalAccountId(params.executeParams?.accountId) ?? + normalizeOptionalAccountId(params.defaultAccountId), + }); +} + +export function createFeishuToolClient(params: { + api: Pick; + executeParams?: AccountAwareParams; + defaultAccountId?: string; +}): Lark.Client { + return createFeishuClient(resolveFeishuToolAccount(params)); +} + +export function resolveAnyEnabledFeishuToolsConfig( + accounts: ResolvedFeishuAccount[], +): Required { + const merged: Required = { + doc: false, + wiki: false, + drive: false, + perm: false, + scopes: false, + }; + for (const account of accounts) { + const cfg = resolveToolsConfig(account.config.tools); + merged.doc = merged.doc || cfg.doc; + merged.wiki = merged.wiki || cfg.wiki; + merged.drive = merged.drive || cfg.drive; + merged.perm = merged.perm || cfg.perm; + merged.scopes = merged.scopes || cfg.scopes; + } + return merged; +} diff --git a/extensions/feishu/src/tool-factory-test-harness.ts b/extensions/feishu/src/tool-factory-test-harness.ts new file mode 100644 index 000000000..a945e0639 --- /dev/null +++ b/extensions/feishu/src/tool-factory-test-harness.ts @@ -0,0 +1,76 @@ +import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk"; + +type ToolContextLike = { + agentAccountId?: string; +}; + +type ToolFactoryLike = (ctx: ToolContextLike) => AnyAgentTool | AnyAgentTool[] | null | undefined; + +export type ToolLike = { + name: string; + execute: (toolCallId: string, params: unknown) => Promise | unknown; +}; + +type RegisteredTool = { + tool: AnyAgentTool | ToolFactoryLike; + opts?: { name?: string }; +}; + +function toToolList(value: AnyAgentTool | AnyAgentTool[] | null | undefined): AnyAgentTool[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function asToolLike(tool: AnyAgentTool, fallbackName?: string): ToolLike { + const candidate = tool as Partial; + const name = candidate.name ?? fallbackName; + const execute = candidate.execute; + if (!name || typeof execute !== "function") { + throw new Error(`Resolved tool is missing required fields (name=${String(name)})`); + } + return { + name, + execute: (toolCallId, params) => execute(toolCallId, params), + }; +} + +export function createToolFactoryHarness(cfg: OpenClawPluginApi["config"]) { + const registered: RegisteredTool[] = []; + + const api: Pick = { + config: cfg, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + registerTool: (tool, opts) => { + registered.push({ tool, opts }); + }, + }; + + const resolveTool = (name: string, ctx: ToolContextLike = {}): ToolLike => { + for (const entry of registered) { + if (entry.opts?.name === name && typeof entry.tool !== "function") { + return asToolLike(entry.tool, name); + } + + if (typeof entry.tool === "function") { + const builtTools = toToolList(entry.tool(ctx)); + const hit = builtTools.find((tool) => (tool as { name?: string }).name === name); + if (hit) { + return asToolLike(hit, name); + } + } else if ((entry.tool as { name?: string }).name === name) { + return asToolLike(entry.tool, name); + } + } + throw new Error(`Tool not registered: ${name}`); + }; + + return { + api: api as OpenClawPluginApi, + resolveTool, + }; +} diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index dc76bcc6d..0c4383b06 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -1,8 +1,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js"; // ============ Helpers ============ @@ -168,62 +167,68 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.wiki) { api.logger.debug?.("feishu_wiki: wiki tool disabled in config"); return; } - const getClient = () => createFeishuClient(firstAccount); + type FeishuWikiExecuteParams = FeishuWikiParams & { accountId?: string }; api.registerTool( - { - name: "feishu_wiki", - label: "Feishu Wiki", - description: - "Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename", - parameters: FeishuWikiSchema, - async execute(_toolCallId, params) { - const p = params as FeishuWikiParams; - try { - const client = getClient(); - switch (p.action) { - case "spaces": - return json(await listSpaces(client)); - case "nodes": - return json(await listNodes(client, p.space_id, p.parent_node_token)); - case "get": - return json(await getNode(client, p.token)); - case "search": - return json({ - error: - "Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.", - }); - case "create": - return json( - await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token), - ); - case "move": - return json( - await moveNode( - client, - p.space_id, - p.node_token, - p.target_space_id, - p.target_parent_token, - ), - ); - case "rename": - return json(await renameNode(client, p.space_id, p.node_token, p.title)); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback - return json({ error: `Unknown action: ${(p as any).action}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_wiki", + label: "Feishu Wiki", + description: + "Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename", + parameters: FeishuWikiSchema, + async execute(_toolCallId, params) { + const p = params as FeishuWikiExecuteParams; + try { + const client = createFeishuToolClient({ + api, + executeParams: p, + defaultAccountId, + }); + switch (p.action) { + case "spaces": + return json(await listSpaces(client)); + case "nodes": + return json(await listNodes(client, p.space_id, p.parent_node_token)); + case "get": + return json(await getNode(client, p.token)); + case "search": + return json({ + error: + "Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.", + }); + case "create": + return json( + await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token), + ); + case "move": + return json( + await moveNode( + client, + p.space_id, + p.node_token, + p.target_space_id, + p.target_parent_token, + ), + ); + case "rename": + return json(await renameNode(client, p.space_id, p.node_token, p.title)); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback + return json({ error: `Unknown action: ${(p as any).action}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); } - } catch (err) { - return json({ error: err instanceof Error ? err.message : String(err) }); - } - }, + }, + }; }, { name: "feishu_wiki" }, );