@max1874/feishu 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/docx.ts +283 -142
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max1874/feishu",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "license": "MIT",
package/src/docx.ts CHANGED
@@ -64,101 +64,115 @@ const BLOCK_TYPE_NAMES: Record<number, string> = {
64
64
  32: "TableCell",
65
65
  };
66
66
 
67
- // Block types that cannot be created via documentBlockChildren.create API
68
- const UNSUPPORTED_CREATE_TYPES = new Set([
69
- 31, // Table - must use different API or workaround
70
- 32, // TableCell - child of Table
71
- ]);
72
-
73
- /** Clean blocks for insertion (remove unsupported types and read-only fields) */
74
- function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
75
- const skipped: string[] = [];
76
- const cleaned = blocks
77
- .filter((block) => {
78
- if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) {
79
- const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`;
80
- skipped.push(typeName);
81
- return false;
82
- }
83
- return true;
84
- })
85
- .map((block) => {
86
- // Remove any read-only fields that might slip through
87
- if (block.block_type === 31 && block.table?.merge_info) {
88
- const { merge_info, ...tableRest } = block.table;
89
- return { ...block, table: tableRest };
90
- }
91
- return block;
92
- });
93
- return { cleaned, skipped };
67
+ // Block types that need special handling (not via standard documentBlockChildren.create)
68
+ const TABLE_BLOCK_TYPE = 31;
69
+ const TABLE_CELL_BLOCK_TYPE = 32;
70
+
71
+ /** Extracted table data from convert API result */
72
+ interface TableData {
73
+ rowSize: number;
74
+ colSize: number;
75
+ cells: string[][]; // 2D array of cell text content
94
76
  }
95
77
 
96
- // ============ Core Functions ============
78
+ /** Extract table data from converted blocks (flat list with ID references) */
79
+ function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBlocks: any[] } {
80
+ const tables: TableData[] = [];
81
+ const otherBlocks: any[] = [];
97
82
 
98
- /**
99
- * Convert Markdown tables to code blocks.
100
- * Feishu API doesn't support creating Table blocks directly, so we preserve
101
- * table formatting by wrapping them in code blocks.
102
- */
103
- function convertTablesToCodeBlocks(markdown: string): string {
104
- const lines = markdown.split("\n");
105
- const result: string[] = [];
106
- let tableLines: string[] = [];
107
- let inTable = false;
108
-
109
- const isTableLine = (line: string): boolean => {
110
- const trimmed = line.trim();
111
- // Table line starts with | or is a separator line like |---|---|
112
- return trimmed.startsWith("|") && trimmed.endsWith("|");
113
- };
83
+ // Build a map of block_id -> block for quick lookup
84
+ const blockMap = new Map<string, any>();
85
+ for (const block of blocks) {
86
+ if (block.block_id) {
87
+ blockMap.set(block.block_id, block);
88
+ }
89
+ }
114
90
 
115
- const isSeparatorLine = (line: string): boolean => {
116
- const trimmed = line.trim();
117
- // Separator line contains only |, -, :, and spaces
118
- return /^\|[\s\-:|]+\|$/.test(trimmed);
119
- };
91
+ // Track which blocks are part of tables (to exclude from otherBlocks)
92
+ const tableRelatedIds = new Set<string>();
93
+
94
+ for (const block of blocks) {
95
+ if (block.block_type === TABLE_BLOCK_TYPE) {
96
+ tableRelatedIds.add(block.block_id);
97
+
98
+ const tableInfo = block.table;
99
+ if (tableInfo) {
100
+ const rowSize = tableInfo.property?.row_size ?? 0;
101
+ const colSize = tableInfo.property?.column_size ?? 0;
102
+
103
+ // Initialize cells array
104
+ const cells: string[][] = Array.from({ length: rowSize }, () =>
105
+ Array.from({ length: colSize }, () => ""),
106
+ );
107
+
108
+ // table.cells is a flat array of cell block IDs in row-major order
109
+ const cellIds: string[] = tableInfo.cells ?? [];
110
+
111
+ let cellIndex = 0;
112
+ for (let row = 0; row < rowSize; row++) {
113
+ for (let col = 0; col < colSize; col++) {
114
+ if (cellIndex >= cellIds.length) break;
115
+
116
+ const cellId = cellIds[cellIndex];
117
+ tableRelatedIds.add(cellId);
118
+
119
+ // Find the TableCell block
120
+ const cellBlock = blockMap.get(cellId);
121
+ if (cellBlock?.block_type === TABLE_CELL_BLOCK_TYPE) {
122
+ // Get text content from cell's children (Text blocks)
123
+ const childIds: string[] = cellBlock.children ?? [];
124
+ const textParts: string[] = [];
125
+
126
+ for (const childId of childIds) {
127
+ tableRelatedIds.add(childId);
128
+ const textBlock = blockMap.get(childId);
129
+ if (textBlock?.block_type === 2 && textBlock.text?.elements) {
130
+ const text = textBlock.text.elements
131
+ .filter((e: any) => e.text_run)
132
+ .map((e: any) => e.text_run.content)
133
+ .join("");
134
+ textParts.push(text);
135
+ }
136
+ }
137
+
138
+ cells[row][col] = textParts.join("\n");
139
+ }
140
+
141
+ cellIndex++;
142
+ }
143
+ }
120
144
 
121
- const flushTable = () => {
122
- if (tableLines.length >= 2) {
123
- // Check if we have a valid table (at least header + separator)
124
- const hasSeparator = tableLines.some(isSeparatorLine);
125
- if (hasSeparator) {
126
- // Wrap table in code block to preserve formatting
127
- result.push("```");
128
- result.push(...tableLines);
129
- result.push("```");
130
- } else {
131
- // Not a valid table, keep as-is
132
- result.push(...tableLines);
145
+ tables.push({ rowSize, colSize, cells });
133
146
  }
134
- } else {
135
- // Single line with |, keep as-is
136
- result.push(...tableLines);
137
147
  }
