diff --git a/extensions/feishu/index.secretref.test.ts b/extensions/feishu/index.secretref.test.ts new file mode 100644 index 00000000000..36260b20e29 --- /dev/null +++ b/extensions/feishu/index.secretref.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import { createPluginRuntimeMock } from "../test-utils/plugin-runtime-mock.js"; +import plugin from "./index.js"; + +describe("feishu plugin register SecretRef regression", () => { + it("does not resolve SecretRefs while registering tools", () => { + const api = createTestPluginApi({ + id: plugin.id, + name: plugin.name, + source: "extensions/feishu/index.ts", + config: { + channels: { + feishu: { + enabled: true, + accounts: { + main: { + appId: "app-id", + appSecret: { source: "file", provider: "default", id: "path/to/app-secret" }, + tools: { + chat: true, + doc: true, + drive: true, + perm: true, + wiki: true, + }, + }, + }, + }, + }, + } as never, + runtime: createPluginRuntimeMock(), + }); + + expect(() => plugin.register(api)).not.toThrow(); + }); +}); diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index cfe8d0abcdc..fcaee6b740c 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + listEnabledFeishuAccountConfigs, resolveDefaultFeishuAccountId, resolveDefaultFeishuAccountSelection, resolveFeishuAccount, @@ -369,3 +370,82 @@ describe("resolveFeishuAccount", () => { ).not.toThrow(); }); }); + +describe("listEnabledFeishuAccountConfigs", () => { + it("treats SecretRef-backed accounts as configured without resolving them", () => { + const accounts = listEnabledFeishuAccountConfigs({ + channels: { + feishu: { + enabled: true, + appId: "app-id", + appSecret: { source: "file", provider: "default", id: "path/to/app-secret" }, + accounts: { + main: { + enabled: true, + tools: { doc: true }, + }, + }, + }, + }, + } as never); + + expect(accounts).toHaveLength(1); + expect(accounts[0]).toEqual( + expect.objectContaining({ + accountId: "main", + enabled: true, + configured: true, + config: expect.objectContaining({ + tools: { doc: true }, + }), + }), + ); + }); + + it("does not treat SecretRef-style appId objects as configured in config-only preflight", () => { + const accounts = listEnabledFeishuAccountConfigs({ + channels: { + feishu: { + enabled: true, + appId: { source: "file", provider: "default", id: "path/to/app-id" } as never, + appSecret: { source: "file", provider: "default", id: "path/to/app-secret" }, + accounts: { + main: { + enabled: true, + tools: { doc: true }, + }, + }, + }, + }, + } as never); + + expect(accounts).toHaveLength(0); + }); + + it("preserves inherited tools flags when account tools only override a subset", () => { + const accounts = listEnabledFeishuAccountConfigs({ + channels: { + feishu: { + enabled: true, + appId: "app", + appSecret: "secret", // pragma: allowlist secret + tools: { + chat: false, + }, + accounts: { + main: { + enabled: true, + tools: { doc: true }, + }, + }, + }, + }, + } as never); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.config.tools).toEqual({ + chat: false, + doc: true, + }); + }); +}); diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts index ede2be08635..6886ab39890 100644 --- a/extensions/feishu/src/accounts.ts +++ b/extensions/feishu/src/accounts.ts @@ -1,9 +1,14 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { ClawdbotConfig } from "../runtime-api.js"; -import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js"; +import { + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "./secret-input.js"; import type { FeishuConfig, FeishuAccountConfig, + FeishuAccountSelectionSource, FeishuDefaultAccountSelectionSource, FeishuDomain, ResolvedFeishuAccount, @@ -95,8 +100,62 @@ function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): Feish // Get account-specific overrides const account = resolveAccountConfig(cfg, accountId) ?? {}; - // Merge: account config overrides base config - return { ...base, ...account } as FeishuConfig; + // Merge account overrides over base config while preserving nested tools defaults. + const merged = { ...base, ...account } as FeishuConfig; + const baseTools = base.tools; + const accountTools = account.tools; + if (baseTools || accountTools) { + merged.tools = { + ...(baseTools ?? {}), + ...(accountTools ?? {}), + }; + } + return merged; +} + +export function resolveFeishuAccountConfigState(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): { + accountId: string; + selectionSource: FeishuAccountSelectionSource; + enabled: boolean; + configured: boolean; + name?: string; + domain: FeishuDomain; + config: FeishuConfig; +} { + const hasExplicitAccountId = + typeof params.accountId === "string" && params.accountId.trim() !== ""; + const defaultSelection = hasExplicitAccountId + ? null + : resolveDefaultFeishuAccountSelection(params.cfg); + const accountId = hasExplicitAccountId + ? normalizeAccountId(params.accountId) + : (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID); + const selectionSource: FeishuAccountSelectionSource = hasExplicitAccountId + ? "explicit" + : (defaultSelection?.source ?? "fallback"); + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + + const baseEnabled = feishuCfg?.enabled !== false; + const merged = mergeFeishuAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const configured = Boolean( + normalizeSecretInputString(merged.appId) && hasConfiguredSecretInput(merged.appSecret), + ); + const accountName = (merged as FeishuAccountConfig).name; + + return { + accountId, + selectionSource, + enabled, + configured, + name: typeof accountName === "string" ? accountName.trim() || undefined : undefined, + domain: merged.domain ?? "feishu", + config: merged, + }; } /** @@ -192,48 +251,42 @@ export function resolveFeishuAccount(params: { cfg: ClawdbotConfig; accountId?: string | null; }): ResolvedFeishuAccount { - const hasExplicitAccountId = - typeof params.accountId === "string" && params.accountId.trim() !== ""; - const defaultSelection = hasExplicitAccountId - ? null - : resolveDefaultFeishuAccountSelection(params.cfg); - const accountId = hasExplicitAccountId - ? normalizeAccountId(params.accountId) - : (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID); - const selectionSource = hasExplicitAccountId - ? "explicit" - : (defaultSelection?.source ?? "fallback"); - const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; - - // Base enabled state (top-level) - const baseEnabled = feishuCfg?.enabled !== false; - - // Merge configs - const merged = mergeFeishuAccountConfig(params.cfg, accountId); - - // Account-level enabled state - const accountEnabled = merged.enabled !== false; - const enabled = baseEnabled && accountEnabled; - - // Resolve credentials from merged config - const creds = resolveFeishuCredentials(merged); - const accountName = (merged as FeishuAccountConfig).name; + const state = resolveFeishuAccountConfigState(params); + const creds = resolveFeishuCredentials(state.config); return { - accountId, - selectionSource, - enabled, + accountId: state.accountId, + selectionSource: state.selectionSource, + enabled: state.enabled, configured: Boolean(creds), - name: typeof accountName === "string" ? accountName.trim() || undefined : undefined, + name: state.name, appId: creds?.appId, appSecret: creds?.appSecret, encryptKey: creds?.encryptKey, verificationToken: creds?.verificationToken, - domain: creds?.domain ?? "feishu", - config: merged, + domain: creds?.domain ?? state.domain, + config: state.config, }; } +/** + * List all enabled accounts that appear configured from raw config input alone. + * This preflight path intentionally does not resolve SecretRefs. + */ +export function listEnabledFeishuAccountConfigs(cfg: ClawdbotConfig): Array<{ + accountId: string; + selectionSource: FeishuAccountSelectionSource; + enabled: boolean; + configured: boolean; + name?: string; + domain: FeishuDomain; + config: FeishuConfig; +}> { + return listFeishuAccountIds(cfg) + .map((accountId) => resolveFeishuAccountConfigState({ cfg, accountId })) + .filter((account) => account.enabled && account.configured); +} + /** * List all enabled and configured accounts. */ diff --git a/extensions/feishu/src/bitable.ts b/extensions/feishu/src/bitable.ts index 451839edb1f..3518d96d97e 100644 --- a/extensions/feishu/src/bitable.ts +++ b/extensions/feishu/src/bitable.ts @@ -1,7 +1,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "../runtime-api.js"; -import { listEnabledFeishuAccounts } from "./accounts.js"; +import { listEnabledFeishuAccountConfigs } from "./accounts.js"; import { createFeishuToolClient } from "./tool-account.js"; // ============ Helpers ============ @@ -538,7 +538,7 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) { return; } - const accounts = listEnabledFeishuAccounts(api.config); + const accounts = listEnabledFeishuAccountConfigs(api.config); if (accounts.length === 0) { api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools"); return; diff --git a/extensions/feishu/src/chat-schema.ts b/extensions/feishu/src/chat-schema.ts index 5460f11dcc9..702773d4b73 100644 --- a/extensions/feishu/src/chat-schema.ts +++ b/extensions/feishu/src/chat-schema.ts @@ -9,6 +9,11 @@ export const FeishuChatSchema = Type.Object({ enum: [...CHAT_ACTION_VALUES], description: "Action to run: members | info | member_info", }), + accountId: Type.Optional( + Type.String({ + description: "Optional Feishu account override for multi-account setups", + }), + ), chat_id: Type.Optional(Type.String({ description: "Chat ID (from URL or event payload)" })), member_id: Type.Optional(Type.String({ description: "Member ID for member_info lookups" })), page_size: Type.Optional(Type.Number({ description: "Page size (1-100, default 50)" })), diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts index d06442b12f8..1fb05c6c3e9 100644 --- a/extensions/feishu/src/chat.test.ts +++ b/extensions/feishu/src/chat.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerFeishuChatTools } from "./chat.js"; +import { createToolFactoryHarness } from "./tool-factory-test-harness.js"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); const chatGetMock = vi.hoisted(() => vi.fn()); @@ -25,8 +26,7 @@ describe("registerFeishuChatTools", () => { }); it("registers feishu_chat and handles info/members actions", async () => { - const registerTool = vi.fn(); - registerFeishuChatTools({ + const { api, resolveTool } = createToolFactoryHarness({ config: { channels: { feishu: { @@ -37,13 +37,10 @@ describe("registerFeishuChatTools", () => { }, }, } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); + }); + registerFeishuChatTools(api); - expect(registerTool).toHaveBeenCalledTimes(1); - const tool = registerTool.mock.calls[0]?.[0]; - expect(tool?.name).toBe("feishu_chat"); + const tool = resolveTool("feishu_chat"); chatGetMock.mockResolvedValueOnce({ code: 0, @@ -97,8 +94,7 @@ describe("registerFeishuChatTools", () => { }); it("skips registration when chat tool is disabled", () => { - const registerTool = vi.fn(); - registerFeishuChatTools({ + const { api, resolveTool } = createToolFactoryHarness({ config: { channels: { feishu: { @@ -109,9 +105,8 @@ describe("registerFeishuChatTools", () => { }, }, } as any, - logger: { debug: vi.fn(), info: vi.fn() } as any, - registerTool, - } as any); - expect(registerTool).not.toHaveBeenCalled(); + }); + registerFeishuChatTools(api); + expect(() => resolveTool("feishu_chat")).toThrow("Tool not registered: feishu_chat"); }); }); diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts index b32dfb41230..2706ed9d08b 100644 --- a/extensions/feishu/src/chat.ts +++ b/extensions/feishu/src/chat.ts @@ -1,9 +1,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "../runtime-api.js"; -import { listEnabledFeishuAccounts } from "./accounts.js"; +import { listEnabledFeishuAccountConfigs } from "./accounts.js"; import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js"; -import { createFeishuClient } from "./client.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { + createFeishuToolClient, + isFeishuToolEnabledForRoutedAccount, + resolveAnyEnabledFeishuToolsConfig, + resolveFeishuToolAccountConfigState, +} from "./tool-account.js"; function json(data: unknown) { return { @@ -126,64 +130,87 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { return; } - const accounts = listEnabledFeishuAccounts(api.config); + const accounts = listEnabledFeishuAccountConfigs(api.config); if (accounts.length === 0) { api.logger.debug?.("feishu_chat: No Feishu accounts configured, skipping chat tools"); return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.chat) { api.logger.debug?.("feishu_chat: chat tool disabled in config"); return; } - const getClient = () => createFeishuClient(firstAccount); + type FeishuChatExecuteParams = FeishuChatParams & { accountId?: string }; api.registerTool( - { - name: "feishu_chat", - label: "Feishu Chat", - description: "Feishu chat operations. Actions: members, info, member_info", - parameters: FeishuChatSchema, - async execute(_toolCallId, params) { - const p = params as FeishuChatParams; - try { - const client = getClient(); - switch (p.action) { - case "members": - if (!p.chat_id) { - return json({ error: "chat_id is required for action members" }); - } - return json( - await getChatMembers( - client, - p.chat_id, - p.page_size, - p.page_token, - p.member_id_type, - ), - ); - case "info": - if (!p.chat_id) { - return json({ error: "chat_id is required for action info" }); - } - return json(await getChatInfo(client, p.chat_id)); - case "member_info": - if (!p.member_id) { - return json({ error: "member_id is required for action member_info" }); - } - return json( - await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"), - ); - default: - return json({ error: `Unknown action: ${String(p.action)}` }); + (ctx) => { + const defaultAccountId = ctx.agentAccountId; + return { + name: "feishu_chat", + label: "Feishu Chat", + description: "Feishu chat operations. Actions: members, info, member_info", + parameters: FeishuChatSchema, + async execute(_toolCallId, params) { + const p = params as FeishuChatExecuteParams; + try { + const account = resolveFeishuToolAccountConfigState({ + api, + executeParams: p, + defaultAccountId, + }); + if ( + !isFeishuToolEnabledForRoutedAccount({ + api, + executeParams: p, + defaultAccountId, + tool: "chat", + }) + ) { + return json({ + error: `Feishu chat is disabled for account "${account.accountId}".`, + }); + } + const client = createFeishuToolClient({ + api, + executeParams: p, + defaultAccountId, + }); + switch (p.action) { + case "members": + if (!p.chat_id) { + return json({ error: "chat_id is required for action members" }); + } + return json( + await getChatMembers( + client, + p.chat_id, + p.page_size, + p.page_token, + p.member_id_type, + ), + ); + case "info": + if (!p.chat_id) { + return json({ error: "chat_id is required for action info" }); + } + return json(await getChatInfo(client, p.chat_id)); + case "member_info": + if (!p.member_id) { + return json({ error: "member_id is required for action member_info" }); + } + return json( + await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"), + ); + default: + return json({ error: `Unknown action: ${String(p.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_chat" }, ); diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts index 6ac1b9dbfa5..6487c2cff0e 100644 --- a/extensions/feishu/src/docx.account-selection.test.ts +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -5,6 +5,16 @@ import { createToolFactoryHarness } from "./tool-factory-test-harness.js"; const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({ __appId: creds?.appId, + application: { + scope: { + list: vi.fn().mockResolvedValue({ + code: 0, + data: { + scopes: [], + }, + }), + }, + }, })); vi.mock("./client.js", () => { @@ -67,4 +77,68 @@ describe("feishu_doc account selection", () => { expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a"); }); + + test("blocks execution when configured defaultAccount disables doc", async () => { + const cfg = { + channels: { + feishu: { + enabled: true, + defaultAccount: "b", + accounts: { + a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret + b: { appId: "app-b", appSecret: "sec-b", tools: { doc: false } }, // pragma: allowlist secret + }, + }, + }, + } as OpenClawPluginApi["config"]; + + const { api, resolveTool } = createToolFactoryHarness(cfg); + registerFeishuDocTools(api); + + const docTool = resolveTool("feishu_doc", { agentAccountId: "a" }); + const result = await docTool.execute("call-disabled", { + action: "list_blocks", + doc_token: "d", + }); + + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + error: 'Feishu doc is disabled for account "b".', + }), + }), + ); + expect(createFeishuClientMock).not.toHaveBeenCalled(); + }); + + test("feishu_app_scopes allows explicit accountId override when defaultAccount disables scopes", async () => { + const cfg = { + channels: { + feishu: { + enabled: true, + defaultAccount: "b", + accounts: { + a: { appId: "app-a", appSecret: "sec-a", tools: { scopes: true } }, // pragma: allowlist secret + b: { appId: "app-b", appSecret: "sec-b", tools: { scopes: false } }, // pragma: allowlist secret + }, + }, + }, + } as OpenClawPluginApi["config"]; + + const { api, resolveTool } = createToolFactoryHarness(cfg); + registerFeishuDocTools(api); + + const scopesTool = resolveTool("feishu_app_scopes", { agentAccountId: "a" }); + await scopesTool.execute("call-enabled", { accountId: "a" }); + const blocked = await scopesTool.execute("call-blocked", {}); + + expect(createFeishuClientMock.mock.calls[0]?.[0]?.appId).toBe("app-a"); + expect(blocked).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + error: 'Feishu scopes are disabled for account "b".', + }), + }), + ); + }); }); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 7debd446a14..f1fea67d30c 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -5,7 +5,7 @@ import { basename } from "node:path"; import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "../runtime-api.js"; -import { listEnabledFeishuAccounts } from "./accounts.js"; +import { listEnabledFeishuAccountConfigs } from "./accounts.js"; import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js"; import { updateColorText } from "./docx-color-text.js"; @@ -20,7 +20,9 @@ import { import { getFeishuRuntime } from "./runtime.js"; import { createFeishuToolClient, + isFeishuToolEnabledForRoutedAccount, resolveAnyEnabledFeishuToolsConfig, + resolveFeishuToolAccountConfigState, resolveFeishuToolAccount, } from "./tool-account.js"; @@ -1233,7 +1235,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { } // Check if any account is configured - const accounts = listEnabledFeishuAccounts(api.config); + const accounts = listEnabledFeishuAccountConfigs(api.config); if (accounts.length === 0) { api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools"); return; @@ -1273,6 +1275,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { async execute(_toolCallId, params) { const p = params as FeishuDocExecuteParams; try { + const account = resolveFeishuToolAccountConfigState({ + api, + executeParams: p, + defaultAccountId, + }); + if ( + !isFeishuToolEnabledForRoutedAccount({ + api, + executeParams: p, + defaultAccountId, + tool: "doc", + }) + ) { + return json({ + error: `Feishu doc is disabled for account "${account.accountId}".`, + }); + } const client = getClient(p, defaultAccountId); switch (p.action) { case "read": @@ -1439,10 +1458,30 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { label: "Feishu App Scopes", description: "List current app permissions (scopes). Use to debug permission issues or check available capabilities.", - parameters: Type.Object({}), - async execute() { + parameters: Type.Object({ + accountId: Type.Optional(Type.String()), + }), + async execute(_toolCallId, params) { + const p = params as { accountId?: string }; try { - const result = await listAppScopes(getClient(undefined, ctx.agentAccountId)); + const account = resolveFeishuToolAccountConfigState({ + api, + executeParams: p, + defaultAccountId: ctx.agentAccountId, + }); + if ( + !isFeishuToolEnabledForRoutedAccount({ + api, + executeParams: p, + defaultAccountId: ctx.agentAccountId, + tool: "scopes", + }) + ) { + return json({ + error: `Feishu scopes are disabled for account "${account.accountId}".`, + }); + } + const result = await listAppScopes(getClient(p, ctx.agentAccountId)); return json(result); } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) }); diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index 495b6aaaef9..842015fec8f 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -1,8 +1,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "../runtime-api.js"; -import { listEnabledFeishuAccounts } from "./accounts.js"; +import { listEnabledFeishuAccountConfigs } from "./accounts.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; -import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; +import { + createFeishuToolClient, + isFeishuToolEnabledForRoutedAccount, + resolveAnyEnabledFeishuToolsConfig, + resolveFeishuToolAccountConfigState, +} from "./tool-account.js"; import { jsonToolResult, toolExecutionErrorResult, @@ -169,7 +174,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { return; } - const accounts = listEnabledFeishuAccounts(api.config); + const accounts = listEnabledFeishuAccountConfigs(api.config); if (accounts.length === 0) { api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools"); return; @@ -195,6 +200,23 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { async execute(_toolCallId, params) { const p = params as FeishuDriveExecuteParams; try { + const account = resolveFeishuToolAccountConfigState({ + api, + executeParams: p, + defaultAccountId, + }); + if ( + !isFeishuToolEnabledForRoutedAccount({ + api, + executeParams: p, + defaultAccountId, + tool: "drive", + }) + ) { + return jsonToolResult({ + error: `Feishu drive is disabled for account "${account.accountId}".`, + }); + } const client = createFeishuToolClient({ api, executeParams: p, diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index a9d2e062eec..edee640bb9f 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -1,8 +1,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "../runtime-api.js"; -import { listEnabledFeishuAccounts } from "./accounts.js"; +import { listEnabledFeishuAccountConfigs } from "./accounts.js"; import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js"; -import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; +import { + createFeishuToolClient, + isFeishuToolEnabledForRoutedAccount, + resolveAnyEnabledFeishuToolsConfig, + resolveFeishuToolAccountConfigState, +} from "./tool-account.js"; import { jsonToolResult, toolExecutionErrorResult, @@ -118,7 +123,7 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) { return; } - const accounts = listEnabledFeishuAccounts(api.config); + const accounts = listEnabledFeishuAccountConfigs(api.config); if (accounts.length === 0) { api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools"); return; @@ -143,6 +148,23 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) { async execute(_toolCallId, params) { const p = params as FeishuPermExecuteParams; try { + const account = resolveFeishuToolAccountConfigState({ + api, + executeParams: p, + defaultAccountId, + }); + if ( + !isFeishuToolEnabledForRoutedAccount({ + api, + executeParams: p, + defaultAccountId, + tool: "perm", + }) + ) { + return jsonToolResult({ + error: `Feishu perm is disabled for account "${account.accountId}".`, + }); + } const client = createFeishuToolClient({ api, executeParams: p, diff --git a/extensions/feishu/src/tool-account-routing.test.ts b/extensions/feishu/src/tool-account-routing.test.ts index 6cc9172de3e..ba4bd4a1552 100644 --- a/extensions/feishu/src/tool-account-routing.test.ts +++ b/extensions/feishu/src/tool-account-routing.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { registerFeishuBitableTools } from "./bitable.js"; +import { registerFeishuChatTools } from "./chat.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 chatGetMock = vi.fn(); const createFeishuClientMock = vi.fn((account: { appId?: string } | undefined) => ({ __appId: account?.appId, + im: { + chat: { get: chatGetMock }, + }, })); vi.mock("./client.js", () => ({ @@ -16,11 +21,13 @@ vi.mock("./client.js", () => ({ function createConfig(params: { toolsA?: { + chat?: boolean; wiki?: boolean; drive?: boolean; perm?: boolean; }; toolsB?: { + chat?: boolean; wiki?: boolean; drive?: boolean; perm?: boolean; @@ -100,6 +107,101 @@ describe("feishu tool account routing", () => { expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); }); + test("chat tool registers when first account disables it and routes to agentAccountId", async () => { + chatGetMock.mockResolvedValue({ code: 0, data: { name: "chat", user_count: 1 } }); + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { chat: false }, + toolsB: { chat: true }, + }), + ); + registerFeishuChatTools(api); + + const tool = resolveTool("feishu_chat", { agentAccountId: "b" }); + await tool.execute("call", { action: "info", chat_id: "oc_b" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("chat tool prefers configured defaultAccount over inherited default account context", async () => { + chatGetMock.mockResolvedValue({ code: 0, data: { name: "chat", user_count: 1 } }); + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + defaultAccount: "b", + toolsA: { chat: true }, + toolsB: { chat: true }, + }), + ); + registerFeishuChatTools(api); + + const tool = resolveTool("feishu_chat", { agentAccountId: "a" }); + await tool.execute("call", { action: "info", chat_id: "oc_b" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b"); + }); + + test("chat tool allows explicit accountId override when defaultAccount disables chat", async () => { + chatGetMock.mockResolvedValue({ code: 0, data: { name: "chat", user_count: 1 } }); + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + defaultAccount: "b", + toolsA: { chat: true }, + toolsB: { chat: false }, + }), + ); + registerFeishuChatTools(api); + + const tool = resolveTool("feishu_chat", { agentAccountId: "a" }); + await tool.execute("call", { action: "info", chat_id: "oc_a", accountId: "a" }); + + expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a"); + }); + + test("chat tool blocks execution when the routed account disables chat", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + toolsA: { chat: true }, + toolsB: { chat: false }, + }), + ); + registerFeishuChatTools(api); + + const tool = resolveTool("feishu_chat", { agentAccountId: "b" }); + const result = await tool.execute("call", { action: "info", chat_id: "oc_b" }); + + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + error: 'Feishu chat is disabled for account "b".', + }), + }), + ); + expect(createFeishuClientMock).not.toHaveBeenCalled(); + }); + + test("chat tool checks the same routed account precedence as execution", async () => { + const { api, resolveTool } = createToolFactoryHarness( + createConfig({ + defaultAccount: "b", + toolsA: { chat: true }, + toolsB: { chat: false }, + }), + ); + registerFeishuChatTools(api); + + const tool = resolveTool("feishu_chat", { agentAccountId: "a" }); + const result = await tool.execute("call", { action: "info", chat_id: "oc_b" }); + + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + error: 'Feishu chat is disabled for account "b".', + }), + }), + ); + expect(createFeishuClientMock).not.toHaveBeenCalled(); + }); + test("perm tool registers when only second account enables it and routes to agentAccountId", async () => { const { api, resolveTool } = createToolFactoryHarness( createConfig({ diff --git a/extensions/feishu/src/tool-account.ts b/extensions/feishu/src/tool-account.ts index dff71b424dc..a6d3b84d15d 100644 --- a/extensions/feishu/src/tool-account.ts +++ b/extensions/feishu/src/tool-account.ts @@ -1,6 +1,6 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "../runtime-api.js"; -import { resolveFeishuAccount } from "./accounts.js"; +import { resolveFeishuAccount, resolveFeishuAccountConfigState } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { resolveToolsConfig } from "./tools-config.js"; import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js"; @@ -38,6 +38,33 @@ export function resolveFeishuToolAccount(params: { }); } +export function resolveFeishuToolAccountConfigState(params: { + api: Pick; + executeParams?: AccountAwareParams; + defaultAccountId?: string; +}) { + if (!params.api.config) { + throw new Error("Feishu config unavailable"); + } + return resolveFeishuAccountConfigState({ + cfg: params.api.config, + accountId: + normalizeOptionalAccountId(params.executeParams?.accountId) ?? + readConfiguredDefaultAccountId(params.api.config) ?? + normalizeOptionalAccountId(params.defaultAccountId), + }); +} + +export function isFeishuToolEnabledForRoutedAccount(params: { + api: Pick; + executeParams?: AccountAwareParams; + defaultAccountId?: string; + tool: keyof Required; +}): boolean { + const account = resolveFeishuToolAccountConfigState(params); + return resolveToolsConfig(account.config.tools)[params.tool]; +} + export function createFeishuToolClient(params: { api: Pick; executeParams?: AccountAwareParams; @@ -47,7 +74,7 @@ export function createFeishuToolClient(params: { } export function resolveAnyEnabledFeishuToolsConfig( - accounts: ResolvedFeishuAccount[], + accounts: Array<{ config: { tools?: FeishuToolsConfig } }>, ): Required { const merged: Required = { doc: false, diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index a2df89ff0fe..1ec4187987c 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -1,7 +1,12 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "../runtime-api.js"; -import { listEnabledFeishuAccounts } from "./accounts.js"; -import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; +import { listEnabledFeishuAccountConfigs } from "./accounts.js"; +import { + createFeishuToolClient, + isFeishuToolEnabledForRoutedAccount, + resolveAnyEnabledFeishuToolsConfig, + resolveFeishuToolAccountConfigState, +} from "./tool-account.js"; import { jsonToolResult, toolExecutionErrorResult, @@ -157,7 +162,7 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { return; } - const accounts = listEnabledFeishuAccounts(api.config); + const accounts = listEnabledFeishuAccountConfigs(api.config); if (accounts.length === 0) { api.logger.debug?.("feishu_wiki: No Feishu accounts configured, skipping wiki tools"); return; @@ -183,6 +188,23 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { async execute(_toolCallId, params) { const p = params as FeishuWikiExecuteParams; try { + const account = resolveFeishuToolAccountConfigState({ + api, + executeParams: p, + defaultAccountId, + }); + if ( + !isFeishuToolEnabledForRoutedAccount({ + api, + executeParams: p, + defaultAccountId, + tool: "wiki", + }) + ) { + return jsonToolResult({ + error: `Feishu wiki is disabled for account "${account.accountId}".`, + }); + } const client = createFeishuToolClient({ api, executeParams: p,