mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(feishu): support Docx table create/write + image/file upload actions in feishu_doc (#20304)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof FeishuDocSchema>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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 = "";
|
||||
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) });
|
||||
|
||||
Reference in New Issue
Block a user