138
- tableLines = [];
139
- };
148
+ }
140
149
 
141
- for (const line of lines) {
142
- if (isTableLine(line)) {
143
- inTable = true;
144
- tableLines.push(line);
145
- } else {
146
- if (inTable) {
147
- flushTable();
148
- inTable = false;
149
- }
150
- result.push(line);
150
+ // Collect non-table blocks
151
+ for (const block of blocks) {
152
+ if (!tableRelatedIds.has(block.block_id)) {
153
+ otherBlocks.push(block);
151
154
  }
152
155
  }
153
156
 
154
- // Flush remaining table if any
155
- if (inTable) {
156
- flushTable();
157
- }
157
+ return { tables, otherBlocks };
158
+ }
158
159
 
159
- return result.join("\n");
160
+ /** Clean blocks for insertion (remove read-only fields) */
161
+ function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
162
+ const skipped: string[] = [];
163
+ const cleaned = blocks.map((block) => {
164
+ // Remove any read-only fields that might slip through
165
+ if (block.block_type === TABLE_BLOCK_TYPE && block.table?.merge_info) {
166
+ const { merge_info, ...tableRest } = block.table;
167
+ return { ...block, table: tableRest };
168
+ }
169
+ return block;
170
+ });
171
+ return { cleaned, skipped };
160
172
  }
161
173
 
174
+ // ============ Core Functions ============
175
+
162
176
  /** Convert markdown to Feishu blocks using the Convert API */
