@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
@@ -1,19 +1,45 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
4
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { ClawdbotConfig } from "../runtime-api.js";
5
7
 
6
8
  const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
7
9
  const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
10
+ const sendCardFeishuMock = vi.hoisted(() => vi.fn());
8
11
  const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
12
+ const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
13
+ const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
14
+ const cleanupAmbientCommentTypingReactionMock = vi.hoisted(() => vi.fn(async () => false));
9
15
 
10
16
  vi.mock("./media.js", () => ({
11
17
  sendMediaFeishu: sendMediaFeishuMock,
12
18
  }));
13
19
 
14
20
  vi.mock("./send.js", () => ({
21
+ sendCardFeishu: sendCardFeishuMock,
15
22
  sendMessageFeishu: sendMessageFeishuMock,
16
23
  sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
24
+ sendStructuredCardFeishu: sendStructuredCardFeishuMock,
25
+ resolveFeishuCardTemplate: (template?: string) =>
26
+ new Set([
27
+ "blue",
28
+ "green",
29
+ "red",
30
+ "orange",
31
+ "purple",
32
+ "indigo",
33
+ "wathet",
34
+ "turquoise",
35
+ "yellow",
36
+ "grey",
37
+ "carmine",
38
+ "violet",
39
+ "lime",
40
+ ]).has(template ?? "")
41
+ ? template
42
+ : undefined,
17
43
  }));
18
44
 
