@max1874/feishu 0.2.12 → 0.2.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max1874/feishu",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "license": "MIT",
package/src/bot.ts CHANGED
@@ -227,7 +227,7 @@ function parsePostContent(content: string): {
227
227
  * Format merge_forward child messages into readable text.
228
228
  */
229
229
  function formatMergeForwardContent(
230
- messages: Array<{ content: string; contentType: string }>,
230
+ messages: Array<{ content: string; contentType: string; senderName?: string; senderId?: string }>,
231
231
  ): string {
232
232
  if (messages.length === 0) {
233
233
  return "[转发的聊天记录 - 无内容]";
@@ -236,20 +236,22 @@ function formatMergeForwardContent(
236
236
  const lines: string[] = ["[转发的聊天记录]", "---"];
237
237
 
238
238
  for (const msg of messages) {
239
+ const speaker = msg.senderName || msg.senderId || "未知";
240
+
239
241
  if (msg.contentType === "text" || msg.contentType === "post") {
240
- lines.push(msg.content);
242
+ lines.push(`${speaker}: ${msg.content}`);
241
243
  } else if (msg.contentType === "image") {
242
- lines.push("[图片]");
244
+ lines.push(`${speaker}: [图片]`);
243
245
  } else if (msg.contentType === "file") {
244
- lines.push("[文件]");
246
+ lines.push(`${speaker}: [文件]`);
245
247
  } else if (msg.contentType === "audio") {
246
- lines.push("[语音]");
248
+ lines.push(`${speaker}: [语音]`);
247
249
  } else if (msg.contentType === "video") {
248
- lines.push("[视频]");
250
+ lines.push(`${speaker}: [视频]`);
249
251
  } else if (msg.contentType === "sticker") {
250
- lines.push("[表情]");
252
+ lines.push(`${speaker}: [表情]`);
251
253
  } else {
252
- lines.push(`[${msg.contentType}]`);
254
+ lines.push(`${speaker}: [${msg.contentType}]`);
253
255
  }
254
256
  }
255
257
 
@@ -488,7 +490,18 @@ export async function handleFeishuMessage(params: {
488
490
  cfg,
489
491
  messageId: ctx.messageId,
490
492
  });
491
- const formattedContent = formatMergeForwardContent(childMessages);
493
+
494
+ // Resolve sender names for child messages (only when ID type is open_id)
495
+ const messagesWithNames = await Promise.all(
496
+ childMessages.map(async (msg) => ({
497
+ ...msg,
498
+ senderName: (msg.senderId && msg.senderIdType === "open_id")
499
+ ? await resolveFeishuSenderName({ feishuCfg, senderOpenId: msg.senderId, log })
500
+ : undefined,
501
+ }))
502
+ );
503
+
504
+ const formattedContent = formatMergeForwardContent(messagesWithNames);
492
505
  ctx = { ...ctx, content: formattedContent };
493
506
  log(`feishu: resolved merge_forward with ${childMessages.length} child message(s)`);
494
507
  } catch (err) {
package/src/docx.ts CHANGED
@@ -263,6 +263,231 @@ function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[
263
263
  return { cleaned, skipped };
264
264
  }
265
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
+
266
491
  // ============ Core Functions ============
267
492
 
268
493
  /** Convert markdown to Feishu blocks using the Convert API */
@@ -811,124 +1036,75 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
811
1036
  const deleted = await clearDocumentContent(client, docToken);
812
1037
 
813
1038
  // 2. Convert markdown to blocks
814
- const { blocks } = await convertMarkdown(client, markdown);
1039
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
815
1040
  if (blocks.length === 0) {
816
1041
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0, tables_created: 0 };
817
1042
  }
818
1043
 
819
- // 3. Convert to content items (preserves table positions)
820
- const contentItems = convertBlocksToContentItems(blocks);
821
-
822
- // 4. Insert content items in order
823
- let insertIndex = 0;
824
- const allInserted: any[] = [];
825
- const allSkipped: string[] = [];
826
- let tablesCreated = 0;
827
- let pendingBlocks: any[] = [];
828
-
829
- const flushPendingBlocks = async () => {
830
- if (pendingBlocks.length === 0) return;
831
- const { children, skipped } = await insertBlocksAtIndex(client, docToken, pendingBlocks, insertIndex);
832
- allInserted.push(...children);
833
- allSkipped.push(...skipped);
834
- insertIndex += children.length;
835
- pendingBlocks = [];
836
- };
837
-
838
- for (const item of contentItems) {
839
- if (item.type === "block") {
840
- pendingBlocks.push(item.block);
841
- } else if (item.type === "table") {
842
- await flushPendingBlocks();
843
- const success = await createAndFillTableAtIndex(client, docToken, docToken, item.table, insertIndex);
844
- if (success) {
845
- tablesCreated++;
846
- insertIndex++;
847
- }
848
- }
849
- }
850
-
851
- await flushPendingBlocks();
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
+ );
852
1052
 
853
- // 5. Process images
854
- const imagesProcessed = await processImages(client, docToken, markdown, allInserted);
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);
855
1060
 
856
1061
  return {
857
1062
  success: true,
858
1063
  blocks_deleted: deleted,
859
- blocks_added: allInserted.length,
1064
+ blocks_added: blocksInserted,
860
1065
  tables_created: tablesCreated,
861
1066
  images_processed: imagesProcessed,
862
- ...(allSkipped.length > 0 && {
863
- warning: `Skipped unsupported block types: ${allSkipped.join(", ")}.`,
864
- }),
865
1067
  };
866
1068
  }