163
177
  async function convertMarkdown(client: Lark.Client, markdown: string) {
164
178
  const res = await client.docx.document.convert({
@@ -185,19 +199,12 @@ async function insertBlocks(
185
199
  return { children: [], skipped };
186
200
  }
187
201
 
188
- try {
189
- const res = await client.docx.documentBlockChildren.create({
190
- path: { document_id: docToken, block_id: blockId },
191
- data: { children: cleaned },
192
- });
193
- if (res.code !== 0) throw new Error(res.msg);
194
- return { children: res.data?.children ?? [], skipped };
195
- } catch (err) {
196
- // Log block types for debugging
197
- const blockTypes = cleaned.map((b) => BLOCK_TYPE_NAMES[b.block_type] || `type_${b.block_type}`);
198
- const errMsg = err instanceof Error ? err.message : String(err);
199
- throw new Error(`insertBlocks failed: ${errMsg}. Block types: [${blockTypes.join(", ")}]. Count: ${cleaned.length}`);
200
- }
202
+ const res = await client.docx.documentBlockChildren.create({
203
+ path: { document_id: docToken, block_id: blockId },
204
+ data: { children: cleaned },
205
+ });
206
+ if (res.code !== 0) throw new Error(res.msg);
207
+ return { children: res.data?.children ?? [], skipped };
201
208
  }
202
209
 
203
210
  /** Delete all child blocks from a parent */
@@ -256,6 +263,140 @@ async function downloadImage(url: string): Promise<Buffer> {
256
263
  return Buffer.from(await response.arrayBuffer());
257
264
  }
258
265
 
266
+ /** Create an empty table and return its block ID */
267
+ async function createEmptyTable(
268
+ client: Lark.Client,
269
+ docToken: string,
270
+ parentBlockId: string,
271
+ rowSize: number,
272
+ colSize: number,
273
+ ): Promise<string | null> {
274
+ const tableBlock = {
275
+ block_type: TABLE_BLOCK_TYPE,
276
+ table: {
277
+ property: {
278
+ row_size: rowSize,
279
+ column_size: colSize,
280
+ header_row: true,
281
+ },
282
+ cells: [], // Empty cells, will be filled later
283
+ },
284
+ };
285
+
286
+ const res = await client.docx.documentBlockChildren.create({
287
+ path: { document_id: docToken, block_id: parentBlockId },
288
+ data: { children: [tableBlock] },
289
+ });
290
+
291
+ if (res.code !== 0) {
292
+ console.error(`Failed to create table: ${res.msg}`);
293
+ return null;
294
+ }
295
+
296
+ const created = res.data?.children ?? [];
297
+ return created[0]?.block_id ?? null;
298
+ }
299
+
300
+ /** Get children blocks of a parent block */
301
+ async function getBlockChildren(
302
+ client: Lark.Client,
303
+ docToken: string,
304
+ blockId: string,
305
+ ): Promise<any[]> {
306
+ const res = await client.docx.documentBlockChildren.get({
307
+ path: { document_id: docToken, block_id: blockId },
308
+ });
309
+ if (res.code !== 0) {
310
+ throw new Error(`Failed to get block children: ${res.msg}`);
311
+ }
312
+ return res.data?.items ?? [];
313
+ }
314
+
315
+ /** Update a text block's content */
316
+ async function updateTextBlock(
317
+ client: Lark.Client,
318
+ docToken: string,
319
+ blockId: string,
320
+ content: string,
321
+ ): Promise<void> {
322
+ const res = await client.docx.documentBlock.patch({
323
+ path: { document_id: docToken, block_id: blockId },
324
+ data: {
325
+ update_text_elements: {
326
+ elements: [{ text_run: { content } }],
327
+ },
328
+ },
329
+ });
330
+ if (res.code !== 0) {
331
+ console.error(`Failed to update text block ${blockId}: ${res.msg}`);
332
+ }
333
+ }
334
+
335
+ /** Fill table cells with content */
336
+ async function fillTableContent(
337
+ client: Lark.Client,
338
+ docToken: string,
339
+ tableBlockId: string,
340
+ cells: string[][],
341
+ ): Promise<void> {
342
+ // Get table's cell blocks (they are direct children of the table)
343
+ const cellBlocks = await getBlockChildren(client, docToken, tableBlockId);
344
+
345
+ // Cells are in row-major order
346
+ const colSize = cells[0]?.length ?? 0;
347
+ let cellIndex = 0;
348
+
349
+ for (let row = 0; row < cells.length; row++) {
350
+ for (let col = 0; col < colSize; col++) {
351
+ if (cellIndex >= cellBlocks.length) break;
352
+
353
+ const cellBlock = cellBlocks[cellIndex];
354
+ const cellBlockId = cellBlock?.block_id;
355
+ const cellText = cells[row]?.[col] ?? "";
356
+
357
+ if (cellBlockId && cellText) {
358
+ // Each cell block contains text blocks as children
359
+ const cellChildren = await getBlockChildren(client, docToken, cellBlockId);
360
+ if (cellChildren.length > 0) {
361
+ // Update the first text block in the cell
362
+ const textBlockId = cellChildren[0]?.block_id;
363
+ if (textBlockId) {
364
+ await updateTextBlock(client, docToken, textBlockId, cellText);
365
+ }
366
+ }
367
+ }
368
+
369
+ cellIndex++;
370
+ }
371
+ }
372
+ }
373
+
374
+ /** Create and fill a table */
375
+ async function createAndFillTable(
376
+ client: Lark.Client,
377
+ docToken: string,
378
+ parentBlockId: string,
379
+ tableData: TableData,
380
+ ): Promise<boolean> {
381
+ // 1. Create empty table
382
+ const tableBlockId = await createEmptyTable(
383
+ client,
384
+ docToken,
385
+ parentBlockId,
386
+ tableData.rowSize,
387
+ tableData.colSize,
388
+ );
389
+
390
+ if (!tableBlockId) {
391
+ return false;
392
+ }
393
+
394
+ // 2. Fill table content
395
+ await fillTableContent(client, docToken, tableBlockId, tableData.cells);
396
+
397
+ return true;
398
+ }
399
+
259
400
  /** Process images in markdown: download from URL, upload to Feishu, update blocks */
260
401
  async function processImages(
261
402
  client: Lark.Client,
@@ -487,74 +628,74 @@ async function createDoc(
487
628
  }
488
629
 
489
630
  async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
490
- let step = "init";
491
- try {
492
- // 1. Clear existing content
493
- step = "clear_content";
494
- const deleted = await clearDocumentContent(client, docToken);
495
-
496
- // 2. Pre-process markdown (convert tables to code blocks)
497
- step = "preprocess_markdown";
498
- const processedMarkdown = convertTablesToCodeBlocks(markdown);
499
-
500
- // 3. Convert markdown to blocks
501
- step = "convert_markdown";
502
- const res = await client.docx.document.convert({
503
- data: { content_type: "markdown", content: processedMarkdown },
504
- });
505
- if (res.code !== 0) throw new Error(`convert failed: ${res.msg}`);
506
- const blocks = res.data?.blocks ?? [];
631
+ // 1. Clear existing content
632
+ const deleted = await clearDocumentContent(client, docToken);
507
633
 
508
- if (blocks.length === 0) {
509
- return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
510
- }
634
+ // 2. Convert markdown to blocks
635
+ const { blocks } = await convertMarkdown(client, markdown);
636
+ if (blocks.length === 0) {
637
+ return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0, tables_created: 0 };
638
+ }
511
639
 
512
- // 4. Insert new blocks (unsupported types like Table are filtered)
513
- step = "insert_blocks";
514
- const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
640
+ // 3. Separate tables from other blocks
641
+ const { tables, otherBlocks } = extractTableFromBlocks(blocks);
515
642
 
516
- // 5. Process images
517
- step = "process_images";
518
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
643
+ // 4. Insert non-table blocks
644
+ const { children: inserted, skipped } = await insertBlocks(client, docToken, otherBlocks);
519
645
 
520
- return {
521
- success: true,
522
- blocks_deleted: deleted,
523
- blocks_added: inserted.length,
524
- images_processed: imagesProcessed,
525
- ...(skipped.length > 0 && {
526
- warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
527
- }),
528
- };
529
- } catch (err) {
530
- const errMsg = err instanceof Error ? err.message : String(err);
531
- throw new Error(`writeDoc failed at step '${step}': ${errMsg}`);
646
+ // 5. Create and fill tables
647
+ let tablesCreated = 0;
648
+ for (const tableData of tables) {
649
+ const success = await createAndFillTable(client, docToken, docToken, tableData);
650
+ if (success) tablesCreated++;
532
651
  }
652
+
653
+ // 6. Process images
654
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted);
655
+
656
+ return {
657
+ success: true,
658
+ blocks_deleted: deleted,
659
+ blocks_added: inserted.length,
660
+ tables_created: tablesCreated,
661
+ images_processed: imagesProcessed,
662
+ ...(skipped.length > 0 && {
663
+ warning: `Skipped unsupported block types: ${skipped.join(", ")}.`,
664
+ }),
665
+ };
533
666
  }
534
667
 
535
668
  async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
536
- // 1. Pre-process markdown (convert tables to code blocks)
537
- const processedMarkdown = convertTablesToCodeBlocks(markdown);
538
-
539
- // 2. Convert markdown to blocks
540
- const { blocks } = await convertMarkdown(client, processedMarkdown);
669
+ // 1. Convert markdown to blocks
670
+ const { blocks } = await convertMarkdown(client, markdown);
541
671
  if (blocks.length === 0) {
542
672
  throw new Error("Content is empty");
543
673
  }
544
674
 
545
- // 3. Insert blocks (unsupported types like Table are filtered)
546
- const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
675
+ // 2. Separate tables from other blocks
676
+ const { tables, otherBlocks } = extractTableFromBlocks(blocks);
677
+
678
+ // 3. Insert non-table blocks
679
+ const { children: inserted, skipped } = await insertBlocks(client, docToken, otherBlocks);
680
+
681
+ // 4. Create and fill tables
682
+ let tablesCreated = 0;
683
+ for (const tableData of tables) {
684
+ const success = await createAndFillTable(client, docToken, docToken, tableData);
685
+ if (success) tablesCreated++;
686
+ }
547
687
 
548
- // 4. Process images
688
+ // 5. Process images
549
689
  const imagesProcessed = await processImages(client, docToken, markdown, inserted);
550
690
 
551
691
  return {
552
692
  success: true,
553
693
  blocks_added: inserted.length,
694
+ tables_created: tablesCreated,
554
695
  images_processed: imagesProcessed,
555
696
  block_ids: inserted.map((b: any) => b.block_id),
556
697
  ...(skipped.length > 0 && {
557
- warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
698
+ warning: `Skipped unsupported block types: ${skipped.join(", ")}.`,
558
699
  }),
559
700
  };
560
701
  }
@@ -793,7 +934,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
793
934
  name: "feishu_doc_write",
794
935
  label: "Feishu Doc Write",
795
936
  description:
796
- "Write markdown content to a Feishu document (replaces all content). Supports headings, lists, code blocks, quotes, links, images, and text styling. Note: tables are not supported.",
937
+ "Write markdown content to a Feishu document (replaces all content). Supports headings, lists, code blocks, quotes, links, images, tables, and text styling.",
797
938
  parameters: WriteDocSchema,
798
939
  async execute(_toolCallId, params) {
799
940
  const { doc_token, content } = params as { doc_token: string; content: string };