@openclaw/feishu 2026.3.13 → 2026.5.2-beta.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 (187) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1827 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
@@ -0,0 +1,1219 @@
1
+ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { OpenClawPluginApi, PluginRuntime } from "../runtime-api.js";
4
+
5
+ const createFeishuToolClientMock = vi.hoisted(() => vi.fn());
6
+ const resolveAnyEnabledFeishuToolsConfigMock = vi.hoisted(() => vi.fn());
7
+ const cleanupAmbientCommentTypingReactionMock = vi.hoisted(() => vi.fn(async () => false));
8
+
9
+ vi.mock("./tool-account.js", () => ({
10
+ createFeishuToolClient: createFeishuToolClientMock,
11
+ resolveAnyEnabledFeishuToolsConfig: resolveAnyEnabledFeishuToolsConfigMock,
12
+ }));
13
+
14
+ vi.mock("./comment-reaction.js", () => ({
15
+ cleanupAmbientCommentTypingReaction: cleanupAmbientCommentTypingReactionMock,
16
+ }));
17
+
18
+ let registerFeishuDriveTools: typeof import("./drive.js").registerFeishuDriveTools;
19
+
20
+ function createFeishuToolRuntime(): PluginRuntime {
21
+ return {} as PluginRuntime;
22
+ }
23
+
24
+ function createDriveToolApi(params: {
25
+ config: OpenClawPluginApi["config"];
26
+ registerTool: OpenClawPluginApi["registerTool"];
27
+ }): OpenClawPluginApi {
28
+ return createTestPluginApi({
29
+ id: "feishu-test",
30
+ name: "Feishu Test",
31
+ source: "local",
32
+ config: params.config,
33
+ runtime: createFeishuToolRuntime(),
34
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
35
+ registerTool: params.registerTool,
36
+ });
37
+ }
38
+
39
+ describe("registerFeishuDriveTools", () => {
40
+ const requestMock = vi.fn();
41
+
42
+ beforeAll(async () => {
43
+ ({ registerFeishuDriveTools } = await import("./drive.js"));
44
+ });
45
+
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ resolveAnyEnabledFeishuToolsConfigMock.mockReturnValue({
49
+ doc: false,
50
+ chat: false,
51
+ wiki: false,
52
+ drive: true,
53
+ perm: false,
54
+ scopes: false,
55
+ });
56
+ createFeishuToolClientMock.mockReturnValue({
57
+ request: requestMock,
58
+ });
59
+ cleanupAmbientCommentTypingReactionMock.mockResolvedValue(false);
60
+ });
61
+
62
+ it("registers feishu_drive and handles comment actions", async () => {
63
+ const registerTool = vi.fn();
64
+ registerFeishuDriveTools(
65
+ createDriveToolApi({
66
+ config: {
67
+ channels: {
68
+ feishu: {
69
+ enabled: true,
70
+ appId: "app_id",
71
+ appSecret: "app_secret", // pragma: allowlist secret
72
+ tools: { drive: true },
73
+ },
74
+ },
75
+ },
76
+ registerTool,
77
+ }),
78
+ );
79
+
80
+ expect(registerTool).toHaveBeenCalledTimes(1);
81
+ const toolFactory = registerTool.mock.calls[0]?.[0];
82
+ const tool = toolFactory?.({ agentAccountId: undefined });
83
+ expect(tool?.name).toBe("feishu_drive");
84
+
85
+ requestMock.mockResolvedValueOnce({
86
+ code: 0,
87
+ data: {
88
+ has_more: false,
89
+ page_token: "0",
90
+ items: [
91
+ {
92
+ comment_id: "c1",
93
+ quote: "quoted text",
94
+ reply_list: {
95
+ replies: [
96
+ {
97
+ reply_id: "r1",
98
+ user_id: "ou_author",
99
+ content: {
100
+ elements: [
101
+ {
102
+ type: "text_run",
103
+ text_run: { text: "root comment" },
104
+ },
105
+ ],
106
+ },
107
+ },
108
+ {
109
+ reply_id: "r2",
110
+ user_id: "ou_reply",
111
+ content: {
112
+ elements: [
113
+ {
114
+ type: "text_run",
115
+ text_run: { text: "reply text" },
116
+ },
117
+ ],
118
+ },
119
+ },
120
+ ],
121
+ },
122
+ },
123
+ ],
124
+ },
125
+ });
126
+ const listResult = await tool.execute("call-1", {
127
+ action: "list_comments",
128
+ file_token: "doc_1",
129
+ file_type: "docx",
130
+ });
131
+ expect(requestMock).toHaveBeenNthCalledWith(
132
+ 1,
133
+ expect.objectContaining({
134
+ method: "GET",
135
+ url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&user_id_type=open_id",
136
+ }),
137
+ );
138
+ expect(listResult.details).toEqual(
139
+ expect.objectContaining({
140
+ comments: [
141
+ expect.objectContaining({
142
+ comment_id: "c1",
143
+ text: "root comment",
144
+ quote: "quoted text",
145
+ replies: [expect.objectContaining({ reply_id: "r2", text: "reply text" })],
146
+ }),
147
+ ],
148
+ }),
149
+ );
150
+
151
+ requestMock.mockResolvedValueOnce({
152
+ code: 0,
153
+ data: {
154
+ has_more: false,
155
+ page_token: "0",
156
+ items: [
157
+ {
158
+ reply_id: "r3",
159
+ user_id: "ou_reply_2",
160
+ content: {
161
+ elements: [
162
+ {
163
+ type: "text_run",
164
+ text_run: { content: "reply from api" },
165
+ },
166
+ ],
167
+ },
168
+ },
169
+ ],
170
+ },
171
+ });
172
+ const repliesResult = await tool.execute("call-2", {
173
+ action: "list_comment_replies",
174
+ file_token: "doc_1",
175
+ file_type: "docx",
176
+ comment_id: "c1",
177
+ });
178
+ expect(requestMock).toHaveBeenNthCalledWith(
179
+ 2,
180
+ expect.objectContaining({
181
+ method: "GET",
182
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&user_id_type=open_id",
183
+ }),
184
+ );
185
+ expect(repliesResult.details).toEqual(
186
+ expect.objectContaining({
187
+ replies: [expect.objectContaining({ reply_id: "r3", text: "reply from api" })],
188
+ }),
189
+ );
190
+
191
+ requestMock.mockResolvedValueOnce({
192
+ code: 0,
193
+ data: { comment_id: "c2" },
194
+ });
195
+ const addCommentResult = await tool.execute("call-3", {
196
+ action: "add_comment",
197
+ file_token: "doc_1",
198
+ file_type: "docx",
199
+ block_id: "blk_1",
200
+ content: "please update this section",
201
+ });
202
+ expect(requestMock).toHaveBeenNthCalledWith(
203
+ 3,
204
+ expect.objectContaining({
205
+ method: "POST",
206
+ url: "/open-apis/drive/v1/files/doc_1/new_comments",
207
+ data: {
208
+ file_type: "docx",
209
+ reply_elements: [{ type: "text", text: "please update this section" }],
210
+ anchor: { block_id: "blk_1" },
211
+ },
212
+ }),
213
+ );
214
+ expect(addCommentResult.details).toEqual(
215
+ expect.objectContaining({ success: true, comment_id: "c2" }),
216
+ );
217
+
218
+ requestMock
219
+ .mockResolvedValueOnce({
220
+ code: 0,
221
+ data: {
222
+ items: [{ comment_id: "c1", is_whole: false }],
223
+ },
224
+ })
225
+ .mockResolvedValueOnce({
226
+ code: 0,
227
+ data: { reply_id: "r4" },
228
+ });
229
+ const replyCommentResult = await tool.execute("call-4", {
230
+ action: "reply_comment",
231
+ file_token: "doc_1",
232
+ file_type: "docx",
233
+ comment_id: "c1",
234
+ content: "handled",
235
+ });
236
+ expect(requestMock).toHaveBeenNthCalledWith(
237
+ 4,
238
+ expect.objectContaining({
239
+ method: "POST",
240
+ url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
241
+ data: {
242
+ comment_ids: ["c1"],
243
+ },
244
+ }),
245
+ );
246
+ expect(requestMock).toHaveBeenNthCalledWith(
247
+ 5,
248
+ expect.objectContaining({
249
+ method: "POST",
250
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
251
+ params: { file_type: "docx" },
252
+ data: {
253
+ content: {
254
+ elements: [
255
+ {
256
+ type: "text_run",
257
+ text_run: {
258
+ text: "handled",
259
+ },
260
+ },
261
+ ],
262
+ },
263
+ },
264
+ }),
265
+ );
266
+ expect(replyCommentResult.details).toEqual(
267
+ expect.objectContaining({ success: true, reply_id: "r4" }),
268
+ );
269
+ });
270
+
271
+ it("defaults add_comment file_type to docx when omitted", async () => {
272
+ const registerTool = vi.fn();
273
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
274
+ registerFeishuDriveTools(
275
+ createDriveToolApi({
276
+ config: {
277
+ channels: {
278
+ feishu: {
279
+ enabled: true,
280
+ appId: "app_id",
281
+ appSecret: "app_secret", // pragma: allowlist secret
282
+ tools: { drive: true },
283
+ },
284
+ },
285
+ },
286
+ registerTool,
287
+ }),
288
+ );
289
+
290
+ const toolFactory = registerTool.mock.calls[0]?.[0];
291
+ const tool = toolFactory?.({ agentAccountId: undefined });
292
+
293
+ requestMock.mockResolvedValueOnce({
294
+ code: 0,
295
+ data: { comment_id: "c-default-docx" },
296
+ });
297
+
298
+ const result = await tool.execute("call-default-docx", {
299
+ action: "add_comment",
300
+ file_token: "doc_1",
301
+ content: "defaulted file type",
302
+ });
303
+
304
+ expect(requestMock).toHaveBeenCalledWith(
305
+ expect.objectContaining({
306
+ method: "POST",
307
+ url: "/open-apis/drive/v1/files/doc_1/new_comments",
308
+ data: {
309
+ file_type: "docx",
310
+ reply_elements: [{ type: "text", text: "defaulted file type" }],
311
+ },
312
+ }),
313
+ );
314
+ expect(infoSpy).toHaveBeenCalledWith(
315
+ expect.stringContaining("add_comment missing file_type; defaulting to docx"),
316
+ );
317
+ expect(result.details).toEqual(
318
+ expect.objectContaining({ success: true, comment_id: "c-default-docx" }),
319
+ );
320
+ });
321
+
322
+ it("defaults list_comments file_type to docx when omitted", async () => {
323
+ const registerTool = vi.fn();
324
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
325
+ registerFeishuDriveTools(
326
+ createDriveToolApi({
327
+ config: {
328
+ channels: {
329
+ feishu: {
330
+ enabled: true,
331
+ appId: "app_id",
332
+ appSecret: "app_secret", // pragma: allowlist secret
333
+ tools: { drive: true },
334
+ },
335
+ },
336
+ },
337
+ registerTool,
338
+ }),
339
+ );
340
+
341
+ const toolFactory = registerTool.mock.calls[0]?.[0];
342
+ const tool = toolFactory?.({ agentAccountId: undefined });
343
+
344
+ requestMock.mockResolvedValueOnce({
345
+ code: 0,
346
+ data: { has_more: false, items: [] },
347
+ });
348
+
349
+ await tool.execute("call-list-default-docx", {
350
+ action: "list_comments",
351
+ file_token: "doc_1",
352
+ });
353
+
354
+ expect(requestMock).toHaveBeenCalledWith(
355
+ expect.objectContaining({
356
+ method: "GET",
357
+ url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&user_id_type=open_id",
358
+ }),
359
+ );
360
+ expect(infoSpy).toHaveBeenCalledWith(
361
+ expect.stringContaining("list_comments missing file_type; defaulting to docx"),
362
+ );
363
+ });
364
+
365
+ it("defaults list_comment_replies file_type to docx when omitted", async () => {
366
+ const registerTool = vi.fn();
367
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
368
+ registerFeishuDriveTools(
369
+ createDriveToolApi({
370
+ config: {
371
+ channels: {
372
+ feishu: {
373
+ enabled: true,
374
+ appId: "app_id",
375
+ appSecret: "app_secret", // pragma: allowlist secret
376
+ tools: { drive: true },
377
+ },
378
+ },
379
+ },
380
+ registerTool,
381
+ }),
382
+ );
383
+
384
+ const toolFactory = registerTool.mock.calls[0]?.[0];
385
+ const tool = toolFactory?.({ agentAccountId: undefined });
386
+
387
+ requestMock.mockResolvedValueOnce({
388
+ code: 0,
389
+ data: { has_more: false, items: [] },
390
+ });
391
+
392
+ await tool.execute("call-replies-default-docx", {
393
+ action: "list_comment_replies",
394
+ file_token: "doc_1",
395
+ comment_id: "c1",
396
+ });
397
+
398
+ expect(requestMock).toHaveBeenCalledWith(
399
+ expect.objectContaining({
400
+ method: "GET",
401
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&user_id_type=open_id",
402
+ }),
403
+ );
404
+ expect(infoSpy).toHaveBeenCalledWith(
405
+ expect.stringContaining("list_comment_replies missing file_type; defaulting to docx"),
406
+ );
407
+ });
408
+
409
+ it("surfaces reply_comment HTTP errors when the single supported body fails", async () => {
410
+ const registerTool = vi.fn();
411
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
412
+ registerFeishuDriveTools(
413
+ createDriveToolApi({
414
+ config: {
415
+ channels: {
416
+ feishu: {
417
+ enabled: true,
418
+ appId: "app_id",
419
+ appSecret: "app_secret", // pragma: allowlist secret
420
+ tools: { drive: true },
421
+ },
422
+ },
423
+ },
424
+ registerTool,
425
+ }),
426
+ );
427
+
428
+ const toolFactory = registerTool.mock.calls[0]?.[0];
429
+ const tool = toolFactory?.({ agentAccountId: undefined });
430
+
431
+ requestMock
432
+ .mockResolvedValueOnce({
433
+ code: 0,
434
+ data: {
435
+ items: [{ comment_id: "c1", is_whole: false }],
436
+ },
437
+ })
438
+ .mockRejectedValueOnce({
439
+ message: "Request failed with status code 400",
440
+ code: "ERR_BAD_REQUEST",
441
+ config: {
442
+ method: "post",
443
+ url: "https://open.feishu.cn/open-apis/drive/v1/files/doc_1/comments/c1/replies",
444
+ params: { file_type: "docx" },
445
+ },
446
+ response: {
447
+ status: 400,
448
+ data: {
449
+ code: 99992402,
450
+ msg: "field validation failed",
451
+ log_id: "log_legacy_400",
452
+ },
453
+ },
454
+ });
455
+
456
+ const replyCommentResult = await tool.execute("call-throw", {
457
+ action: "reply_comment",
458
+ file_token: "doc_1",
459
+ file_type: "docx",
460
+ comment_id: "c1",
461
+ content: "inserted successfully",
462
+ });
463
+
464
+ expect(requestMock).toHaveBeenNthCalledWith(
465
+ 1,
466
+ expect.objectContaining({
467
+ method: "POST",
468
+ url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
469
+ data: {
470
+ comment_ids: ["c1"],
471
+ },
472
+ }),
473
+ );
474
+ expect(requestMock).toHaveBeenNthCalledWith(
475
+ 2,
476
+ expect.objectContaining({
477
+ method: "POST",
478
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
479
+ params: { file_type: "docx" },
480
+ data: {
481
+ content: {
482
+ elements: [
483
+ {
484
+ type: "text_run",
485
+ text_run: {
486
+ text: "inserted successfully",
487
+ },
488
+ },
489
+ ],
490
+ },
491
+ },
492
+ }),
493
+ );
494
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("replyComment threw"));
495
+ expect(replyCommentResult.details).toEqual(
496
+ expect.objectContaining({ error: "Request failed with status code 400" }),
497
+ );
498
+ });
499
+
500
+ it("does not wait for ambient typing cleanup before reply_comment sends visible output", async () => {
501
+ const registerTool = vi.fn();
502
+ registerFeishuDriveTools(
503
+ createDriveToolApi({
504
+ config: {
505
+ channels: {
506
+ feishu: {
507
+ enabled: true,
508
+ appId: "app_id",
509
+ appSecret: "app_secret", // pragma: allowlist secret
510
+ tools: { drive: true },
511
+ },
512
+ },
513
+ },
514
+ registerTool,
515
+ }),
516
+ );
517
+
518
+ const toolFactory = registerTool.mock.calls[0]?.[0];
519
+ const tool = toolFactory?.({
520
+ agentAccountId: undefined,
521
+ deliveryContext: {
522
+ channel: "feishu",
523
+ to: "comment:docx:doc_1:c1",
524
+ threadId: "reply_ambient_1",
525
+ },
526
+ });
527
+
528
+ requestMock
529
+ .mockResolvedValueOnce({
530
+ code: 0,
531
+ data: {
532
+ items: [{ comment_id: "c1", is_whole: false }],
533
+ },
534
+ })
535
+ .mockResolvedValueOnce({
536
+ code: 0,
537
+ data: { reply_id: "r6" },
538
+ });
539
+
540
+ let resolveCleanup: ((value: boolean) => void) | undefined;
541
+ cleanupAmbientCommentTypingReactionMock.mockImplementationOnce(
542
+ () =>
543
+ new Promise<boolean>((resolve) => {
544
+ resolveCleanup = resolve;
545
+ }),
546
+ );
547
+
548
+ const replyCommentPromise = tool.execute("call-ambient", {
549
+ action: "reply_comment",
550
+ content: "ambient success",
551
+ });
552
+ const status = await Promise.race([
553
+ replyCommentPromise.then(() => "done"),
554
+ new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 0)),
555
+ ]);
556
+
557
+ expect(status).toBe("done");
558
+ expect(requestMock).toHaveBeenNthCalledWith(
559
+ 1,
560
+ expect.objectContaining({
561
+ method: "POST",
562
+ url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
563
+ data: {
564
+ comment_ids: ["c1"],
565
+ },
566
+ }),
567
+ );
568
+ expect(requestMock).toHaveBeenNthCalledWith(
569
+ 2,
570
+ expect.objectContaining({
571
+ method: "POST",
572
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
573
+ params: { file_type: "docx" },
574
+ data: {
575
+ content: {
576
+ elements: [
577
+ {
578
+ type: "text_run",
579
+ text_run: {
580
+ text: "ambient success",
581
+ },
582
+ },
583
+ ],
584
+ },
585
+ },
586
+ }),
587
+ );
588
+ expect(cleanupAmbientCommentTypingReactionMock).toHaveBeenCalledWith({
589
+ client: expect.anything(),
590
+ deliveryContext: {
591
+ channel: "feishu",
592
+ to: "comment:docx:doc_1:c1",
593
+ threadId: "reply_ambient_1",
594
+ },
595
+ });
596
+ const replyCommentResult = await replyCommentPromise;
597
+ expect(replyCommentResult.details).toEqual(
598
+ expect.objectContaining({ success: true, reply_id: "r6" }),
599
+ );
600
+
601
+ resolveCleanup?.(false);
602
+ });
603
+
604
+ it("does not wait for ambient typing cleanup before add_comment sends visible output", async () => {
605
+ const registerTool = vi.fn();
606
+ registerFeishuDriveTools(
607
+ createDriveToolApi({
608
+ config: {
609
+ channels: {
610
+ feishu: {
611
+ enabled: true,
612
+ appId: "app_id",
613
+ appSecret: "app_secret", // pragma: allowlist secret
614
+ tools: { drive: true },
615
+ },
616
+ },
617
+ },
618
+ registerTool,
619
+ }),
620
+ );
621
+
622
+ const toolFactory = registerTool.mock.calls[0]?.[0];
623
+ const tool = toolFactory?.({
624
+ agentAccountId: undefined,
625
+ deliveryContext: {
626
+ channel: "feishu",
627
+ to: "comment:docx:doc_1:c1",
628
+ threadId: "reply_ambient_1",
629
+ },
630
+ });
631
+
632
+ requestMock.mockResolvedValueOnce({
633
+ code: 0,
634
+ data: { comment_id: "c_add" },
635
+ });
636
+
637
+ let resolveCleanup: ((value: boolean) => void) | undefined;
638
+ cleanupAmbientCommentTypingReactionMock.mockImplementationOnce(
639
+ () =>
640
+ new Promise<boolean>((resolve) => {
641
+ resolveCleanup = resolve;
642
+ }),
643
+ );
644
+
645
+ const addCommentPromise = tool.execute("call-add-ambient", {
646
+ action: "add_comment",
647
+ content: "ambient top-level comment",
648
+ });
649
+ const status = await Promise.race([
650
+ addCommentPromise.then(() => "done"),
651
+ new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 0)),
652
+ ]);
653
+
654
+ expect(status).toBe("done");
655
+ expect(requestMock).toHaveBeenCalledWith(
656
+ expect.objectContaining({
657
+ method: "POST",
658
+ url: "/open-apis/drive/v1/files/doc_1/new_comments",
659
+ data: {
660
+ file_type: "docx",
661
+ reply_elements: [{ type: "text", text: "ambient top-level comment" }],
662
+ },
663
+ }),
664
+ );
665
+ expect(cleanupAmbientCommentTypingReactionMock).toHaveBeenCalledWith({
666
+ client: expect.anything(),
667
+ deliveryContext: {
668
+ channel: "feishu",
669
+ to: "comment:docx:doc_1:c1",
670
+ threadId: "reply_ambient_1",
671
+ },
672
+ });
673
+ const addCommentResult = await addCommentPromise;
674
+ expect(addCommentResult.details).toEqual(
675
+ expect.objectContaining({ success: true, comment_id: "c_add" }),
676
+ );
677
+
678
+ resolveCleanup?.(false);
679
+ });
680
+
681
+ it("does not inherit non-doc ambient file types for add_comment", async () => {
682
+ const registerTool = vi.fn();
683
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
684
+ registerFeishuDriveTools(
685
+ createDriveToolApi({
686
+ config: {
687
+ channels: {
688
+ feishu: {
689
+ enabled: true,
690
+ appId: "app_id",
691
+ appSecret: "app_secret", // pragma: allowlist secret
692
+ tools: { drive: true },
693
+ },
694
+ },
695
+ },
696
+ registerTool,
697
+ }),
698
+ );
699
+
700
+ const toolFactory = registerTool.mock.calls[0]?.[0];
701
+ const tool = toolFactory?.({
702
+ agentAccountId: undefined,
703
+ deliveryContext: {
704
+ channel: "feishu",
705
+ to: "comment:sheet:sheet_1:c1",
706
+ },
707
+ });
708
+
709
+ requestMock.mockResolvedValueOnce({
710
+ code: 0,
711
+ data: { comment_id: "c-add-docx" },
712
+ });
713
+
714
+ const result = await tool.execute("call-add-ignore-sheet-ambient", {
715
+ action: "add_comment",
716
+ file_token: "doc_1",
717
+ content: "default add comment",
718
+ });
719
+
720
+ expect(requestMock).toHaveBeenCalledWith(
721
+ expect.objectContaining({
722
+ method: "POST",
723
+ url: "/open-apis/drive/v1/files/doc_1/new_comments",
724
+ data: {
725
+ file_type: "docx",
726
+ reply_elements: [{ type: "text", text: "default add comment" }],
727
+ },
728
+ }),
729
+ );
730
+ expect(infoSpy).toHaveBeenCalledWith(
731
+ expect.stringContaining("add_comment missing file_type; defaulting to docx"),
732
+ );
733
+ expect(result.details).toEqual(
734
+ expect.objectContaining({ success: true, comment_id: "c-add-docx" }),
735
+ );
736
+ });
737
+
738
+ it("defaults reply_comment file_type to docx when omitted", async () => {
739
+ const registerTool = vi.fn();
740
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
741
+ registerFeishuDriveTools(
742
+ createDriveToolApi({
743
+ config: {
744
+ channels: {
745
+ feishu: {
746
+ enabled: true,
747
+ appId: "app_id",
748
+ appSecret: "app_secret", // pragma: allowlist secret
749
+ tools: { drive: true },
750
+ },
751
+ },
752
+ },
753
+ registerTool,
754
+ }),
755
+ );
756
+
757
+ const toolFactory = registerTool.mock.calls[0]?.[0];
758
+ const tool = toolFactory?.({ agentAccountId: undefined });
759
+
760
+ requestMock
761
+ .mockResolvedValueOnce({
762
+ code: 0,
763
+ data: {
764
+ items: [{ comment_id: "c1", is_whole: false }],
765
+ },
766
+ })
767
+ .mockResolvedValueOnce({
768
+ code: 0,
769
+ data: { reply_id: "r-default-docx" },
770
+ });
771
+
772
+ const result = await tool.execute("call-reply-default-docx", {
773
+ action: "reply_comment",
774
+ file_token: "doc_1",
775
+ comment_id: "c1",
776
+ content: "default reply docx",
777
+ });
778
+
779
+ expect(requestMock).toHaveBeenNthCalledWith(
780
+ 1,
781
+ expect.objectContaining({
782
+ method: "POST",
783
+ url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
784
+ data: { comment_ids: ["c1"] },
785
+ }),
786
+ );
787
+ expect(requestMock).toHaveBeenNthCalledWith(
788
+ 2,
789
+ expect.objectContaining({
790
+ method: "POST",
791
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
792
+ params: { file_type: "docx" },
793
+ data: {
794
+ content: {
795
+ elements: [
796
+ {
797
+ type: "text_run",
798
+ text_run: {
799
+ text: "default reply docx",
800
+ },
801
+ },
802
+ ],
803
+ },
804
+ },
805
+ }),
806
+ );
807
+ expect(infoSpy).toHaveBeenCalledWith(
808
+ expect.stringContaining("reply_comment missing file_type; defaulting to docx"),
809
+ );
810
+ expect(result.details).toEqual(
811
+ expect.objectContaining({ success: true, reply_id: "r-default-docx" }),
812
+ );
813
+ });
814
+
815
+ it("routes whole-document reply_comment requests through add_comment compatibility", async () => {
816
+ const registerTool = vi.fn();
817
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
818
+ registerFeishuDriveTools(
819
+ createDriveToolApi({
820
+ config: {
821
+ channels: {
822
+ feishu: {
823
+ enabled: true,
824
+ appId: "app_id",
825
+ appSecret: "app_secret", // pragma: allowlist secret
826
+ tools: { drive: true },
827
+ },
828
+ },
829
+ },
830
+ registerTool,
831
+ }),
832
+ );
833
+
834
+ const toolFactory = registerTool.mock.calls[0]?.[0];
835
+ const tool = toolFactory?.({ agentAccountId: undefined });
836
+
837
+ requestMock
838
+ .mockResolvedValueOnce({
839
+ code: 0,
840
+ data: {
841
+ items: [{ comment_id: "c1", is_whole: true }],
842
+ },
843
+ })
844
+ .mockResolvedValueOnce({
845
+ code: 0,
846
+ data: { comment_id: "c2" },
847
+ });
848
+
849
+ const result = await tool.execute("call-whole", {
850
+ action: "reply_comment",
851
+ file_token: "doc_1",
852
+ file_type: "docx",
853
+ comment_id: "c1",
854
+ content: "whole comment follow-up",
855
+ });
856
+
857
+ expect(requestMock).toHaveBeenNthCalledWith(
858
+ 1,
859
+ expect.objectContaining({
860
+ method: "POST",
861
+ url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
862
+ data: {
863
+ comment_ids: ["c1"],
864
+ },
865
+ }),
866
+ );
867
+ expect(requestMock).toHaveBeenNthCalledWith(
868
+ 2,
869
+ expect.objectContaining({
870
+ method: "POST",
871
+ url: "/open-apis/drive/v1/files/doc_1/new_comments",
872
+ data: {
873
+ file_type: "docx",
874
+ reply_elements: [{ type: "text", text: "whole comment follow-up" }],
875
+ },
876
+ }),
877
+ );
878
+ expect(infoSpy).toHaveBeenCalledWith(
879
+ expect.stringContaining("whole-comment compatibility path"),
880
+ );
881
+ expect(result.details).toEqual(
882
+ expect.objectContaining({
883
+ success: true,
884
+ comment_id: "c2",
885
+ delivery_mode: "add_comment",
886
+ }),
887
+ );
888
+ });
889
+
890
+ it("continues with reply_comment when comment metadata preflight fails", async () => {
891
+ const registerTool = vi.fn();
892
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
893
+ registerFeishuDriveTools(
894
+ createDriveToolApi({
895
+ config: {
896
+ channels: {
897
+ feishu: {
898
+ enabled: true,
899
+ appId: "app_id",
900
+ appSecret: "app_secret", // pragma: allowlist secret
901
+ tools: { drive: true },
902
+ },
903
+ },
904
+ },
905
+ registerTool,
906
+ }),
907
+ );
908
+
909
+ const toolFactory = registerTool.mock.calls[0]?.[0];
910
+ const tool = toolFactory?.({ agentAccountId: undefined });
911
+
912
+ requestMock.mockRejectedValueOnce(new Error("preflight unavailable")).mockResolvedValueOnce({
913
+ code: 0,
914
+ data: { reply_id: "r-preflight-fallback" },
915
+ });
916
+
917
+ const result = await tool.execute("call-preflight-fallback", {
918
+ action: "reply_comment",
919
+ file_token: "doc_1",
920
+ file_type: "docx",
921
+ comment_id: "c1",
922
+ content: "preflight fallback reply",
923
+ });
924
+
925
+ expect(requestMock).toHaveBeenNthCalledWith(
926
+ 1,
927
+ expect.objectContaining({
928
+ method: "POST",
929
+ url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
930
+ data: {
931
+ comment_ids: ["c1"],
932
+ },
933
+ }),
934
+ );
935
+ expect(requestMock).toHaveBeenNthCalledWith(
936
+ 2,
937
+ expect.objectContaining({
938
+ method: "POST",
939
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
940
+ params: { file_type: "docx" },
941
+ data: {
942
+ content: {
943
+ elements: [
944
+ {
945
+ type: "text_run",
946
+ text_run: {
947
+ text: "preflight fallback reply",
948
+ },
949
+ },
950
+ ],
951
+ },
952
+ },
953
+ }),
954
+ );
955
+ expect(warnSpy).toHaveBeenCalledWith(
956
+ expect.stringContaining("comment metadata preflight failed"),
957
+ );
958
+ expect(result.details).toEqual(
959
+ expect.objectContaining({
960
+ success: true,
961
+ reply_id: "r-preflight-fallback",
962
+ delivery_mode: "reply_comment",
963
+ }),
964
+ );
965
+ });
966
+
967
+ it("continues with reply_comment when batch_query returns no exact comment match", async () => {
968
+ const registerTool = vi.fn();
969
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
970
+ registerFeishuDriveTools(
971
+ createDriveToolApi({
972
+ config: {
973
+ channels: {
974
+ feishu: {
975
+ enabled: true,
976
+ appId: "app_id",
977
+ appSecret: "app_secret", // pragma: allowlist secret
978
+ tools: { drive: true },
979
+ },
980
+ },
981
+ },
982
+ registerTool,
983
+ }),
984
+ );
985
+
986
+ const toolFactory = registerTool.mock.calls[0]?.[0];
987
+ const tool = toolFactory?.({ agentAccountId: undefined });
988
+
989
+ requestMock
990
+ .mockResolvedValueOnce({
991
+ code: 0,
992
+ data: {
993
+ items: [{ comment_id: "different_comment", is_whole: true }],
994
+ },
995
+ })
996
+ .mockResolvedValueOnce({
997
+ code: 0,
998
+ data: { reply_id: "r-no-exact-match" },
999
+ });
1000
+
1001
+ const result = await tool.execute("call-preflight-no-exact-match", {
1002
+ action: "reply_comment",
1003
+ file_token: "doc_1",
1004
+ file_type: "docx",
1005
+ comment_id: "c1",
1006
+ content: "fallback on exact match miss",
1007
+ });
1008
+
1009
+ expect(requestMock).toHaveBeenNthCalledWith(
1010
+ 1,
1011
+ expect.objectContaining({
1012
+ method: "POST",
1013
+ url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
1014
+ data: {
1015
+ comment_ids: ["c1"],
1016
+ },
1017
+ }),
1018
+ );
1019
+ expect(requestMock).toHaveBeenNthCalledWith(
1020
+ 2,
1021
+ expect.objectContaining({
1022
+ method: "POST",
1023
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
1024
+ params: { file_type: "docx" },
1025
+ data: {
1026
+ content: {
1027
+ elements: [
1028
+ {
1029
+ type: "text_run",
1030
+ text_run: {
1031
+ text: "fallback on exact match miss",
1032
+ },
1033
+ },
1034
+ ],
1035
+ },
1036
+ },
1037
+ }),
1038
+ );
1039
+ expect(warnSpy).not.toHaveBeenCalledWith(
1040
+ expect.stringContaining("whole-comment compatibility path"),
1041
+ );
1042
+ expect(result.details).toEqual(
1043
+ expect.objectContaining({
1044
+ success: true,
1045
+ reply_id: "r-no-exact-match",
1046
+ delivery_mode: "reply_comment",
1047
+ }),
1048
+ );
1049
+ });
1050
+
1051
+ it("falls back to add_comment when reply_comment returns compatibility code 1069302 even without is_whole metadata", async () => {
1052
+ const registerTool = vi.fn();
1053
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
1054
+ registerFeishuDriveTools(
1055
+ createDriveToolApi({
1056
+ config: {
1057
+ channels: {
1058
+ feishu: {
1059
+ enabled: true,
1060
+ appId: "app_id",
1061
+ appSecret: "app_secret", // pragma: allowlist secret
1062
+ tools: { drive: true },
1063
+ },
1064
+ },
1065
+ },
1066
+ registerTool,
1067
+ }),
1068
+ );
1069
+
1070
+ const toolFactory = registerTool.mock.calls[0]?.[0];
1071
+ const tool = toolFactory?.({ agentAccountId: undefined });
1072
+
1073
+ requestMock
1074
+ .mockResolvedValueOnce({
1075
+ code: 0,
1076
+ data: {
1077
+ items: [{ comment_id: "c1", is_whole: false }],
1078
+ },
1079
+ })
1080
+ .mockRejectedValueOnce({
1081
+ message: "Request failed with status code 400",
1082
+ code: "ERR_BAD_REQUEST",
1083
+ config: {
1084
+ method: "post",
1085
+ url: "https://open.feishu.cn/open-apis/drive/v1/files/doc_1/comments/c1/replies",
1086
+ params: { file_type: "docx" },
1087
+ },
1088
+ response: {
1089
+ status: 400,
1090
+ data: {
1091
+ code: 1069302,
1092
+ msg: "param error",
1093
+ log_id: "log_reply_forbidden",
1094
+ },
1095
+ },
1096
+ })
1097
+ .mockResolvedValueOnce({
1098
+ code: 0,
1099
+ data: { comment_id: "c3" },
1100
+ });
1101
+
1102
+ const result = await tool.execute("call-reply-forbidden", {
1103
+ action: "reply_comment",
1104
+ file_token: "doc_1",
1105
+ file_type: "docx",
1106
+ comment_id: "c1",
1107
+ content: "compat follow-up",
1108
+ });
1109
+
1110
+ expect(requestMock).toHaveBeenNthCalledWith(
1111
+ 3,
1112
+ expect.objectContaining({
1113
+ method: "POST",
1114
+ url: "/open-apis/drive/v1/files/doc_1/new_comments",
1115
+ data: {
1116
+ file_type: "docx",
1117
+ reply_elements: [{ type: "text", text: "compat follow-up" }],
1118
+ },
1119
+ }),
1120
+ );
1121
+ expect(infoSpy).toHaveBeenCalledWith(
1122
+ expect.stringContaining("reply-not-allowed compatibility path"),
1123
+ );
1124
+ expect(result.details).toEqual(
1125
+ expect.objectContaining({
1126
+ success: true,
1127
+ comment_id: "c3",
1128
+ delivery_mode: "add_comment",
1129
+ }),
1130
+ );
1131
+ });
1132
+
1133
+ it("clamps comment list page sizes to the Feishu API maximum", async () => {
1134
+ const registerTool = vi.fn();
1135
+ registerFeishuDriveTools(
1136
+ createDriveToolApi({
1137
+ config: {
1138
+ channels: {
1139
+ feishu: {
1140
+ enabled: true,
1141
+ appId: "app_id",
1142
+ appSecret: "app_secret", // pragma: allowlist secret
1143
+ tools: { drive: true },
1144
+ },
1145
+ },
1146
+ },
1147
+ registerTool,
1148
+ }),
1149
+ );
1150
+
1151
+ const toolFactory = registerTool.mock.calls[0]?.[0];
1152
+ const tool = toolFactory?.({ agentAccountId: undefined });
1153
+
1154
+ requestMock.mockResolvedValueOnce({ code: 0, data: { has_more: false, items: [] } });
1155
+ await tool.execute("call-list", {
1156
+ action: "list_comments",
1157
+ file_token: "doc_1",
1158
+ file_type: "docx",
1159
+ page_size: 200,
1160
+ });
1161
+ expect(requestMock).toHaveBeenNthCalledWith(
1162
+ 1,
1163
+ expect.objectContaining({
1164
+ method: "GET",
1165
+ url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&page_size=100&user_id_type=open_id",
1166
+ }),
1167
+ );
1168
+
1169
+ requestMock.mockResolvedValueOnce({ code: 0, data: { has_more: false, items: [] } });
1170
+ await tool.execute("call-replies", {
1171
+ action: "list_comment_replies",
1172
+ file_token: "doc_1",
1173
+ file_type: "docx",
1174
+ comment_id: "c1",
1175
+ page_size: 200,
1176
+ });
1177
+ expect(requestMock).toHaveBeenNthCalledWith(
1178
+ 2,
1179
+ expect.objectContaining({
1180
+ method: "GET",
1181
+ url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&page_size=100&user_id_type=open_id",
1182
+ }),
1183
+ );
1184
+ });
1185
+
1186
+ it("rejects block-scoped comments for non-docx files", async () => {
1187
+ const registerTool = vi.fn();
1188
+ registerFeishuDriveTools(
1189
+ createDriveToolApi({
1190
+ config: {
1191
+ channels: {
1192
+ feishu: {
1193
+ enabled: true,
1194
+ appId: "app_id",
1195
+ appSecret: "app_secret", // pragma: allowlist secret
1196
+ tools: { drive: true },
1197
+ },
1198
+ },
1199
+ },
1200
+ registerTool,
1201
+ }),
1202
+ );
1203
+
1204
+ const toolFactory = registerTool.mock.calls[0]?.[0];
1205
+ const tool = toolFactory?.({ agentAccountId: undefined });
1206
+ const result = await tool.execute("call-5", {
1207
+ action: "add_comment",
1208
+ file_token: "doc_1",
1209
+ file_type: "doc",
1210
+ block_id: "blk_1",
1211
+ content: "invalid",
1212
+ });
1213
+ expect(result.details).toEqual(
1214
+ expect.objectContaining({
1215
+ error: "block_id is only supported for docx comments",
1216
+ }),
1217
+ );
1218
+ });
1219
+ });