19
45
  vi.mock("./runtime.js", () => ({
@@ -26,14 +52,41 @@ vi.mock("./runtime.js", () => ({
26
52
  }),
27
53
  }));
28
54
 
55
+ vi.mock("./client.js", () => ({
56
+ createFeishuClient: vi.fn(() => ({ request: vi.fn() })),
57
+ }));
58
+
59
+ vi.mock("./drive.js", () => ({
60
+ deliverCommentThreadText: deliverCommentThreadTextMock,
61
+ }));
62
+
63
+ vi.mock("./comment-reaction.js", () => ({
64
+ cleanupAmbientCommentTypingReaction: cleanupAmbientCommentTypingReactionMock,
65
+ }));
66
+
29
67
  import { feishuOutbound } from "./outbound.js";
30
68
  const sendText = feishuOutbound.sendText!;
69
+ const emptyConfig: ClawdbotConfig = {};
70
+ const cardRenderConfig: ClawdbotConfig = {
71
+ channels: {
72
+ feishu: {
73
+ renderMode: "card",
74
+ },
75
+ },
76
+ };
31
77
 
32
78
  function resetOutboundMocks() {
33
79
  vi.clearAllMocks();
34
80
  sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
81
+ sendCardFeishuMock.mockResolvedValue({ messageId: "native_card_msg" });
35
82
  sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
83
+ sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
36
84
  sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
85
+ deliverCommentThreadTextMock.mockResolvedValue({
86
+ delivery_mode: "reply_comment",
87
+ reply_id: "reply_msg",
88
+ });
89
+ cleanupAmbientCommentTypingReactionMock.mockResolvedValue(false);
37
90
  }
38
91
 
39
92
  describe("feishuOutbound.sendText local-image auto-convert", () => {
@@ -41,6 +94,16 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
41
94
  resetOutboundMocks();
42
95
  });
43
96
 
97
+ it("chunks outbound text without requiring Feishu runtime initialization", () => {
98
+ const chunker = feishuOutbound.chunker;
99
+ if (!chunker) {
100
+ throw new Error("feishuOutbound.chunker missing");
101
+ }
102
+
103
+ expect(() => chunker("hello world", 5)).not.toThrow();
104
+ expect(chunker("hello world", 5)).toEqual(["hello", "world"]);
105
+ });
106
+
44
107
  async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
45
108
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-outbound-"));
46
109
  const file = path.join(dir, `sample${ext}`);
@@ -52,7 +115,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
52
115
  const { dir, file } = await createTmpImage();
53
116
  try {
54
117
  const result = await sendText({
55
- cfg: {} as any,
118
+ cfg: emptyConfig,
56
119
  to: "chat_1",
57
120
  text: file,
58
121
  accountId: "main",
@@ -78,7 +141,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
78
141
 
79
142
  it("keeps non-path text on the text-send path", async () => {
80
143
  await sendText({
81
- cfg: {} as any,
144
+ cfg: emptyConfig,
82
145
  to: "chat_1",
83
146
  text: "please upload /tmp/example.png",
84
147
  accountId: "main",
@@ -99,7 +162,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
99
162
  sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
100
163
  try {
101
164
  await sendText({
102
- cfg: {} as any,
165
+ cfg: emptyConfig,
103
166
  to: "chat_1",
104
167
  text: file,
105
168
  accountId: "main",
@@ -120,19 +183,13 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
120
183
 
121
184
  it("uses markdown cards when renderMode=card", async () => {
122
185
  const result = await sendText({
123
- cfg: {
124
- channels: {
125
- feishu: {
126
- renderMode: "card",
127
- },
128
- },
129
- } as any,
186
+ cfg: cardRenderConfig,
130
187
  to: "chat_1",
131
188
  text: "| a | b |\n| - | - |",
132
189
  accountId: "main",
133
190
  });
134
191
 
135
- expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
192
+ expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
136
193
  expect.objectContaining({
137
194
  to: "chat_1",
138
195
  text: "| a | b |\n| - | - |",
@@ -145,12 +202,12 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
145
202
 
146
203
  it("forwards replyToId as replyToMessageId on sendText", async () => {
147
204
  await sendText({
148
- cfg: {} as any,
205
+ cfg: emptyConfig,
149
206
  to: "chat_1",
150
207
  text: "hello",
151
208
  replyToId: "om_reply_1",
152
209
  accountId: "main",
153
- } as any);
210
+ });
154
211
 
155
212
  expect(sendMessageFeishuMock).toHaveBeenCalledWith(
156
213
  expect.objectContaining({
@@ -164,13 +221,13 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
164
221
 
165
222
  it("falls back to threadId when replyToId is empty on sendText", async () => {
166
223
  await sendText({
167
- cfg: {} as any,
224
+ cfg: emptyConfig,
168
225
  to: "chat_1",
169
226
  text: "hello",
170
227
  replyToId: " ",
171
228
  threadId: "om_thread_2",
172
229
  accountId: "main",
173
- } as any);
230
+ });
174
231
 
175
232
  expect(sendMessageFeishuMock).toHaveBeenCalledWith(
176
233
  expect.objectContaining({
@@ -183,6 +240,522 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
183
240
  });
184
241
  });
185
242
 
243
+ describe("feishuOutbound.sendPayload native cards", () => {
244
+ beforeEach(() => {
245
+ resetOutboundMocks();
246
+ });
247
+
248
+ async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
249
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-payload-"));
250
+ const file = path.join(dir, `sample${ext}`);
251
+ await fs.writeFile(file, "image-data");
252
+ return { dir, file };
253
+ }
254
+
255
+ it("renders presentation-only payloads into Feishu channelData cards for core delivery", async () => {
256
+ const presentation: MessagePresentation = {
257
+ title: "Approval",
258
+ tone: "success",
259
+ blocks: [
260
+ { type: "text", text: "Approve the request?" },
261
+ {
262
+ type: "buttons",
263
+ buttons: [
264
+ { label: "Approve", value: "/approve req_1 allow-once", style: "success" as const },
265
+ ],
266
+ },
267
+ ],
268
+ };
269
+ const payload = { presentation };
270
+ const rendered = await feishuOutbound.renderPresentation?.({
271
+ payload,
272
+ presentation,
273
+ ctx: {
274
+ cfg: emptyConfig,
275
+ to: "chat_1",
276
+ text: "",
277
+ accountId: "main",
278
+ payload,
279
+ },
280
+ });
281
+
282
+ expect(rendered).toEqual(
283
+ expect.objectContaining({
284
+ text: "Approval\n\nApprove the request?\n\n- Approve",
285
+ channelData: {
286
+ feishu: {
287
+ card: expect.objectContaining({
288
+ schema: "2.0",
289
+ header: {
290
+ title: { tag: "plain_text", content: "Approval" },
291
+ template: "green",
292
+ },
293
+ body: {
294
+ elements: expect.arrayContaining([
295
+ { tag: "markdown", content: "Approve the request?" },
296
+ expect.objectContaining({ tag: "action" }),
297
+ ]),
298
+ },
299
+ }),
300
+ },
301
+ },
302
+ }),
303
+ );
304
+
305
+ if (!rendered) {
306
+ throw new Error("expected Feishu presentation renderer to return a payload");
307
+ }
308
+ const { presentation: _presentation, ...coreRenderedPayload } = rendered;
309
+ const result = await feishuOutbound.sendPayload?.({
310
+ cfg: emptyConfig,
311
+ to: "chat_1",
312
+ text: coreRenderedPayload.text ?? "",
313
+ accountId: "main",
314
+ payload: coreRenderedPayload,
315
+ });
316
+
317
+ expect(sendCardFeishuMock).toHaveBeenCalledWith(
318
+ expect.objectContaining({
319
+ to: "chat_1",
320
+ card: expect.objectContaining({
321
+ header: {
322
+ title: { tag: "plain_text", content: "Approval" },
323
+ template: "green",
324
+ },
325
+ }),
326
+ }),
327
+ );
328
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
329
+ expect(result).toEqual(
330
+ expect.objectContaining({ channel: "feishu", messageId: "native_card_msg" }),
331
+ );
332
+ });
333
+
334
+ it("sends interactive button payloads as native Feishu cards", async () => {
335
+ const result = await feishuOutbound.sendPayload?.({
336
+ cfg: emptyConfig,
337
+ to: "chat_1",
338
+ text: "Choose an action",
339
+ accountId: "main",
340
+ payload: {
341
+ text: "Choose an action",
342
+ interactive: {
343
+ blocks: [
344
+ { type: "text", text: "Approve the request?" },
345
+ {
346
+ type: "buttons",
347
+ buttons: [
348
+ { label: "Approve", value: "/approve req_1 allow-once", style: "success" },
349
+ { label: "Deny", value: "/approve req_1 deny", style: "danger" },
350
+ ],
351
+ },
352
+ ],
353
+ },
354
+ },
355
+ });
356
+
357
+ expect(sendCardFeishuMock).toHaveBeenCalledWith(
358
+ expect.objectContaining({
359
+ cfg: emptyConfig,
360
+ to: "chat_1",
361
+ accountId: "main",
362
+ }),
363
+ );
364
+ const card = sendCardFeishuMock.mock.calls[0][0].card;
365
+ expect(card).toEqual(
366
+ expect.objectContaining({
367
+ schema: "2.0",
368
+ body: {
369
+ elements: expect.arrayContaining([
370
+ { tag: "markdown", content: "Choose an action" },
371
+ { tag: "markdown", content: "Approve the request?" },
372
+ expect.objectContaining({
373
+ tag: "action",
374
+ actions: [
375
+ expect.objectContaining({
376
+ text: { tag: "plain_text", content: "Approve" },
377
+ type: "primary",
378
+ value: expect.objectContaining({
379
+ oc: "ocf1",
380
+ k: "quick",
381
+ q: "/approve req_1 allow-once",
382
+ }),
383
+ }),
384
+ expect.objectContaining({
385
+ text: { tag: "plain_text", content: "Deny" },
386
+ type: "danger",
387
+ value: expect.objectContaining({
388
+ oc: "ocf1",
389
+ k: "quick",
390
+ q: "/approve req_1 deny",
391
+ }),
392
+ }),
393
+ ],
394
+ }),
395
+ ]),
396
+ },
397
+ }),
398
+ );
399
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
400
+ expect(result).toEqual(
401
+ expect.objectContaining({ channel: "feishu", messageId: "native_card_msg" }),
402
+ );
403
+ });
404
+
405
+ it("escapes generated markdown card text and drops unsafe button URLs", async () => {
406
+ await feishuOutbound.sendPayload?.({
407
+ cfg: emptyConfig,
408
+ to: "chat_1",
409
+ text: "Choose <at id=\"ou_1\">",
410
+ accountId: "main",
411
+ payload: {
412
+ text: "Choose <at id=\"ou_1\">",
413
+ presentation: {
414
+ blocks: [
415
+ { type: "context", text: "</font><at id=\"ou_2\">Injected</at>" },
416
+ {
417
+ type: "buttons",
418
+ buttons: [
419
+ { label: "Open", url: "https://example.com/path" },
420
+ { label: "Bad", url: "javascript:alert(1)" },
421
+ ],
422
+ },
423
+ ],
424
+ },
425
+ },
426
+ });
427
+
428
+ const card = sendCardFeishuMock.mock.calls[0][0].card;
429
+ expect(card.body.elements).toEqual(
430
+ expect.arrayContaining([
431
+ { tag: "markdown", content: "Choose &lt;at id=\"ou_1\"&gt;" },
432
+ {
433
+ tag: "markdown",
434
+ content: "<font color='grey'>&lt;/font&gt;&lt;at id=\"ou_2\"&gt;Injected&lt;/at&gt;</font>",
435
+ },
436
+ {
437
+ tag: "action",
438
+ actions: [
439
+ expect.objectContaining({
440
+ text: { tag: "plain_text", content: "Open" },
441
+ url: "https://example.com/path",
442
+ }),
443
+ ],
444
+ },
445
+ ]),
446
+ );
447
+ expect(JSON.stringify(card)).not.toContain("javascript:");
448
+ });
449
+
450
+ it("normalizes caller-supplied native Feishu cards before sending", async () => {
451
+ await feishuOutbound.sendPayload?.({
452
+ cfg: emptyConfig,
453
+ to: "chat_1",
454
+ text: "fallback",
455
+ accountId: "main",
456
+ payload: {
457
+ text: "fallback",
458
+ channelData: {
459
+ feishu: {
460
+ card: {
461
+ schema: "2.0",
462
+ header: {
463
+ title: { tag: "plain_text", content: "Unsafe card" },
464
+ template: "not-a-template",
465
+ },
466
+ body: {
467
+ elements: [
468
+ { tag: "img", img_key: "image-secret" },
469
+ { tag: "markdown", content: "<at id=\"ou_1\">ping</at>" },
470
+ {
471
+ tag: "action",
472
+ actions: [
473
+ {
474
+ tag: "button",
475
+ text: { tag: "plain_text", content: "Bad link" },
476
+ url: "file:///etc/passwd",
477
+ },
478
+ {
479
+ tag: "button",
480
+ text: { tag: "plain_text", content: "Good link" },
481
+ url: "https://example.com",
482
+ },
483
+ ],
484
+ },
485
+ ],
486
+ },
487
+ },
488
+ },
489
+ },
490
+ },
491
+ });
492
+
493
+ const card = sendCardFeishuMock.mock.calls[0][0].card;
494
+ expect(card.header.template).toBe("blue");
495
+ expect(card.body.elements).toEqual([
496
+ { tag: "markdown", content: "&lt;at id=\"ou_1\"&gt;ping&lt;/at&gt;" },
497
+ {
498
+ tag: "action",
499
+ actions: [
500
+ expect.objectContaining({
501
+ text: { tag: "plain_text", content: "Good link" },
502
+ url: "https://example.com",
503
+ }),
504
+ ],
505
+ },
506
+ ]);
507
+ expect(JSON.stringify(card)).not.toContain("file://");
508
+ expect(JSON.stringify(card)).not.toContain("image-secret");
509
+ });
510
+
511
+ it("sends payload media before final native cards", async () => {
512
+ const result = await feishuOutbound.sendPayload?.({
513
+ cfg: emptyConfig,
514
+ to: "chat_1",
515
+ text: "See attached",
516
+ accountId: "main",
517
+ mediaLocalRoots: ["/tmp"],
518
+ payload: {
519
+ text: "See attached",
520
+ mediaUrl: "/tmp/image.png",
521
+ interactive: {
522
+ blocks: [{ type: "buttons", buttons: [{ label: "Open", url: "https://example.com" }] }],
523
+ },
524
+ },
525
+ });
526
+
527
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
528
+ expect.objectContaining({
529
+ to: "chat_1",
530
+ mediaUrl: "/tmp/image.png",
531
+ mediaLocalRoots: ["/tmp"],
532
+ accountId: "main",
533
+ }),
534
+ );
535
+ expect(sendCardFeishuMock).toHaveBeenCalledWith(
536
+ expect.objectContaining({
537
+ to: "chat_1",
538
+ accountId: "main",
539
+ }),
540
+ );
541
+ expect(result).toEqual(
542
+ expect.objectContaining({ channel: "feishu", messageId: "native_card_msg" }),
543
+ );
544
+ });
545
+
546
+ it("keeps text/media fallback behavior for non-card payloads, including local image text", async () => {
547
+ const { dir, file } = await createTmpImage();
548
+ try {
549
+ const result = await feishuOutbound.sendPayload?.({
550
+ cfg: emptyConfig,
551
+ to: "chat_1",
552
+ text: file,
553
+ accountId: "main",
554
+ mediaLocalRoots: [dir],
555
+ payload: { text: file },
556
+ });
557
+
558
+ expect(sendCardFeishuMock).not.toHaveBeenCalled();
559
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
560
+ expect.objectContaining({
561
+ to: "chat_1",
562
+ mediaUrl: file,
563
+ mediaLocalRoots: [dir],
564
+ accountId: "main",
565
+ }),
566
+ );
567
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
568
+ expect(result).toEqual(
569
+ expect.objectContaining({ channel: "feishu", messageId: "media_msg" }),
570
+ );
571
+ } finally {
572
+ await fs.rm(dir, { recursive: true, force: true });
573
+ }
574
+ });
575
+
576
+ it("falls back to comment-thread text instead of sending native cards to document comments", async () => {
577
+ const result = await feishuOutbound.sendPayload?.({
578
+ cfg: emptyConfig,
579
+ to: "comment:docx:doxcn123:7623358762119646411",
580
+ text: "Review this",
581
+ accountId: "main",
582
+ payload: {
583
+ text: "Review this",
584
+ interactive: {
585
+ blocks: [{ type: "buttons", buttons: [{ label: "Approve", value: "/approve req_1" }] }],
586
+ },
587
+ },
588
+ });
589
+
590
+ expect(sendCardFeishuMock).not.toHaveBeenCalled();
591
+ expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
592
+ expect.anything(),
593
+ expect.objectContaining({
594
+ content: "Review this\n\n- Approve",
595
+ }),
596
+ );
597
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" }));
598
+ });
599
+ });
600
+
601
+ describe("feishuOutbound comment-thread routing", () => {
602
+ beforeEach(() => {
603
+ resetOutboundMocks();
604
+ });
605
+
606
+ it("routes comment-thread text through deliverCommentThreadText", async () => {
607
+ const result = await sendText({
608
+ cfg: emptyConfig,
609
+ to: "comment:docx:doxcn123:7623358762119646411",
610
+ text: "handled in thread",
611
+ accountId: "main",
612
+ });
613
+
614
+ expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
615
+ expect.anything(),
616
+ expect.objectContaining({
617
+ file_token: "doxcn123",
618
+ file_type: "docx",
619
+ comment_id: "7623358762119646411",
620
+ content: "handled in thread",
621
+ }),
622
+ );
623
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
624
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" }));
625
+ });
626
+
627
+ it("routes comment-thread code-block replies through deliverCommentThreadText instead of IM cards", async () => {
628
+ const result = await sendText({
629
+ cfg: emptyConfig,
630
+ to: "comment:docx:doxcn123:7623358762119646411",
631
+ text: "```ts\nconst x = 1\n```",
632
+ accountId: "main",
633
+ });
634
+
635
+ expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
636
+ expect.anything(),
637
+ expect.objectContaining({
638
+ file_token: "doxcn123",
639
+ file_type: "docx",
640
+ comment_id: "7623358762119646411",
641
+ content: "```ts\nconst x = 1\n```",
642
+ }),
643
+ );
644
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
645
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
646
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" }));
647
+ });
648
+
649
+ it("routes comment-thread replies through deliverCommentThreadText even when renderMode=card", async () => {
650
+ const result = await sendText({
651
+ cfg: cardRenderConfig,
652
+ to: "comment:docx:doxcn123:7623358762119646411",
653
+ text: "handled in thread",
654
+ accountId: "main",
655
+ });
656
+
657
+ expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
658
+ expect.anything(),
659
+ expect.objectContaining({
660
+ file_token: "doxcn123",
661
+ file_type: "docx",
662
+ comment_id: "7623358762119646411",
663
+ content: "handled in thread",
664
+ }),
665
+ );
666
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
667
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
668
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" }));
669
+ });
670
+
671
+ it("falls back to a text-only comment reply for media payloads", async () => {
672
+ const result = await feishuOutbound.sendMedia?.({
673
+ cfg: emptyConfig,
674
+ to: "comment:docx:doxcn123:7623358762119646411",
675
+ text: "see attachment",
676
+ mediaUrl: "https://example.com/file.png",
677
+ accountId: "main",
678
+ });
679
+
680
+ expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
681
+ expect.anything(),
682
+ expect.objectContaining({
683
+ content: "see attachment\n\nhttps://example.com/file.png",
684
+ }),
685
+ );
686
+ expect(sendMediaFeishuMock).not.toHaveBeenCalled();
687
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" }));
688
+ });
689
+
690
+ it("preserves comment-thread routing when deliverCommentThreadText falls back to add_comment", async () => {
691
+ deliverCommentThreadTextMock.mockResolvedValueOnce({
692
+ delivery_mode: "add_comment",
693
+ comment_id: "comment_msg",
694
+ reply_id: "reply_from_add_comment",
695
+ });
696
+
697
+ const result = await sendText({
698
+ cfg: emptyConfig,
699
+ to: "comment:docx:doxcn123:7623358762119646411",
700
+ text: "whole-comment follow-up",
701
+ accountId: "main",
702
+ });
703
+
704
+ expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
705
+ expect.anything(),
706
+ expect.objectContaining({
707
+ file_token: "doxcn123",
708
+ file_type: "docx",
709
+ comment_id: "7623358762119646411",
710
+ content: "whole-comment follow-up",
711
+ }),
712
+ );
713
+ expect(result).toEqual(
714
+ expect.objectContaining({
715
+ channel: "feishu",
716
+ messageId: "reply_from_add_comment",
717
+ }),
718
+ );
719
+ });
720
+
721
+ it("does not wait for ambient comment typing cleanup before sending comment-thread replies", async () => {
722
+ let resolveCleanup: ((value: boolean) => void) | undefined;
723
+ cleanupAmbientCommentTypingReactionMock.mockImplementationOnce(
724
+ () =>
725
+ new Promise<boolean>((resolve) => {
726
+ resolveCleanup = resolve;
727
+ }),
728
+ );
729
+
730
+ const sendPromise = sendText({
731
+ cfg: emptyConfig,
732
+ to: "comment:docx:doxcn123:7623358762119646411",
733
+ text: "handled in thread",
734
+ replyToId: "reply_ambient_1",
735
+ accountId: "main",
736
+ });
737
+
738
+ const status = await Promise.race([
739
+ sendPromise.then(() => "done"),
740
+ new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 0)),
741
+ ]);
742
+
743
+ expect(status).toBe("done");
744
+ expect(deliverCommentThreadTextMock).toHaveBeenCalled();
745
+ expect(cleanupAmbientCommentTypingReactionMock).toHaveBeenCalledWith({
746
+ client: expect.anything(),
747
+ deliveryContext: {
748
+ channel: "feishu",
749
+ to: "comment:docx:doxcn123:7623358762119646411",
750
+ threadId: "reply_ambient_1",
751
+ },
752
+ });
753
+
754
+ resolveCleanup?.(false);
755
+ await sendPromise;
756
+ });
757
+ });
758
+
186
759
  describe("feishuOutbound.sendText replyToId forwarding", () => {
187
760
  beforeEach(() => {
188
761
  resetOutboundMocks();
@@ -190,7 +763,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
190
763
 
191
764
  it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => {
192
765
  await sendText({
193
- cfg: {} as any,
766
+ cfg: emptyConfig,
194
767
  to: "chat_1",
195
768
  text: "hello",
196
769
  replyToId: "om_reply_target",
@@ -207,22 +780,16 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
207
780
  );
208
781
  });
209
782
 
210
- it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => {
783
+ it("forwards replyToId to sendStructuredCardFeishu when renderMode=card", async () => {
211
784
  await sendText({
212
- cfg: {
213
- channels: {
214
- feishu: {
215
- renderMode: "card",
216
- },
217
- },
218
- } as any,
785
+ cfg: cardRenderConfig,
219
786
  to: "chat_1",
220
787
  text: "```code```",
221
788
  replyToId: "om_reply_target",
222
789
  accountId: "main",
223
790
  });
224
791
 
225
- expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
792
+ expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
226
793
  expect.objectContaining({
227
794
  replyToMessageId: "om_reply_target",
228
795
  }),
@@ -231,7 +798,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
231
798
 
232
799
  it("does not pass replyToMessageId when replyToId is absent", async () => {
233
800
  await sendText({
234
- cfg: {} as any,
801
+ cfg: emptyConfig,
235
802
  to: "chat_1",
236
803
  text: "hello",
237
804
  accountId: "main",
@@ -255,7 +822,7 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
255
822
 
256
823
  it("forwards replyToId to sendMediaFeishu", async () => {
257
824
  await feishuOutbound.sendMedia?.({
258
- cfg: {} as any,
825
+ cfg: emptyConfig,
259
826
  to: "chat_1",
260
827
  text: "",
261
828
  mediaUrl: "https://example.com/image.png",
@@ -270,9 +837,27 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
270
837
  );
271
838
  });
272
839
 
840
+ it("forwards audioAsVoice to sendMediaFeishu", async () => {
841
+ await feishuOutbound.sendMedia?.({
842
+ cfg: emptyConfig,
843
+ to: "chat_1",
844
+ text: "",
845
+ mediaUrl: "https://example.com/reply.mp3",
846
+ audioAsVoice: true,
847
+ accountId: "main",
848
+ });
849
+
850
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
851
+ expect.objectContaining({
852
+ mediaUrl: "https://example.com/reply.mp3",
853
+ audioAsVoice: true,
854
+ }),
855
+ );
856
+ });
857
+
273
858
  it("forwards replyToId to text caption send", async () => {
274
859
  await feishuOutbound.sendMedia?.({
275
- cfg: {} as any,
860
+ cfg: emptyConfig,
276
861
  to: "chat_1",
277
862
  text: "caption text",
278
863
  mediaUrl: "https://example.com/image.png",
@@ -295,13 +880,7 @@ describe("feishuOutbound.sendMedia renderMode", () => {
295
880
 
296
881
  it("uses markdown cards for captions when renderMode=card", async () => {
297
882
  const result = await feishuOutbound.sendMedia?.({
298
- cfg: {
299
- channels: {
300
- feishu: {
301
- renderMode: "card",
302
- },
303
- },
304
- } as any,
883
+ cfg: cardRenderConfig,
305
884
  to: "chat_1",
306
885
  text: "| a | b |\n| - | - |",
307
886
  mediaUrl: "https://example.com/image.png",
@@ -328,13 +907,13 @@ describe("feishuOutbound.sendMedia renderMode", () => {
328
907
 
329
908
  it("uses threadId fallback as replyToMessageId on sendMedia", async () => {
330
909
  await feishuOutbound.sendMedia?.({
331
- cfg: {} as any,
910
+ cfg: emptyConfig,
332
911
  to: "chat_1",
333
912
  text: "caption",
334
913
  mediaUrl: "https://example.com/image.png",
335
914
  threadId: "om_thread_1",
336
915
  accountId: "main",
337
- } as any);
916
+ });
338
917
 
339
918
  expect(sendMediaFeishuMock).toHaveBeenCalledWith(
340
919
  expect.objectContaining({