fix(feishu): route doc tools by agent account

Previously feishu_doc always used accounts[0], so multi-account setups created docs under the first bot regardless of the calling agent.

This change resolves accountId via a before_tool_call hook (defaulting from agentAccountId) and selects the Feishu client per call.

Fixes #27321
This commit is contained in:
root
2026-02-26 08:27:23 +00:00
committed by Peter Steinberger
parent 8bdda7a651
commit 151ee6014a
4 changed files with 150 additions and 7 deletions

View File

@@ -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);

View File

@@ -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<OpenClawPluginApi> = {
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");
});
});

View File

@@ -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":

View File

@@ -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<string, unknown> | 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 },
};
};