@openclaw/feishu 2026.5.2 → 2026.5.3-beta.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 (224) hide show
  1. package/dist/accounts-Ba3-WP1z.js +423 -0
  2. package/dist/api.js +2280 -0
  3. package/dist/app-registration-B8qc1MCM.js +184 -0
  4. package/dist/audio-preflight.runtime-BPlzkO3l.js +7 -0
  5. package/dist/card-interaction-BfRLgvw_.js +96 -0
  6. package/dist/channel-CSD_Jt8I.js +1668 -0
  7. package/dist/channel-entry.js +22 -0
  8. package/dist/channel-plugin-api.js +2 -0
  9. package/dist/channel.runtime-DYsXcD36.js +700 -0
  10. package/dist/client-DBVoQL5w.js +157 -0
  11. package/dist/contract-api.js +9 -0
  12. package/dist/conversation-id-DWS3Ep2A.js +139 -0
  13. package/dist/directory.static-f3EeoRJd.js +44 -0
  14. package/dist/drive-C5eJLJr7.js +883 -0
  15. package/dist/index.js +68 -0
  16. package/dist/monitor-CT189QfR.js +60 -0
  17. package/dist/monitor.account-dJV2jO8C.js +4990 -0
  18. package/dist/monitor.state-DYM02ipp.js +100 -0
  19. package/dist/policy-D6c-wMPl.js +118 -0
  20. package/dist/probe-BNzzU_uR.js +149 -0
  21. package/dist/rolldown-runtime-DUslC3ob.js +14 -0
  22. package/dist/runtime-CG0DuRCy.js +8 -0
  23. package/dist/runtime-api.js +14 -0
  24. package/dist/secret-contract-Dm4Z_zQN.js +119 -0
  25. package/dist/secret-contract-api.js +2 -0
  26. package/dist/security-audit-DqJdocrN.js +11 -0
  27. package/dist/security-audit-shared-ByuMx9cJ.js +38 -0
  28. package/dist/security-contract-api.js +2 -0
  29. package/dist/send-DowxxbpH.js +1218 -0
  30. package/dist/session-conversation-B4nrW-vo.js +27 -0
  31. package/dist/session-key-api.js +2 -0
  32. package/dist/setup-api.js +2 -0
  33. package/dist/setup-entry.js +15 -0
  34. package/dist/subagent-hooks-C3UhPVLV.js +227 -0
  35. package/dist/subagent-hooks-api.js +23 -0
  36. package/dist/targets-JMFJRKSe.js +48 -0
  37. package/dist/thread-bindings-BmS6TLes.js +222 -0
  38. package/package.json +15 -6
  39. package/api.ts +0 -31
  40. package/channel-entry.ts +0 -20
  41. package/channel-plugin-api.ts +0 -1
  42. package/contract-api.ts +0 -16
  43. package/index.ts +0 -82
  44. package/runtime-api.ts +0 -55
  45. package/secret-contract-api.ts +0 -5
  46. package/security-contract-api.ts +0 -1
  47. package/session-key-api.ts +0 -1
  48. package/setup-api.ts +0 -3
  49. package/setup-entry.test.ts +0 -14
  50. package/setup-entry.ts +0 -13
  51. package/src/accounts.test.ts +0 -459
  52. package/src/accounts.ts +0 -326
  53. package/src/app-registration.ts +0 -331
  54. package/src/approval-auth.test.ts +0 -24
  55. package/src/approval-auth.ts +0 -25
  56. package/src/async.test.ts +0 -35
  57. package/src/async.ts +0 -104
  58. package/src/audio-preflight.runtime.ts +0 -9
  59. package/src/bitable.test.ts +0 -131
  60. package/src/bitable.ts +0 -762
  61. package/src/bot-content.ts +0 -474
  62. package/src/bot-group-name.test.ts +0 -108
  63. package/src/bot-runtime-api.ts +0 -12
  64. package/src/bot-sender-name.ts +0 -125
  65. package/src/bot.broadcast.test.ts +0 -463
  66. package/src/bot.card-action.test.ts +0 -577
  67. package/src/bot.checkBotMentioned.test.ts +0 -265
  68. package/src/bot.helpers.test.ts +0 -118
  69. package/src/bot.stripBotMention.test.ts +0 -126
  70. package/src/bot.test.ts +0 -3040
  71. package/src/bot.ts +0 -1559
  72. package/src/card-action.ts +0 -447
  73. package/src/card-interaction.test.ts +0 -129
  74. package/src/card-interaction.ts +0 -159
  75. package/src/card-test-helpers.ts +0 -47
  76. package/src/card-ux-approval.ts +0 -65
  77. package/src/card-ux-launcher.test.ts +0 -99
  78. package/src/card-ux-launcher.ts +0 -121
  79. package/src/card-ux-shared.ts +0 -33
  80. package/src/channel-runtime-api.ts +0 -16
  81. package/src/channel.runtime.ts +0 -47
  82. package/src/channel.test.ts +0 -959
  83. package/src/channel.ts +0 -1313
  84. package/src/chat-schema.ts +0 -25
  85. package/src/chat.test.ts +0 -196
  86. package/src/chat.ts +0 -188
  87. package/src/client.test.ts +0 -433
  88. package/src/client.ts +0 -290
  89. package/src/comment-dispatcher-runtime-api.ts +0 -6
  90. package/src/comment-dispatcher.test.ts +0 -169
  91. package/src/comment-dispatcher.ts +0 -107
  92. package/src/comment-handler-runtime-api.ts +0 -3
  93. package/src/comment-handler.test.ts +0 -486
  94. package/src/comment-handler.ts +0 -309
  95. package/src/comment-reaction.test.ts +0 -166
  96. package/src/comment-reaction.ts +0 -259
  97. package/src/comment-shared.test.ts +0 -182
  98. package/src/comment-shared.ts +0 -406
  99. package/src/comment-target.ts +0 -44
  100. package/src/config-schema.test.ts +0 -309
  101. package/src/config-schema.ts +0 -333
  102. package/src/conversation-id.test.ts +0 -18
  103. package/src/conversation-id.ts +0 -199
  104. package/src/dedup-runtime-api.ts +0 -1
  105. package/src/dedup.ts +0 -141
  106. package/src/directory.static.ts +0 -61
  107. package/src/directory.test.ts +0 -136
  108. package/src/directory.ts +0 -124
  109. package/src/doc-schema.ts +0 -182
  110. package/src/docx-batch-insert.test.ts +0 -91
  111. package/src/docx-batch-insert.ts +0 -223
  112. package/src/docx-color-text.ts +0 -154
  113. package/src/docx-table-ops.test.ts +0 -53
  114. package/src/docx-table-ops.ts +0 -316
  115. package/src/docx-types.ts +0 -38
  116. package/src/docx.account-selection.test.ts +0 -79
  117. package/src/docx.test.ts +0 -685
  118. package/src/docx.ts +0 -1616
  119. package/src/drive-schema.ts +0 -92
  120. package/src/drive.test.ts +0 -1219
  121. package/src/drive.ts +0 -829
  122. package/src/dynamic-agent.ts +0 -137
  123. package/src/event-types.ts +0 -45
  124. package/src/external-keys.test.ts +0 -20
  125. package/src/external-keys.ts +0 -19
  126. package/src/lifecycle.test-support.ts +0 -220
  127. package/src/media.test.ts +0 -900
  128. package/src/media.ts +0 -861
  129. package/src/mention-target.types.ts +0 -5
  130. package/src/mention.ts +0 -114
  131. package/src/message-action-contract.ts +0 -13
  132. package/src/monitor-state-runtime-api.ts +0 -7
  133. package/src/monitor-transport-runtime-api.ts +0 -7
  134. package/src/monitor.account.ts +0 -468
  135. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +0 -219
  136. package/src/monitor.bot-identity.ts +0 -86
  137. package/src/monitor.bot-menu-handler.ts +0 -165
  138. package/src/monitor.bot-menu.lifecycle.test-support.ts +0 -224
  139. package/src/monitor.bot-menu.test.ts +0 -178
  140. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +0 -264
  141. package/src/monitor.card-action.lifecycle.test-support.ts +0 -373
  142. package/src/monitor.cleanup.test.ts +0 -376
  143. package/src/monitor.comment-notice-handler.ts +0 -105
  144. package/src/monitor.comment.test.ts +0 -937
  145. package/src/monitor.comment.ts +0 -1386
  146. package/src/monitor.lifecycle.test.ts +0 -4
  147. package/src/monitor.message-handler.ts +0 -339
  148. package/src/monitor.reaction.lifecycle.test-support.ts +0 -68
  149. package/src/monitor.reaction.test.ts +0 -713
  150. package/src/monitor.startup.test.ts +0 -192
  151. package/src/monitor.startup.ts +0 -74
  152. package/src/monitor.state.defaults.test.ts +0 -46
  153. package/src/monitor.state.ts +0 -170
  154. package/src/monitor.synthetic-error.ts +0 -18
  155. package/src/monitor.test-mocks.ts +0 -45
  156. package/src/monitor.transport.ts +0 -424
  157. package/src/monitor.ts +0 -100
  158. package/src/monitor.webhook-e2e.test.ts +0 -272
  159. package/src/monitor.webhook-security.test.ts +0 -264
  160. package/src/monitor.webhook.test-helpers.ts +0 -116
  161. package/src/outbound-runtime-api.ts +0 -1
  162. package/src/outbound.test.ts +0 -935
  163. package/src/outbound.ts +0 -718
  164. package/src/perm-schema.ts +0 -52
  165. package/src/perm.ts +0 -170
  166. package/src/pins.ts +0 -108
  167. package/src/policy.test.ts +0 -334
  168. package/src/policy.ts +0 -236
  169. package/src/post.test.ts +0 -105
  170. package/src/post.ts +0 -275
  171. package/src/probe.test.ts +0 -275
  172. package/src/probe.ts +0 -166
  173. package/src/processing-claims.ts +0 -59
  174. package/src/qr-terminal.ts +0 -1
  175. package/src/reactions.ts +0 -123
  176. package/src/reasoning-preview.test.ts +0 -59
  177. package/src/reasoning-preview.ts +0 -20
  178. package/src/reply-dispatcher-runtime-api.ts +0 -7
  179. package/src/reply-dispatcher.test.ts +0 -1144
  180. package/src/reply-dispatcher.ts +0 -650
  181. package/src/runtime.ts +0 -9
  182. package/src/secret-contract.ts +0 -145
  183. package/src/secret-input.ts +0 -1
  184. package/src/security-audit-shared.ts +0 -69
  185. package/src/security-audit.test.ts +0 -61
  186. package/src/security-audit.ts +0 -1
  187. package/src/send-result.ts +0 -29
  188. package/src/send-target.test.ts +0 -80
  189. package/src/send-target.ts +0 -35
  190. package/src/send.reply-fallback.test.ts +0 -292
  191. package/src/send.test.ts +0 -550
  192. package/src/send.ts +0 -800
  193. package/src/sequential-key.test.ts +0 -72
  194. package/src/sequential-key.ts +0 -28
  195. package/src/sequential-queue.test.ts +0 -92
  196. package/src/sequential-queue.ts +0 -16
  197. package/src/session-conversation.ts +0 -42
  198. package/src/session-route.ts +0 -48
  199. package/src/setup-core.ts +0 -51
  200. package/src/setup-surface.test.ts +0 -174
  201. package/src/setup-surface.ts +0 -581
  202. package/src/streaming-card.test.ts +0 -190
  203. package/src/streaming-card.ts +0 -490
  204. package/src/subagent-hooks.test.ts +0 -603
  205. package/src/subagent-hooks.ts +0 -397
  206. package/src/targets.ts +0 -97
  207. package/src/test-support/lifecycle-test-support.ts +0 -453
  208. package/src/thread-bindings.test.ts +0 -143
  209. package/src/thread-bindings.ts +0 -330
  210. package/src/tool-account-routing.test.ts +0 -187
  211. package/src/tool-account.test.ts +0 -44
  212. package/src/tool-account.ts +0 -93
  213. package/src/tool-factory-test-harness.ts +0 -79
  214. package/src/tool-result.test.ts +0 -32
  215. package/src/tool-result.ts +0 -16
  216. package/src/tools-config.test.ts +0 -21
  217. package/src/tools-config.ts +0 -22
  218. package/src/types.ts +0 -104
  219. package/src/typing.test.ts +0 -144
  220. package/src/typing.ts +0 -214
  221. package/src/wiki-schema.ts +0 -55
  222. package/src/wiki.ts +0 -227
  223. package/subagent-hooks-api.ts +0 -31
  224. package/tsconfig.json +0 -16
