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:
XuHao
2026-02-28 08:00:56 +08:00
committed by GitHub
parent 1725839720
commit 56fa05838a
5 changed files with 726 additions and 9 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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>;

View File

@@ -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);
}
});
});

View File

@@ -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 = "![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) });