@openclaw/feishu 2026.2.25 → 2026.3.1

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 (64) 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 +90 -0
  5. package/src/accounts.ts +11 -2
  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 +55 -0
  10. package/src/bot.test.ts +863 -9
  11. package/src/bot.ts +414 -200
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +6 -0
  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 +107 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +82 -1
  20. package/src/config-schema.ts +54 -3
  21. package/src/doc-schema.ts +141 -0
  22. package/src/docx-batch-insert.ts +190 -0
  23. package/src/docx-color-text.ts +149 -0
  24. package/src/docx-table-ops.ts +298 -0
  25. package/src/docx.account-selection.test.ts +76 -0
  26. package/src/docx.test.ts +470 -0
  27. package/src/docx.ts +996 -72
  28. package/src/drive.ts +38 -33
  29. package/src/media.test.ts +123 -6
  30. package/src/media.ts +31 -10
  31. package/src/monitor.account.ts +286 -0
  32. package/src/monitor.reaction.test.ts +235 -0
  33. package/src/monitor.startup.test.ts +187 -0
  34. package/src/monitor.startup.ts +51 -0
  35. package/src/monitor.state.ts +76 -0
  36. package/src/monitor.transport.ts +163 -0
  37. package/src/monitor.ts +44 -346
  38. package/src/monitor.webhook-security.test.ts +27 -1
  39. package/src/outbound.test.ts +181 -0
  40. package/src/outbound.ts +94 -7
  41. package/src/perm.ts +37 -30
  42. package/src/policy.test.ts +56 -1
  43. package/src/policy.ts +5 -1
  44. package/src/post.test.ts +105 -0
  45. package/src/post.ts +274 -0
  46. package/src/probe.test.ts +253 -0
  47. package/src/probe.ts +99 -7
  48. package/src/reply-dispatcher.test.ts +259 -0
  49. package/src/reply-dispatcher.ts +139 -45
  50. package/src/send.reply-fallback.test.ts +105 -0
  51. package/src/send.test.ts +168 -0
  52. package/src/send.ts +143 -18
  53. package/src/streaming-card.ts +131 -43
  54. package/src/targets.test.ts +26 -1
  55. package/src/targets.ts +11 -6
  56. package/src/tool-account-routing.test.ts +129 -0
  57. package/src/tool-account.ts +70 -0
  58. package/src/tool-factory-test-harness.ts +76 -0
  59. package/src/tools-config.test.ts +21 -0
  60. package/src/tools-config.ts +2 -1
  61. package/src/types.ts +1 -0
  62. package/src/typing.test.ts +144 -0
  63. package/src/typing.ts +140 -10
  64. package/src/wiki.ts +55 -50
@@ -1,3 +1,4 @@
1
+ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
1
2
  import { z } from "zod";
2
3
  export { z };
3
4
 
@@ -82,6 +83,7 @@ const DynamicAgentCreationSchema = z
82
83
  const FeishuToolsConfigSchema = z