package/src/docx.ts DELETED
@@ -1,1616 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { isAbsolute, resolve } from "node:path";
4
- import { basename } from "node:path";
5
- import type * as Lark from "@larksuiteoapi/node-sdk";
6
- import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7
- import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
8
- import { Type } from "typebox";
9
- import type { OpenClawPluginApi } from "../runtime-api.js";
10
- import { listEnabledFeishuAccounts } from "./accounts.js";
11
- import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
12
- import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
13
- import { updateColorText } from "./docx-color-text.js";
14
- import {
15
- cleanBlocksForDescendant,
16
- insertTableRow,
17
- insertTableColumn,
18
- deleteTableRows,
19
- deleteTableColumns,
20
- mergeTableCells,
21
- } from "./docx-table-ops.js";
22
- import type { FeishuDocxBlock, FeishuDocxBlockChild } from "./docx-types.js";
23
- import { getFeishuRuntime } from "./runtime.js";
24
- import {
25
- createFeishuToolClient,
26
- resolveAnyEnabledFeishuToolsConfig,
27
- resolveFeishuToolAccount,
28
- } from "./tool-account.js";
29
-
30
- // ============ Helpers ============
31
-
32
- function json(data: unknown) {
33
- return {
34
- content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
35
- details: data,
36
- };
37
- }
38
-
39
- function resolveDocToolLocalRoots(ctx: {
40
- workspaceDir?: string;
41
- fsPolicy?: { workspaceOnly: boolean };
42
- }): string[] | undefined {
43
- if (ctx.fsPolicy?.workspaceOnly !== true) {
44
- return undefined;
45
- }
46
- const workspaceDir = ctx.workspaceDir?.trim();
47
- // Fail closed: workspace-only with no resolved workspace must not fall back
48
- // to default managed roots.
49
- if (!workspaceDir) {
50
- return [];
51
- }
52
- // Workspace paths are expected to be absolute; resolve() normalizes any
53
- // accidental relative input before passing roots to loadWebMedia.
54
- return [resolve(workspaceDir)];
55
- }
56
-
57
- /** Extract image URLs from markdown content */
58
- function extractImageUrls(markdown: string): string[] {
59
- const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
60
- const urls: string[] = [];
61
- let match;
62
- while ((match = regex.exec(markdown)) !== null) {
63
- const url = match[1].trim();
64
- if (url.startsWith("http://") || url.startsWith("https://")) {
65
- urls.push(url);
66
- }
67
- }
68
- return urls;
69
- }
70
-
71
- const BLOCK_TYPE_NAMES: Record<number, string> = {
72
- 1: "Page",
73
- 2: "Text",
74
- 3: "Heading1",
75
- 4: "Heading2",
76
- 5: "Heading3",
77
- 12: "Bullet",
78
- 13: "Ordered",
79
- 14: "Code",
80
- 15: "Quote",
81
- 17: "Todo",
82
- 18: "Bitable",
83
- 21: "Diagram",
84
- 22: "Divider",
85
- 23: "File",
86
- 27: "Image",
87
- 30: "Sheet",
88
- 31: "Table",
89
- 32: "TableCell",
90
- };
91
-
92
- // Block types that cannot be created via documentBlockChildren.create API
93
- const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]);
94
-
95
- /** Clean blocks for insertion (remove unsupported types and read-only fields) */
96
- function cleanBlocksForInsert(blocks: FeishuDocxBlock[]): {
97
- cleaned: FeishuDocxBlock[];
98
- skipped: string[];
99
- } {
100
- const skipped: string[] = [];
101
- const cleaned = blocks
102
- .filter((block) => {
103
- if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) {
104
- const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`;
105
- skipped.push(typeName);
106
- return false;
107
- }
108
- return true;
109
- })
110
- .map((block) => {
111
- if (block.block_type === 31 && block.table?.merge_info) {
112
- const { merge_info: _merge_info, ...tableRest } = block.table;
113
- return Object.assign({}, block, { table: tableRest });
114
- }
115
- return block;
116
- });
117
- return { cleaned, skipped };
118
- }
119
-
120
- // ============ Core Functions ============
121
-
122
- /** Max blocks per documentBlockChildren.create request */
123
- const MAX_BLOCKS_PER_INSERT = 50;
124
- const MAX_CONVERT_RETRY_DEPTH = 8;
125
-
126
- async function convertMarkdown(client: Lark.Client, markdown: string) {
127
- const res = await client.docx.document.convert({
128
- data: { content_type: "markdown", content: markdown },
129
- });
130
- if (res.code !== 0) {
131
- throw new Error(res.msg);
132
- }
133
- return {
134
- blocks: res.data?.blocks ?? [],
135
- firstLevelBlockIds: res.data?.first_level_block_ids ?? [],
136
- };
137
- }
138
-
139
- function normalizeChildIds(children: unknown): string[] {
140
- if (Array.isArray(children)) {
141
- return children.filter((child): child is string => typeof child === "string");
142
- }
143
- if (typeof children === "string") {
144
- return [children];
145
- }
146
- return [];
147
- }
148
-
149
- type DocxChildrenCreatePayload = NonNullable<
150
- Parameters<Lark.Client["docx"]["documentBlockChildren"]["create"]>[0]
151
- >;
152
- type DocxChildrenCreateChild = NonNullable<
153
- NonNullable<DocxChildrenCreatePayload["data"]>["children"]
154
- >[number];
155
- type DocxDescendantCreatePayload = NonNullable<
156
- Parameters<Lark.Client["docx"]["documentBlockDescendant"]["create"]>[0]
157
- >;
158
- type DocxDescendantCreateBlock = NonNullable<
159
- NonNullable<DocxDescendantCreatePayload["data"]>["descendants"]
160
- >[number];
161
- type DriveMediaUploadAllPayload = NonNullable<
162
- Parameters<Lark.Client["drive"]["media"]["uploadAll"]>[0]
163
- >;
164
- type DriveMediaUploadFile = NonNullable<NonNullable<DriveMediaUploadAllPayload["data"]>["file"]>;
165
-
166
- function toCreateChildBlock(block: FeishuDocxBlock): DocxChildrenCreateChild {
167
- return block as DocxChildrenCreateChild;
168
- }
169
-
170
- function toDescendantBlock(block: FeishuDocxBlock): DocxDescendantCreateBlock {
171
- const children = normalizeChildIds(block.children);
172
- return {
173
- ...(block.block_id ? { block_id: block.block_id } : {}),
174
- ...(children.length > 0 ? { children } : {}),
175
- ...block,
176
- } as DocxDescendantCreateBlock;
177
- }
178
-
179
- function normalizeInsertedChildBlocks(
180
- children: string[] | FeishuDocxBlockChild[] | undefined,
181
- ): FeishuDocxBlockChild[] {
182
- if (!Array.isArray(children)) {
183
- return [];
184
- }
185
- return children.filter(
186
- (child): child is FeishuDocxBlockChild => typeof child === "object" && child !== null,
187
- );
188
- }
189
-
190
- // Convert API may return `blocks` in a non-render order.
191
- // Reconstruct the document tree using first_level_block_ids plus children/parent links,
192
- // then emit blocks in pre-order so Descendant/Children APIs receive one normalized tree contract.
193
- function normalizeConvertedBlockTree(
194
- blocks: FeishuDocxBlock[],
195
- firstLevelIds: string[],
196
- ): { orderedBlocks: FeishuDocxBlock[]; rootIds: string[] } {
197
- if (blocks.length <= 1) {
198
- const rootIds =
199
- blocks.length === 1 && typeof blocks[0]?.block_id === "string" ? [blocks[0].block_id] : [];
200
- return { orderedBlocks: blocks, rootIds };
201
- }
202
-
203
- const byId = new Map<string, FeishuDocxBlock>();
204
- const originalOrder = new Map<string, number>();
205
- for (const [index, block] of blocks.entries()) {
206
- if (typeof block?.block_id === "string") {
207
- byId.set(block.block_id, block);
208
- originalOrder.set(block.block_id, index);
209
- }
210
- }
211
-
212
- const childIds = new Set<string>();
213
- for (const block of blocks) {
214
- for (const childId of normalizeChildIds(block?.children)) {
215
- childIds.add(childId);
216
- }
217
- }
218
-
219
- const inferredTopLevelIds = blocks
220
- .filter((block) => {
221
- const blockId = block?.block_id;
222
- if (typeof blockId !== "string") {
223
- return false;
224
- }
225
- const parentId = typeof block?.parent_id === "string" ? block.parent_id : "";
226
- return !childIds.has(blockId) && (!parentId || !byId.has(parentId));
227
- })
228
- .toSorted(
229
- (a, b) =>
230
- (originalOrder.get(a.block_id ?? "__missing__") ?? 0) -
231
- (originalOrder.get(b.block_id ?? "__missing__") ?? 0),
232
- )
233
- .map((block) => block.block_id)
234
- .filter((blockId): blockId is string => typeof blockId === "string");
235
-
236
- const rootIds = (
237
- firstLevelIds && firstLevelIds.length > 0 ? firstLevelIds : inferredTopLevelIds
238
- ).filter((id, index, arr) => typeof id === "string" && byId.has(id) && arr.indexOf(id) === index);
239
-
240
- const orderedBlocks: FeishuDocxBlock[] = [];
241
- const visited = new Set<string>();
242
-
243
- const visit = (blockId: string) => {
244
- if (!byId.has(blockId) || visited.has(blockId)) {
245
- return;
246
- }
247
- visited.add(blockId);
248
- const block = byId.get(blockId);
249
- if (!block) {
250
- return;
251
- }
252
- orderedBlocks.push(block);
253
- for (const childId of normalizeChildIds(block?.children)) {
254
- visit(childId);
255
- }
256
- };
257
-
258
- for (const rootId of rootIds) {
259
- visit(rootId);
260
- }
261
-
262
- // Fallback for malformed/partial trees from Convert API: keep any leftovers in original order.
263
- for (const block of blocks) {
264
- if (typeof block?.block_id === "string") {
265
- visit(block.block_id);
266
- } else {
267
- orderedBlocks.push(block);
268
- }
269
- }
270
-
271
- return { orderedBlocks, rootIds: rootIds.filter((id): id is string => typeof id === "string") };
272
- }
273
-
274
- async function insertBlocks(
275
- client: Lark.Client,
276
- docToken: string,
277
- blocks: FeishuDocxBlock[],
278
- parentBlockId?: string,
279
- index?: number,
280
- ): Promise<{ children: FeishuDocxBlockChild[]; skipped: string[] }> {
281
- const { cleaned, skipped } = cleanBlocksForInsert(blocks);
282
- const blockId = parentBlockId ?? docToken;
283
-
284
- if (cleaned.length === 0) {
285
- return { children: [], skipped };
286
- }
287
-
288
- // Insert blocks one at a time to preserve document order.
289
- // The batch API (sending all children at once) does not guarantee ordering
290
- // because Feishu processes the batch asynchronously. Sequential single-block
291
- // inserts (each appended to the end) produce deterministic results.
292
- const allInserted: FeishuDocxBlockChild[] = [];
293
- for (const [offset, block] of cleaned.entries()) {
294
- const res = await client.docx.documentBlockChildren.create({
295
- path: { document_id: docToken, block_id: blockId },
296
- data: {
297
- children: [toCreateChildBlock(block)],
298
- ...(index !== undefined ? { index: index + offset } : {}),
299
- },
300
- });
301
- if (res.code !== 0) {
302
- throw new Error(res.msg);
303
- }
304
- allInserted.push(...(res.data?.children ?? []));
305
- }
306
- return { children: allInserted, skipped };
307
- }
308
-
309
- /** Split markdown into chunks at top-level headings (# or ##) to stay within API content limits */
310
- function splitMarkdownByHeadings(markdown: string): string[] {
311
- const lines = markdown.split("\n");
312
- const chunks: string[] = [];
313
- let current: string[] = [];
314
- let inFencedBlock = false;
315
-
316
- for (const line of lines) {
317
- if (/^(`{3,}|~{3,})/.test(line)) {
318
- inFencedBlock = !inFencedBlock;
319
- }
320
- if (!inFencedBlock && /^#{1,2}\s/.test(line) && current.length > 0) {
321
- chunks.push(current.join("\n"));
322
- current = [];
323
- }
324
- current.push(line);
325
- }
326
- if (current.length > 0) {
327
- chunks.push(current.join("\n"));
328
- }
329
- return chunks;
330
- }
331
-
332
- /** Split markdown by size, preferring to break outside fenced code blocks when possible */
333
- function splitMarkdownBySize(markdown: string, maxChars: number): string[] {
334
- if (markdown.length <= maxChars) {
335
- return [markdown];
336
- }
337
-
338
- const lines = markdown.split("\n");
339
- const chunks: string[] = [];
340
- let current: string[] = [];
341
- let currentLength = 0;
342
- let inFencedBlock = false;
343
-
344
- for (const line of lines) {
345
- if (/^(`{3,}|~{3,})/.test(line)) {
346
- inFencedBlock = !inFencedBlock;
347
- }
348
-
349
- const lineLength = line.length + 1;
350
- const wouldExceed = currentLength + lineLength > maxChars;
351
- if (current.length > 0 && wouldExceed && !inFencedBlock) {
352
- chunks.push(current.join("\n"));
353
- current = [];
354
- currentLength = 0;
355
- }
356
-
357
- current.push(line);
358
- currentLength += lineLength;
359
- }
360
-
361
- if (current.length > 0) {
362
- chunks.push(current.join("\n"));
363
- }
364
-
365
- if (chunks.length > 1) {
366
- return chunks;
367
- }
368
-
369
- // Degenerate case: no safe boundary outside fenced content.
370
- const midpoint = Math.floor(lines.length / 2);
371
- if (midpoint <= 0 || midpoint >= lines.length) {
372
- return [markdown];
373
- }
374
- return [lines.slice(0, midpoint).join("\n"), lines.slice(midpoint).join("\n")];
375
- }
376
-
377
- async function convertMarkdownWithFallback(client: Lark.Client, markdown: string, depth = 0) {
378
- try {
379
- return await convertMarkdown(client, markdown);
380
- } catch (error) {
381
- if (depth >= MAX_CONVERT_RETRY_DEPTH || markdown.length < 2) {
382
- throw error;
383
- }
384
-
385
- const splitTarget = Math.max(256, Math.floor(markdown.length / 2));
386
- const chunks = splitMarkdownBySize(markdown, splitTarget);
387
- if (chunks.length <= 1) {
388
- throw error;
389
- }
390
-
391
- const blocks: FeishuDocxBlock[] = [];
392
- const firstLevelBlockIds: string[] = [];
393
-
394
- for (const chunk of chunks) {
395
- const converted = await convertMarkdownWithFallback(client, chunk, depth + 1);
396
- blocks.push(...converted.blocks);
397
- firstLevelBlockIds.push(...converted.firstLevelBlockIds);
398
- }
399
-
400
- return { blocks, firstLevelBlockIds };
401
- }
402
- }
403
-
404
- /** Convert markdown in chunks to avoid document.convert content size limits */
405
- async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) {
406
- const chunks = splitMarkdownByHeadings(markdown);
407
- const allBlocks: FeishuDocxBlock[] = [];
408
- const allRootIds: string[] = [];
409
- for (const chunk of chunks) {
410
- const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk);
411
- const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
412
- allBlocks.push(...orderedBlocks);
413
- allRootIds.push(...rootIds);
414
- }
415
- return { blocks: allBlocks, firstLevelBlockIds: allRootIds };
416
- }
417
-
418
- /** Insert blocks in batches of MAX_BLOCKS_PER_INSERT to avoid API 400 errors */
419
- async function _chunkedInsertBlocks(
420
- client: Lark.Client,
421
- docToken: string,
422
- blocks: FeishuDocxBlock[],
423
- parentBlockId?: string,
424
- ): Promise<{ children: FeishuDocxBlockChild[]; skipped: string[] }> {
425
- const allChildren: FeishuDocxBlockChild[] = [];
426
- const allSkipped: string[] = [];
427
-
428
- for (let i = 0; i < blocks.length; i += MAX_BLOCKS_PER_INSERT) {
429
- const batch = blocks.slice(i, i + MAX_BLOCKS_PER_INSERT);
430
- const { children, skipped } = await insertBlocks(client, docToken, batch, parentBlockId);
431
- allChildren.push(...children);
432
- allSkipped.push(...skipped);
433
- }
434
-
435
- return { children: allChildren, skipped: allSkipped };
436
- }
437
-
438
- type Logger = { info?: (msg: string) => void };
439
-
440
- /**
441
- * Insert blocks using the Descendant API (supports tables, nested lists, large docs).
442
- * Unlike the Children API, this supports block_type 31/32 (Table/TableCell).
443
- *
444
- * @param parentBlockId - Parent block to insert into (defaults to docToken = document root)
445
- * @param index - Position within parent's children (-1 = end, 0 = first)
446
- */
447
- async function insertBlocksWithDescendant(
448
- client: Lark.Client,
449
- docToken: string,
450
- blocks: FeishuDocxBlock[],
451
- firstLevelBlockIds: string[],
452
- { parentBlockId = docToken, index = -1 }: { parentBlockId?: string; index?: number } = {},
453
- ): Promise<{ children: FeishuDocxBlockChild[] }> {
454
- const descendants = cleanBlocksForDescendant(blocks);
455
- if (descendants.length === 0) {
456
- return { children: [] };
457
- }
458
-
459
- const res = await client.docx.documentBlockDescendant.create({
460
- path: { document_id: docToken, block_id: parentBlockId },
461
- data: {
462
- children_id: firstLevelBlockIds,
463
- descendants: descendants.map(toDescendantBlock),
464
- index,
465
- },
466
- });
467
-
468
- if (res.code !== 0) {
469
- throw new Error(`${res.msg} (code: ${res.code})`);
470
- }
471
-
472
- return { children: res.data?.children ?? [] };
473
- }
474
-
475
- async function clearDocumentContent(client: Lark.Client, docToken: string) {
476
- const existing = await client.docx.documentBlock.list({
477
- path: { document_id: docToken },
478
- });
479
- if (existing.code !== 0) {
480
- throw new Error(existing.msg);
481
- }
482
-
483
- const childIds =
484
- existing.data?.items
485
- ?.filter((b) => b.parent_id === docToken && b.block_type !== 1)
486
- .map((b) => b.block_id) ?? [];
487
-
488
- if (childIds.length > 0) {
489
- const res = await client.docx.documentBlockChildren.batchDelete({
490
- path: { document_id: docToken, block_id: docToken },
491
- data: { start_index: 0, end_index: childIds.length },
492
- });
493
- if (res.code !== 0) {
494
- throw new Error(res.msg);
495
- }
496
- }
497
-
498
- return childIds.length;
499
- }
500
-
501
- async function uploadImageToDocx(
502
- client: Lark.Client,
503
- blockId: string,
504
- imageBuffer: Buffer,
505
- fileName: string,
506
- docToken?: string,
507
- ): Promise<string> {
508
- const res = await client.drive.media.uploadAll({
509
- data: {
510
- file_name: fileName,
511
- parent_type: "docx_image",
512
- parent_node: blockId,
513
- size: imageBuffer.length,
514
- // Pass Buffer directly so form-data can calculate Content-Length correctly.
515
- // Readable.from() produces a stream with unknown length, causing Content-Length
516
- // mismatch that silently truncates uploads for images larger than ~1KB.
517
- file: imageBuffer as DriveMediaUploadFile,
518
- // Required when the document block belongs to a non-default datacenter:
519
- // tells the drive service which document the block belongs to for routing.
520
- // Per API docs: certain upload scenarios require the cloud document token.
521
- ...(docToken ? { extra: JSON.stringify({ drive_route_token: docToken }) } : {}),
522
- },
523
- });
524
-
525
- const fileToken = res?.file_token;
526
- if (!fileToken) {
527
- throw new Error("Image upload failed: no file_token returned");
528
- }
529
- return fileToken;
530
- }
531
-
532
- async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
533
- const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
534
- return fetched.buffer;
535
- }
536
-
537
- async function resolveUploadInput(
538
- url: string | undefined,
539
- filePath: string | undefined,
540
- maxBytes: number,
541
- localRoots?: readonly string[],
542
- explicitFileName?: string,
543
- imageInput?: string, // data URI, plain base64, or local path
544
- ): Promise<{ buffer: Buffer; fileName: string }> {
545
- // Enforce mutual exclusivity: exactly one input source must be provided.
546
- const inputSources = (
547
- [url ? "url" : null, filePath ? "file_path" : null, imageInput ? "image" : null] as (
548
- | string
549
- | null
550
- )[]
551
- ).filter(Boolean);
552
- if (inputSources.length > 1) {
553
- throw new Error(`Provide only one image source; got: ${inputSources.join(", ")}`);
554
- }
555
-
556
- // data URI: data:image/png;base64,xxxx
557
- if (imageInput?.startsWith("data:")) {
558
- const commaIdx = imageInput.indexOf(",");
559
- if (commaIdx === -1) {
560
- throw new Error("Invalid data URI: missing comma separator.");
561
- }
562
- const header = imageInput.slice(0, commaIdx);
563
- const data = imageInput.slice(commaIdx + 1);
564
- // Only base64-encoded data URIs are supported; reject plain/URL-encoded ones.
565
- if (!header.includes(";base64")) {
566
- throw new Error(
567
- `Invalid data URI: missing ';base64' marker. ` +
568
- `Expected format: data:image/png;base64,<base64data>`,
569
- );
570
- }
571
- // Validate the payload is actually base64 before decoding; Node's decoder
572
- // is permissive and would silently accept garbage bytes otherwise.
573
- const trimmedData = data.trim();
574
- if (trimmedData.length === 0 || !/^[A-Za-z0-9+/]+=*$/.test(trimmedData)) {
575
- throw new Error(
576
- `Invalid data URI: base64 payload contains characters outside the standard alphabet.`,
577
- );
578
- }
579
- const mimeMatch = header.match(/data:([^;]+)/);
580
- const ext = mimeMatch?.[1]?.split("/")[1] ?? "png";
581
- // Estimate decoded byte count from base64 length BEFORE allocating the
582
- // full buffer to avoid spiking memory on oversized payloads.
583
- const estimatedBytes = Math.ceil((trimmedData.length * 3) / 4);
584
- if (estimatedBytes > maxBytes) {
585
- throw new Error(
586
- `Image data URI exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`,
587
- );
588
- }
589
- const buffer = Buffer.from(trimmedData, "base64");
590
- return { buffer, fileName: explicitFileName ?? `image.${ext}` };
591
- }
592
-
593
- // local path: ~, ./ and ../ are unambiguous (not in base64 alphabet).
594
- // Absolute paths (/...) are supported but must exist on disk. If an absolute
595
- // path does not exist we throw immediately rather than falling through to
596
- // base64 decoding, which would silently upload garbage bytes.
597
- // Note: JPEG base64 starts with "/9j/" — pass as data:image/jpeg;base64,...
598
- // to avoid ambiguity with absolute paths.
599
- if (imageInput) {
600
- const candidate = imageInput.startsWith("~") ? imageInput.replace(/^~/, homedir()) : imageInput;
601
- const unambiguousPath =
602
- imageInput.startsWith("~") || imageInput.startsWith("./") || imageInput.startsWith("../");
603
- const absolutePath = isAbsolute(imageInput);
604
-
605
- if (unambiguousPath || (absolutePath && existsSync(candidate))) {
606
- // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu).
607
- const resolvedPath = resolve(candidate);
608
- const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedPath, {
609
- maxBytes,
610
- optimizeImages: false,
611
- localRoots,
612
- });
613
- return { buffer: loaded.buffer, fileName: explicitFileName ?? basename(candidate) };
614
- }
615
-
616
- if (absolutePath && !existsSync(candidate)) {
617
- throw new Error(
618
- `File not found: "${candidate}". ` +
619
- `If you intended to pass image binary data, use a data URI instead: data:image/jpeg;base64,...`,
620
- );
621
- }
622
- }
623
-
624
- // plain base64 string (standard base64 alphabet includes '+', '/', '=')
625
- if (imageInput) {
626
- const trimmed = imageInput.trim();
627
- // Node's Buffer.from is permissive and silently ignores out-of-alphabet chars,
628
- // which would decode malformed strings into arbitrary bytes. Reject early.
629
- if (trimmed.length === 0 || !/^[A-Za-z0-9+/]+=*$/.test(trimmed)) {
630
- throw new Error(
631
- `Invalid base64: image input contains characters outside the standard base64 alphabet. ` +
632
- `Use a data URI (data:image/png;base64,...) or a local file path instead.`,
633
- );
634
- }
635
- // Estimate decoded byte count from base64 length BEFORE allocating the
636
- // full buffer to avoid spiking memory on oversized payloads.
637
- const estimatedBytes = Math.ceil((trimmed.length * 3) / 4);
638
- if (estimatedBytes > maxBytes) {
639
- throw new Error(
640
- `Base64 image exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`,
641
- );
642
- }
643
- const buffer = Buffer.from(trimmed, "base64");
644
- if (buffer.length === 0) {
645
- throw new Error("Base64 image decoded to empty buffer; check the input.");
646
- }
647
- return { buffer, fileName: explicitFileName ?? "image.png" };
648
- }
649
-
650
- if (!url && !filePath) {
651
- throw new Error("Either url, file_path, or image (base64/data URI) must be provided");
652
- }
653
- if (url && filePath) {
654
- throw new Error("Provide only one of url or file_path");
655
- }
656
-
657
- if (url) {
658
- const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
659
- const urlPath = new URL(url).pathname;
660
- const guessed = urlPath.split("/").pop() || "upload.bin";
661
- return {
662
- buffer: fetched.buffer,
663
- fileName: explicitFileName || guessed,
664
- };
665
- }
666
-
667
- // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu).
668
- const resolvedFilePath = resolve(filePath!);
669
- const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedFilePath, {
670
- maxBytes,
671
- optimizeImages: false,
672
- localRoots,
673
- });
674
- return {
675
- buffer: loaded.buffer,
676
- fileName: explicitFileName || basename(filePath!),
677
- };
678
- }
679
-
680
- async function processImages(
681
- client: Lark.Client,
682
- docToken: string,
683
- markdown: string,
684
- insertedBlocks: FeishuDocxBlockChild[],
685
- maxBytes: number,
686
- ): Promise<number> {
687
- const imageUrls = extractImageUrls(markdown);
688
- if (imageUrls.length === 0) {
689
- return 0;
690
- }
691
-
692
- const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27);
693
-
694
- let processed = 0;
695
- for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) {
696
- const url = imageUrls[i];
697
- const blockId = imageBlocks[i]?.block_id;
698
- if (!blockId) {
699
- continue;
700
- }
701
-
702
- try {
703
- const buffer = await downloadImage(url, maxBytes);
704
- const urlPath = new URL(url).pathname;
705
- const fileName = urlPath.split("/").pop() || `image_${i}.png`;
706
- const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName, docToken);
707
-
708
- await client.docx.documentBlock.patch({
709
- path: { document_id: docToken, block_id: blockId },
710
- data: {
711
- replace_image: { token: fileToken },
712
- },
713
- });
714
-
715
- processed++;
716
- } catch (err) {
717
- console.error(`Failed to process image ${url}:`, err);
718
- }
719
- }
720
-
721
- return processed;
722
- }
723
-
724
- async function uploadImageBlock(
725
- client: Lark.Client,
726
- docToken: string,
727
- maxBytes: number,
728
- localRoots?: readonly string[],
729
- url?: string,
730
- filePath?: string,
731
- parentBlockId?: string,
732
- filename?: string,
733
- index?: number,
734
- imageInput?: string, // data URI, plain base64, or local path
735
- ) {
736
- // Step 1: Create an empty image block (block_type 27).
737
- // Per Feishu FAQ: image token cannot be set at block creation time.
738
- const insertRes = await client.docx.documentBlockChildren.create({
739
- path: { document_id: docToken, block_id: parentBlockId ?? docToken },
740
- params: { document_revision_id: -1 },
741
- data: { children: [{ block_type: 27, image: {} }], index: index ?? -1 },
742
- });
743
- if (insertRes.code !== 0) {
744
- throw new Error(`Failed to create image block: ${insertRes.msg}`);
745
- }
746
- const imageBlockId = insertRes.data?.children?.find((b) => b.block_type === 27)?.block_id;
747
- if (!imageBlockId) {
748
- throw new Error("Failed to create image block");
749
- }
750
-
751
- // Step 2: Resolve and upload the image buffer.
752
- const upload = await resolveUploadInput(
753
- url,
754
- filePath,
755
- maxBytes,
756
- localRoots,
757
- filename,
758
- imageInput,
759
- );
760
- const fileToken = await uploadImageToDocx(
761
- client,
762
- imageBlockId,
763
- upload.buffer,
764
- upload.fileName,
765
- docToken, // drive_route_token for multi-datacenter routing
766
- );
767
-
768
- // Step 3: Set the image token on the block.
769
- const patchRes = await client.docx.documentBlock.patch({
770
- path: { document_id: docToken, block_id: imageBlockId },
771
- data: { replace_image: { token: fileToken } },
772
- });
773
- if (patchRes.code !== 0) {
774
- throw new Error(patchRes.msg);
775
- }
776
-
777
- return {
778
- success: true,
779
- block_id: imageBlockId,
780
- file_token: fileToken,
781
- file_name: upload.fileName,
782
- size: upload.buffer.length,
783
- };
784
- }
785
-
786
- async function uploadFileBlock(
787
- client: Lark.Client,
788
- docToken: string,
789
- maxBytes: number,
790
- localRoots?: readonly string[],
791
- url?: string,
792
- filePath?: string,
793
- parentBlockId?: string,
794
- filename?: string,
795
- ) {
796
- const blockId = parentBlockId ?? docToken;
797
-
798
- // Feishu API does not allow creating empty file blocks (block_type 23).
799
- // Workaround: create a placeholder text block, then replace it with file content.
800
- // Actually, file blocks need a different approach: use markdown link as placeholder.
801
- const upload = await resolveUploadInput(url, filePath, maxBytes, localRoots, filename);
802
-
803
- // Create a placeholder text block first
804
- const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`;
805
- const converted = await convertMarkdown(client, placeholderMd);
806
- const { orderedBlocks } = normalizeConvertedBlockTree(
807
- converted.blocks,
808
- converted.firstLevelBlockIds,
809
- );
810
- const { children: inserted } = await insertBlocks(client, docToken, orderedBlocks, blockId);
811
-
812
- // Get the first inserted block - we'll delete it and create the file in its place
813
- const placeholderBlock = inserted[0];
814
- if (!placeholderBlock?.block_id) {
815
- throw new Error("Failed to create placeholder block for file upload");
816
- }
817
-
818
- // Delete the placeholder
819
- const parentId = placeholderBlock.parent_id ?? blockId;
820
- const childrenRes = await client.docx.documentBlockChildren.get({
821
- path: { document_id: docToken, block_id: parentId },
822
- });
823
- if (childrenRes.code !== 0) {
824
- throw new Error(childrenRes.msg);
825
- }
826
- const items = childrenRes.data?.items ?? [];
827
- const placeholderIdx = items.findIndex((item) => item.block_id === placeholderBlock.block_id);
828
- if (placeholderIdx >= 0) {
829
- const deleteRes = await client.docx.documentBlockChildren.batchDelete({
830
- path: { document_id: docToken, block_id: parentId },
831
- data: { start_index: placeholderIdx, end_index: placeholderIdx + 1 },
832
- });
833
- if (deleteRes.code !== 0) {
834
- throw new Error(deleteRes.msg);
835
- }
836
- }
837
-
838
- // Upload file to Feishu drive
839
- const fileRes = await client.drive.media.uploadAll({
840
- data: {
841
- file_name: upload.fileName,
842
- parent_type: "docx_file",
843
- parent_node: docToken,
844
- size: upload.buffer.length,
845
- file: upload.buffer as DriveMediaUploadFile,
846
- },
847
- });
848
-
849
- const fileToken = fileRes?.file_token;
850
- if (!fileToken) {
851
- throw new Error("File upload failed: no file_token returned");
852
- }
853
-
854
- return {
855
- success: true,
856
- file_token: fileToken,
857
- file_name: upload.fileName,
858
- size: upload.buffer.length,
859
- note: "File uploaded to drive. Use the file_token to reference it. Direct file block creation is not supported by the Feishu API.",
860
- };
861
- }
862
-
863
- // ============ Actions ============
864
-
865
- const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
866
-
867
- async function readDoc(client: Lark.Client, docToken: string) {
868
- const [contentRes, infoRes, blocksRes] = await Promise.all([
869
- client.docx.document.rawContent({ path: { document_id: docToken } }),
870
- client.docx.document.get({ path: { document_id: docToken } }),
871
- client.docx.documentBlock.list({ path: { document_id: docToken } }),
872
- ]);
873
-
874
- if (contentRes.code !== 0) {
875
- throw new Error(contentRes.msg);
876
- }
877
-
878
- const blocks = blocksRes.data?.items ?? [];
879
- const blockCounts: Record<string, number> = {};
880
- const structuredTypes: string[] = [];
881
-
882
- for (const b of blocks) {
883
- const type = b.block_type ?? 0;
884
- const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
885
- blockCounts[name] = (blockCounts[name] || 0) + 1;
886
-
887
- if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
888
- structuredTypes.push(name);
889
- }
890
- }
891
-
892
- let hint: string | undefined;
893
- if (structuredTypes.length > 0) {
894
- hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
895
- }
896
-
897
- return {
898
- title: infoRes.data?.document?.title,
899
- content: contentRes.data?.content,
900
- revision_id: infoRes.data?.document?.revision_id,
901
- block_count: blocks.length,
902
- block_types: blockCounts,
903
- ...(hint && { hint }),
904
- };
905
- }
906
-
907
- async function createDoc(
908
- client: Lark.Client,
909
- title: string,
910
- folderToken?: string,
911
- options?: { grantToRequester?: boolean; requesterOpenId?: string },
912
- ) {
913
- const res = await client.docx.document.create({
914
- data: { title, folder_token: folderToken },
915
- });
916
- if (res.code !== 0) {
917
- throw new Error(res.msg);
918
- }
919
- const doc = res.data?.document;
920
- const docToken = doc?.document_id;
921
- if (!docToken) {
922
- throw new Error("Document creation succeeded but no document_id was returned");
923
- }
924
- const shouldGrantToRequester = options?.grantToRequester !== false;
925
- const requesterOpenId = options?.requesterOpenId?.trim();
926
- const requesterPermType = "edit" as const;
927
-
928
- let requesterPermissionAdded = false;
929
- let requesterPermissionSkippedReason: string | undefined;
930
- let requesterPermissionError: string | undefined;
931
-
932
- if (shouldGrantToRequester) {
933
- if (!requesterOpenId) {
934
- requesterPermissionSkippedReason = "trusted requester identity unavailable";
935
- } else {
936
- try {
937
- await client.drive.permissionMember.create({
938
- path: { token: docToken },
939
- params: { type: "docx", need_notification: false },
940
- data: {
941
- member_type: "openid",
942
- member_id: requesterOpenId,
943
- perm: requesterPermType,
944
- },
945
- });
946
- requesterPermissionAdded = true;
947
- } catch (err) {
948
- requesterPermissionError = formatErrorMessage(err);
949
- }
950
- }
951
- }
952
-
953
- return {
954
- document_id: docToken,
955
- title: doc?.title,
956
- url: `https://feishu.cn/docx/${docToken}`,
957
- ...(shouldGrantToRequester && {
958
- requester_permission_added: requesterPermissionAdded,
959
- ...(requesterOpenId && { requester_open_id: requesterOpenId }),
960
- requester_perm_type: requesterPermType,
961
- ...(requesterPermissionSkippedReason && {
962
- requester_permission_skipped_reason: requesterPermissionSkippedReason,
963
- }),
964
- ...(requesterPermissionError && { requester_permission_error: requesterPermissionError }),
965
- }),
966
- };
967
- }
968
-
969
- async function writeDoc(
970
- client: Lark.Client,
971
- docToken: string,
972
- markdown: string,
973
- maxBytes: number,
974
- logger?: Logger,
975
- ) {
976
- const deleted = await clearDocumentContent(client, docToken);
977
- logger?.info?.("feishu_doc: Converting markdown...");
978
- const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
979
- if (blocks.length === 0) {
980
- return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
981
- }
982
-
983
- logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
984
- const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
985
- const { children: inserted } =
986
- blocks.length > BATCH_SIZE
987
- ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger)
988
- : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds);
989
- const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
990
- logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
991
-
992
- return {
993
- success: true,
994
- blocks_deleted: deleted,
995
- blocks_added: blocks.length,
996
- images_processed: imagesProcessed,
997
- };
998
- }
999
-
1000
- async function appendDoc(
1001
- client: Lark.Client,
1002
- docToken: string,
1003
- markdown: string,
1004
- maxBytes: number,
1005
- logger?: Logger,
1006
- ) {
1007
- logger?.info?.("feishu_doc: Converting markdown...");
1008
- const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
1009
- if (blocks.length === 0) {
1010
- throw new Error("Content is empty");
1011
- }
1012
-
1013
- logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
1014
- const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
1015
- const { children: inserted } =
1016
- blocks.length > BATCH_SIZE
1017
- ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger)
1018
- : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds);
1019
- const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
1020
- logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
1021
-
1022
- return {
1023
- success: true,
1024
- blocks_added: blocks.length,
1025
- images_processed: imagesProcessed,
1026
- block_ids: inserted.map((b) => b.block_id),
1027
- };
1028
- }
1029
-
1030
- async function insertDoc(
1031
- client: Lark.Client,
1032
- docToken: string,
1033
- markdown: string,
1034
- afterBlockId: string,
1035
- maxBytes: number,
1036
- logger?: Logger,
1037
- ) {
1038
- const blockInfo = await client.docx.documentBlock.get({
1039
- path: { document_id: docToken, block_id: afterBlockId },
1040
- });
1041
- if (blockInfo.code !== 0) {
1042
- throw new Error(blockInfo.msg);
1043
- }
1044
-
1045
- const parentId = blockInfo.data?.block?.parent_id ?? docToken;
1046
-
1047
- // Paginate through all children to reliably locate after_block_id.
1048
- // documentBlockChildren.get returns up to 200 children per page; large
1049
- // parents require multiple requests.
1050
- const items: FeishuDocxBlock[] = [];
1051
- let pageToken: string | undefined;
1052
- do {
1053
- const childrenRes = await client.docx.documentBlockChildren.get({
1054
- path: { document_id: docToken, block_id: parentId },
1055
- params: pageToken ? { page_token: pageToken } : {},
1056
- });
1057
- if (childrenRes.code !== 0) {
1058
- throw new Error(childrenRes.msg);
1059
- }
1060
- items.push(...(childrenRes.data?.items ?? []));
1061
- pageToken = childrenRes.data?.page_token ?? undefined;
1062
- } while (pageToken);
1063
-
1064
- const blockIndex = items.findIndex((item) => item.block_id === afterBlockId);
1065
- if (blockIndex === -1) {
1066
- throw new Error(
1067
- `after_block_id "${afterBlockId}" was not found among the children of parent block "${parentId}". ` +
1068
- `Use list_blocks to verify the block ID.`,
1069
- );
1070
- }
1071
- const insertIndex = blockIndex + 1;
1072
-
1073
- logger?.info?.("feishu_doc: Converting markdown...");
1074
- const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
1075
- if (blocks.length === 0) {
1076
- throw new Error("Content is empty");
1077
- }
1078
- const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds);
1079
-
1080
- logger?.info?.(
1081
- `feishu_doc: Converted to ${blocks.length} blocks, inserting at index ${insertIndex}...`,
1082
- );
1083
- const { children: inserted } =
1084
- blocks.length > BATCH_SIZE
1085
- ? await insertBlocksInBatches(
1086
- client,
1087
- docToken,
1088
- orderedBlocks,
1089
- rootIds,
1090
- logger,
1091
- parentId,
1092
- insertIndex,
1093
- )
1094
- : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds, {
1095
- parentBlockId: parentId,
1096
- index: insertIndex,
1097
- });
1098
-
1099
- const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
1100
- logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
1101
-
1102
- return {
1103
- success: true,
1104
- blocks_added: blocks.length,
1105
- images_processed: imagesProcessed,
1106
- block_ids: inserted.map((b) => b.block_id),
1107
- };
1108
- }
1109
-
1110
- async function createTable(
1111
- client: Lark.Client,
1112
- docToken: string,
1113
- rowSize: number,
1114
- columnSize: number,
1115
- parentBlockId?: string,
1116
- columnWidth?: number[],
1117
- ) {
1118
- if (columnWidth && columnWidth.length !== columnSize) {
1119
- throw new Error("column_width length must equal column_size");
1120
- }
1121
-
1122
- const blockId = parentBlockId ?? docToken;
1123
- const res = await client.docx.documentBlockChildren.create({
1124
- path: { document_id: docToken, block_id: blockId },
1125
- data: {
1126
- children: [
1127
- {
1128
- block_type: 31,
1129
- table: {
1130
- property: {
1131
- row_size: rowSize,
1132
- column_size: columnSize,
1133
- ...(columnWidth && columnWidth.length > 0 ? { column_width: columnWidth } : {}),
1134
- },
1135
- },
1136
- },
1137
- ],
1138
- },
1139
- });
1140
-
1141
- if (res.code !== 0) {
1142
- throw new Error(res.msg);
1143
- }
1144
-
1145
- const tableBlock = res.data?.children?.find((b) => b.block_type === 31);
1146
- const cells = normalizeInsertedChildBlocks(tableBlock?.children);
1147
-
1148
- return {
1149
- success: true,
1150
- table_block_id: tableBlock?.block_id,
1151
- row_size: rowSize,
1152
- column_size: columnSize,
1153
- // row-major cell ids, if API returns them directly
1154
- table_cell_block_ids: cells.map((c) => c.block_id).filter(Boolean),
1155
- raw_children_count: res.data?.children?.length ?? 0,
1156
- };
1157
- }
1158
-
1159
- async function writeTableCells(
1160
- client: Lark.Client,
1161
- docToken: string,
1162
- tableBlockId: string,
1163
- values: string[][],
1164
- ) {
1165
- if (!values.length || !values[0]?.length) {
1166
- throw new Error("values must be a non-empty 2D array");
1167
- }
1168
-
1169
- const tableRes = await client.docx.documentBlock.get({
1170
- path: { document_id: docToken, block_id: tableBlockId },
1171
- });
1172
- if (tableRes.code !== 0) {
1173
- throw new Error(tableRes.msg);
1174
- }
1175
-
1176
- const tableBlock = tableRes.data?.block;
1177
- if (tableBlock?.block_type !== 31) {
1178
- throw new Error("table_block_id is not a table block");
1179
- }
1180
-
1181
- const tableData = tableBlock.table;
1182
- const rows = tableData?.property?.row_size;
1183
- const cols = tableData?.property?.column_size;
1184
- const cellIds = tableData?.cells ?? [];
1185
-
1186
- if (!rows || !cols || !cellIds.length) {
1187
- throw new Error(
1188
- "Table cell IDs unavailable from table block. Use list_blocks/get_block and pass explicit cell block IDs if needed.",
1189
- );
1190
- }
1191
-
1192
- const writeRows = Math.min(values.length, rows);
1193
- let written = 0;
1194
-
1195
- for (let r = 0; r < writeRows; r++) {
1196
- const rowValues = values[r] ?? [];
1197
- const writeCols = Math.min(rowValues.length, cols);
1198
-
1199
- for (let c = 0; c < writeCols; c++) {
1200
- const cellId = cellIds[r * cols + c];
1201
- if (!cellId) {
1202
- continue;
1203
- }
1204
-
1205
- // table cell is a container block: clear existing children, then create text child blocks
1206
- const childrenRes = await client.docx.documentBlockChildren.get({
1207
- path: { document_id: docToken, block_id: cellId },
1208
- });
1209
- if (childrenRes.code !== 0) {
1210
- throw new Error(childrenRes.msg);
1211
- }
1212
-
1213
- const existingChildren = childrenRes.data?.items ?? [];
1214
- if (existingChildren.length > 0) {
1215
- const delRes = await client.docx.documentBlockChildren.batchDelete({
1216
- path: { document_id: docToken, block_id: cellId },
1217
- data: { start_index: 0, end_index: existingChildren.length },
1218
- });
1219
- if (delRes.code !== 0) {
1220
- throw new Error(delRes.msg);
1221
- }
1222
- }
1223
-
1224
- const text = rowValues[c] ?? "";
1225
- const converted = await convertMarkdown(client, text);
1226
- const { orderedBlocks } = normalizeConvertedBlockTree(
1227
- converted.blocks,
1228
- converted.firstLevelBlockIds,
1229
- );
1230
-
1231
- if (orderedBlocks.length > 0) {
1232
- await insertBlocks(client, docToken, orderedBlocks, cellId);
1233
- }
1234
-
1235
- written++;
1236
- }
1237
- }
1238
-
1239
- return {
1240
- success: true,
1241
- table_block_id: tableBlockId,
1242
- cells_written: written,
1243
- table_size: { rows, cols },
1244
- };
1245
- }
1246
-
1247
- async function createTableWithValues(
1248
- client: Lark.Client,
1249
- docToken: string,
1250
- rowSize: number,
1251
- columnSize: number,
1252
- values: string[][],
1253
- parentBlockId?: string,
1254
- columnWidth?: number[],
1255
- ) {
1256
- const created = await createTable(
1257
- client,
1258
- docToken,
1259
- rowSize,
1260
- columnSize,
1261
- parentBlockId,
1262
- columnWidth,
1263
- );
1264
-
1265
- const tableBlockId = created.table_block_id;
1266
- if (!tableBlockId) {
1267
- throw new Error("create_table succeeded but table_block_id is missing");
1268
- }
1269
-
1270
- const written = await writeTableCells(client, docToken, tableBlockId, values);
1271
- return {
1272
- success: true,
1273
- table_block_id: tableBlockId,
1274
- row_size: rowSize,
1275
- column_size: columnSize,
1276
- cells_written: written.cells_written,
1277
- };
1278
- }
1279
-
1280
- async function updateBlock(
1281
- client: Lark.Client,
1282
- docToken: string,
1283
- blockId: string,
1284
- content: string,
1285
- ) {
1286
- const blockInfo = await client.docx.documentBlock.get({
1287
- path: { document_id: docToken, block_id: blockId },
1288
- });
1289
- if (blockInfo.code !== 0) {
1290
- throw new Error(blockInfo.msg);
1291
- }
1292
-
1293
- const res = await client.docx.documentBlock.patch({
1294
- path: { document_id: docToken, block_id: blockId },
1295
- data: {
1296
- update_text_elements: {
1297
- elements: [{ text_run: { content } }],
1298
- },
1299
- },
1300
- });
1301
- if (res.code !== 0) {
1302
- throw new Error(res.msg);
1303
- }
1304
-
1305
- return { success: true, block_id: blockId };
1306
- }
1307
-
1308
- async function deleteBlock(client: Lark.Client, docToken: string, blockId: string) {
1309
- const blockInfo = await client.docx.documentBlock.get({
1310
- path: { document_id: docToken, block_id: blockId },
1311
- });
1312
- if (blockInfo.code !== 0) {
1313
- throw new Error(blockInfo.msg);
1314
- }
1315
-
1316
- const parentId = blockInfo.data?.block?.parent_id ?? docToken;
1317
-
1318
- const children = await client.docx.documentBlockChildren.get({
1319
- path: { document_id: docToken, block_id: parentId },
1320
- });
1321
- if (children.code !== 0) {
1322
- throw new Error(children.msg);
1323
- }
1324
-
1325
- const items = children.data?.items ?? [];
1326
- const index = items.findIndex((item) => item.block_id === blockId);
1327
- if (index === -1) {
1328
- throw new Error("Block not found");
1329
- }
1330
-
1331
- const res = await client.docx.documentBlockChildren.batchDelete({
1332
- path: { document_id: docToken, block_id: parentId },
1333
- data: { start_index: index, end_index: index + 1 },
1334
- });
1335
- if (res.code !== 0) {
1336
- throw new Error(res.msg);
1337
- }
1338
-
1339
- return { success: true, deleted_block_id: blockId };
1340
- }
1341
-
1342
- async function listBlocks(client: Lark.Client, docToken: string) {
1343
- const res = await client.docx.documentBlock.list({
1344
- path: { document_id: docToken },
1345
- });
1346
- if (res.code !== 0) {
1347
- throw new Error(res.msg);
1348
- }
1349
-
1350
- return {
1351
- blocks: res.data?.items ?? [],
1352
- };
1353
- }
1354
-
1355
- async function getBlock(client: Lark.Client, docToken: string, blockId: string) {
1356
- const res = await client.docx.documentBlock.get({
1357
- path: { document_id: docToken, block_id: blockId },
1358
- });
1359
- if (res.code !== 0) {
1360
- throw new Error(res.msg);
1361
- }
1362
-
1363
- return {
1364
- block: res.data?.block,
1365
- };
1366
- }
1367
-
1368
- async function listAppScopes(client: Lark.Client) {
1369
- const res = await client.application.scope.list({});
1370
- if (res.code !== 0) {
1371
- throw new Error(res.msg);
1372
- }
1373
-
1374
- const scopes = res.data?.scopes ?? [];
1375
- const granted = scopes.filter((s) => s.grant_status === 1);
1376
- const pending = scopes.filter((s) => s.grant_status !== 1);
1377
-
1378
- return {
1379
- granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })),
1380
- pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })),
1381
- summary: `${granted.length} granted, ${pending.length} pending`,
1382
- };
1383
- }
1384
-
1385
- // ============ Tool Registration ============
1386
-
1387
- export function registerFeishuDocTools(api: OpenClawPluginApi) {
1388
- if (!api.config) {
1389
- return;
1390
- }
1391
-
1392
- // Check if any account is configured
1393
- const accounts = listEnabledFeishuAccounts(api.config);
1394
- if (accounts.length === 0) {
1395
- return;
1396
- }
1397
-
1398
- // Register if enabled on any account; account routing is resolved per execution.
1399
- const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
1400
-
1401
- const registered: string[] = [];
1402
- type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
1403
-
1404
- const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
1405
- createFeishuToolClient({ api, executeParams: params, defaultAccountId });
1406
-
1407
- const getMediaMaxBytes = (
1408
- params: { accountId?: string } | undefined,
1409
- defaultAccountId?: string,
1410
- ) =>
1411
- (resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
1412
- ?.mediaMaxMb ?? 30) *
1413
- 1024 *
1414
- 1024;
1415
-
1416
- // Main document tool with action-based dispatch
1417
- if (toolsCfg.doc) {
1418
- api.registerTool(
1419
- (ctx) => {
1420
- const defaultAccountId = ctx.agentAccountId;
1421
- const mediaLocalRoots = resolveDocToolLocalRoots(ctx);
1422
- const trustedRequesterOpenId =
1423
- ctx.messageChannel === "feishu"
1424
- ? normalizeOptionalString(ctx.requesterSenderId)
1425
- : undefined;
1426
- return {
1427
- name: "feishu_doc",
1428
- label: "Feishu Doc",
1429
- description:
1430
- "Feishu document operations. Actions: read, write, append, insert, create, list_blocks, get_block, update_block, delete_block, create_table, write_table_cells, create_table_with_values, insert_table_row, insert_table_column, delete_table_rows, delete_table_columns, merge_table_cells, upload_image, upload_file, color_text",
1431
- parameters: FeishuDocSchema,
1432
- async execute(_toolCallId, params) {
1433
- const p = params as FeishuDocExecuteParams;
1434
- try {
1435
- const client = getClient(p, defaultAccountId);
1436
- switch (p.action) {
1437
- case "read":
1438
- return json(await readDoc(client, p.doc_token));
1439
- case "write":
1440
- return json(
1441
- await writeDoc(
1442
- client,
1443
- p.doc_token,
1444
- p.content,
1445
- getMediaMaxBytes(p, defaultAccountId),
1446
- api.logger,
1447
- ),
1448
- );
1449
- case "append":
1450
- return json(
1451
- await appendDoc(
1452
- client,
1453
- p.doc_token,
1454
- p.content,
1455
- getMediaMaxBytes(p, defaultAccountId),
1456
- api.logger,
1457
- ),
1458
- );
1459
- case "insert":
1460
- return json(
1461
- await insertDoc(
1462
- client,
1463
- p.doc_token,
1464
- p.content,
1465
- p.after_block_id,
1466
- getMediaMaxBytes(p, defaultAccountId),
1467
- api.logger,
1468
- ),
1469
- );
1470
- case "create":
1471
- return json(
1472
- await createDoc(client, p.title, p.folder_token, {
1473
- grantToRequester: p.grant_to_requester,
1474
- requesterOpenId: trustedRequesterOpenId,
1475
- }),
1476
- );
1477
- case "list_blocks":
1478
- return json(await listBlocks(client, p.doc_token));
1479
- case "get_block":
1480
- return json(await getBlock(client, p.doc_token, p.block_id));
1481
- case "update_block":
1482
- return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
1483
- case "delete_block":
1484
- return json(await deleteBlock(client, p.doc_token, p.block_id));
1485
- case "create_table":
1486
- return json(
1487
- await createTable(
1488
- client,
1489
- p.doc_token,
1490
- p.row_size,
1491
- p.column_size,
1492
- p.parent_block_id,
1493
- p.column_width,
1494
- ),
1495
- );
1496
- case "write_table_cells":
1497
- return json(
1498
- await writeTableCells(client, p.doc_token, p.table_block_id, p.values),
1499
- );
1500
- case "create_table_with_values":
1501
- return json(
1502
- await createTableWithValues(
1503
- client,
1504
- p.doc_token,
1505
- p.row_size,
1506
- p.column_size,
1507
- p.values,
1508
- p.parent_block_id,
1509
- p.column_width,
1510
- ),
1511
- );
1512
- case "upload_image":
1513
- return json(
1514
- await uploadImageBlock(
1515
- client,
1516
- p.doc_token,
1517
- getMediaMaxBytes(p, defaultAccountId),
1518
- mediaLocalRoots,
1519
- p.url,
1520
- p.file_path,
1521
- p.parent_block_id,
1522
- p.filename,
1523
- p.index,
1524
- p.image, // data URI or plain base64
1525
- ),
1526
- );
1527
- case "upload_file":
1528
- return json(
1529
- await uploadFileBlock(
1530
- client,
1531
- p.doc_token,
1532
- getMediaMaxBytes(p, defaultAccountId),
1533
- mediaLocalRoots,
1534
- p.url,
1535
- p.file_path,
1536
- p.parent_block_id,
1537
- p.filename,
1538
- ),
1539
- );
1540
- case "color_text":
1541
- return json(await updateColorText(client, p.doc_token, p.block_id, p.content));
1542
- case "insert_table_row":
1543
- return json(await insertTableRow(client, p.doc_token, p.block_id, p.row_index));
1544
- case "insert_table_column":
1545
- return json(
1546
- await insertTableColumn(client, p.doc_token, p.block_id, p.column_index),
1547
- );
1548
- case "delete_table_rows":
1549
- return json(
1550
- await deleteTableRows(
1551
- client,
1552
- p.doc_token,
1553
- p.block_id,
1554
- p.row_start,
1555
- p.row_count,
1556
- ),
1557
- );
1558
- case "delete_table_columns":
1559
- return json(
1560
- await deleteTableColumns(
1561
- client,
1562
- p.doc_token,
1563
- p.block_id,
1564
- p.column_start,
1565
- p.column_count,
1566
- ),
1567
- );
1568
- case "merge_table_cells":
1569
- return json(
1570
- await mergeTableCells(
1571
- client,
1572
- p.doc_token,
1573
- p.block_id,
1574
- p.row_start,
1575
- p.row_end,
1576
- p.column_start,
1577
- p.column_end,
1578
- ),
1579
- );
1580
- default:
1581
- return json({ error: "Unknown action" });
1582
- }
1583
- } catch (err) {
1584
- return json({ error: formatErrorMessage(err) });
1585
- }
1586
- },
1587
- };
1588
- },
1589
- { name: "feishu_doc" },
1590
- );
1591
- registered.push("feishu_doc");
1592
- }
1593
-
1594
- // Keep feishu_app_scopes as independent tool
1595
- if (toolsCfg.scopes) {
1596
- api.registerTool(
1597
- (ctx) => ({
1598
- name: "feishu_app_scopes",
1599
- label: "Feishu App Scopes",
1600
- description:
1601
- "List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
1602
- parameters: Type.Object({}),
1603
- async execute() {
1604
- try {
1605
- const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
1606
- return json(result);
1607
- } catch (err) {
1608
- return json({ error: formatErrorMessage(err) });
1609
- }
1610
- },
1611
- }),
1612
- { name: "feishu_app_scopes" },
1613
- );
1614
- registered.push("feishu_app_scopes");
1615
- }
1616
- }