diff --git a/CHANGELOG.md b/CHANGELOG.md index 717837c731b..0ae0fd65e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim. - Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras. - Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels..accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras. +- Feishu/Doc tools: route `feishu_doc` and `feishu_app_scopes` through the active agent account context (with explicit `accountId` override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725. - Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy. - Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673. - Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded `sendChatAction` retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber. diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 46733295561..7b2375acf54 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -52,7 +52,6 @@ const plugin = { register(api: OpenClawPluginApi) { setFeishuRuntime(api.runtime); api.registerChannel({ plugin: feishuPlugin }); - registerFeishuDocTools(api); registerFeishuWikiTools(api); registerFeishuDriveTools(api); diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts index b5eb940b630..d292b6f0c7f 100644 --- a/extensions/feishu/src/docx.account-selection.test.ts +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -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; +}; + +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: { @@ -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"); }); }); diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index 14f400fab08..bcf1774f086 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -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(); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index ae96b753171..b6c5c7f4ad1 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -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");