@openclaw/feishu 2026.3.12 → 2026.5.1-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 (188) 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 +1653 -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 +115 -22
  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 +798 -786
  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 +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -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 +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +77 -25
  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 +76 -35
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  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 +413 -87
  91. package/src/media.ts +488 -154
  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 +220 -313
  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 +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +122 -118
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +23 -60
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
@@ -1,9 +1,27 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
1
+ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
4
+ import {
5
+ FeishuRetryableCardActionError,
6
+ handleFeishuCardAction,
7
+ resetProcessedFeishuCardActionTokensForTests,
8
+ type FeishuCardActionEvent,
9
+ } from "./card-action.js";
10
+ import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
11
+ import {
12
+ expectFirstSentCardUsesFillWidthOnly,
13
+ expectSentCardHasP2pAction,
14
+ } from "./card-test-helpers.js";
15
+ import {
16
+ FEISHU_APPROVAL_CANCEL_ACTION,
17
+ FEISHU_APPROVAL_CONFIRM_ACTION,
18
+ FEISHU_APPROVAL_REQUEST_ACTION,
19
+ } from "./card-ux-approval.js";
3
20
 
4
- // Mock resolveFeishuAccount
21
+ // Mock account resolution
5
22
  vi.mock("./accounts.js", () => ({
6
23
  resolveFeishuAccount: vi.fn().mockReturnValue({ accountId: "mock-account" }),
24
+ resolveFeishuRuntimeAccount: vi.fn().mockReturnValue({ accountId: "mock-account" }),
7
25
  }));
8
26
 
9
27
  // Mock bot.js to verify handleFeishuMessage call
@@ -11,11 +29,87 @@ vi.mock("./bot.js", () => ({
11
29
  handleFeishuMessage: vi.fn(),
12
30
  }));
13
31
 
32
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
33
+ const sendCardFeishuMock = vi.hoisted(() => vi.fn());
34
+ const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
35
+
36
+ vi.mock("./client.js", () => ({
37
+ createFeishuClient: createFeishuClientMock,
38
+ }));
39
+
40
+ vi.mock("./send.js", () => ({
41
+ sendCardFeishu: sendCardFeishuMock,
42
+ sendMessageFeishu: sendMessageFeishuMock,
43
+ }));
44
+
14
45
  import { handleFeishuMessage } from "./bot.js";
15
46
 
16
47
  describe("Feishu Card Action Handler", () => {
17
- const cfg = {} as any; // Minimal mock
18
- const runtime = { log: vi.fn(), error: vi.fn() } as any;
48
+ const cfg: ClawdbotConfig = {};
49
+ const runtime: RuntimeEnv = createRuntimeEnv();
50
+
51
+ function createCardActionEvent(params: {
52
+ token: string;
53
+ actionValue: Record<string, unknown>;
54
+ chatId?: string;
55
+ openId?: string;
56
+ userId?: string;
57
+ unionId?: string;
58
+ }): FeishuCardActionEvent {
59
+ const openId = params.openId ?? "u123";
60
+ const userId = params.userId ?? "uid1";
61
+ return {
62
+ operator: { open_id: openId, user_id: userId, union_id: params.unionId ?? "un1" },
63
+ token: params.token,
64
+ action: {
65
+ value: params.actionValue,
66
+ tag: "button",
67
+ },
68
+ context: { open_id: openId, user_id: userId, chat_id: params.chatId ?? "chat1" },
69
+ };
70
+ }
71
+
72
+ function createStructuredQuickActionEvent(params: {
73
+ token: string;
74
+ action: string;
75
+ command?: string;
76
+ chatId?: string;
77
+ chatType?: "group" | "p2p";
78
+ operatorOpenId?: string;
79
+ actionOpenId?: string;
80
+ }): FeishuCardActionEvent {
81
+ return createCardActionEvent({
82
+ token: params.token,
83
+ chatId: params.chatId,
84
+ openId: params.operatorOpenId,
85
+ actionValue: createFeishuCardInteractionEnvelope({
86
+ k: "quick",
87
+ a: params.action,
88
+ ...(params.command ? { q: params.command } : {}),
89
+ c: {
90
+ u: params.actionOpenId ?? params.operatorOpenId ?? "u123",
91
+ h: params.chatId ?? "chat1",
92
+ t: params.chatType ?? "group",
93
+ e: Date.now() + 60_000,
94
+ },
95
+ }),
96
+ });
97
+ }
98
+
99
+ beforeEach(() => {
100
+ vi.clearAllMocks();
101
+ createFeishuClientMock.mockReset().mockReturnValue({
102
+ im: {
103
+ chat: {
104
+ get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }),
105
+ },
106
+ },
107
+ });
108
+ vi.mocked(handleFeishuMessage)
109
+ .mockReset()
110
+ .mockResolvedValue(undefined as never);
111
+ resetProcessedFeishuCardActionTokensForTests();
112
+ });
19
113
 
