feat(feishu): add markdown tables, insert, color_text, table ops, and image fixes

Extends feishu_doc on top of #20304 with capabilities that are not yet covered:

Markdown → native table rendering:
- write/append now use the Descendant API instead of Children API,
  enabling GFM markdown tables (block_type 31/32) to render as native
  Feishu tables automatically
- Adaptive column widths calculated from cell content (CJK chars 2x weight)
- Batch insertion for large documents (>1000 blocks, docx-batch-insert.ts)

New actions:
- insert: positional markdown insertion after a given block_id
- color_text: apply color/bold to a text block via [red]...[/red] markup
- insert_table_row / insert_table_column: add rows or columns to a table
- delete_table_rows / delete_table_columns: remove rows or columns
- merge_table_cells: merge a rectangular cell range

Image upload fixes (affects write, append, and upload_image):
- upload_image now accepts data URI and plain base64 in addition to
  url/file_path, covering DALL-E b64_json, canvas screenshots, etc.
- Fix: pass Buffer directly to drive.media.uploadAll instead of
  Readable.from(), which caused Content-Length mismatch for large images
- Fix: same Readable bug fixed in upload_file
- Fix: pass drive_route_token via extra field for correct multi-datacenter
  routing (per API docs: required when parent_node is a document block ID)
This commit is contained in:
Elarwei
2026-02-28 11:55:24 +08:00
committed by Tak Hoffman
parent 9b39490d6a
commit 3349034829
5 changed files with 988 additions and 42 deletions

View File

