@openclaw/feishu 2026.2.25 → 2026.3.2

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 (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
@@ -1,5 +1,7 @@
1
+ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
1
2
  import { z } from "zod";
2
3
  export { z };
4
+ import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
3
5
 
4
6
  const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
5
7
  const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
@@ -82,6 +84,7 @@ const DynamicAgentCreationSchema = z
82
84
  const FeishuToolsConfigSchema = z
83
85
  .object({
84
86
  doc: z.boolean().optional(), // Document operations (default: true)
87
+ chat: z.boolean().optional(), // Chat info + member query operations (default: true)
85
88
  wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
86
89
  drive: z.boolean().optional(), // Cloud storage operations (default: true)
87
90
  perm: z.boolean().optional(), // Permission management (default: false, sensitive)
@@ -91,14 +94,39 @@ const FeishuToolsConfigSchema = z
91
94
  .optional();
92
95
 
93
96
  /**
97
+ * Group session scope for routing Feishu group messages.
98
+ * - "group" (default): one session per group chat
99
+ * - "group_sender": one session per (group + sender)
100
+ * - "group_topic": one session per group topic thread (falls back to group if no topic)
101
+ * - "group_topic_sender": one session per (group + topic thread + sender),
102
+ * falls back to (group + sender) if no topic
103
+ */
104
+ const GroupSessionScopeSchema = z
105
+ .enum(["group", "group_sender", "group_topic", "group_topic_sender"])
106
+ .optional();
107
+
108
+ /**
109
+ * @deprecated Use groupSessionScope instead.
110
+ *
94
111
  * Topic session isolation mode for group chats.
95
112
  * - "disabled" (default): All messages in a group share one session
96
113
  * - "enabled": Messages in different topics get separate sessions
97
114
  *
98
- * When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
99
- * for messages within a topic thread, allowing isolated conversations.
115
+ * Topic routing uses `root_id` when present to keep session continuity and
116
+ * falls back to `thread_id` when `root_id` is unavailable.
100
117
  */
101
118
  const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
119
+ const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
120
+
121
+ /**
122
+ * Reply-in-thread mode for group chats.
123
+ * - "disabled" (default): Bot replies are normal inline replies
124
+ * - "enabled": Bot replies create or continue a Feishu topic thread
125
+ *
126
+ * When enabled, the Feishu reply API is called with `reply_in_thread: true`,
127
+ * causing the reply to appear as a topic (话题) under the original message.
128
+ */
129
+ const ReplyInThreadSchema = z.enum(["disabled", "enabled"]).optional();
102
130
 
103
131
  export const FeishuGroupSchema = z
104
132
  .object({
@@ -108,7 +136,9 @@ export const FeishuGroupSchema = z
108
136
  enabled: z.boolean().optional(),
109
137
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
110
138
  systemPrompt: z.string().optional(),
139
+ groupSessionScope: GroupSessionScopeSchema,
111
140
  topicSessionMode: TopicSessionModeSchema,
141
+ replyInThread: ReplyInThreadSchema,
112
142
  })
113
143
  .strict();
114
144
 
@@ -122,6 +152,7 @@ const FeishuSharedConfigShape = {
122
152
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
123
153
  groupPolicy: GroupPolicySchema.optional(),
124
154
  groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
155
+ groupSenderAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
125
156
  requireMention: z.boolean().optional(),
126
157
  groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
127
158
  historyLimit: z.number().int().min(0).optional(),
@@ -135,6 +166,10 @@ const FeishuSharedConfigShape = {
135
166
  renderMode: RenderModeSchema,
136
167
  streaming: StreamingModeSchema,
137
168
  tools: FeishuToolsConfigSchema,
169
+ replyInThread: ReplyInThreadSchema,
170
+ reactionNotifications: ReactionNotificationModeSchema,
171
+ typingIndicator: z.boolean().optional(),
172
+ resolveSenderNames: z.boolean().optional(),
138
173
  };
139
174
 
140
175
  /**
@@ -146,42 +181,62 @@ export const FeishuAccountConfigSchema = z
146
181
  enabled: z.boolean().optional(),
147
182
  name: z.string().optional(), // Display name for this account
148
183
  appId: z.string().optional(),
149
- appSecret: z.string().optional(),
184
+ appSecret: buildSecretInputSchema().optional(),
150
185
  encryptKey: z.string().optional(),
151
- verificationToken: z.string().optional(),
186
+ verificationToken: buildSecretInputSchema().optional(),
152
187
  domain: FeishuDomainSchema.optional(),
153
188
  connectionMode: FeishuConnectionModeSchema.optional(),
154
189
  webhookPath: z.string().optional(),
155
190
  ...FeishuSharedConfigShape,
191
+ groupSessionScope: GroupSessionScopeSchema,
192
+ topicSessionMode: TopicSessionModeSchema,
156
193
  })
157
194
  .strict();
158
195
 
159
196
  export const FeishuConfigSchema = z
160
197
  .object({
161
198
  enabled: z.boolean().optional(),
199
+ defaultAccount: z.string().optional(),
162
200
  // Top-level credentials (backward compatible for single-account mode)
163
201
  appId: z.string().optional(),
164
- appSecret: z.string().optional(),
202
+ appSecret: buildSecretInputSchema().optional(),
165
203
  encryptKey: z.string().optional(),
166
- verificationToken: z.string().optional(),
204
+ verificationToken: buildSecretInputSchema().optional(),
167
205
  domain: FeishuDomainSchema.optional().default("feishu"),
168
206
  connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
169
207
  webhookPath: z.string().optional().default("/feishu/events"),
170
208
  ...FeishuSharedConfigShape,
171
209
  dmPolicy: DmPolicySchema.optional().default("pairing"),
210
+ reactionNotifications: ReactionNotificationModeSchema.optional().default("own"),
172
211
  groupPolicy: GroupPolicySchema.optional().default("allowlist"),
173
212
  requireMention: z.boolean().optional().default(true),
213
+ groupSessionScope: GroupSessionScopeSchema,
174
214
  topicSessionMode: TopicSessionModeSchema,
175
215
  // Dynamic agent creation for DM users
176
216
  dynamicAgentCreation: DynamicAgentCreationSchema,
217
+ // Optimization flags
218
+ typingIndicator: z.boolean().optional().default(true),
219
+ resolveSenderNames: z.boolean().optional().default(true),
177
220
  // Multi-account configuration
178
221
  accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
179
222
  })
180
223
  .strict()
181
224
  .superRefine((value, ctx) => {
225
+ const defaultAccount = value.defaultAccount?.trim();
226
+ if (defaultAccount && value.accounts && Object.keys(value.accounts).length > 0) {
227
+ const normalizedDefaultAccount = normalizeAccountId(defaultAccount);
228
+ if (!Object.prototype.hasOwnProperty.call(value.accounts, normalizedDefaultAccount)) {
229
+ ctx.addIssue({
230
+ code: z.ZodIssueCode.custom,
231
+ path: ["defaultAccount"],
232
+ message: `channels.feishu.defaultAccount="${defaultAccount}" does not match a configured account key`,
233
+ });
234
+ }
235
+ }
236
+
182
237
  const defaultConnectionMode = value.connectionMode ?? "websocket";
183
- const defaultVerificationToken = value.verificationToken?.trim();
184
- if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
238
+ const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
239
+ if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
185
240
  ctx.addIssue({
186
241
  code: z.ZodIssueCode.custom,
187
242
  path: ["verificationToken"],
@@ -198,9 +253,9 @@ export const FeishuConfigSchema = z
198
253
  if (accountConnectionMode !== "webhook") {
199
254
  continue;
200
255
  }
201
- const accountVerificationToken =
202
- account.verificationToken?.trim() || defaultVerificationToken;
203
- if (!accountVerificationToken) {
256
+ const accountVerificationTokenConfigured =
257
+ hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
258
+ if (!accountVerificationTokenConfigured) {
204
259
  ctx.addIssue({
205
260
  code: z.ZodIssueCode.custom,
206
261
  path: ["accounts", accountId, "verificationToken"],
package/src/dedup.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
3
+ import {
4
+ createDedupeCache,
5
+ createPersistentDedupe,
6
+ readJsonFileWithFallback,
7
+ } from "openclaw/plugin-sdk";
4
8
 
5
9
  // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
6
10
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
7
11
  const MEMORY_MAX_SIZE = 1_000;
8
12
  const FILE_MAX_ENTRIES = 10_000;
13
+ type PersistentDedupeData = Record<string, number>;
9
14
 
10
15
  const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
11
16
 
@@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean {
40
45
  return !memoryDedupe.check(messageId);
41
46
  }
42
47
 
48
+ export function hasRecordedMessage(messageId: string): boolean {
49
+ const trimmed = messageId.trim();
50
+ if (!trimmed) {
51
+ return false;
52
+ }
53
+ return memoryDedupe.peek(trimmed);
54
+ }
55
+
43
56
  export async function tryRecordMessagePersistent(
44
57
  messageId: string,
45
58
  namespace = "global",
@@ -52,3 +65,36 @@ export async function tryRecordMessagePersistent(
52
65
  },
53
66
  });
54
67
  }
68
+
69
+ export async function hasRecordedMessagePersistent(
70
+ messageId: string,
71
+ namespace = "global",
72
+ log?: (...args: unknown[]) => void,
73
+ ): Promise<boolean> {
74
+ const trimmed = messageId.trim();
75
+ if (!trimmed) {
76
+ return false;
77
+ }
78
+ const now = Date.now();
79
+ const filePath = resolveNamespaceFilePath(namespace);
80
+ try {
81
+ const { value } = await readJsonFileWithFallback<PersistentDedupeData>(filePath, {});
82
+ const seenAt = value[trimmed];
83
+ if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) {
84
+ return false;
85
+ }
86
+ return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS;
87
+ } catch (error) {
88
+ log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export async function warmupDedupFromDisk(
94
+ namespace: string,
95
+ log?: (...args: unknown[]) => void,
96
+ ): Promise<number> {
97
+ return persistentDedupe.warmup(namespace, (error) => {
98
+ log?.(`feishu-dedup: warmup disk error: ${String(error)}`);
99
+ });
100
+ }
package/src/doc-schema.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
 
3
+ const tableCreationProperties = {
4
+ doc_token: Type.String({ description: "Document token" }),
5
+ parent_block_id: Type.Optional(
6
+ Type.String({ description: "Parent block ID (default: document root)" }),
7
+ ),
8
+ row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
9
+ column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
10
+ column_width: Type.Optional(
11
+ Type.Array(Type.Number({ minimum: 1 }), {
12
+ description: "Column widths in px (length should match column_size)",
13
+ }),
14
+ ),
15
+ };
16
+
3
17
  export const FeishuDocSchema = Type.Union([
4
18
  Type.Object({
5
19
  action: Type.Literal("read"),
@@ -17,10 +31,24 @@ export const FeishuDocSchema = Type.Union([
17
31
  doc_token: Type.String({ description: "Document token" }),
18
32
  content: Type.String({ description: "Markdown content to append to end of document" }),
19
33
  }),
34
+ Type.Object({
35
+ action: Type.Literal("insert"),
36
+ doc_token: Type.String({ description: "Document token" }),
37
+ content: Type.String({ description: "Markdown content to insert" }),
38
+ after_block_id: Type.String({
39
+ description: "Insert content after this block ID. Use list_blocks to find block IDs.",
40
+ }),
41
+ }),
20
42
  Type.Object({
21
43
  action: Type.Literal("create"),
22
44
  title: Type.String({ description: "Document title" }),
23
45
  folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
46
+ grant_to_requester: Type.Optional(
47
+ Type.Boolean({
48
+ description:
49
+ "Grant edit permission to the trusted requesting Feishu user from runtime context (default: true).",
50
+ }),
51
+ ),
24
52
  }),
25
53
  Type.Object({
26
54
  action: Type.Literal("list_blocks"),
@@ -42,6 +70,113 @@ export const FeishuDocSchema = Type.Union([
42
70
  doc_token: Type.String({ description: "Document token" }),
43
71
  block_id: Type.String({ description: "Block ID" }),
44
72
  }),
73
+ // Table creation (explicit structure)
74
+ Type.Object({
75
+ action: Type.Literal("create_table"),
76
+ ...tableCreationProperties,
77
+ }),
78
+ Type.Object({
79
+ action: Type.Literal("write_table_cells"),
80
+ doc_token: Type.String({ description: "Document token" }),
81
+ table_block_id: Type.String({ description: "Table block ID" }),
82
+ values: Type.Array(Type.Array(Type.String()), {
83
+ description: "2D matrix values[row][col] to write into table cells",
84
+ minItems: 1,
85
+ }),
86
+ }),
87
+ Type.Object({
88
+ action: Type.Literal("create_table_with_values"),
89
+ ...tableCreationProperties,
90
+ values: Type.Array(Type.Array(Type.String()), {
91
+ description: "2D matrix values[row][col] to write into table cells",
92
+ minItems: 1,
93
+ }),
94
+ }),
95
+ // Table row/column manipulation
96
+ Type.Object({
97
+ action: Type.Literal("insert_table_row"),
98
+ doc_token: Type.String({ description: "Document token" }),
99
+ block_id: Type.String({ description: "Table block ID" }),
100
+ row_index: Type.Optional(
101
+ Type.Number({ description: "Row index to insert at (-1 for end, default: -1)" }),
102
+ ),
103
+ }),
104
+ Type.Object({
105
+ action: Type.Literal("insert_table_column"),
106
+ doc_token: Type.String({ description: "Document token" }),
107
+ block_id: Type.String({ description: "Table block ID" }),
108
+ column_index: Type.Optional(
109
+ Type.Number({ description: "Column index to insert at (-1 for end, default: -1)" }),
110
+ ),
111
+ }),
112
+ Type.Object({
113
+ action: Type.Literal("delete_table_rows"),
114
+ doc_token: Type.String({ description: "Document token" }),
115
+ block_id: Type.String({ description: "Table block ID" }),
116
+ row_start: Type.Number({ description: "Start row index (0-based)" }),
117
+ row_count: Type.Optional(Type.Number({ description: "Number of rows to delete (default: 1)" })),
118
+ }),
119
+ Type.Object({
120
+ action: Type.Literal("delete_table_columns"),
121
+ doc_token: Type.String({ description: "Document token" }),
122
+ block_id: Type.String({ description: "Table block ID" }),
123
+ column_start: Type.Number({ description: "Start column index (0-based)" }),
124
+ column_count: Type.Optional(
125
+ Type.Number({ description: "Number of columns to delete (default: 1)" }),
126
+ ),
127
+ }),
128
+ Type.Object({
129
+ action: Type.Literal("merge_table_cells"),
130
+ doc_token: Type.String({ description: "Document token" }),
131
+ block_id: Type.String({ description: "Table block ID" }),
132
+ row_start: Type.Number({ description: "Start row index" }),
133
+ row_end: Type.Number({ description: "End row index (exclusive)" }),
134
+ column_start: Type.Number({ description: "Start column index" }),
135
+ column_end: Type.Number({ description: "End column index (exclusive)" }),
136
+ }),
137
+ // Image / file upload
138
+ Type.Object({
139
+ action: Type.Literal("upload_image"),
140
+ doc_token: Type.String({ description: "Document token" }),
141
+ url: Type.Optional(Type.String({ description: "Remote image URL (http/https)" })),
142
+ file_path: Type.Optional(Type.String({ description: "Local image file path" })),
143
+ image: Type.Optional(
144
+ Type.String({
145
+ description:
146
+ "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.",
147
+ }),
148
+ ),
149
+ parent_block_id: Type.Optional(
150
+ Type.String({ description: "Parent block ID (default: document root)" }),
151
+ ),
152
+ filename: Type.Optional(Type.String({ description: "Optional filename override" })),
153
+ index: Type.Optional(
154
+ Type.Integer({
155
+ minimum: 0,
156
+ description: "Insert position (0-based index among siblings). Omit to append.",
157
+ }),
158
+ ),
159
+ }),
160
+ Type.Object({
161
+ action: Type.Literal("upload_file"),
162
+ doc_token: Type.String({ description: "Document token" }),
163
+ url: Type.Optional(Type.String({ description: "Remote file URL (http/https)" })),
164
+ file_path: Type.Optional(Type.String({ description: "Local file path" })),
165
+ parent_block_id: Type.Optional(
166
+ Type.String({ description: "Parent block ID (default: document root)" }),
167
+ ),
168
+ filename: Type.Optional(Type.String({ description: "Optional filename override" })),
169
+ }),
170
+ // Text color / style
171
+ Type.Object({
172
+ action: Type.Literal("color_text"),
173
+ doc_token: Type.String({ description: "Document token" }),
174
+ block_id: Type.String({ description: "Text block ID to update" }),
175
+ content: Type.String({
176
+ description:
177
+ 'Text with color markup. Tags: [red], [green], [blue], [orange], [yellow], [purple], [grey], [bold], [bg:yellow]. Example: "Revenue [green]+15%[/green] YoY"',
178
+ }),
179
+ }),
45
180
  ]);
46
181
 
47
182
  export type FeishuDocParams = Static<typeof FeishuDocSchema>;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Batch insertion for large Feishu documents (>1000 blocks).
3
+ *
4
+ * The Feishu Descendant API has a limit of 1000 blocks per request.
5
+ * This module handles splitting large documents into batches while
6
+ * preserving parent-child relationships between blocks.
7
+ */
8
+
9
+ import type * as Lark from "@larksuiteoapi/node-sdk";
10
+ import { cleanBlocksForDescendant } from "./docx-table-ops.js";
11
+
12
+ export const BATCH_SIZE = 1000; // Feishu API limit per request
13
+
14
+ type Logger = { info?: (msg: string) => void };
15
+
16
+ /**
17
+ * Collect all descendant blocks for a given set of first-level block IDs.
18
+ * Recursively traverses the block tree to gather all children.
19
+ */
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
21
+ function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] {
22
+ const blockMap = new Map<string, any>();
23
+ for (const block of blocks) {
24
+ blockMap.set(block.block_id, block);
25
+ }
26
+
27
+ const result: any[] = [];
28
+ const visited = new Set<string>();
29
+
30
+ function collect(blockId: string) {
31
+ if (visited.has(blockId)) return;
32
+ visited.add(blockId);
33
+
34
+ const block = blockMap.get(blockId);
35
+ if (!block) return;
36
+
37
+ result.push(block);
38
+
39
+ // Recursively collect children
40
+ const children = block.children;
41
+ if (Array.isArray(children)) {
42
+ for (const childId of children) {
43
+ collect(childId);
44
+ }
45
+ } else if (typeof children === "string") {
46
+ collect(children);
47
+ }
48
+ }
49
+
50
+ for (const id of firstLevelIds) {
51
+ collect(id);
52
+ }
53
+
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Insert a single batch of blocks using Descendant API.
59
+ *
60
+ * @param parentBlockId - Parent block to insert into (defaults to docToken)
61
+ * @param index - Position within parent's children (-1 = end)
62
+ */
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
64
+ async function insertBatch(
65
+ client: Lark.Client,
66
+ docToken: string,
67
+ blocks: any[],
68
+ firstLevelBlockIds: string[],
69
+ parentBlockId: string = docToken,
70
+ index: number = -1,
71
+ ): Promise<any[]> {
72
+ const descendants = cleanBlocksForDescendant(blocks);
73
+
74
+ if (descendants.length === 0) {
75
+ return [];
76
+ }
77
+
78
+ const res = await client.docx.documentBlockDescendant.create({
79
+ path: { document_id: docToken, block_id: parentBlockId },
80
+ data: {
81
+ children_id: firstLevelBlockIds,
82
+ descendants,
83
+ index,
84
+ },
85
+ });
86
+
87
+ if (res.code !== 0) {
88
+ throw new Error(`${res.msg} (code: ${res.code})`);
89
+ }
90
+
91
+ return res.data?.children ?? [];
92
+ }
93
+
94
+ /**
95
+ * Insert blocks in batches for large documents (>1000 blocks).
96
+ *
97
+ * Batches are split to ensure BOTH children_id AND descendants
98
+ * arrays stay under the 1000 block API limit.
99
+ *
100
+ * @param client - Feishu API client
101
+ * @param docToken - Document ID
102
+ * @param blocks - All blocks from Convert API
103
+ * @param firstLevelBlockIds - IDs of top-level blocks to insert
104
+ * @param logger - Optional logger for progress updates
105
+ * @param parentBlockId - Parent block to insert into (defaults to docToken = document root)
106
+ * @param startIndex - Starting position within parent (-1 = end). For multi-batch inserts,
107
+ * each batch advances this by the number of first-level IDs inserted so far.
108
+ * @returns Inserted children blocks and any skipped block IDs
109
+ */
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
111
+ export async function insertBlocksInBatches(
112
+ client: Lark.Client,
113
+ docToken: string,
114
+ blocks: any[],
115
+ firstLevelBlockIds: string[],
116
+ logger?: Logger,
117
+ parentBlockId: string = docToken,
118
+ startIndex: number = -1,
119
+ ): Promise<{ children: any[]; skipped: string[] }> {
120
+ const allChildren: any[] = [];
121
+
122
+ // Build batches ensuring each batch has ≤1000 total descendants
123
+ const batches: { firstLevelIds: string[]; blocks: any[] }[] = [];
124
+ let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] };
125
+ const usedBlockIds = new Set<string>();
126
+
127
+ for (const firstLevelId of firstLevelBlockIds) {
128
+ const descendants = collectDescendants(blocks, [firstLevelId]);
129
+ const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id));
130
+
131
+ // A single block whose subtree exceeds the API limit cannot be split
132
+ // (a table or other compound block must be inserted atomically).
133
+ if (newBlocks.length > BATCH_SIZE) {
134
+ throw new Error(
135
+ `Block "${firstLevelId}" has ${newBlocks.length} descendants, which exceeds the ` +
136
+ `Feishu API limit of ${BATCH_SIZE} blocks per request. ` +
137
+ `Please split the content into smaller sections.`,
138
+ );
139
+ }
140
+
141
+ // If adding this first-level block would exceed limit, start new batch
142
+ if (
143
+ currentBatch.blocks.length + newBlocks.length > BATCH_SIZE &&
144
+ currentBatch.blocks.length > 0
145
+ ) {
146
+ batches.push(currentBatch);
147
+ currentBatch = { firstLevelIds: [], blocks: [] };
148
+ }
149
+
150
+ // Add to current batch
151
+ currentBatch.firstLevelIds.push(firstLevelId);
152
+ for (const block of newBlocks) {
153
+ currentBatch.blocks.push(block);
154
+ usedBlockIds.add(block.block_id);
155
+ }
156
+ }
157
+
158
+ // Don't forget the last batch
159
+ if (currentBatch.blocks.length > 0) {
160
+ batches.push(currentBatch);
161
+ }
162
+
163
+ // Insert each batch, advancing index for position-aware inserts.
164
+ // When startIndex == -1 (append to end), each batch appends after the previous.
165
+ // When startIndex >= 0, each batch starts at startIndex + count of first-level IDs already inserted.
166
+ let currentIndex = startIndex;
167
+ for (let i = 0; i < batches.length; i++) {
168
+ const batch = batches[i];
169
+ logger?.info?.(
170
+ `feishu_doc: Inserting batch ${i + 1}/${batches.length} (${batch.blocks.length} blocks)...`,
171
+ );
172
+
173
+ const children = await insertBatch(
174
+ client,
175
+ docToken,
176
+ batch.blocks,
177
+ batch.firstLevelIds,
178
+ parentBlockId,
179
+ currentIndex,
180
+ );
181
+ allChildren.push(...children);
182
+
183
+ // Advance index only for explicit positions; -1 always means "after last inserted"
184
+ if (currentIndex !== -1) {
185
+ currentIndex += batch.firstLevelIds.length;
186
+ }
187
+ }
188
+
189
+ return { children: allChildren, skipped: [] };
190
+ }