20
114
  it("handles card action with text payload", async () => {
21
115
  const event: FeishuCardActionEvent = {
@@ -60,4 +154,424 @@ describe("Feishu Card Action Handler", () => {
60
154
  }),
61
155
  );
62
156
  });
157
+
158
+ it("routes quick command actions with operator and conversation context", async () => {
159
+ const event = createStructuredQuickActionEvent({
160
+ token: "tok3",
161
+ action: "feishu.quick_actions.help",
162
+ command: "/help",
163
+ });
164
+
165
+ await handleFeishuCardAction({ cfg, event, runtime });
166
+
167
+ expect(handleFeishuMessage).toHaveBeenCalledWith(
168
+ expect.objectContaining({
169
+ event: expect.objectContaining({
170
+ sender: expect.objectContaining({
171
+ sender_id: expect.objectContaining({
172
+ open_id: "u123",
173
+ user_id: "uid1",
174
+ union_id: "un1",
175
+ }),
176
+ }),
177
+ message: expect.objectContaining({
178
+ chat_id: "chat1",
179
+ content: '{"text":"/help"}',
180
+ }),
181
+ }),
182
+ }),
183
+ );
184
+ });
185
+
186
+ it("opens an approval card for metadata actions", async () => {
187
+ const event: FeishuCardActionEvent = {
188
+ operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
189
+ token: "tok4",
190
+ action: {
191
+ value: createFeishuCardInteractionEnvelope({
192
+ k: "meta",
193
+ a: FEISHU_APPROVAL_REQUEST_ACTION,
194
+ m: {
195
+ command: "/new",
196
+ prompt: "Start a fresh session?",
197
+ },
198
+ c: {
199
+ u: "u123",
200
+ h: "chat1",
201
+ t: "group",
202
+ s: "agent:codex:feishu:chat:chat1",
203
+ e: Date.now() + 60_000,
204
+ },
205
+ }),
206
+ tag: "button",
207
+ },
208
+ context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
209
+ };
210
+
211
+ await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
212
+
213
+ expect(sendCardFeishuMock).toHaveBeenCalledWith(
214
+ expect.objectContaining({
215
+ to: "chat:chat1",
216
+ accountId: "main",
217
+ card: expect.objectContaining({
218
+ config: expect.objectContaining({
219
+ width_mode: "fill",
220
+ }),
221
+ header: expect.objectContaining({
222
+ title: expect.objectContaining({ content: "Confirm action" }),
223
+ }),
224
+ body: expect.objectContaining({
225
+ elements: expect.arrayContaining([
226
+ expect.objectContaining({
227
+ tag: "action",
228
+ actions: expect.arrayContaining([
229
+ expect.objectContaining({
230
+ value: expect.objectContaining({
231
+ c: expect.objectContaining({
232
+ u: "u123",
233
+ h: "chat1",
234
+ t: "group",
235
+ s: "agent:codex:feishu:chat:chat1",
236
+ }),
237
+ }),
238
+ }),
239
+ ]),
240
+ }),
241
+ ]),
242
+ }),
243
+ }),
244
+ }),
245
+ );
246
+ expectFirstSentCardUsesFillWidthOnly(sendCardFeishuMock);
247
+ expect(handleFeishuMessage).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it("runs approval confirmation through the normal message path", async () => {
251
+ const event = createStructuredQuickActionEvent({
252
+ token: "tok5",
253
+ action: FEISHU_APPROVAL_CONFIRM_ACTION,
254
+ command: "/new",
255
+ });
256
+
257
+ await handleFeishuCardAction({ cfg, event, runtime });
258
+
259
+ expect(handleFeishuMessage).toHaveBeenCalledWith(
260
+ expect.objectContaining({
261
+ event: expect.objectContaining({
262
+ message: expect.objectContaining({
263
+ content: '{"text":"/new"}',
264
+ }),
265
+ }),
266
+ }),
267
+ );
268
+ });
269
+
270
+ it("safely rejects stale structured actions", async () => {
271
+ const event = createCardActionEvent({
272
+ token: "tok6",
273
+ actionValue: createFeishuCardInteractionEnvelope({
274
+ k: "quick",
275
+ a: "feishu.quick_actions.help",
276
+ q: "/help",
277
+ c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 },
278
+ }),
279
+ });
280
+
281
+ await handleFeishuCardAction({ cfg, event, runtime });
282
+
283
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
284
+ expect.objectContaining({
285
+ to: "chat:chat1",
286
+ text: expect.stringContaining("expired"),
287
+ }),
288
+ );
289
+ expect(handleFeishuMessage).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("safely rejects wrong-user structured actions", async () => {
293
+ const event = createStructuredQuickActionEvent({
294
+ token: "tok7",
295
+ action: "feishu.quick_actions.help",
296
+ command: "/help",
297
+ operatorOpenId: "u999",
298
+ actionOpenId: "u123",
299
+ });
300
+
301
+ await handleFeishuCardAction({ cfg, event, runtime });
302
+
303
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
304
+ expect.objectContaining({
305
+ text: expect.stringContaining("different user"),
306
+ }),
307
+ );
308
+ expect(handleFeishuMessage).not.toHaveBeenCalled();
309
+ });
310
+
311
+ it("sends a lightweight cancellation notice", async () => {
312
+ const event: FeishuCardActionEvent = {
313
+ operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
314
+ token: "tok8",
315
+ action: {
316
+ value: createFeishuCardInteractionEnvelope({
317
+ k: "button",
318
+ a: FEISHU_APPROVAL_CANCEL_ACTION,
319
+ c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
320
+ }),
321
+ tag: "button",
322
+ },
323
+ context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
324
+ };
325
+
326
+ await handleFeishuCardAction({ cfg, event, runtime });
327
+
328
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
329
+ expect.objectContaining({
330
+ to: "chat:chat1",
331
+ text: "Cancelled.",
332
+ }),
333
+ );
334
+ });
335
+
336
+ it("preserves p2p callbacks for DM quick actions", async () => {
337
+ const event = createStructuredQuickActionEvent({
338
+ token: "tok9",
339
+ action: "feishu.quick_actions.help",
340
+ command: "/help",
341
+ chatId: "p2p-chat-1",
342
+ chatType: "p2p",
343
+ });
344
+
345
+ await handleFeishuCardAction({ cfg, event, runtime });
346
+
347
+ expect(handleFeishuMessage).toHaveBeenCalledWith(
348
+ expect.objectContaining({
349
+ event: expect.objectContaining({
350
+ message: expect.objectContaining({
351
+ chat_id: "p2p-chat-1",
352
+ chat_type: "p2p",
353
+ }),
354
+ }),
355
+ }),
356
+ );
357
+ });
358
+
359
+ it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
360
+ createFeishuClientMock.mockReturnValueOnce({
361
+ im: {
362
+ chat: {
363
+ get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }),
364
+ },
365
+ },
366
+ });
367
+ const event = createCardActionEvent({
368
+ token: "tok9b",
369
+ chatId: "oc_dm_chat_123",
370
+ actionValue: { text: "/help" },
371
+ });
372
+
373
+ await handleFeishuCardAction({ cfg, event, runtime });
374
+
375
+ expect(handleFeishuMessage).toHaveBeenCalledWith(
376
+ expect.objectContaining({
377
+ event: expect.objectContaining({
378
+ message: expect.objectContaining({
379
+ chat_id: "oc_dm_chat_123",
380
+ chat_type: "p2p",
381
+ }),
382
+ }),
383
+ }),
384
+ );
385
+ expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
386
+ });
387
+
388
+ it("uses resolved DM chat type when building approval cards without stored context", async () => {
389
+ createFeishuClientMock.mockReturnValueOnce({
390
+ im: {
391
+ chat: {
392
+ get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }),
393
+ },
394
+ },
395
+ });
396
+ const event = createCardActionEvent({
397
+ token: "tok9c",
398
+ chatId: "oc_dm_chat_234",
399
+ actionValue: createFeishuCardInteractionEnvelope({
400
+ k: "meta",
401
+ a: FEISHU_APPROVAL_REQUEST_ACTION,
402
+ m: {
403
+ command: "/new",
404
+ prompt: "Start a fresh session?",
405
+ },
406
+ c: {
407
+ u: "u123",
408
+ h: "oc_dm_chat_234",
409
+ e: Date.now() + 60_000,
410
+ },
411
+ }),
412
+ });
413
+
414
+ await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
415
+
416
+ expectSentCardHasP2pAction(sendCardFeishuMock);
417
+ expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
418
+ });
419
+
420
+ it("falls back to p2p when Feishu chat API returns an error", async () => {
421
+ createFeishuClientMock.mockReturnValueOnce({
422
+ im: {
423
+ chat: {
424
+ get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }),
425
+ },
426
+ },
427
+ });
428
+ const event = createCardActionEvent({
429
+ token: "tok9d",
430
+ chatId: "oc_unknown_chat_456",
431
+ actionValue: { text: "/help" },
432
+ });
433
+
434
+ await handleFeishuCardAction({ cfg, event, runtime });
435
+
436
+ expect(handleFeishuMessage).toHaveBeenCalledWith(
437
+ expect.objectContaining({
438
+ event: expect.objectContaining({
439
+ message: expect.objectContaining({
440
+ chat_type: "p2p",
441
+ }),
442
+ }),
443
+ }),
444
+ );
445
+ });
446
+
447
+ it("falls back to p2p when Feishu chat API throws", async () => {
448
+ createFeishuClientMock.mockReturnValueOnce({
449
+ im: {
450
+ chat: {
451
+ get: vi.fn().mockRejectedValue(new Error("network failure")),
452
+ },
453
+ },
454
+ });
455
+ const event = createCardActionEvent({
456
+ token: "tok9e",
457
+ chatId: "oc_broken_chat_789",
458
+ actionValue: { text: "/help" },
459
+ });
460
+
461
+ await handleFeishuCardAction({ cfg, event, runtime });
462
+
463
+ expect(handleFeishuMessage).toHaveBeenCalledWith(
464
+ expect.objectContaining({
465
+ event: expect.objectContaining({
466
+ message: expect.objectContaining({
467
+ chat_type: "p2p",
468
+ }),
469
+ }),
470
+ }),
471
+ );
472
+ });
473
+
474
+ it("drops duplicate structured callback tokens", async () => {
475
+ const event = createStructuredQuickActionEvent({
476
+ token: "tok10",
477
+ action: "feishu.quick_actions.help",
478
+ command: "/help",
479
+ });
480
+
481
+ await handleFeishuCardAction({ cfg, event, runtime });
482
+ await handleFeishuCardAction({ cfg, event, runtime });
483
+
484
+ expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
485
+ });
486
+
487
+ it("rejects empty callback tokens before dispatch", async () => {
488
+ const log = vi.fn();
489
+ const event = createStructuredQuickActionEvent({
490
+ token: " ",
491
+ action: "feishu.quick_actions.help",
492
+ command: "/help",
493
+ });
494
+
495
+ await handleFeishuCardAction({
496
+ cfg,
497
+ event,
498
+ runtime: {
499
+ ...runtime,
500
+ log,
501
+ },
502
+ });
503
+
504
+ expect(handleFeishuMessage).not.toHaveBeenCalled();
505
+ expect(log).toHaveBeenCalledWith(
506
+ "feishu[mock-account]: rejected card action from u123: missing token",
507
+ );
508
+ });
509
+
510
+ it("keeps a claimed token completed after a non-retryable dispatch failure", async () => {
511
+ const event = createStructuredQuickActionEvent({
512
+ token: "tok11",
513
+ action: "feishu.quick_actions.help",
514
+ command: "/help",
515
+ });
516
+ vi.mocked(handleFeishuMessage)
517
+ .mockRejectedValueOnce(new Error("transient"))
518
+ .mockResolvedValueOnce(undefined as never);
519
+
520
+ await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient");
521
+ await handleFeishuCardAction({ cfg, event, runtime });
522
+
523
+ expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
524
+ });
525
+
526
+ it("releases a claimed token for explicit retryable dispatch failures", async () => {
527
+ const event = createStructuredQuickActionEvent({
528
+ token: "tok11-retryable",
529
+ action: "feishu.quick_actions.help",
530
+ command: "/help",
531
+ });
532
+ vi.mocked(handleFeishuMessage)
533
+ .mockRejectedValueOnce(new FeishuRetryableCardActionError("retry me"))
534
+ .mockResolvedValueOnce(undefined as never);
535
+
536
+ await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("retry me");
537
+ await handleFeishuCardAction({ cfg, event, runtime });
538
+
539
+ expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
540
+ });
541
+
542
+ it("keeps an in-flight token claimed while a slow dispatch is still running", async () => {
543
+ vi.useFakeTimers();
544
+ const event: FeishuCardActionEvent = {
545
+ operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
546
+ token: "tok12",
547
+ action: {
548
+ value: createFeishuCardInteractionEnvelope({
549
+ k: "quick",
550
+ a: "feishu.quick_actions.help",
551
+ q: "/help",
552
+ c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
553
+ }),
554
+ tag: "button",
555
+ },
556
+ context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
557
+ };
558
+
559
+ let resolveDispatch: (() => void) | undefined;
560
+ vi.mocked(handleFeishuMessage).mockImplementation(
561
+ () =>
562
+ new Promise<void>((resolve) => {
563
+ resolveDispatch = resolve;
564
+ }) as never,
565
+ );
566
+
567
+ const first = handleFeishuCardAction({ cfg, event, runtime });
568
+ await vi.advanceTimersByTimeAsync(61_000);
569
+ await handleFeishuCardAction({ cfg, event, runtime });
570
+
571
+ expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
572
+
573
+ resolveDispatch?.();
574
+ await first;
575
+ vi.useRealTimers();
576
+ });
63
577
  });