From bf9585d0562afaec4ca1740d7b7fa7133d003345 Mon Sep 17 00:00:00 2001 From: zhoulc777 <65058500+zhoulongchao77@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:34:18 +0800 Subject: [PATCH] PR: Feishu Plugin - Auto-grant document permissions to requesting user (openclaw#28295) thanks @zhoulongchao77 Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: zhoulongchao77 <65058500+zhoulongchao77@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/doc-schema.ts | 8 ++ extensions/feishu/src/docx.test.ts | 114 ++++++++++++++++++++++++++++ extensions/feishu/src/docx.ts | 49 +++++++++++- 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1531b6f4a..4679c37ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz. - Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus. - Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus. +- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77. ### Fixes diff --git a/extensions/feishu/src/doc-schema.ts b/extensions/feishu/src/doc-schema.ts index 811835f75..abd63cd9f 100644 --- a/extensions/feishu/src/doc-schema.ts +++ b/extensions/feishu/src/doc-schema.ts @@ -21,6 +21,14 @@ export const FeishuDocSchema = Type.Union([ action: Type.Literal("create"), title: Type.String({ description: "Document title" }), folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })), + owner_open_id: Type.Optional( + Type.String({ description: "Open ID of the user to grant ownership permission" }), + ), + owner_perm_type: Type.Optional( + Type.Union([Type.Literal("view"), Type.Literal("edit"), Type.Literal("full_access")], { + description: "Permission type (default: full_access)", + }), + ), }), Type.Object({ action: Type.Literal("list_blocks"), diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index bcf1774f0..f149dac45 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -21,9 +21,11 @@ import { registerFeishuDocTools } from "./docx.js"; describe("feishu_doc image fetch hardening", () => { const convertMock = vi.hoisted(() => vi.fn()); + const documentCreateMock = vi.hoisted(() => vi.fn()); const blockListMock = vi.hoisted(() => vi.fn()); const blockChildrenCreateMock = vi.hoisted(() => vi.fn()); const driveUploadAllMock = vi.hoisted(() => vi.fn()); + const permissionMemberCreateMock = vi.hoisted(() => vi.fn()); const blockPatchMock = vi.hoisted(() => vi.fn()); const scopeListMock = vi.hoisted(() => vi.fn()); @@ -34,6 +36,7 @@ describe("feishu_doc image fetch hardening", () => { docx: { document: { convert: convertMock, + create: documentCreateMock, }, documentBlock: { list: blockListMock, @@ -47,6 +50,9 @@ describe("feishu_doc image fetch hardening", () => { media: { uploadAll: driveUploadAllMock, }, + permissionMember: { + create: permissionMemberCreateMock, + }, }, application: { scope: { @@ -78,6 +84,11 @@ describe("feishu_doc image fetch hardening", () => { }); driveUploadAllMock.mockResolvedValue({ file_token: "token_1" }); + documentCreateMock.mockResolvedValue({ + code: 0, + data: { document: { document_id: "doc_created", title: "Created Doc" } }, + }); + permissionMemberCreateMock.mockResolvedValue({ code: 0 }); blockPatchMock.mockResolvedValue({ code: 0 }); scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } }); }); @@ -121,4 +132,107 @@ describe("feishu_doc image fetch hardening", () => { expect(consoleErrorSpy).toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); + + it("reports owner permission details when grant succeeds", async () => { + const registerTool = vi.fn(); + registerFeishuDocTools({ + config: { + channels: { + feishu: { + appId: "app_id", + appSecret: "app_secret", + }, + }, + } as any, + logger: { debug: vi.fn(), info: vi.fn() } as any, + registerTool, + } as any); + + 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(); + + const result = await feishuDocTool.execute("tool-call", { + action: "create", + title: "Demo", + owner_open_id: "ou_123", + owner_perm_type: "edit", + }); + + expect(permissionMemberCreateMock).toHaveBeenCalled(); + expect(result.details.owner_permission_added).toBe(true); + expect(result.details.owner_open_id).toBe("ou_123"); + expect(result.details.owner_perm_type).toBe("edit"); + }); + + it("does not report owner permission details when grant fails", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + permissionMemberCreateMock.mockRejectedValueOnce(new Error("permission denied")); + + const registerTool = vi.fn(); + registerFeishuDocTools({ + config: { + channels: { + feishu: { + appId: "app_id", + appSecret: "app_secret", + }, + }, + } as any, + logger: { debug: vi.fn(), info: vi.fn() } as any, + registerTool, + } as any); + + 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(); + + const result = await feishuDocTool.execute("tool-call", { + action: "create", + title: "Demo", + owner_open_id: "ou_123", + owner_perm_type: "edit", + }); + + expect(permissionMemberCreateMock).toHaveBeenCalled(); + expect(result.details.owner_permission_added).toBeUndefined(); + expect(result.details.owner_open_id).toBeUndefined(); + expect(result.details.owner_perm_type).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + + it("skips permission grant when owner_open_id is omitted", async () => { + const registerTool = vi.fn(); + registerFeishuDocTools({ + config: { + channels: { + feishu: { + appId: "app_id", + appSecret: "app_secret", + }, + }, + } as any, + logger: { debug: vi.fn(), info: vi.fn() } as any, + registerTool, + } as any); + + 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(); + + const result = await feishuDocTool.execute("tool-call", { + action: "create", + title: "Demo", + }); + + expect(permissionMemberCreateMock).not.toHaveBeenCalled(); + expect(result.details.owner_permission_added).toBeUndefined(); + }); }); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 33cfe924d..298f23d54 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -271,7 +271,13 @@ async function readDoc(client: Lark.Client, docToken: string) { }; } -async function createDoc(client: Lark.Client, title: string, folderToken?: string) { +async function createDoc( + client: Lark.Client, + title: string, + folderToken?: string, + ownerOpenId?: string, + ownerPermType: "view" | "edit" | "full_access" = "full_access", +) { const res = await client.docx.document.create({ data: { title, folder_token: folderToken }, }); @@ -279,10 +285,37 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin throw new Error(res.msg); } const doc = res.data?.document; + const docToken = doc?.document_id; + let ownerPermissionAdded = false; + + // Auto add owner permission if ownerOpenId is provided + if (docToken && ownerOpenId) { + try { + await client.drive.permissionMember.create({ + path: { token: docToken }, + params: { type: "docx", need_notification: false }, + data: { + member_type: "openid", + member_id: ownerOpenId, + perm: ownerPermType, + }, + }); + ownerPermissionAdded = true; + } catch (err) { + console.warn("Failed to add owner permission (non-critical):", err); + } + } + return { - document_id: doc?.document_id, + document_id: docToken, title: doc?.title, - url: `https://feishu.cn/docx/${doc?.document_id}`, + url: `https://feishu.cn/docx/${docToken}`, + ...(ownerOpenId && + ownerPermissionAdded && { + owner_permission_added: true, + owner_open_id: ownerOpenId, + owner_perm_type: ownerPermType, + }), }; } @@ -512,7 +545,15 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { ), ); case "create": - return json(await createDoc(client, p.title, p.folder_token)); + return json( + await createDoc( + client, + p.title, + p.folder_token, + p.owner_open_id, + p.owner_perm_type, + ), + ); case "list_blocks": return json(await listBlocks(client, p.doc_token)); case "get_block":