diff --git a/CHANGELOG.md b/CHANGELOG.md index 6857047a623..73ed652bc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - 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. +- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1. ### Fixes diff --git a/extensions/feishu/skills/feishu-doc/SKILL.md b/extensions/feishu/skills/feishu-doc/SKILL.md index 13a790228af..d402233cca3 100644 --- a/extensions/feishu/skills/feishu-doc/SKILL.md +++ b/extensions/feishu/skills/feishu-doc/SKILL.md @@ -6,7 +6,7 @@ description: | # Feishu Document Tool -Single tool `feishu_doc` with action parameter for all document operations. +Single tool `feishu_doc` with action parameter for all document operations, including table creation for Docx. ## Token Extraction @@ -43,15 +43,22 @@ Appends markdown to end of document. ### Create Document ```json -{ "action": "create", "title": "New Document" } +{ "action": "create", "title": "New Document", "owner_open_id": "ou_xxx" } ``` With folder: ```json -{ "action": "create", "title": "New Document", "folder_token": "fldcnXXX" } +{ + "action": "create", + "title": "New Document", + "folder_token": "fldcnXXX", + "owner_open_id": "ou_xxx" +} ``` +**Important:** Always pass `owner_open_id` with the requesting user's `open_id` (from inbound metadata `sender_id`) so the user automatically gets `full_access` permission on the created document. Without this, only the bot app has access. + ### List Blocks ```json @@ -83,6 +90,105 @@ Returns full block data including tables, images. Use this to read structured co { "action": "delete_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" } ``` +### Create Table (Docx Table Block) + +```json +{ + "action": "create_table", + "doc_token": "ABC123def", + "row_size": 2, + "column_size": 2, + "column_width": [200, 200] +} +``` + +Optional: `parent_block_id` to insert under a specific block. + +### Write Table Cells + +```json +{ + "action": "write_table_cells", + "doc_token": "ABC123def", + "table_block_id": "doxcnTABLE", + "values": [ + ["A1", "B1"], + ["A2", "B2"] + ] +} +``` + +### Create Table With Values (One-step) + +```json +{ + "action": "create_table_with_values", + "doc_token": "ABC123def", + "row_size": 2, + "column_size": 2, + "column_width": [200, 200], + "values": [ + ["A1", "B1"], + ["A2", "B2"] + ] +} +``` + +Optional: `parent_block_id` to insert under a specific block. + +### Upload Image to Docx (from URL or local file) + +```json +{ + "action": "upload_image", + "doc_token": "ABC123def", + "url": "https://example.com/image.png" +} +``` + +Or local path with position control: + +```json +{ + "action": "upload_image", + "doc_token": "ABC123def", + "file_path": "/tmp/image.png", + "parent_block_id": "doxcnParent", + "index": 5 +} +``` + +Optional `index` (0-based) inserts the image at a specific position among sibling blocks. Omit to append at end. + +**Note:** Image display size is determined by the uploaded image's pixel dimensions. For small images (e.g. 480x270 GIFs), scale to 800px+ width before uploading to ensure proper display. + +### Upload File Attachment to Docx (from URL or local file) + +```json +{ + "action": "upload_file", + "doc_token": "ABC123def", + "url": "https://example.com/report.pdf" +} +``` + +Or local path: + +```json +{ + "action": "upload_file", + "doc_token": "ABC123def", + "file_path": "/tmp/report.pdf", + "filename": "Q1-report.pdf" +} +``` + +Rules: + +- exactly one of `url` / `file_path` +- optional `filename` override +- optional `parent_block_id` + ## Reading Workflow 1. Start with `action: "read"` - get plain text + statistics diff --git a/extensions/feishu/src/doc-schema.ts b/extensions/feishu/src/doc-schema.ts index abd63cd9f38..d92173c0ff5 100644 --- a/extensions/feishu/src/doc-schema.ts +++ b/extensions/feishu/src/doc-schema.ts @@ -50,6 +50,73 @@ export const FeishuDocSchema = Type.Union([ doc_token: Type.String({ description: "Document token" }), block_id: Type.String({ description: "Block ID" }), }), + Type.Object({ + action: Type.Literal("create_table"), + doc_token: Type.String({ description: "Document token" }), + parent_block_id: Type.Optional( + Type.String({ description: "Parent block ID (default: document root)" }), + ), + row_size: Type.Integer({ description: "Table row count", minimum: 1 }), + column_size: Type.Integer({ description: "Table column count", minimum: 1 }), + column_width: Type.Optional( + Type.Array(Type.Number({ minimum: 1 }), { + description: "Column widths in px (length should match column_size)", + }), + ), + }), + Type.Object({ + action: Type.Literal("write_table_cells"), + doc_token: Type.String({ description: "Document token" }), + table_block_id: Type.String({ description: "Table block ID" }), + values: Type.Array(Type.Array(Type.String()), { + description: "2D matrix values[row][col] to write into table cells", + minItems: 1, + }), + }), + Type.Object({ + action: Type.Literal("create_table_with_values"), + doc_token: Type.String({ description: "Document token" }), + parent_block_id: Type.Optional( + Type.String({ description: "Parent block ID (default: document root)" }), + ), + row_size: Type.Integer({ description: "Table row count", minimum: 1 }), + column_size: Type.Integer({ description: "Table column count", minimum: 1 }), + column_width: Type.Optional( + Type.Array(Type.Number({ minimum: 1 }), { + description: "Column widths in px (length should match column_size)", + }), + ), + values: Type.Array(Type.Array(Type.String()), { + description: "2D matrix values[row][col] to write into table cells", + minItems: 1, + }), + }), + Type.Object({ + action: Type.Literal("upload_image"), + doc_token: Type.String({ description: "Document token" }), + url: Type.Optional(Type.String({ description: "Remote image URL (http/https)" })), + file_path: Type.Optional(Type.String({ description: "Local image file path" })), + parent_block_id: Type.Optional( + Type.String({ description: "Parent block ID (default: document root)" }), + ), + filename: Type.Optional(Type.String({ description: "Optional filename override" })), + index: Type.Optional( + Type.Integer({ + minimum: 0, + description: "Insert position (0-based index among siblings). Omit to append.", + }), + ), + }), + Type.Object({ + action: Type.Literal("upload_file"), + doc_token: Type.String({ description: "Document token" }), + url: Type.Optional(Type.String({ description: "Remote file URL (http/https)" })), + file_path: Type.Optional(Type.String({ description: "Local file path" })), + parent_block_id: Type.Optional( + Type.String({ description: "Parent block ID (default: document root)" }), + ), + filename: Type.Optional(Type.String({ description: "Optional filename override" })), + }), ]); export type FeishuDocParams = Static; diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index f149dac450d..14e36e09c0a 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -1,3 +1,6 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const createFeishuClientMock = vi.hoisted(() => vi.fn()); @@ -24,6 +27,8 @@ describe("feishu_doc image fetch hardening", () => { const documentCreateMock = vi.hoisted(() => vi.fn()); const blockListMock = vi.hoisted(() => vi.fn()); const blockChildrenCreateMock = vi.hoisted(() => vi.fn()); + const blockChildrenGetMock = vi.hoisted(() => vi.fn()); + const blockChildrenBatchDeleteMock = vi.hoisted(() => vi.fn()); const driveUploadAllMock = vi.hoisted(() => vi.fn()); const permissionMemberCreateMock = vi.hoisted(() => vi.fn()); const blockPatchMock = vi.hoisted(() => vi.fn()); @@ -44,6 +49,8 @@ describe("feishu_doc image fetch hardening", () => { }, documentBlockChildren: { create: blockChildrenCreateMock, + get: blockChildrenGetMock, + batchDelete: blockChildrenBatchDeleteMock, }, }, drive: { @@ -83,6 +90,11 @@ describe("feishu_doc image fetch hardening", () => { }, }); + blockChildrenGetMock.mockResolvedValue({ + code: 0, + data: { items: [{ block_id: "placeholder_block_1" }] }, + }); + blockChildrenBatchDeleteMock.mockResolvedValue({ code: 0 }); driveUploadAllMock.mockResolvedValue({ file_token: "token_1" }); documentCreateMock.mockResolvedValue({ code: 0, @@ -235,4 +247,144 @@ describe("feishu_doc image fetch hardening", () => { expect(permissionMemberCreateMock).not.toHaveBeenCalled(); expect(result.details.owner_permission_added).toBeUndefined(); }); + + it("returns an error when create response omits document_id", async () => { + documentCreateMock.mockResolvedValueOnce({ + code: 0, + data: { document: { title: "Created Doc" } }, + }); + + 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(result.details.error).toContain("no document_id"); + }); + + it("uploads local file to doc via upload_file action", async () => { + blockChildrenCreateMock.mockResolvedValueOnce({ + code: 0, + data: { + children: [{ block_type: 23, block_id: "file_block_1" }], + }, + }); + + const localPath = join(tmpdir(), `feishu-docx-upload-${Date.now()}.txt`); + await fs.writeFile(localPath, "hello from local file", "utf8"); + + 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: "upload_file", + doc_token: "doc_1", + file_path: localPath, + filename: "test-local.txt", + }); + + expect(result.details.success).toBe(true); + expect(result.details.file_token).toBe("token_1"); + expect(result.details.file_name).toBe("test-local.txt"); + + expect(driveUploadAllMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + parent_type: "docx_file", + parent_node: "doc_1", + file_name: "test-local.txt", + }), + }), + ); + + await fs.unlink(localPath); + }); + + it("returns an error when upload_file cannot list placeholder siblings", async () => { + blockChildrenCreateMock.mockResolvedValueOnce({ + code: 0, + data: { + children: [{ block_type: 23, block_id: "file_block_1" }], + }, + }); + blockChildrenGetMock.mockResolvedValueOnce({ + code: 999, + msg: "list failed", + data: { items: [] }, + }); + + const localPath = join(tmpdir(), `feishu-docx-upload-fail-${Date.now()}.txt`); + await fs.writeFile(localPath, "hello from local file", "utf8"); + + try { + 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: "upload_file", + doc_token: "doc_1", + file_path: localPath, + filename: "test-local.txt", + }); + + expect(result.details.error).toBe("list failed"); + expect(driveUploadAllMock).not.toHaveBeenCalled(); + } finally { + await fs.unlink(localPath); + } + }); }); diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 298f23d5473..a934f1d3aa8 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -1,3 +1,5 @@ +import { promises as fs } from "node:fs"; +import { basename } from "node:path"; import { Readable } from "stream"; import type * as Lark from "@larksuiteoapi/node-sdk"; import { Type } from "@sinclair/typebox"; @@ -110,6 +112,7 @@ async function insertBlocks( docToken: string, blocks: any[], parentBlockId?: string, + index?: number, ): Promise<{ children: any[]; skipped: string[] }> { /* eslint-enable @typescript-eslint/no-explicit-any */ const { cleaned, skipped } = cleanBlocksForInsert(blocks); @@ -121,7 +124,10 @@ async function insertBlocks( const res = await client.docx.documentBlockChildren.create({ path: { document_id: docToken, block_id: blockId }, - data: { children: cleaned }, + data: { + children: cleaned, + ...(index !== undefined && { index }), + }, }); if (res.code !== 0) { throw new Error(res.msg); @@ -184,6 +190,39 @@ async function downloadImage(url: string, maxBytes: number): Promise { return fetched.buffer; } +async function resolveUploadInput( + url: string | undefined, + filePath: string | undefined, + maxBytes: number, + explicitFileName?: string, +): Promise<{ buffer: Buffer; fileName: string }> { + if (!url && !filePath) { + throw new Error("Either url or file_path is required"); + } + if (url && filePath) { + throw new Error("Provide only one of url or file_path"); + } + + if (url) { + const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes }); + const urlPath = new URL(url).pathname; + const guessed = urlPath.split("/").pop() || "upload.bin"; + return { + buffer: fetched.buffer, + fileName: explicitFileName || guessed, + }; + } + + const buffer = await fs.readFile(filePath!); + if (buffer.length > maxBytes) { + throw new Error(`Local file exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`); + } + return { + buffer, + fileName: explicitFileName || basename(filePath!), + }; +} + /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */ async function processImages( client: Lark.Client, @@ -227,6 +266,133 @@ async function processImages( return processed; } +async function uploadImageBlock( + client: Lark.Client, + docToken: string, + maxBytes: number, + url?: string, + filePath?: string, + parentBlockId?: string, + filename?: string, + index?: number, +) { + const blockId = parentBlockId ?? docToken; + + // Feishu API does not allow creating empty image blocks (block_type 27). + // Workaround: use markdown conversion to create a placeholder image block, + // then upload the real image and patch the block. + const placeholderMd = "![img](https://via.placeholder.com/800x600.png)"; + const converted = await convertMarkdown(client, placeholderMd); + const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds); + const { children: inserted } = await insertBlocks(client, docToken, sorted, blockId, index); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape + const imageBlock = inserted.find((b: any) => b.block_type === 27); + const imageBlockId = imageBlock?.block_id; + if (!imageBlockId) { + throw new Error("Failed to create image block via markdown placeholder"); + } + + const upload = await resolveUploadInput(url, filePath, maxBytes, filename); + const fileToken = await uploadImageToDocx(client, imageBlockId, upload.buffer, upload.fileName); + + const patchRes = await client.docx.documentBlock.patch({ + path: { document_id: docToken, block_id: imageBlockId }, + data: { + replace_image: { token: fileToken }, + }, + }); + if (patchRes.code !== 0) { + throw new Error(patchRes.msg); + } + + return { + success: true, + block_id: imageBlockId, + file_token: fileToken, + file_name: upload.fileName, + size: upload.buffer.length, + }; +} + +async function uploadFileBlock( + client: Lark.Client, + docToken: string, + maxBytes: number, + url?: string, + filePath?: string, + parentBlockId?: string, + filename?: string, +) { + const blockId = parentBlockId ?? docToken; + + // Feishu API does not allow creating empty file blocks (block_type 23). + // Workaround: create a placeholder text block, then replace it with file content. + // Actually, file blocks need a different approach: use markdown link as placeholder. + const upload = await resolveUploadInput(url, filePath, maxBytes, filename); + + // Create a placeholder text block first + const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`; + const converted = await convertMarkdown(client, placeholderMd); + const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds); + const { children: inserted } = await insertBlocks(client, docToken, sorted, blockId); + + // Get the first inserted block - we'll delete it and create the file in its place + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape + const placeholderBlock = inserted[0]; + if (!placeholderBlock?.block_id) { + throw new Error("Failed to create placeholder block for file upload"); + } + + // Delete the placeholder + const parentId = placeholderBlock.parent_id ?? blockId; + const childrenRes = await client.docx.documentBlockChildren.get({ + path: { document_id: docToken, block_id: parentId }, + }); + if (childrenRes.code !== 0) { + throw new Error(childrenRes.msg); + } + const items = childrenRes.data?.items ?? []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type + const placeholderIdx = items.findIndex( + (item: any) => item.block_id === placeholderBlock.block_id, + ); + if (placeholderIdx >= 0) { + const deleteRes = await client.docx.documentBlockChildren.batchDelete({ + path: { document_id: docToken, block_id: parentId }, + data: { start_index: placeholderIdx, end_index: placeholderIdx + 1 }, + }); + if (deleteRes.code !== 0) { + throw new Error(deleteRes.msg); + } + } + + // Upload file to Feishu drive + const fileRes = await client.drive.media.uploadAll({ + data: { + file_name: upload.fileName, + parent_type: "docx_file", + parent_node: docToken, + size: upload.buffer.length, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type + file: Readable.from(upload.buffer) as any, + }, + }); + + const fileToken = fileRes?.file_token; + if (!fileToken) { + throw new Error("File upload failed: no file_token returned"); + } + + return { + success: true, + file_token: fileToken, + file_name: upload.fileName, + size: upload.buffer.length, + note: "File uploaded to drive. Use the file_token to reference it. Direct file block creation is not supported by the Feishu API.", + }; +} + // ============ Actions ============ const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]); @@ -286,6 +452,9 @@ async function createDoc( } const doc = res.data?.document; const docToken = doc?.document_id; + if (!docToken) { + throw new Error("Document creation succeeded but no document_id was returned"); + } let ownerPermissionAdded = false; // Auto add owner permission if ownerOpenId is provided @@ -369,6 +538,177 @@ async function appendDoc( }; } +async function createTable( + client: Lark.Client, + docToken: string, + rowSize: number, + columnSize: number, + parentBlockId?: string, + columnWidth?: number[], +) { + if (columnWidth && columnWidth.length !== columnSize) { + throw new Error("column_width length must equal column_size"); + } + + const blockId = parentBlockId ?? docToken; + const res = await client.docx.documentBlockChildren.create({ + path: { document_id: docToken, block_id: blockId }, + data: { + children: [ + { + block_type: 31, + table: { + property: { + row_size: rowSize, + column_size: columnSize, + ...(columnWidth && columnWidth.length > 0 ? { column_width: columnWidth } : {}), + }, + }, + }, + ], + }, + }); + + if (res.code !== 0) { + throw new Error(res.msg); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return type + const tableBlock = (res.data?.children as any[] | undefined)?.find((b) => b.block_type === 31); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape may vary by version + const cells = (tableBlock?.children as any[] | undefined) ?? []; + + return { + success: true, + table_block_id: tableBlock?.block_id, + row_size: rowSize, + column_size: columnSize, + // row-major cell ids, if API returns them directly + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return type + table_cell_block_ids: cells.map((c: any) => c.block_id).filter(Boolean), + raw_children_count: res.data?.children?.length ?? 0, + }; +} + +async function writeTableCells( + client: Lark.Client, + docToken: string, + tableBlockId: string, + values: string[][], +) { + if (!values.length || !values[0]?.length) { + throw new Error("values must be a non-empty 2D array"); + } + + const tableRes = await client.docx.documentBlock.get({ + path: { document_id: docToken, block_id: tableBlockId }, + }); + if (tableRes.code !== 0) { + throw new Error(tableRes.msg); + } + + const tableBlock = tableRes.data?.block; + if (tableBlock?.block_type !== 31) { + throw new Error("table_block_id is not a table block"); + } + + // SDK types are loose here across versions + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block payload + const tableData = (tableBlock as any).table; + const rows = tableData?.property?.row_size as number | undefined; + const cols = tableData?.property?.column_size as number | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block payload + const cellIds = (tableData?.cells as any[] | undefined) ?? []; + + if (!rows || !cols || !cellIds.length) { + throw new Error( + "Table cell IDs unavailable from table block. Use list_blocks/get_block and pass explicit cell block IDs if needed.", + ); + } + + const writeRows = Math.min(values.length, rows); + let written = 0; + + for (let r = 0; r < writeRows; r++) { + const rowValues = values[r] ?? []; + const writeCols = Math.min(rowValues.length, cols); + + for (let c = 0; c < writeCols; c++) { + const cellId = cellIds[r * cols + c]; + if (!cellId) continue; + + // table cell is a container block: clear existing children, then create text child blocks + const childrenRes = await client.docx.documentBlockChildren.get({ + path: { document_id: docToken, block_id: cellId }, + }); + if (childrenRes.code !== 0) { + throw new Error(childrenRes.msg); + } + + const existingChildren = childrenRes.data?.items ?? []; + if (existingChildren.length > 0) { + const delRes = await client.docx.documentBlockChildren.batchDelete({ + path: { document_id: docToken, block_id: cellId }, + data: { start_index: 0, end_index: existingChildren.length }, + }); + if (delRes.code !== 0) { + throw new Error(delRes.msg); + } + } + + const text = rowValues[c] ?? ""; + const converted = await convertMarkdown(client, text); + const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds); + + if (sorted.length > 0) { + await insertBlocks(client, docToken, sorted, cellId); + } + + written++; + } + } + + return { + success: true, + table_block_id: tableBlockId, + cells_written: written, + table_size: { rows, cols }, + }; +} + +async function createTableWithValues( + client: Lark.Client, + docToken: string, + rowSize: number, + columnSize: number, + values: string[][], + parentBlockId?: string, + columnWidth?: number[], +) { + const created = await createTable( + client, + docToken, + rowSize, + columnSize, + parentBlockId, + columnWidth, + ); + + const tableBlockId = created.table_block_id; + if (!tableBlockId) { + throw new Error("create_table succeeded but table_block_id is missing"); + } + + const written = await writeTableCells(client, docToken, tableBlockId, values); + return { + success: true, + table_block_id: tableBlockId, + row_size: rowSize, + column_size: columnSize, + cells_written: written.cells_written, + }; +} + async function updateBlock( client: Lark.Client, docToken: string, @@ -517,7 +857,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { name: "feishu_doc", label: "Feishu Doc", description: - "Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block", + "Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block, create_table, write_table_cells, create_table_with_values, upload_image, upload_file", parameters: FeishuDocSchema, async execute(_toolCallId, params) { const p = params as FeishuDocExecuteParams; @@ -562,10 +902,61 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { 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)}` }); - } + case "create_table": + return json( + await createTable( + client, + p.doc_token, + p.row_size, + p.column_size, + p.parent_block_id, + p.column_width, + ), + ); + case "write_table_cells": + return json( + await writeTableCells(client, p.doc_token, p.table_block_id, p.values), + ); + case "create_table_with_values": + return json( + await createTableWithValues( + client, + p.doc_token, + p.row_size, + p.column_size, + p.values, + p.parent_block_id, + p.column_width, + ), + ); + case "upload_image": + return json( + await uploadImageBlock( + client, + p.doc_token, + getMediaMaxBytes(p, defaultAccountId), + p.url, + p.file_path, + p.parent_block_id, + p.filename, + p.index, + ), + ); + case "upload_file": + return json( + await uploadFileBlock( + client, + p.doc_token, + getMediaMaxBytes(p, defaultAccountId), + p.url, + p.file_path, + p.parent_block_id, + p.filename, + ), + ); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback + return json({ error: `Unknown action: ${(p as any).action}` }); } } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) });