@max1874/feishu 0.2.11 → 0.2.13

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 +424 -98
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max1874/feishu",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "license": "MIT",
package/src/docx.ts CHANGED
@@ -129,12 +129,63 @@ interface TableData {
129
129
  cells: string[][]; // 2D array of cell text content
130
130
  }
131
131
 
132
- /** Extract table data from converted blocks (flat list with ID references) */
133
- function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBlocks: any[] } {
134
- const tables: TableData[] = [];
135
- const otherBlocks: any[] = [];
132
+ /** A content item that can be either a regular block or a table */
133
+ type ContentItem =
134
+ | { type: "block"; block: any }
135
+ | { type: "table"; table: TableData };
136
+
137
+ /** Extract table data from a table block */
138
+ function extractTableData(tableBlock: any, blockMap: Map<string, any>): { tableData: TableData; relatedIds: Set<string> } {
139
+ const relatedIds = new Set<string>();
140
+ relatedIds.add(tableBlock.block_id);
141
+
142
+ const tableInfo = tableBlock.table;
143
+ const rowSize = tableInfo?.property?.row_size ?? 0;
144
+ const colSize = tableInfo?.property?.column_size ?? 0;
145
+
146
+ const cells: string[][] = Array.from({ length: rowSize }, () =>
147
+ Array.from({ length: colSize }, () => ""),
148
+ );
136
149
 
137
- // Build a map of block_id -> block for quick lookup
150
+ const cellIds: string[] = tableInfo?.cells ?? [];
151
+ let cellIndex = 0;
152
+
153
+ for (let row = 0; row < rowSize; row++) {
154
+ for (let col = 0; col < colSize; col++) {
155
+ if (cellIndex >= cellIds.length) break;
156
+
157
+ const cellId = cellIds[cellIndex];
158
+ relatedIds.add(cellId);
159
+
160
+ const cellBlock = blockMap.get(cellId);
161
+ if (cellBlock?.block_type === TABLE_CELL_BLOCK_TYPE) {
162
+ const childIds: string[] = cellBlock.children ?? [];
163
+ const textParts: string[] = [];
164
+
165
+ for (const childId of childIds) {
166
+ relatedIds.add(childId);
167
+ const textBlock = blockMap.get(childId);
168
+ if (textBlock?.block_type === 2 && textBlock.text?.elements) {
169
+ const text = textBlock.text.elements
170
+ .filter((e: any) => e.text_run)
171
+ .map((e: any) => e.text_run.content)
172
+ .join("");
173
+ textParts.push(text);
174
+ }
175
+ }
176
+
177
+ cells[row][col] = textParts.join("\n");
178
+ }
179
+
180
+ cellIndex++;
181
+ }
182
+ }
183
+
184
+ return { tableData: { rowSize, colSize, cells }, relatedIds };
185
+ }
186
+
187
+ /** Convert blocks to content items, preserving order with tables in correct positions */
188
+ function convertBlocksToContentItems(blocks: any[]): ContentItem[] {
138
189
  const blockMap = new Map<string, any>();
139
190
  for (const block of blocks) {
140
191
  if (block.block_id) {
@@ -142,69 +193,52 @@ function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBloc
142
193
  }
143
194
  }
144
195
 
145
- // Track which blocks are part of tables (to exclude from otherBlocks)
196
+ // Track which blocks are part of tables
146
197
  const tableRelatedIds = new Set<string>();
198
+ const tableDataMap = new Map<string, TableData>();
147
199
 
200
+ // First pass: extract all table data and mark related IDs
148
201
  for (const block of blocks) {
149
202
  if (block.block_type === TABLE_BLOCK_TYPE) {
150
- tableRelatedIds.add(block.block_id);
151
-
152
- const tableInfo = block.table;
153
- if (tableInfo) {
154
- const rowSize = tableInfo.property?.row_size ?? 0;
155
- const colSize = tableInfo.property?.column_size ?? 0;
156
-
157
- // Initialize cells array
158
- const cells: string[][] = Array.from({ length: rowSize }, () =>
159
- Array.from({ length: colSize }, () => ""),
160
- );
161
-
162
- // table.cells is a flat array of cell block IDs in row-major order
163
- const cellIds: string[] = tableInfo.cells ?? [];
164
-
165
- let cellIndex = 0;
166
- for (let row = 0; row < rowSize; row++) {
167
- for (let col = 0; col < colSize; col++) {
168
- if (cellIndex >= cellIds.length) break;
169
-
170
- const cellId = cellIds[cellIndex];
171
- tableRelatedIds.add(cellId);
172
-
173
- // Find the TableCell block
174
- const cellBlock = blockMap.get(cellId);
175
- if (cellBlock?.block_type === TABLE_CELL_BLOCK_TYPE) {
176
- // Get text content from cell's children (Text blocks)
177
- const childIds: string[] = cellBlock.children ?? [];
178
- const textParts: string[] = [];
179
-
180
- for (const childId of childIds) {
181
- tableRelatedIds.add(childId);
182
- const textBlock = blockMap.get(childId);
183
- if (textBlock?.block_type === 2 && textBlock.text?.elements) {
184
- const text = textBlock.text.elements
185
- .filter((e: any) => e.text_run)
186
- .map((e: any) => e.text_run.content)
187
- .join("");
188
- textParts.push(text);
189
- }
190
- }
191
-
192
- cells[row][col] = textParts.join("\n");
193
- }
194
-
195
- cellIndex++;
196
- }
197
- }
198
-
199
- tables.push({ rowSize, colSize, cells });
203
+ const { tableData, relatedIds } = extractTableData(block, blockMap);
204
+ tableDataMap.set(block.block_id, tableData);
205
+ for (const id of relatedIds) {
206
+ tableRelatedIds.add(id);
200
207
  }
201
208
  }
202
209
  }
203
210
 
204
- // Collect non-table blocks
211
+ // Second pass: build content items in order
212
+ const items: ContentItem[] = [];
205
213
  for (const block of blocks) {
206
- if (!tableRelatedIds.has(block.block_id)) {
207
- otherBlocks.push(block);
214
+ if (tableRelatedIds.has(block.block_id)) {
215
+ // If this is a table block itself, add the table item
216
+ if (block.block_type === TABLE_BLOCK_TYPE) {
217
+ const tableData = tableDataMap.get(block.block_id);
218
+ if (tableData) {
219
+ items.push({ type: "table", table: tableData });
220
+ }
221
+ }
222
+ // Skip table-related blocks (cells, cell contents)
223
+ } else {
224
+ items.push({ type: "block", block });
225
+ }
226
+ }
227
+
228
+ return items;
229
+ }
230
+
231
+ /** Extract table data from converted blocks (flat list with ID references) - legacy function */
232
+ function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBlocks: any[] } {
233
+ const items = convertBlocksToContentItems(blocks);
234
+ const tables: TableData[] = [];
235
+ const otherBlocks: any[] = [];
236
+
237
+ for (const item of items) {
238
+ if (item.type === "table") {
239
+ tables.push(item.table);
240
+ } else {
241
+ otherBlocks.push(item.block);
208
242
  }
209
243
  }
210
244
 
@@ -229,6 +263,231 @@ function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[
229
263
  return { cleaned, skipped };
230
264
  }
231
265
 
266
+ // ============ Helpers for Recursive Insert ============
267
+
268
+ /** Delay helper for rate limiting */
269
+ const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
270
+
271
+ /** API call with retry for rate limiting */
272
+ async function createChildrenWithRetry(
273
+ client: Lark.Client,
274
+ docToken: string,
275
+ parentBlockId: string,
276
+ children: any[],
277
+ index?: number,
278
+ retries = 3
279
+ ): Promise<any> {
280
+ for (let i = 0; i < retries; i++) {
281
+ try {
282
+ const res = await client.docx.documentBlockChildren.create({
283
+ path: { document_id: docToken, block_id: parentBlockId },
284
+ data: {
285
+ children,
286
+ ...(index !== undefined && { index })
287
+ },
288
+ });
289
+ if (res.code === 0) return res;
290
+ // Rate limit error
291
+ if (res.code === 99991400 || res.msg?.includes('rate')) {
292
+ await delay((i + 1) * 2000);
293
+ continue;
294
+ }
295
+ return res;
296
+ } catch (e: any) {
297
+ if (e.message?.includes('429') || e.response?.status === 429) {
298
+ await delay((i + 1) * 2000);
299
+ continue;
300
+ }
301
+ throw e;
302
+ }
303
+ }
304
+ return { code: -1, msg: 'Max retries exceeded' };
305
+ }
306
+
307
+ /** Context for recursive block insertion */
308
+ interface InsertContext {
309
+ client: Lark.Client;
310
+ docToken: string;
311
+ blockMap: Map<string, any>;
312
+ tableRelatedIds: Set<string>;
313
+ tableDataMap: Map<string, TableData>;
314
+ insertedCount: number;
315
+ tablesCreated: number;
316
+ }
317
+
318
+ /** Recursively insert a block and its children */
319
+ async function insertBlockWithChildren(
320
+ ctx: InsertContext,
321
+ block: any,
322
+ parentBlockId: string,
323
+ index?: number
324
+ ): Promise<string | null> {
325
+ // Skip table-related blocks (handled separately)
326
+ if (ctx.tableRelatedIds.has(block.block_id)) {
327
+ if (block.block_type === TABLE_BLOCK_TYPE) {
328
+ const tableData = ctx.tableDataMap.get(block.block_id);
329
+ if (tableData) {
330
+ return await createTableWithRetry(ctx, parentBlockId, tableData, index);
331
+ }
332
+ }
333
+ return null;
334
+ }
335
+
336
+ // Clean block (remove read-only fields but preserve children reference)
337
+ const { block_id, parent_id, children, ...cleanBlock } = block;
338
+
339
+ // Add delay to avoid rate limiting
340
+ await delay(100);
341
+
342
+ // Insert current block
343
+ const insertRes = await createChildrenWithRetry(
344
+ ctx.client,
345
+ ctx.docToken,
346
+ parentBlockId,
347
+ [cleanBlock],
348
+ index
349
+ );
350
+
351
+ if (insertRes.code !== 0) {
352
+ console.error(`Failed to insert block: ${insertRes.msg}`);
353
+ return null;
354
+ }
355
+
356
+ const newBlockId = insertRes.data?.children?.[0]?.block_id;
357
+ if (!newBlockId) return null;
358
+
359
+ ctx.insertedCount++;
360
+
361
+ // Recursively insert children
362
+ if (children && children.length > 0) {
363
+ for (let i = 0; i < children.length; i++) {
364
+ const childId = children[i];
365
+ const childBlock = ctx.blockMap.get(childId);
366
+ if (childBlock) {
367
+ await insertBlockWithChildren(ctx, childBlock, newBlockId, i);
368
+ }
369
+ }
370
+ }
371
+
372
+ return newBlockId;
373
+ }
374
+
375
+ /** Create and fill a table with retry */
376
+ async function createTableWithRetry(
377
+ ctx: InsertContext,
378
+ parentBlockId: string,
379
+ tableData: TableData,
380
+ index?: number
381
+ ): Promise<string | null> {
382
+ await delay(100);
383
+
384
+ const createRes = await createChildrenWithRetry(
385
+ ctx.client,
386
+ ctx.docToken,
387
+ parentBlockId,
388
+ [{
389
+ block_type: TABLE_BLOCK_TYPE,
390
+ table: {
391
+ property: { row_size: tableData.rowSize, column_size: tableData.colSize, header_row: true },
392
+ cells: []
393
+ }
394
+ }],
395
+ index
396
+ );
397
+
398
+ if (createRes.code !== 0) {
399
+ console.error(`Failed to create table: ${createRes.msg}`);
400
+ return null;
401
+ }
402
+
403
+ const tableBlockId = createRes.data?.children?.[0]?.block_id;
404
+ if (!tableBlockId) return null;
405
+
406
+ // Fill table content
407
+ const cellBlocksRes = await ctx.client.docx.documentBlockChildren.get({
408
+ path: { document_id: ctx.docToken, block_id: tableBlockId },
409
+ });
410
+ const cellBlocks = cellBlocksRes.data?.items ?? [];
411
+
412
+ let cellIndex = 0;
413
+ for (let row = 0; row < tableData.rowSize; row++) {
414
+ for (let col = 0; col < tableData.colSize; col++) {
415
+ if (cellIndex >= cellBlocks.length) break;
416
+ const cellBlockId = cellBlocks[cellIndex]?.block_id;
417
+ const cellText = tableData.cells[row]?.[col] ?? "";
418
+ if (cellBlockId && cellText) {
419
+ await delay(50);
420
+ const cellChildrenRes = await ctx.client.docx.documentBlockChildren.get({
421
+ path: { document_id: ctx.docToken, block_id: cellBlockId },
422
+ });
423
+ const textBlockId = cellChildrenRes.data?.items?.[0]?.block_id;
424
+ if (textBlockId) {
425
+ await ctx.client.docx.documentBlock.patch({
426
+ path: { document_id: ctx.docToken, block_id: textBlockId },
427
+ data: { update_text_elements: { elements: [{ text_run: { content: cellText } }] } },
428
+ });
429
+ }
430
+ }
431
+ cellIndex++;
432
+ }
433
+ }
434
+
435
+ ctx.tablesCreated++;
436
+ return tableBlockId;
437
+ }
438
+
439
+ /** Insert blocks recursively preserving nested structure */
440
+ async function insertBlocksRecursively(
441
+ client: Lark.Client,
442
+ docToken: string,
443
+ blocks: any[],
444
+ firstLevelBlockIds: string[],
445
+ startIndex: number = 0
446
+ ): Promise<{ blocksInserted: number; tablesCreated: number }> {
447
+ // Build block map
448
+ const blockMap = new Map<string, any>();
449
+ for (const block of blocks) {
450
+ if (block.block_id) {
451
+ blockMap.set(block.block_id, block);
452
+ }
453
+ }
454
+
455
+ // Extract table data and related IDs
456
+ const tableRelatedIds = new Set<string>();
457
+ const tableDataMap = new Map<string, TableData>();
458
+
459
+ for (const block of blocks) {
460
+ if (block.block_type === TABLE_BLOCK_TYPE) {
461
+ const { tableData, relatedIds } = extractTableData(block, blockMap);
462
+ tableDataMap.set(block.block_id, tableData);
463
+ for (const id of relatedIds) {
464
+ tableRelatedIds.add(id);
465
+ }
466
+ }
467
+ }
468
+
469
+ const ctx: InsertContext = {
470
+ client,
471
+ docToken,
472
+ blockMap,
473
+ tableRelatedIds,
474
+ tableDataMap,
475
+ insertedCount: 0,
476
+ tablesCreated: 0,
477
+ };
478
+
479
+ // Insert blocks in order of first_level_block_ids
480
+ for (let i = 0; i < firstLevelBlockIds.length; i++) {
481
+ const blockId = firstLevelBlockIds[i];
482
+ const block = blockMap.get(blockId);
483
+ if (block) {
484
+ await insertBlockWithChildren(ctx, block, docToken, startIndex + i);
485
+ }
486
+ }
487
+
488
+ return { blocksInserted: ctx.insertedCount, tablesCreated: ctx.tablesCreated };
489
+ }
490
+
232
491
  // ============ Core Functions ============
233
492
 
234
493
  /** Convert markdown to Feishu blocks using the Convert API */
@@ -289,6 +548,40 @@ async function insertBlocks(
289
548
  return { children: allChildren, skipped };
290
549
  }
291
550
 
551
+ /** Insert blocks at a specific index (with batching for >50 blocks) */
552
+ async function insertBlocksAtIndex(
553
+ client: Lark.Client,
554
+ docToken: string,
555
+ blocks: any[],
556
+ startIndex: number,
557
+ parentBlockId?: string,
558
+ ): Promise<{ children: any[]; skipped: string[] }> {
559
+ const { cleaned, skipped } = cleanBlocksForInsert(blocks);
560
+ const blockId = parentBlockId ?? docToken;
561
+
562
+ if (cleaned.length === 0) {
563
+ return { children: [], skipped };
564
+ }
565
+
566
+ const BATCH_SIZE = 50;
567
+ const allChildren: any[] = [];
568
+ let insertIndex = startIndex;
569
+
570
+ for (let i = 0; i < cleaned.length; i += BATCH_SIZE) {
571
+ const batch = cleaned.slice(i, i + BATCH_SIZE);
572
+ const res = await client.docx.documentBlockChildren.create({
573
+ path: { document_id: docToken, block_id: blockId },
574
+ data: { children: batch, index: insertIndex },
575
+ });
576
+ if (res.code !== 0) throw new Error(res.msg);
577
+ const inserted = res.data?.children ?? [];
578
+ allChildren.push(...inserted);
579
+ insertIndex += inserted.length;
580
+ }
581
+
582
+ return { children: allChildren, skipped };
583
+ }
584
+
292
585
  /** Delete all child blocks from a parent */
293
586
  async function clearDocumentContent(client: Lark.Client, docToken: string) {
294
587
  const existing = await client.docx.documentBlock.list({
@@ -352,6 +645,7 @@ async function createEmptyTable(
352
645
  parentBlockId: string,
353
646
  rowSize: number,
354
647
  colSize: number,
648
+ index?: number,
355
649
  ): Promise<string | null> {
356
650
  const tableBlock = {
357
651
  block_type: TABLE_BLOCK_TYPE,
@@ -367,7 +661,7 @@ async function createEmptyTable(
367
661
 
368
662
  const res = await client.docx.documentBlockChildren.create({
369
663
  path: { document_id: docToken, block_id: parentBlockId },
370
- data: { children: [tableBlock] },
664
+ data: { children: [tableBlock], ...(index !== undefined && { index }) },
371
665
  });
372
666
 
373
667
  if (res.code !== 0) {
@@ -479,6 +773,34 @@ async function createAndFillTable(
479
773
  return true;
480
774
  }
481
775
 
776
+ /** Create and fill a table at a specific index */
777
+ async function createAndFillTableAtIndex(
778
+ client: Lark.Client,
779
+ docToken: string,
780
+ parentBlockId: string,
781
+ tableData: TableData,
782
+ index: number,
783
+ ): Promise<boolean> {
784
+ // 1. Create empty table at specific index
785
+ const tableBlockId = await createEmptyTable(
786
+ client,
787
+ docToken,
788
+ parentBlockId,
789
+ tableData.rowSize,
790
+ tableData.colSize,
791
+ index,
792
+ );
793
+
794
+ if (!tableBlockId) {
795
+ return false;
796
+ }
797
+
798
+ // 2. Fill table content
799
+ await fillTableContent(client, docToken, tableBlockId, tableData.cells);
800
+
801
+ return true;
802
+ }
803
+
482
804
  /** Process images in markdown: download from URL, upload to Feishu, update blocks */
483
805
  async function processImages(
484
806
  client: Lark.Client,
@@ -714,71 +1036,75 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
714
1036
  const deleted = await clearDocumentContent(client, docToken);
715
1037
 
716
1038
  // 2. Convert markdown to blocks
717
- const { blocks } = await convertMarkdown(client, markdown);
1039
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
718
1040
  if (blocks.length === 0) {
719
1041
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0, tables_created: 0 };
720
1042
  }
721
1043
 
722
- // 3. Separate tables from other blocks
723
- const { tables, otherBlocks } = extractTableFromBlocks(blocks);
724
-
725
- // 4. Insert non-table blocks
726
- const { children: inserted, skipped } = await insertBlocks(client, docToken, otherBlocks);
727
-
728
- // 5. Create and fill tables
729
- let tablesCreated = 0;
730
- for (const tableData of tables) {
731
- const success = await createAndFillTable(client, docToken, docToken, tableData);
732
- if (success) tablesCreated++;
733
- }
1044
+ // 3. Insert blocks recursively (preserves nested structure)
1045
+ const { blocksInserted, tablesCreated } = await insertBlocksRecursively(
1046
+ client,
1047
+ docToken,
1048
+ blocks,
1049
+ firstLevelBlockIds,
1050
+ 0
1051
+ );
734
1052
 
735
- // 6. Process images
736
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
1053
+ // 4. Process images (get inserted blocks for image processing)
1054
+ const insertedRes = await client.docx.documentBlock.list({
1055
+ path: { document_id: docToken },
1056
+ params: { page_size: 500 },
1057
+ });
1058
+ const insertedBlocks = insertedRes.data?.items ?? [];
1059
+ const imagesProcessed = await processImages(client, docToken, markdown, insertedBlocks);
737
1060
 
738
1061
  return {
739
1062
  success: true,
740
1063
  blocks_deleted: deleted,
741
- blocks_added: inserted.length,
1064
+ blocks_added: blocksInserted,
742
1065
  tables_created: tablesCreated,
743
1066
  images_processed: imagesProcessed,
744
- ...(skipped.length > 0 && {
745
- warning: `Skipped unsupported block types: ${skipped.join(", ")}.`,
746
- }),
747
1067
  };
748
1068
  }
749
1069
 
750
1070
  async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
751
1071
  // 1. Convert markdown to blocks
752
- const { blocks } = await convertMarkdown(client, markdown);
1072
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
753
1073
  if (blocks.length === 0) {
754
1074
  throw new Error("Content is empty");
755
1075
  }
756
1076
 
757
- // 2. Separate tables from other blocks
758
- const { tables, otherBlocks } = extractTableFromBlocks(blocks);
759
-
760
- // 3. Insert non-table blocks
761
- const { children: inserted, skipped } = await insertBlocks(client, docToken, otherBlocks);
762
-
763
- // 4. Create and fill tables
764
- let tablesCreated = 0;
765
- for (const tableData of tables) {
766
- const success = await createAndFillTable(client, docToken, docToken, tableData);
767
- if (success) tablesCreated++;
1077
+ // 2. Get current insert index
1078
+ let startIndex = 0;
1079
+ const existing = await client.docx.documentBlockChildren.get({
1080
+ path: { document_id: docToken, block_id: docToken },
1081
+ });
1082
+ if (existing.code === 0) {
1083
+ startIndex = existing.data?.items?.length ?? 0;
768
1084
  }
769
1085
 
770
- // 5. Process images
771
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
1086
+ // 3. Insert blocks recursively (preserves nested structure)
1087
+ const { blocksInserted, tablesCreated } = await insertBlocksRecursively(
1088
+ client,
1089
+ docToken,
1090
+ blocks,
1091
+ firstLevelBlockIds,
1092
+ startIndex
1093
+ );
1094
+
1095
+ // 4. Process images (get inserted blocks for image processing)
1096
+ const insertedRes = await client.docx.documentBlock.list({
1097
+ path: { document_id: docToken },
1098
+ params: { page_size: 500 },
1099
+ });
1100
+ const insertedBlocks = insertedRes.data?.items ?? [];
1101
+ const imagesProcessed = await processImages(client, docToken, markdown, insertedBlocks);
772
1102
 
773
1103
  return {
774
1104
  success: true,
775
- blocks_added: inserted.length,
1105
+ blocks_added: blocksInserted,
776
1106
  tables_created: tablesCreated,
777
1107
  images_processed: imagesProcessed,
778
- block_ids: inserted.map((b: any) => b.block_id),
779
- ...(skipped.length > 0 && {
780
- warning: `Skipped unsupported block types: ${skipped.join(", ")}.`,
781
- }),
782
1108
  };
783
1109
  }
784
1110