83
84
  .object({
84
85
  doc: z.boolean().optional(), // Document operations (default: true)
86
+ chat: z.boolean().optional(), // Chat info + member query operations (default: true)
85
87
  wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
86
88
  drive: z.boolean().optional(), // Cloud storage operations (default: true)
87
89
  perm: z.boolean().optional(), // Permission management (default: false, sensitive)
@@ -91,14 +93,36 @@ const FeishuToolsConfigSchema = z
91
93
  .optional();
92
94
 
93
95
  /**
96
+ * Group session scope for routing Feishu group messages.
97
+ * - "group" (default): one session per group chat
98
+ * - "group_sender": one session per (group + sender)
99
+ * - "group_topic": one session per group topic thread (falls back to group if no topic)
100
+ * - "group_topic_sender": one session per (group + topic thread + sender),
101
+ * falls back to (group + sender) if no topic
102
+ */
103
+ const GroupSessionScopeSchema = z
104
+ .enum(["group", "group_sender", "group_topic", "group_topic_sender"])
105
+ .optional();
106
+
107
+ /**
108
+ * @deprecated Use groupSessionScope instead.
109
+ *
94
110
  * Topic session isolation mode for group chats.
95
111
  * - "disabled" (default): All messages in a group share one session
96
112
  * - "enabled": Messages in different topics get separate sessions
97
- *
98
- * When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
99
- * for messages within a topic thread, allowing isolated conversations.
100
113
  */
101
114
  const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
115
+ const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
116
+
117
+ /**
118
+ * Reply-in-thread mode for group chats.
119
+ * - "disabled" (default): Bot replies are normal inline replies
120
+ * - "enabled": Bot replies create or continue a Feishu topic thread
121
+ *
122
+ * When enabled, the Feishu reply API is called with `reply_in_thread: true`,
123
+ * causing the reply to appear as a topic (话题) under the original message.
124
+ */
125
+ const ReplyInThreadSchema = z.enum(["disabled", "enabled"]).optional();
102
126
 
103
127
  export const FeishuGroupSchema = z
104
128
  .object({
@@ -108,7 +132,9 @@ export const FeishuGroupSchema = z
108
132
  enabled: z.boolean().optional(),
109
133
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
110
134
  systemPrompt: z.string().optional(),
135
+ groupSessionScope: GroupSessionScopeSchema,
111
136
  topicSessionMode: TopicSessionModeSchema,
137
+ replyInThread: ReplyInThreadSchema,
112
138
  })
113
139
  .strict();
114
140
 
@@ -122,6 +148,7 @@ const FeishuSharedConfigShape = {
122
148
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
123
149
  groupPolicy: GroupPolicySchema.optional(),
124
150
  groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
151
+ groupSenderAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
125
152
  requireMention: z.boolean().optional(),
126
153
  groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
127
154
  historyLimit: z.number().int().min(0).optional(),
@@ -135,6 +162,10 @@ const FeishuSharedConfigShape = {
135
162
  renderMode: RenderModeSchema,
136
163
  streaming: StreamingModeSchema,
137
164
  tools: FeishuToolsConfigSchema,
165
+ replyInThread: ReplyInThreadSchema,
166
+ reactionNotifications: ReactionNotificationModeSchema,
167
+ typingIndicator: z.boolean().optional(),
168
+ resolveSenderNames: z.boolean().optional(),
138
169
  };
139
170
 
140
171
  /**
@@ -153,12 +184,15 @@ export const FeishuAccountConfigSchema = z
153
184
  connectionMode: FeishuConnectionModeSchema.optional(),
154
185
  webhookPath: z.string().optional(),
155
186
  ...FeishuSharedConfigShape,
187
+ groupSessionScope: GroupSessionScopeSchema,
188
+ topicSessionMode: TopicSessionModeSchema,
156
189
  })
157
190
  .strict();
158
191
 
159
192
  export const FeishuConfigSchema = z
160
193
  .object({
161
194
  enabled: z.boolean().optional(),
195
+ defaultAccount: z.string().optional(),
162
196
  // Top-level credentials (backward compatible for single-account mode)
163
197
  appId: z.string().optional(),
164
198
  appSecret: z.string().optional(),
@@ -169,16 +203,33 @@ export const FeishuConfigSchema = z
169
203
  webhookPath: z.string().optional().default("/feishu/events"),
170
204
  ...FeishuSharedConfigShape,
171
205
  dmPolicy: DmPolicySchema.optional().default("pairing"),
206
+ reactionNotifications: ReactionNotificationModeSchema.optional().default("own"),
172
207
  groupPolicy: GroupPolicySchema.optional().default("allowlist"),
173
208
  requireMention: z.boolean().optional().default(true),
209
+ groupSessionScope: GroupSessionScopeSchema,
174
210
  topicSessionMode: TopicSessionModeSchema,
175
211
  // Dynamic agent creation for DM users
176
212
  dynamicAgentCreation: DynamicAgentCreationSchema,
213
+ // Optimization flags
214
+ typingIndicator: z.boolean().optional().default(true),
215
+ resolveSenderNames: z.boolean().optional().default(true),
177
216
  // Multi-account configuration
178
217
  accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
179
218
  })
180
219
  .strict()
181
220
  .superRefine((value, ctx) => {
221
+ const defaultAccount = value.defaultAccount?.trim();
222
+ if (defaultAccount && value.accounts && Object.keys(value.accounts).length > 0) {
223
+ const normalizedDefaultAccount = normalizeAccountId(defaultAccount);
224
+ if (!Object.prototype.hasOwnProperty.call(value.accounts, normalizedDefaultAccount)) {
225
+ ctx.addIssue({
226
+ code: z.ZodIssueCode.custom,
227
+ path: ["defaultAccount"],
228
+ message: `channels.feishu.defaultAccount="${defaultAccount}" does not match a configured account key`,
229
+ });
230
+ }
231
+ }
232
+
182
233
  const defaultConnectionMode = value.connectionMode ?? "websocket";
183
234
  const defaultVerificationToken = value.verificationToken?.trim();
184
235
  if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
package/src/doc-schema.ts CHANGED
@@ -17,10 +17,24 @@ export const FeishuDocSchema = Type.Union([
17
17
  doc_token: Type.String({ description: "Document token" }),
18
18
  content: Type.String({ description: "Markdown content to append to end of document" }),
19
19
  }),
20
+ Type.Object({
21
+ action: Type.Literal("insert"),
22
+ doc_token: Type.String({ description: "Document token" }),
23
+ content: Type.String({ description: "Markdown content to insert" }),
24
+ after_block_id: Type.String({
25
+ description: "Insert content after this block ID. Use list_blocks to find block IDs.",
26
+ }),
27
+ }),
20
28
  Type.Object({
21
29
  action: Type.Literal("create"),
22
30
  title: Type.String({ description: "Document title" }),
23
31
  folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
32
+ grant_to_requester: Type.Optional(
33
+ Type.Boolean({
34
+ description:
35
+ "Grant edit permission to the trusted requesting Feishu user from runtime context (default: true).",
36
+ }),
37
+ ),
24
38
  }),
25
39
  Type.Object({
26
40
  action: Type.Literal("list_blocks"),
@@ -42,6 +56,133 @@ export const FeishuDocSchema = Type.Union([
42
56
  doc_token: Type.String({ description: "Document token" }),
43
57
  block_id: Type.String({ description: "Block ID" }),
44
58
  }),
59
+ // Table creation (explicit structure)
60
+ Type.Object({
61
+ action: Type.Literal("create_table"),
62
+ doc_token: Type.String({ description: "Document token" }),
63
+ parent_block_id: Type.Optional(
64
+ Type.String({ description: "Parent block ID (default: document root)" }),
65
+ ),
66
+ row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
67
+ column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
68
+ column_width: Type.Optional(
69
+ Type.Array(Type.Number({ minimum: 1 }), {
70
+ description: "Column widths in px (length should match column_size)",
71
+ }),
72
+ ),
73
+ }),
74
+ Type.Object({
75
+ action: Type.Literal("write_table_cells"),
76
+ doc_token: Type.String({ description: "Document token" }),
77
+ table_block_id: Type.String({ description: "Table block ID" }),
78
+ values: Type.Array(Type.Array(Type.String()), {
79
+ description: "2D matrix values[row][col] to write into table cells",
80
+ minItems: 1,
81
+ }),
82
+ }),
83
+ Type.Object({
84
+ action: Type.Literal("create_table_with_values"),
85
+ doc_token: Type.String({ description: "Document token" }),
86
+ parent_block_id: Type.Optional(
87
+ Type.String({ description: "Parent block ID (default: document root)" }),
88
+ ),
89
+ row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
90
+ column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
91
+ column_width: Type.Optional(
92
+ Type.Array(Type.Number({ minimum: 1 }), {
93
+ description: "Column widths in px (length should match column_size)",
94
+ }),
95
+ ),
96
+ values: Type.Array(Type.Array(Type.String()), {
97
+ description: "2D matrix values[row][col] to write into table cells",
98
+ minItems: 1,
99
+ }),
100
+ }),
101
+ // Table row/column manipulation
102
+ Type.Object({
103
+ action: Type.Literal("insert_table_row"),
104
+ doc_token: Type.String({ description: "Document token" }),
105
+ block_id: Type.String({ description: "Table block ID" }),
106
+ row_index: Type.Optional(
107
+ Type.Number({ description: "Row index to insert at (-1 for end, default: -1)" }),
108
+ ),
109
+ }),
110
+ Type.Object({
111
+ action: Type.Literal("insert_table_column"),
112
+ doc_token: Type.String({ description: "Document token" }),
113
+ block_id: Type.String({ description: "Table block ID" }),
114
+ column_index: Type.Optional(
115
+ Type.Number({ description: "Column index to insert at (-1 for end, default: -1)" }),
116
+ ),
117
+ }),
118
+ Type.Object({
119
+ action: Type.Literal("delete_table_rows"),
120
+ doc_token: Type.String({ description: "Document token" }),
121
+ block_id: Type.String({ description: "Table block ID" }),
122
+ row_start: Type.Number({ description: "Start row index (0-based)" }),
123
+ row_count: Type.Optional(Type.Number({ description: "Number of rows to delete (default: 1)" })),
124
+ }),
125
+ Type.Object({
126
+ action: Type.Literal("delete_table_columns"),
127
+ doc_token: Type.String({ description: "Document token" }),
128
+ block_id: Type.String({ description: "Table block ID" }),
129
+ column_start: Type.Number({ description: "Start column index (0-based)" }),
130
+ column_count: Type.Optional(
131
+ Type.Number({ description: "Number of columns to delete (default: 1)" }),
132
+ ),
133
+ }),
134
+ Type.Object({
135
+ action: Type.Literal("merge_table_cells"),
136
+ doc_token: Type.String({ description: "Document token" }),
137
+ block_id: Type.String({ description: "Table block ID" }),
138
+ row_start: Type.Number({ description: "Start row index" }),
139
+ row_end: Type.Number({ description: "End row index (exclusive)" }),
140
+ column_start: Type.Number({ description: "Start column index" }),
141
+ column_end: Type.Number({ description: "End column index (exclusive)" }),
142
+ }),
143
+ // Image / file upload
144
+ Type.Object({
145
+ action: Type.Literal("upload_image"),
146
+ doc_token: Type.String({ description: "Document token" }),
147
+ url: Type.Optional(Type.String({ description: "Remote image URL (http/https)" })),
148
+ file_path: Type.Optional(Type.String({ description: "Local image file path" })),
149
+ image: Type.Optional(
150
+ Type.String({
151
+ description:
152
+ "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.",
153
+ }),
154
+ ),
155
+ parent_block_id: Type.Optional(
156
+ Type.String({ description: "Parent block ID (default: document root)" }),
157
+ ),
158
+ filename: Type.Optional(Type.String({ description: "Optional filename override" })),
159
+ index: Type.Optional(
160
+ Type.Integer({
161
+ minimum: 0,
162
+ description: "Insert position (0-based index among siblings). Omit to append.",
163
+ }),
164
+ ),
165
+ }),
166
+ Type.Object({
167
+ action: Type.Literal("upload_file"),
168
+ doc_token: Type.String({ description: "Document token" }),
169
+ url: Type.Optional(Type.String({ description: "Remote file URL (http/https)" })),
170
+ file_path: Type.Optional(Type.String({ description: "Local file path" })),
171
+ parent_block_id: Type.Optional(
172
+ Type.String({ description: "Parent block ID (default: document root)" }),
173
+ ),
174
+ filename: Type.Optional(Type.String({ description: "Optional filename override" })),
175
+ }),
176
+ // Text color / style
177
+ Type.Object({
178
+ action: Type.Literal("color_text"),
179
+ doc_token: Type.String({ description: "Document token" }),
180
+ block_id: Type.String({ description: "Text block ID to update" }),
181
+ content: Type.String({
182
+ description:
183
+ 'Text with color markup. Tags: [red], [green], [blue], [orange], [yellow], [purple], [grey], [bold], [bg:yellow]. Example: "Revenue [green]+15%[/green] YoY"',
184
+ }),
185
+ }),
45
186
  ]);
46
187
 
47
188
  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
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Colored text support for Feishu documents.
3
+ *
4
+ * Parses a simple color markup syntax and updates a text block
5
+ * with native Feishu text_run color styles.
6
+ *
7
+ * Syntax: [color]text[/color]
8
+ * Supported colors: red, orange, yellow, green, blue, purple, grey
9
+ *
10
+ * Example:
11
+ * "Revenue [green]+15%[/green] YoY, Costs [red]-3%[/red]"
12
+ */
13
+
14
+ import type * as Lark from "@larksuiteoapi/node-sdk";
15
+
16
+ // Feishu text_color values (1-7)
17
+ const TEXT_COLOR: Record<string, number> = {
18
+ red: 1, // Pink (closest to red in Feishu)
19
+ orange: 2,
20
+ yellow: 3,
21
+ green: 4,
22
+ blue: 5,
23
+ purple: 6,
24
+ grey: 7,
25
+ gray: 7,
26
+ };
27
+
28
+ // Feishu background_color values (1-15)
29
+ const BACKGROUND_COLOR: Record<string, number> = {
30
+ red: 1,
31
+ orange: 2,
32
+ yellow: 3,
33
+ green: 4,
34
+ blue: 5,
35
+ purple: 6,
36
+ grey: 7,
37
+ gray: 7,
38
+ };
39
+
40
+ interface Segment {
41
+ text: string;
42
+ textColor?: number;
43
+ bgColor?: number;
44
+ bold?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Parse color markup into segments.
49
+ *
50
+ * Supports:
51
+ * [red]text[/red] → red text
52
+ * [bg:yellow]text[/bg] → yellow background
53
+ * [bold]text[/bold] → bold
54
+ * [green bold]text[/green] → green + bold
55
+ */
56
+ export function parseColorMarkup(content: string): Segment[] {
57
+ const segments: Segment[] = [];
58
+ // Only [known_tag]...[/...] pairs are treated as markup. Using an open
59
+ // pattern like \[([^\]]+)\] would match any bracket token — e.g. [Q1] —
60
+ // and cause it to consume a later real closing tag ([/red]), silently
61
+ // corrupting the surrounding styled spans. Restricting the opening tag to
62
+ // the set of recognised colour/style names prevents that: [Q1] does not
63
+ // match the tag alternative and each of its characters falls through to the
64
+ // plain-text alternatives instead.
65
+ //
66
+ // Closing tag name is still not validated against the opening tag:
67
+ // [red]text[/green] is treated as [red]text[/red] — opening style applies
68
+ // and the closing tag is consumed regardless of its name.
69
+ const KNOWN = "(?:bg:[a-z]+|bold|red|orange|yellow|green|blue|purple|gr[ae]y)";
70
+ const tagPattern = new RegExp(
71
+ `\\[(${KNOWN}(?:\\s+${KNOWN})*)\\](.*?)\\[\\/(?:[^\\]]+)\\]|([^[]+|\\[)`,
72
+ "gis",
73
+ );
74
+ let match;
75
+
76
+ while ((match = tagPattern.exec(content)) !== null) {
77
+ if (match[3] !== undefined) {
78
+ // Plain text segment
79
+ if (match[3]) {
80
+ segments.push({ text: match[3] });
81
+ }
82
+ } else {
83
+ // Tagged segment
84
+ const tagStr = match[1].toLowerCase().trim();
85
+ const text = match[2];
86
+ const tags = tagStr.split(/\s+/);
87
+
88
+ const segment: Segment = { text };
89
+
90
+ for (const tag of tags) {
91
+ if (tag.startsWith("bg:")) {
92
+ const color = tag.slice(3);
93
+ if (BACKGROUND_COLOR[color]) {
94
+ segment.bgColor = BACKGROUND_COLOR[color];
95
+ }
96
+ } else if (tag === "bold") {
97
+ segment.bold = true;
98
+ } else if (TEXT_COLOR[tag]) {
99
+ segment.textColor = TEXT_COLOR[tag];
100
+ }
101
+ }
102
+
103
+ if (text) {
104
+ segments.push(segment);
105
+ }
106
+ }
107
+ }
108
+
109
+ return segments;
110
+ }
111
+
112
+ /**
113
+ * Update a text block with colored segments.
114
+ */
115
+ export async function updateColorText(
116
+ client: Lark.Client,
117
+ docToken: string,
118
+ blockId: string,
119
+ content: string,
120
+ ) {
121
+ const segments = parseColorMarkup(content);
122
+
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK type
124
+ const elements: any[] = segments.map((seg) => ({
125
+ text_run: {
126
+ content: seg.text,
127
+ text_element_style: {
128
+ ...(seg.textColor && { text_color: seg.textColor }),
129
+ ...(seg.bgColor && { background_color: seg.bgColor }),
130
+ ...(seg.bold && { bold: true }),
131
+ },
132
+ },
133
+ }));
134
+
135
+ const res = await client.docx.documentBlock.patch({
136
+ path: { document_id: docToken, block_id: blockId },
137
+ data: { update_text_elements: { elements } },
138
+ });
139
+
140
+ if (res.code !== 0) {
141
+ throw new Error(res.msg);
142
+ }
143
+
144
+ return {
145
+ success: true,
146
+ segments: segments.length,
147
+ block: res.data?.block,
148
+ };
149
+ }