@@ -17,6 +17,14 @@ export const FeishuDocSchema = Type.Union([
doc_token: Type.String({ description: "Document token" }),
content: Type.String({ description: "Markdown content to append to end of document" }),
}),
Type.Object({
action: Type.Literal("insert"),
doc_token: Type.String({ description: "Document token" }),
content: Type.String({ description: "Markdown content to insert" }),
after_block_id: Type.String({
description: "Insert content after this block ID. Use list_blocks to find block IDs.",
}),
}),
Type.Object({
action: Type.Literal("create"),
title: Type.String({ description: "Document title" }),
@@ -50,6 +58,7 @@ export const FeishuDocSchema = Type.Union([
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Block ID" }),
}),
// Table creation (explicit structure)
Type.Object({
action: Type.Literal("create_table"),
doc_token: Type.String({ description: "Document token" }),
@@ -91,11 +100,60 @@ export const FeishuDocSchema = Type.Union([
minItems: 1,
}),
}),
// Table row/column manipulation
Type.Object({
action: Type.Literal("insert_table_row"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_index: Type.Optional(
Type.Number({ description: "Row index to insert at (-1 for end, default: -1)" }),
),
}),
Type.Object({
action: Type.Literal("insert_table_column"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
column_index: Type.Optional(
Type.Number({ description: "Column index to insert at (-1 for end, default: -1)" }),
),
}),
Type.Object({
action: Type.Literal("delete_table_rows"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_start: Type.Number({ description: "Start row index (0-based)" }),
row_count: Type.Optional(Type.Number({ description: "Number of rows to delete (default: 1)" })),
}),
Type.Object({
action: Type.Literal("delete_table_columns"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
column_start: Type.Number({ description: "Start column index (0-based)" }),
column_count: Type.Optional(
Type.Number({ description: "Number of columns to delete (default: 1)" }),
),
}),
Type.Object({
action: Type.Literal("merge_table_cells"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Table block ID" }),
row_start: Type.Number({ description: "Start row index" }),
row_end: Type.Number({ description: "End row index (exclusive)" }),
column_start: Type.Number({ description: "Start column index" }),
column_end: Type.Number({ description: "End column index (exclusive)" }),
}),
// Image / file upload
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" })),
image: Type.Optional(
Type.String({
description:
"Image as data URI (data:image/png;base64,...) or plain base64 string. Use instead of url/file_path for DALL-E outputs, canvas screenshots, etc.",
}),
),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
@@ -117,6 +175,16 @@ export const FeishuDocSchema = Type.Union([
),
filename: Type.Optional(Type.String({ description: "Optional filename override" })),
}),
// Text color / style
Type.Object({
action: Type.Literal("color_text"),
doc_token: Type.String({ description: "Document token" }),
block_id: Type.String({ description: "Text block ID to update" }),
content: Type.String({
description:
'Text with color markup. Tags: [red], [green], [blue], [orange], [yellow], [purple], [grey], [bold], [bg:yellow]. Example: "Revenue [green]+15%[/green] YoY"',
}),
}),
]);
export type FeishuDocParams = Static<typeof FeishuDocSchema>;

View File

@@ -0,0 +1,180 @@
/**
* Batch insertion for large Feishu documents (>1000 blocks).
*
* The Feishu Descendant API has a limit of 1000 blocks per request.
* This module handles splitting large documents into batches while
* preserving parent-child relationships between blocks.
*/
import type * as Lark from "@larksuiteoapi/node-sdk";
import { cleanBlocksForDescendant } from "./docx-table-ops.js";
export const BATCH_SIZE = 1000; // Feishu API limit per request
type Logger = { info?: (msg: string) => void };
/**
* Collect all descendant blocks for a given set of first-level block IDs.
* Recursively traverses the block tree to gather all children.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] {
const blockMap = new Map<string, any>();
for (const block of blocks) {
blockMap.set(block.block_id, block);
}
const result: any[] = [];
const visited = new Set<string>();
function collect(blockId: string) {
if (visited.has(blockId)) return;
visited.add(blockId);
const block = blockMap.get(blockId);
if (!block) return;
result.push(block);
// Recursively collect children
const children = block.children;
if (Array.isArray(children)) {
for (const childId of children) {
collect(childId);
}
} else if (typeof children === "string") {
collect(children);
}
}
for (const id of firstLevelIds) {
collect(id);
}
return result;
}
/**
* Insert a single batch of blocks using Descendant API.
*
* @param parentBlockId - Parent block to insert into (defaults to docToken)
* @param index - Position within parent's children (-1 = end)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
async function insertBatch(
client: Lark.Client,
docToken: string,
blocks: any[],
firstLevelBlockIds: string[],
parentBlockId: string = docToken,
index: number = -1,
): Promise<any[]> {
const descendants = cleanBlocksForDescendant(blocks);
if (descendants.length === 0) {
return [];
}
const res = await client.docx.documentBlockDescendant.create({
path: { document_id: docToken, block_id: parentBlockId },
data: {
children_id: firstLevelBlockIds,
descendants,
index,
},
});
if (res.code !== 0) {
throw new Error(`${res.msg} (code: ${res.code})`);
}
return res.data?.children ?? [];
}
/**
* Insert blocks in batches for large documents (>1000 blocks).
*
* Batches are split to ensure BOTH children_id AND descendants
* arrays stay under the 1000 block API limit.
*
* @param client - Feishu API client
* @param docToken - Document ID
* @param blocks - All blocks from Convert API
* @param firstLevelBlockIds - IDs of top-level blocks to insert
* @param logger - Optional logger for progress updates
* @param parentBlockId - Parent block to insert into (defaults to docToken = document root)
* @param startIndex - Starting position within parent (-1 = end). For multi-batch inserts,
* each batch advances this by the number of first-level IDs inserted so far.
* @returns Inserted children blocks and any skipped block IDs
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
export async function insertBlocksInBatches(
client: Lark.Client,
docToken: string,
blocks: any[],
firstLevelBlockIds: string[],
logger?: Logger,
parentBlockId: string = docToken,
startIndex: number = -1,
): Promise<{ children: any[]; skipped: string[] }> {
const allChildren: any[] = [];
// Build batches ensuring each batch has ≤1000 total descendants
const batches: { firstLevelIds: string[]; blocks: any[] }[] = [];
let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] };
const usedBlockIds = new Set<string>();
for (const firstLevelId of firstLevelBlockIds) {
const descendants = collectDescendants(blocks, [firstLevelId]);
const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id));
// If adding this first-level block would exceed limit, start new batch
if (
currentBatch.blocks.length + newBlocks.length > BATCH_SIZE &&
currentBatch.blocks.length > 0
) {
batches.push(currentBatch);
currentBatch = { firstLevelIds: [], blocks: [] };
}
// Add to current batch
currentBatch.firstLevelIds.push(firstLevelId);
for (const block of newBlocks) {
currentBatch.blocks.push(block);
usedBlockIds.add(block.block_id);
}
}
// Don't forget the last batch
if (currentBatch.blocks.length > 0) {
batches.push(currentBatch);
}
// Insert each batch, advancing index for position-aware inserts.
// When startIndex == -1 (append to end), each batch appends after the previous.
// When startIndex >= 0, each batch starts at startIndex + count of first-level IDs already inserted.
let currentIndex = startIndex;
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
logger?.info?.(
`feishu_doc: Inserting batch ${i + 1}/${batches.length} (${batch.blocks.length} blocks)...`,
);
const children = await insertBatch(
client,
docToken,
batch.blocks,
batch.firstLevelIds,
parentBlockId,
currentIndex,
);
allChildren.push(...children);
// Advance index only for explicit positions; -1 always means "after last inserted"
if (currentIndex !== -1) {
currentIndex += batch.firstLevelIds.length;
}
}
return { children: allChildren, skipped: [] };
}

View File

@@ -0,0 +1,135 @@
/**
* Colored text support for Feishu documents.
*
* Parses a simple color markup syntax and updates a text block
* with native Feishu text_run color styles.
*
* Syntax: [color]text[/color]
* Supported colors: red, orange, yellow, green, blue, purple, grey
*
* Example:
* "Revenue [green]+15%[/green] YoY, Costs [red]-3%[/red]"
*/
import type * as Lark from "@larksuiteoapi/node-sdk";
// Feishu text_color values (1-7)
const TEXT_COLOR: Record<string, number> = {
red: 1, // Pink (closest to red in Feishu)
orange: 2,
yellow: 3,
green: 4,
blue: 5,
purple: 6,
grey: 7,
gray: 7,
};
// Feishu background_color values (1-15)
const BACKGROUND_COLOR: Record<string, number> = {
red: 1,
orange: 2,
yellow: 3,
green: 4,
blue: 5,
purple: 6,
grey: 7,
gray: 7,
};
interface Segment {
text: string;
textColor?: number;
bgColor?: number;
bold?: boolean;
}
/**
* Parse color markup into segments.
*
* Supports:
* [red]text[/red] → red text
* [bg:yellow]text[/bg] → yellow background
* [bold]text[/bold] → bold
* [green bold]text[/green] → green + bold
*/
export function parseColorMarkup(content: string): Segment[] {
const segments: Segment[] = [];
// Match [tag]...[/tag] or plain text between tags
const tagPattern = /\[([^\]]+)\](.*?)\[\/(?:[^\]]+)\]|([^[]+)/gs;
let match;
while ((match = tagPattern.exec(content)) !== null) {
if (match[3] !== undefined) {
// Plain text segment
if (match[3]) {
segments.push({ text: match[3] });
}
} else {
// Tagged segment
const tagStr = match[1].toLowerCase().trim();
const text = match[2];
const tags = tagStr.split(/\s+/);
const segment: Segment = { text };
for (const tag of tags) {
if (tag.startsWith("bg:")) {
const color = tag.slice(3);
if (BACKGROUND_COLOR[color]) {
segment.bgColor = BACKGROUND_COLOR[color];
}
} else if (tag === "bold") {
segment.bold = true;
} else if (TEXT_COLOR[tag]) {
segment.textColor = TEXT_COLOR[tag];
}
}
if (text) {
segments.push(segment);
}
}
}
return segments;
}
/**
* Update a text block with colored segments.
*/
export async function updateColorText(
client: Lark.Client,
docToken: string,
blockId: string,
content: string,
) {
const segments = parseColorMarkup(content);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK type
const elements: any[] = segments.map((seg) => ({
text_run: {
content: seg.text,
text_element_style: {
...(seg.textColor && { text_color: seg.textColor }),
...(seg.bgColor && { background_color: seg.bgColor }),
...(seg.bold && { bold: true }),
},
},
}));
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { update_text_elements: { elements } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return {
success: true,
segments: segments.length,
block: res.data?.block,
};
}

View File

@@ -0,0 +1,293 @@
/**
* Table utilities and row/column manipulation operations for Feishu documents.
*
* Combines:
* - Adaptive column width calculation (content-proportional, CJK-aware)
* - Block cleaning for Descendant API (removes read-only fields)
* - Table row/column insert, delete, and merge operations
*/
import type * as Lark from "@larksuiteoapi/node-sdk";
// ============ Table Utilities ============
// Feishu table constraints
const MIN_COLUMN_WIDTH = 50; // Feishu API minimum
const MAX_COLUMN_WIDTH = 400; // Reasonable maximum for readability
const DEFAULT_TABLE_WIDTH = 730; // Approximate Feishu page content width
/**
* Calculate adaptive column widths based on cell content length.
*
* Algorithm:
* 1. For each column, find the max content length across all rows
* 2. Weight CJK characters as 2x width (they render wider)
* 3. Calculate proportional widths based on content length
* 4. Apply min/max constraints
* 5. Redistribute remaining space to fill total table width
*
* Total width is derived from the original column_width values returned
* by the Convert API, ensuring tables match Feishu's expected dimensions.
*
* @param blocks - Array of blocks from Convert API
* @param tableBlockId - The block_id of the table block
* @returns Array of column widths in pixels
*/
export function calculateAdaptiveColumnWidths(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
blocks: any[],
tableBlockId: string,
): number[] {
// Find the table block
const tableBlock = blocks.find((b) => b.block_id === tableBlockId && b.block_type === 31);
if (!tableBlock?.table?.property) {
return [];
}
const { row_size, column_size, column_width: originalWidths } = tableBlock.table.property;
// Use original total width from Convert API, or fall back to default
const totalWidth =
originalWidths && originalWidths.length > 0
? originalWidths.reduce((a: number, b: number) => a + b, 0)
: DEFAULT_TABLE_WIDTH;
const cellIds: string[] = tableBlock.children || [];
// Build block lookup map
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const blockMap = new Map<string, any>();
for (const block of blocks) {
blockMap.set(block.block_id, block);
}
// Extract text content from a table cell
function getCellText(cellId: string): string {
const cell = blockMap.get(cellId);
if (!cell?.children) return "";
let text = "";
const childIds = Array.isArray(cell.children) ? cell.children : [cell.children];
for (const childId of childIds) {
const child = blockMap.get(childId);
if (child?.text?.elements) {
for (const elem of child.text.elements) {
if (elem.text_run?.content) {
text += elem.text_run.content;
}
}
}
}
return text;
}
// Calculate weighted length (CJK chars count as 2)
// CJK (Chinese/Japanese/Korean) characters render ~2x wider than ASCII
function getWeightedLength(text: string): number {
return [...text].reduce((sum, char) => {
return sum + (char.charCodeAt(0) > 255 ? 2 : 1);
}, 0);
}
// Find max content length per column
const maxLengths: number[] = new Array(column_size).fill(0);
for (let row = 0; row < row_size; row++) {
for (let col = 0; col < column_size; col++) {
const cellIndex = row * column_size + col;
const cellId = cellIds[cellIndex];
if (cellId) {
const content = getCellText(cellId);
const length = getWeightedLength(content);
maxLengths[col] = Math.max(maxLengths[col], length);
}
}
}
// Handle empty table
const totalLength = maxLengths.reduce((a, b) => a + b, 0);
if (totalLength === 0) {
const equalWidth = Math.floor(totalWidth / column_size);
return new Array(column_size).fill(equalWidth);
}
// Calculate proportional widths
let widths = maxLengths.map((len) => {
const proportion = len / totalLength;
return Math.round(proportion * totalWidth);
});
// Apply min/max constraints
widths = widths.map((w) => Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, w)));
// Redistribute remaining space to fill total width
let remaining = totalWidth - widths.reduce((a, b) => a + b, 0);
while (remaining > 0) {
// Find columns that can still grow (not at max)
const growable = widths.map((w, i) => (w < MAX_COLUMN_WIDTH ? i : -1)).filter((i) => i >= 0);
if (growable.length === 0) break;
// Distribute evenly among growable columns
const perColumn = Math.floor(remaining / growable.length);
if (perColumn === 0) break;
for (const i of growable) {
const add = Math.min(perColumn, MAX_COLUMN_WIDTH - widths[i]);
widths[i] += add;
remaining -= add;
}
}
return widths;
}
/**
* Clean blocks for Descendant API with adaptive column widths.
*
* - Removes parent_id from all blocks
* - Fixes children type (string → array) for TableCell blocks
* - Removes merge_info (read-only, causes API error)
* - Calculates and applies adaptive column_width for tables
*
* @param blocks - Array of blocks from Convert API
* @returns Cleaned blocks ready for Descendant API
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function cleanBlocksForDescendant(blocks: any[]): any[] {
// Pre-calculate adaptive widths for all tables
const tableWidths = new Map<string, number[]>();
for (const block of blocks) {
if (block.block_type === 31) {
const widths = calculateAdaptiveColumnWidths(blocks, block.block_id);
tableWidths.set(block.block_id, widths);
}
}
return blocks.map((block) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { parent_id: _parentId, ...cleanBlock } = block;
// Fix: Convert API sometimes returns children as string for TableCell
if (cleanBlock.block_type === 32 && typeof cleanBlock.children === "string") {
cleanBlock.children = [cleanBlock.children];
}
// Clean table blocks
if (cleanBlock.block_type === 31 && cleanBlock.table) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { cells: _cells, ...tableWithoutCells } = cleanBlock.table;
const { row_size, column_size } = tableWithoutCells.property || {};
const adaptiveWidths = tableWidths.get(block.block_id);
cleanBlock.table = {
property: {
row_size,
column_size,
...(adaptiveWidths?.length && { column_width: adaptiveWidths }),
},
};
}
return cleanBlock;
});
}
// ============ Table Row/Column Operations ============
export async function insertTableRow(
client: Lark.Client,
docToken: string,
blockId: string,
rowIndex: number = -1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { insert_table_row: { row_index: rowIndex } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, block: res.data?.block };
}
export async function insertTableColumn(
client: Lark.Client,
docToken: string,
blockId: string,
columnIndex: number = -1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { insert_table_column: { column_index: columnIndex } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, block: res.data?.block };
}
export async function deleteTableRows(
client: Lark.Client,
docToken: string,
blockId: string,
rowStart: number,
rowCount: number = 1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: { delete_table_rows: { row_start_index: rowStart, row_end_index: rowStart + rowCount } },
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, rows_deleted: rowCount, block: res.data?.block };
}
export async function deleteTableColumns(
client: Lark.Client,
docToken: string,
blockId: string,
columnStart: number,
columnCount: number = 1,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
delete_table_columns: {
column_start_index: columnStart,
column_end_index: columnStart + columnCount,
},
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, columns_deleted: columnCount, block: res.data?.block };
}
export async function mergeTableCells(
client: Lark.Client,
docToken: string,
blockId: string,
rowStart: number,
rowEnd: number,
columnStart: number,
columnEnd: number,
) {
const res = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
data: {
merge_table_cells: {
row_start_index: rowStart,
row_end_index: rowEnd,
column_start_index: columnStart,
column_end_index: columnEnd,
},
},
});
if (res.code !== 0) {
throw new Error(res.msg);
}
return { success: true, block: res.data?.block };
}

View File

@@ -1,11 +1,22 @@
import { promises as fs } from "node:fs";
import { existsSync, promises as fs } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute } from "node:path";
import { basename } from "node:path";
import { Readable } from "stream";
import type * as Lark from "@larksuiteoapi/node-sdk";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
import { updateColorText } from "./docx-color-text.js";
import {
cleanBlocksForDescendant,
insertTableRow,
insertTableColumn,
deleteTableRows,
deleteTableColumns,
mergeTableCells,
} from "./docx-table-ops.js";
import { getFeishuRuntime } from "./runtime.js";
import {
createFeishuToolClient,
@@ -248,12 +259,14 @@ async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) {
const chunks = splitMarkdownByHeadings(markdown);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
const allBlocks: any[] = [];
const allFirstLevelBlockIds: string[] = [];
for (const chunk of chunks) {
const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk);
const sorted = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
allBlocks.push(...sorted);
allFirstLevelBlockIds.push(...firstLevelBlockIds);
}
return allBlocks;
return { blocks: allBlocks, firstLevelBlockIds: allFirstLevelBlockIds };
}
/** Insert blocks in batches of MAX_BLOCKS_PER_INSERT to avoid API 400 errors */
@@ -279,6 +292,41 @@ async function chunkedInsertBlocks(
return { children: allChildren, skipped: allSkipped };
}
type Logger = { info?: (msg: string) => void };
/**
* Insert blocks using the Descendant API (supports tables, nested lists, large docs).
* Unlike the Children API, this supports block_type 31/32 (Table/TableCell).
*
* @param parentBlockId - Parent block to insert into (defaults to docToken = document root)
* @param index - Position within parent's children (-1 = end, 0 = first)
*/
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
async function insertBlocksWithDescendant(
client: Lark.Client,
docToken: string,
blocks: any[],
firstLevelBlockIds: string[],
{ parentBlockId = docToken, index = -1 }: { parentBlockId?: string; index?: number } = {},
): Promise<{ children: any[] }> {
/* eslint-enable @typescript-eslint/no-explicit-any */
const descendants = cleanBlocksForDescendant(blocks);
if (descendants.length === 0) {
return { children: [] };
}
const res = await client.docx.documentBlockDescendant.create({
path: { document_id: docToken, block_id: parentBlockId },
data: { children_id: firstLevelBlockIds, descendants, index },
});
if (res.code !== 0) {
throw new Error(`${res.msg} (code: ${res.code})`);
}
return { children: res.data?.children ?? [] };
}
async function clearDocumentContent(client: Lark.Client, docToken: string) {
const existing = await client.docx.documentBlock.list({
path: { document_id: docToken },
@@ -310,6 +358,7 @@ async function uploadImageToDocx(
blockId: string,
imageBuffer: Buffer,
fileName: string,
docToken?: string,
): Promise<string> {
const res = await client.drive.media.uploadAll({
data: {
@@ -317,8 +366,15 @@ async function uploadImageToDocx(
parent_type: "docx_image",
parent_node: blockId,
size: imageBuffer.length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
file: Readable.from(imageBuffer) as any,
// Pass Buffer directly so form-data can calculate Content-Length correctly.
// Readable.from() produces a stream with unknown length, causing Content-Length
// mismatch that silently truncates uploads for images larger than ~1KB.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK file type
file: imageBuffer as any,
// Required when the document block belongs to a non-default datacenter:
// tells the drive service which document the block belongs to for routing.
// Per API docs: certain upload scenarios require the cloud document token.
...(docToken ? { extra: JSON.stringify({ drive_route_token: docToken }) } : {}),
},
});
@@ -339,9 +395,73 @@ async function resolveUploadInput(
filePath: string | undefined,
maxBytes: number,
explicitFileName?: string,
imageInput?: string, // data URI, plain base64, or local path
): Promise<{ buffer: Buffer; fileName: string }> {
const inputSources = (
[url ? "url" : null, filePath ? "file_path" : null, imageInput ? "image" : null] as (
| string
| null
)[]
).filter(Boolean);
if (inputSources.length > 1) {
throw new Error(`Provide only one image source; got: ${inputSources.join(", ")}`);
}
// data URI: data:image/png;base64,xxxx
if (imageInput?.startsWith("data:")) {
const [header, data] = imageInput.split(",");
const mimeMatch = header.match(/data:([^;]+)/);
const ext = mimeMatch?.[1]?.split("/")[1] ?? "png";
const buffer = Buffer.from(data, "base64");
if (buffer.length > maxBytes) {
throw new Error(`Image data URI exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`);
}
return { buffer, fileName: explicitFileName ?? `image.${ext}` };
}
// local path: ~, ./ and ../ are unambiguous (not in base64 alphabet).
// Absolute paths (/...) are supported but must exist on disk.
if (imageInput) {
const resolved = imageInput.startsWith("~") ? imageInput.replace(/^~/, homedir()) : imageInput;
const unambiguousPath =
imageInput.startsWith("~") || imageInput.startsWith("./") || imageInput.startsWith("../");
const absolutePath = isAbsolute(imageInput);
if (unambiguousPath || (absolutePath && existsSync(resolved))) {
const buffer = await fs.readFile(resolved);
if (buffer.length > maxBytes) {
throw new Error(`Local file exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`);
}
return { buffer, fileName: explicitFileName ?? basename(resolved) };
}
if (absolutePath && !existsSync(resolved)) {
throw new Error(
`File not found: "${resolved}". ` +
`If you intended to pass image binary data, use a data URI instead: data:image/jpeg;base64,...`,
);
}
}
// plain base64 string (standard base64 alphabet includes '+', '/', '=')
if (imageInput) {
const trimmed = imageInput.trim();
if (trimmed.length === 0 || !/^[A-Za-z0-9+/]+=*$/.test(trimmed)) {
throw new Error(
`Invalid base64: image input contains characters outside the standard base64 alphabet. ` +
`Use a data URI (data:image/png;base64,...) or a local file path instead.`,
);
}
const buffer = Buffer.from(trimmed, "base64");
if (buffer.length === 0) {
throw new Error("Base64 image decoded to empty buffer; check the input.");
}
if (buffer.length > maxBytes) {
throw new Error(`Base64 image exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`);
}
return { buffer, fileName: explicitFileName ?? "image.png" };
}
if (!url && !filePath) {
throw new Error("Either url or file_path is required");
throw new Error("Either url, file_path, or image (base64/data URI) must be provided");
}
if (url && filePath) {
throw new Error("Provide only one of url or file_path");
@@ -392,7 +512,7 @@ async function processImages(
const buffer = await downloadImage(url, maxBytes);
const urlPath = new URL(url).pathname;
const fileName = urlPath.split("/").pop() || `image_${i}.png`;
const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName, docToken);
await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: blockId },
@@ -419,32 +539,39 @@ async function uploadImageBlock(
parentBlockId?: string,
filename?: string,
index?: number,
imageInput?: string, // data URI, plain base64, or local path
) {
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);
// Step 1: Create an empty image block (block_type 27).
// Per Feishu FAQ: image token cannot be set at block creation time.
const insertRes = await client.docx.documentBlockChildren.create({
path: { document_id: docToken, block_id: parentBlockId ?? docToken },
params: { document_revision_id: -1 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK type
data: { children: [{ block_type: 27, image: {} as any }], index: index ?? -1 },
});
if (insertRes.code !== 0) {
throw new Error(`Failed to create image block: ${insertRes.msg}`);
}
// 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;
const imageBlockId = insertRes.data?.children?.find((b: any) => b.block_type === 27)?.block_id;
if (!imageBlockId) {
throw new Error("Failed to create image block via markdown placeholder");
throw new Error("Failed to create image block");
}
const upload = await resolveUploadInput(url, filePath, maxBytes, filename);
const fileToken = await uploadImageToDocx(client, imageBlockId, upload.buffer, upload.fileName);
// Step 2: Resolve and upload the image buffer.
const upload = await resolveUploadInput(url, filePath, maxBytes, filename, imageInput);
const fileToken = await uploadImageToDocx(
client,
imageBlockId,
upload.buffer,
upload.fileName,
docToken, // drive_route_token for multi-datacenter routing
);
// Step 3: Set the image token on the block.
const patchRes = await client.docx.documentBlock.patch({
path: { document_id: docToken, block_id: imageBlockId },
data: {
replace_image: { token: fileToken },
},
data: { replace_image: { token: fileToken } },
});
if (patchRes.code !== 0) {
throw new Error(patchRes.msg);
@@ -518,8 +645,8 @@ async function uploadFileBlock(
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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK file type
file: upload.buffer as any,
},
});
@@ -632,25 +759,34 @@ async function createDoc(
};
}
async function writeDoc(client: Lark.Client, docToken: string, markdown: string, maxBytes: number) {
async function writeDoc(
client: Lark.Client,
docToken: string,
markdown: string,
maxBytes: number,
logger?: Logger,
) {
const deleted = await clearDocumentContent(client, docToken);
const blocks = await chunkedConvertMarkdown(client, markdown);
logger?.info?.("feishu_doc: Converting markdown...");
const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
if (blocks.length === 0) {
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
}
const { children: inserted, skipped } = await chunkedInsertBlocks(client, docToken, blocks);
logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted } =
blocks.length > BATCH_SIZE
? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger)
: await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
return {
success: true,
blocks_deleted: deleted,
blocks_added: inserted.length,
blocks_added: blocks.length,
images_processed: imagesProcessed,
...(skipped.length > 0 && {
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
}),
};
}
@@ -659,24 +795,104 @@ async function appendDoc(
docToken: string,
markdown: string,
maxBytes: number,
logger?: Logger,
) {
const blocks = await chunkedConvertMarkdown(client, markdown);
logger?.info?.("feishu_doc: Converting markdown...");
const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
if (blocks.length === 0) {
throw new Error("Content is empty");
}
const { children: inserted, skipped } = await chunkedInsertBlocks(client, docToken, blocks);
logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted } =
blocks.length > BATCH_SIZE
? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger)
: await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds);
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
return {
success: true,
blocks_added: inserted.length,
blocks_added: blocks.length,
images_processed: imagesProcessed,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
block_ids: inserted.map((b: any) => b.block_id),
};
}
async function insertDoc(
client: Lark.Client,
docToken: string,
markdown: string,
afterBlockId: string,
maxBytes: number,
logger?: Logger,
) {
const blockInfo = await client.docx.documentBlock.get({
path: { document_id: docToken, block_id: afterBlockId },
});
if (blockInfo.code !== 0) throw new Error(blockInfo.msg);
const parentId = blockInfo.data?.block?.parent_id ?? docToken;
// documentBlockChildren.get pages at 200 entries; scan all pages so insert
// works when parent has lots of children.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
const items: any[] = [];
let pageToken: string | undefined;
do {
const childrenRes = await client.docx.documentBlockChildren.get({
path: { document_id: docToken, block_id: parentId },
params: pageToken ? { page_token: pageToken } : {},
});
if (childrenRes.code !== 0) throw new Error(childrenRes.msg);
items.push(...(childrenRes.data?.items ?? []));
pageToken = childrenRes.data?.page_token ?? undefined;
} while (pageToken);
const blockIndex = items.findIndex((item) => item.block_id === afterBlockId);
if (blockIndex === -1) {
throw new Error(
`after_block_id "${afterBlockId}" was not found among the children of parent block "${parentId}". ` +
`Use list_blocks to verify the block ID.`,
);
}
const insertIndex = blockIndex + 1;
logger?.info?.("feishu_doc: Converting markdown...");
const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
if (blocks.length === 0) throw new Error("Content is empty");
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
logger?.info?.(
`feishu_doc: Converted to ${blocks.length} blocks, inserting at index ${insertIndex}...`,
);
const { children: inserted } =
blocks.length > BATCH_SIZE
? await insertBlocksInBatches(
client,
docToken,
sortedBlocks,
firstLevelBlockIds,
logger,
parentId,
insertIndex,
)
: await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds, {
parentBlockId: parentId,
index: insertIndex,
});
const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
return {
success: true,
blocks_added: blocks.length,
images_processed: imagesProcessed,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
block_ids: inserted.map((b: any) => b.block_id),
...(skipped.length > 0 && {
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
}),
};
}
@@ -999,7 +1215,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, create_table, write_table_cells, create_table_with_values, upload_image, upload_file",
"Feishu document operations. Actions: read, write, append, insert, create, list_blocks, get_block, update_block, delete_block, create_table, write_table_cells, create_table_with_values, insert_table_row, insert_table_column, delete_table_rows, delete_table_columns, merge_table_cells, upload_image, upload_file, color_text",
parameters: FeishuDocSchema,
async execute(_toolCallId, params) {
const p = params as FeishuDocExecuteParams;
@@ -1015,6 +1231,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
api.logger,
),
);
case "append":
@@ -1024,6 +1241,18 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
p.doc_token,
p.content,
getMediaMaxBytes(p, defaultAccountId),
api.logger,
),
);
case "insert":
return json(
await insertDoc(
client,
p.doc_token,
p.content,
p.after_block_id,
getMediaMaxBytes(p, defaultAccountId),
api.logger,
),
);
case "create":
@@ -1082,6 +1311,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
p.parent_block_id,
p.filename,
p.index,
p.image, // data URI or plain base64
),
);
case "upload_file":
@@ -1096,6 +1326,46 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
p.filename,
),
);
case "color_text":
return json(await updateColorText(client, p.doc_token, p.block_id, p.content));
case "insert_table_row":
return json(await insertTableRow(client, p.doc_token, p.block_id, p.row_index));
case "insert_table_column":
return json(
await insertTableColumn(client, p.doc_token, p.block_id, p.column_index),
);
case "delete_table_rows":
return json(
await deleteTableRows(
client,
p.doc_token,
p.block_id,
p.row_start,
p.row_count,
),
);
case "delete_table_columns":
return json(
await deleteTableColumns(
client,
p.doc_token,
p.block_id,
p.column_start,
p.column_count,
),
);
case "merge_table_cells":
return json(
await mergeTableCells(
client,
p.doc_token,
p.block_id,
p.row_start,
p.row_end,
p.column_start,
p.column_end,
),
);
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });