fix: route feishu doc tools by agent account context (#27338) (thanks @AaronL725)

This commit is contained in:
Peter Steinberger
2026-02-26 13:00:15 +01:00
parent 58c100f66f
commit 39b5ffdaa6
5 changed files with 136 additions and 73 deletions

View File

@@ -52,7 +52,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setFeishuRuntime(api.runtime);
api.registerChannel({ plugin: feishuPlugin });
registerFeishuDocTools(api);
registerFeishuWikiTools(api);
registerFeishuDriveTools(api);

View File

@@ -1,25 +1,41 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, test, vi } from "vitest";
import { registerFeishuDocTools } from "./docx.js";
const createFeishuClientMock = vi.fn((creds: any) => ({ __appId: creds?.appId }));
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
__appId: creds?.appId,
}));
vi.mock("./client.js", () => {
return {
createFeishuClient: (creds: any) => createFeishuClientMock(creds),
createFeishuClient: (creds: { appId?: string } | undefined) => 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.
// Patch SDK import so tool execution can run without network concerns.
vi.mock("@larksuiteoapi/node-sdk", () => {
return {
default: {},
};
});
function fakeApi(cfg: any) {
const tools: Array<{ name: string; execute: (id: string, params: any) => any }> = [];
type ToolLike = {
name: string;
execute: (toolCallId: string, params: unknown) => Promise<unknown>;
};
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<OpenClawPluginApi> = {
config: cfg,
logger: {
@@ -28,16 +44,31 @@ function fakeApi(cfg: any) {
error: () => {},
debug: () => {},
},
registerTool: (tool: any) => {
tools.push({ name: tool.name, execute: tool.execute });
return undefined as any;
registerTool: (tool, opts) => {
registered.push({ tool, opts });
},
};
return { api: api as OpenClawPluginApi, tools };
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 accountId param to pick correct account when multiple accounts configured", async () => {
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = {
channels: {
feishu: {
@@ -48,44 +79,45 @@ describe("feishu_doc account selection", () => {
},
},
},
};
} as OpenClawPluginApi["config"];
const { api, tools } = fakeApi(cfg);
const { api, resolveTool } = createApi(cfg);
registerFeishuDocTools(api);
const tool = tools.find((t) => t.name === "feishu_doc");
expect(tool).toBeTruthy();
const docToolA = resolveTool("feishu_doc", { agentAccountId: "a" });
const docToolB = resolveTool("feishu_doc", { agentAccountId: "b" });
// 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" });
await docToolA.execute("call-a", { action: "list_blocks", doc_token: "d" });
await docToolB.execute("call-b", { action: "list_blocks", doc_token: "d" });
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 () => {
test("explicit accountId param overrides agentAccountId context", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
default: { appId: "app-d", appSecret: "sec-d", tools: { doc: true } },
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
};
} as OpenClawPluginApi["config"];
const { api, tools } = fakeApi(cfg);
const { api, resolveTool } = createApi(cfg);
registerFeishuDocTools(api);
const tool = tools.find((t) => t.name === "feishu_doc");
expect(tool).toBeTruthy();
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
await docTool.execute("call-override", {
action: "list_blocks",
doc_token: "d",
accountId: "a",
});
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");
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
});
});

View File

@@ -104,6 +104,7 @@ describe("feishu_doc image fetch hardening", () => {
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();

View File

@@ -460,53 +460,83 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
const registered: string[] = [];
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
const resolveAccount = (params: FeishuDocParams) =>
resolveFeishuAccount({ cfg: api.config!, accountId: (params as any).accountId });
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: FeishuDocParams) => createFeishuClient(resolveAccount(params));
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
createFeishuClient(resolveAccount(params, defaultAccountId));
const getMediaMaxBytes = (params: FeishuDocParams) =>
(resolveAccount(params).config?.mediaMaxMb ?? 30) * 1024 * 1024;
const getMediaMaxBytes = (
params: { accountId?: string } | undefined,
defaultAccountId?: string,
) => (resolveAccount(params, defaultAccountId).config?.mediaMaxMb ?? 30) * 1024 * 1024;
// Main document tool with action-based dispatch
if (toolsCfg.doc) {
api.registerTool(
{
name: "feishu_doc",
label: "Feishu Doc",
description:
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
parameters: FeishuDocSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDocParams;
try {
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, getMediaMaxBytes(p)));
case "append":
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":
return json(await listBlocks(client, p.doc_token));
case "get_block":
return json(await getBlock(client, p.doc_token, p.block_id));
case "update_block":
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
case "delete_block":
return json(await deleteBlock(client, p.doc_token, p.block_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_doc",
label: "Feishu Doc",
description:
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
parameters: FeishuDocSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDocExecuteParams;
try {
const client = getClient(p, defaultAccountId);
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,
getMediaMaxBytes(p, defaultAccountId),
),
);
case "append":
return json(
await appendDoc(
client,
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
),
);
case "create":
return json(await createDoc(client, p.title, p.folder_token));
case "list_blocks":
return json(await listBlocks(client, p.doc_token));
case "get_block":
return json(await getBlock(client, p.doc_token, p.block_id));
case "update_block":
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
case "delete_block":
return json(await deleteBlock(client, p.doc_token, p.block_id));
default: {
const exhaustiveCheck: never = p;
return json({ error: `Unknown action: ${String(exhaustiveCheck)}` });
}
}
} 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_doc" },
);
@@ -516,7 +546,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
// Keep feishu_app_scopes as independent tool
if (toolsCfg.scopes) {
api.registerTool(
{
(ctx) => ({
name: "feishu_app_scopes",
label: "Feishu App Scopes",
description:
@@ -524,13 +554,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
parameters: Type.Object({}),
async execute() {
try {
const result = await listAppScopes(getClient({ action: "create", title: "" } as any));
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
return json(result);
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
}
},
},
}),
{ name: "feishu_app_scopes" },
);
registered.push("feishu_app_scopes");