867
1069
 
868
1070
  async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
869
1071
  // 1. Convert markdown to blocks
870
- const { blocks } = await convertMarkdown(client, markdown);
1072
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
871
1073
  if (blocks.length === 0) {
872
1074
  throw new Error("Content is empty");
873
1075
  }
874
1076
 
875
- // 2. Convert to content items (preserves table positions)
876
- const contentItems = convertBlocksToContentItems(blocks);
877
-
878
- // 3. Get current insert index
879
- let insertIndex = 0;
1077
+ // 2. Get current insert index
1078
+ let startIndex = 0;
880
1079
  const existing = await client.docx.documentBlockChildren.get({
881
1080
  path: { document_id: docToken, block_id: docToken },
882
1081
  });
883
1082
  if (existing.code === 0) {
884
- insertIndex = existing.data?.items?.length ?? 0;
885
- }
886
-
887
- // 4. Insert content items in order, batching consecutive blocks
888
- const allInserted: any[] = [];
889
- const allSkipped: string[] = [];
890
- let tablesCreated = 0;
891
- let pendingBlocks: any[] = [];
892
-
893
- const flushPendingBlocks = async () => {
894
- if (pendingBlocks.length === 0) return;
895
- const { children, skipped } = await insertBlocksAtIndex(client, docToken, pendingBlocks, insertIndex);
896
- allInserted.push(...children);
897
- allSkipped.push(...skipped);
898
- insertIndex += children.length;
899
- pendingBlocks = [];
900
- };
901
-
902
- for (const item of contentItems) {
903
- if (item.type === "block") {
904
- pendingBlocks.push(item.block);
905
- } else if (item.type === "table") {
906
- // Flush pending blocks before creating table
907
- await flushPendingBlocks();
908
- // Create table at current position
909
- const success = await createAndFillTableAtIndex(client, docToken, docToken, item.table, insertIndex);
910
- if (success) {
911
- tablesCreated++;
912
- insertIndex++;
913
- }
914
- }
1083
+ startIndex = existing.data?.items?.length ?? 0;
915
1084
  }
916
1085
 
917
- // Flush any remaining blocks
918
- await flushPendingBlocks();
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
+ );
919
1094
 
920
- // 5. Process images
921
- const imagesProcessed = await processImages(client, docToken, markdown, allInserted);
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);
922
1102
 
923
1103
  return {
924
1104
  success: true,
925
- blocks_added: allInserted.length,
1105
+ blocks_added: blocksInserted,
926
1106
  tables_created: tablesCreated,
927
1107
  images_processed: imagesProcessed,
928
- block_ids: allInserted.map((b: any) => b.block_id),
929
- ...(allSkipped.length > 0 && {
930
- warning: `Skipped unsupported block types: ${allSkipped.join(", ")}.`,
931
- }),
932
1108
  };
933
1109
  }
934
1110
 
package/src/send.ts CHANGED
@@ -22,6 +22,8 @@ export type FeishuMergeForwardMessage = {
22
22
  content: string;
23
23
  contentType: string;
24
24
  upperMessageId?: string;
25
+ senderId?: string;
26
+ senderIdType?: string;
25
27
  };
26
28
 
27
29
  /**
@@ -124,6 +126,7 @@ export async function getMergeForwardMessagesFeishu(params: {
124
126
  msg_type?: string;
125
127
  body?: { content?: string };
126
128
  upper_message_id?: string;
129
+ sender?: { id?: string; id_type?: string };
127
130
  }>;
128
131
  };
129
132
  };
@@ -161,6 +164,8 @@ export async function getMergeForwardMessagesFeishu(params: {
161
164
  content,
162
165
  contentType: item.msg_type ?? "text",
163
166
  upperMessageId: item.upper_message_id,
167
+ senderId: item.sender?.id,
168
+ senderIdType: item.sender?.id_type,
164
169
  });
165
170
  }
166
171