mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-22 13:48:10 +00:00
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:
@@ -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>;
|
||||
|
||||
180
extensions/feishu/src/docx-batch-insert.ts
Normal file
180
extensions/feishu/src/docx-batch-insert.ts
Normal 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: [] };
|
||||
}
|
||||
135
extensions/feishu/src/docx-color-text.ts
Normal file
135
extensions/feishu/src/docx-color-text.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
293
extensions/feishu/src/docx-table-ops.ts
Normal file
293
extensions/feishu/src/docx-table-ops.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 = "";
|
||||
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}` });
|
||||
|
||||
Reference in New Issue
Block a user