@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,15 +1,18 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
4
1
  import {
5
2
  createInboundDebouncer,
6
3
  resolveInboundDebounceMs,
7
- } from "../../../src/auto-reply/inbound-debounce.js";
8
- import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
4
+ } from "openclaw/plugin-sdk/channel-inbound-debounce";
5
+ import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
6
+ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
9
9
  import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
10
10
  import * as dedup from "./dedup.js";
11
- import { monitorSingleAccount } from "./monitor.account.js";
12
- import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
11
+ import {
12
+ monitorSingleAccount,
13
+ resolveReactionSyntheticEvent,
14
+ type FeishuReactionCreatedEvent,
15
+ } from "./monitor.account.js";
13
16
  import { setFeishuRuntime } from "./runtime.js";
14
17
  import type { ResolvedFeishuAccount } from "./types.js";
15
18
 
@@ -17,6 +20,7 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?:
17
20
  const createEventDispatcherMock = vi.hoisted(() => vi.fn());
18
21
  const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
19
22
  const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
23
+ const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
20
24
 
21
25
  let handlers: Record<string, (data: unknown) => Promise<void>> = {};
22
26
 
@@ -37,6 +41,10 @@ vi.mock("./monitor.transport.js", () => ({
37
41
  monitorWebhook: monitorWebhookMock,
38
42
  }));
39
43
 
44
+ vi.mock("./thread-bindings.js", () => ({
45
+ createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
46
+ }));
47
+
40
48
  const cfg = {} as ClawdbotConfig;
41
49
 
42
50
  function makeReactionEvent(
@@ -78,6 +86,25 @@ async function resolveReactionWithLookup(params: {
78
86
  });
79
87
  }
80
88
 
89
+ async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) {
90
+ return await resolveReactionSyntheticEvent({
91
+ cfg: params?.cfg ?? cfg,
92
+ accountId: "default",
93
+ event: makeReactionEvent(),
94
+ botOpenId: "ou_bot",
95
+ fetchMessage: async () => ({
96
+ messageId: "om_msg1",
97
+ chatId: "oc_group",
98
+ chatType: "group",
99
+ senderOpenId: "ou_other",
100
+ senderType: "user",
101
+ content: "hello",
102
+ contentType: "text",
103
+ }),
104
+ ...(params?.uuid ? { uuid: params.uuid } : {}),
105
+ });
106
+ }
107
+
81
108
  type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
82
109
 
83
110
  function buildDebounceConfig(): ClawdbotConfig {
@@ -148,11 +175,7 @@ async function setupDebounceMonitor(params?: {
148
175
  await monitorSingleAccount({
149
176
  cfg: buildDebounceConfig(),
150
177
  account: buildDebounceAccount(),
151
- runtime: {
152
- log: vi.fn(),
153
- error: vi.fn(),
154
- exit: vi.fn(),
155
- } as RuntimeEnv,
178
+ runtime: createNonExitingRuntimeEnv(),
156
179
  botOpenIdSource: {
157
180
  kind: "prefetched",
158
181
  botOpenId: params?.botOpenId ?? "ou_bot",
@@ -179,11 +202,23 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
179
202
  return firstParams.event;
180
203
  }
181
204
 
205
+ function expectSingleDispatchedEvent(): FeishuMessageEvent {
206
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
207
+ return getFirstDispatchedEvent();
208
+ }
209
+
210
+ function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") {
211
+ const dispatched = expectSingleDispatchedEvent();
212
+ return {
213
+ dispatched,
214
+ parsed: parseFeishuMessageEvent(dispatched, botOpenId),
215
+ };
216
+ }
217
+
182
218
  function setDedupPassThroughMocks(): void {
183
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
184
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
185
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
186
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
219
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
220
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
221
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
187
222
  }
188
223
 
189
224
  function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
@@ -194,6 +229,24 @@ function createMention(params: { openId: string; name: string; key?: string }):
194
229
  };
195
230
  }
196
231
 
232
+ function createFeishuMonitorRuntime(params?: {
233
+ createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
234
+ resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
235
+ hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
236
+ }): PluginRuntime {
237
+ return {
238
+ channel: {
239
+ debounce: {
240
+ createInboundDebouncer: params?.createInboundDebouncer ?? createInboundDebouncer,
241
+ resolveInboundDebounceMs: params?.resolveInboundDebounceMs ?? resolveInboundDebounceMs,
242
+ },
243
+ text: {
244
+ hasControlCommand: params?.hasControlCommand ?? hasControlCommand,
245
+ },
246
+ },
247
+ } as unknown as PluginRuntime;
248
+ }
249
+
197
250
  async function enqueueDebouncedMessage(
198
251
  onMessage: (data: unknown) => Promise<void>,
199
252
  event: FeishuMessageEvent,
@@ -203,6 +256,12 @@ async function enqueueDebouncedMessage(
203
256
  await Promise.resolve();
204
257
  }
205
258
 
259
+ function setStaleRetryMocks(messageId = "om_old") {
260
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockImplementation(
261
+ async (currentMessageId) => currentMessageId === messageId,
262
+ );
263
+ }
264
+
206
265
  describe("resolveReactionSyntheticEvent", () => {
207
266
  it("filters app self-reactions", async () => {
208
267
  const event = makeReactionEvent({ operator_type: "app" });
@@ -262,28 +321,12 @@ describe("resolveReactionSyntheticEvent", () => {
262
321
  });
263
322
 
264
323
  it("filters reactions on non-bot messages", async () => {
265
- const event = makeReactionEvent();
266
- const result = await resolveReactionSyntheticEvent({
267
- cfg,
268
- accountId: "default",
269
- event,
270
- botOpenId: "ou_bot",
271
- fetchMessage: async () => ({
272
- messageId: "om_msg1",
273
- chatId: "oc_group",
274
- chatType: "group",
275
- senderOpenId: "ou_other",
276
- senderType: "user",
277
- content: "hello",
278
- contentType: "text",
279
- }),
280
- });
324
+ const result = await resolveNonBotReaction();
281
325
  expect(result).toBeNull();
282
326
  });
283
327
 
284
328
  it("allows non-bot reactions when reactionNotifications is all", async () => {
285
- const event = makeReactionEvent();
286
- const result = await resolveReactionSyntheticEvent({
329
+ const result = await resolveNonBotReaction({
287
330
  cfg: {
288
331
  channels: {
289
332
  feishu: {
@@ -291,18 +334,6 @@ describe("resolveReactionSyntheticEvent", () => {
291
334
  },
292
335
  },
293
336
  } as ClawdbotConfig,
294
- accountId: "default",
295
- event,
296
- botOpenId: "ou_bot",
297
- fetchMessage: async () => ({
298
- messageId: "om_msg1",
299
- chatId: "oc_group",
300
- chatType: "group",
301
- senderOpenId: "ou_other",
302
- senderType: "user",
303
- content: "hello",
304
- contentType: "text",
305
- }),
306
337
  uuid: () => "fixed-uuid",
307
338
  });
308
339
  expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
@@ -410,24 +441,68 @@ describe("resolveReactionSyntheticEvent", () => {
410
441
  });
411
442
  });
412
443
 
444
+ describe("monitorSingleAccount lifecycle", () => {
445
+ beforeEach(() => {
446
+ createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
447
+ stop: vi.fn(),
448
+ }));
449
+ createEventDispatcherMock.mockReset().mockReturnValue({
450
+ register: vi.fn(),
451
+ });
452
+ });
453
+
454
+ it("stops the Feishu thread binding manager when the monitor exits", async () => {
455
+ setFeishuRuntime(createFeishuMonitorRuntime());
456
+
457
+ await monitorSingleAccount({
458
+ cfg: buildDebounceConfig(),
459
+ account: buildDebounceAccount(),
460
+ runtime: createNonExitingRuntimeEnv(),
461
+ botOpenIdSource: {
462
+ kind: "prefetched",
463
+ botOpenId: "ou_bot",
464
+ },
465
+ });
466
+
467
+ const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
468
+ | { stop: ReturnType<typeof vi.fn> }
469
+ | undefined;
470
+ expect(manager?.stop).toHaveBeenCalledTimes(1);
471
+ });
472
+
473
+ it("stops the Feishu thread binding manager when setup fails before transport starts", async () => {
474
+ setFeishuRuntime(createFeishuMonitorRuntime());
475
+ createEventDispatcherMock.mockReturnValue({
476
+ get register() {
477
+ throw new Error("register failed");
478
+ },
479
+ });
480
+
481
+ await expect(
482
+ monitorSingleAccount({
483
+ cfg: buildDebounceConfig(),
484
+ account: buildDebounceAccount(),
485
+ runtime: createNonExitingRuntimeEnv(),
486
+ botOpenIdSource: {
487
+ kind: "prefetched",
488
+ botOpenId: "ou_bot",
489
+ },
490
+ }),
491
+ ).rejects.toThrow("register failed");
492
+
493
+ const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
494
+ | { stop: ReturnType<typeof vi.fn> }
495
+ | undefined;
496
+ expect(manager?.stop).toHaveBeenCalledTimes(1);
497
+ });
498
+ });
499
+
413
500
  describe("Feishu inbound debounce regressions", () => {
414
501
  beforeEach(() => {
415
502
  vi.useFakeTimers();
416
503
  handlers = {};
417
504
  handleFeishuMessageMock.mockClear();
418
- setFeishuRuntime(
419
- createPluginRuntimeMock({
420
- channel: {
421
- debounce: {
422
- createInboundDebouncer,
423
- resolveInboundDebounceMs,
424
- },
425
- text: {
426
- hasControlCommand,
427
- },
428
- },
429
- }),
430
- );
505
+ setFeishuRuntime(createFeishuMonitorRuntime());
431
506
  });
432
507
 
433
508
  afterEach(() => {
@@ -457,18 +532,16 @@ describe("Feishu inbound debounce regressions", () => {
457
532
  );
458
533
  await vi.advanceTimersByTimeAsync(25);
459
534
 
460
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
461
- const dispatched = getFirstDispatchedEvent();
535
+ const dispatched = expectSingleDispatchedEvent();
462
536
  const mergedMentions = dispatched.message.mentions ?? [];
463
537
  expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
464
538
  expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
465
539
  });
466
540
 
467
541
  it("passes prefetched botName through to handleFeishuMessage", async () => {
468
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
469
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
470
- vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
471
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
542
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
543
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
544
+ vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
472
545
  const onMessage = await setupDebounceMonitor({ botName: "OpenClaw Bot" });
473
546
 
474
547
  await onMessage(
@@ -517,9 +590,7 @@ describe("Feishu inbound debounce regressions", () => {
517
590
  );
518
591
  await vi.advanceTimersByTimeAsync(25);
519
592
 
520
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
521
- const dispatched = getFirstDispatchedEvent();
522
- const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
593
+ const { dispatched, parsed } = expectParsedFirstDispatchedEvent();
523
594
  expect(parsed.mentionedBot).toBe(true);
524
595
  expect(parsed.mentionTargets).toBeUndefined();
525
596
  const mergedMentions = dispatched.message.mentions ?? [];
@@ -547,19 +618,14 @@ describe("Feishu inbound debounce regressions", () => {
547
618
  );
548
619
  await vi.advanceTimersByTimeAsync(25);
549
620
 
550
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
551
- const dispatched = getFirstDispatchedEvent();
552
- const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
621
+ const { parsed } = expectParsedFirstDispatchedEvent();
553
622
  expect(parsed.mentionedBot).toBe(true);
554
623
  });
555
624
 
556
625
  it("excludes previously processed retries from combined debounce text", async () => {
557
- vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
558
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
559
- vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
560
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
561
- async (messageId) => messageId === "om_old",
562
- );
626
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
627
+ vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
628
+ setStaleRetryMocks();
563
629
  const onMessage = await setupDebounceMonitor();
564
630
 
565
631
  await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
@@ -576,36 +642,72 @@ describe("Feishu inbound debounce regressions", () => {
576
642
  await Promise.resolve();
577
643
  await vi.advanceTimersByTimeAsync(25);
578
644
 
579
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
580
- const dispatched = getFirstDispatchedEvent();
645
+ const dispatched = expectSingleDispatchedEvent();
581
646
  expect(dispatched.message.message_id).toBe("om_new_2");
582
647
  const combined = JSON.parse(dispatched.message.content) as { text?: string };
583
648
  expect(combined.text).toBe("first\nsecond");
584
649
  });
585
650
 
586
651
  it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
587
- const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
588
- vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
589
- vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
590
- vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
591
- async (messageId) => messageId === "om_old",
592
- );
652
+ vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
653
+ const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
654
+ setStaleRetryMocks("om_old_latest_fresh");
593
655
  const onMessage = await setupDebounceMonitor();
594
656
 
595
- await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
657
+ await onMessage(createTextEvent({ messageId: "om_new_latest_fresh", text: "fresh" }));
596
658
  await Promise.resolve();
597
659
  await Promise.resolve();
598
- await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
660
+ await onMessage(createTextEvent({ messageId: "om_old_latest_fresh", text: "stale" }));
599
661
  await Promise.resolve();
600
662
  await Promise.resolve();
601
663
  await vi.advanceTimersByTimeAsync(25);
602
664
 
603
- expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
604
- const dispatched = getFirstDispatchedEvent();
605
- expect(dispatched.message.message_id).toBe("om_new");
665
+ const dispatched = expectSingleDispatchedEvent();
666
+ expect(dispatched.message.message_id).toBe("om_new_latest_fresh");
606
667
  const combined = JSON.parse(dispatched.message.content) as { text?: string };
607
668
  expect(combined.text).toBe("fresh");
608
- expect(recordSpy).toHaveBeenCalledWith("default:om_old");
609
- expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
669
+ expect(recordSpy).toHaveBeenCalledWith("om_old_latest_fresh", "default", expect.any(Function));
670
+ expect(recordSpy).not.toHaveBeenCalledWith(
671
+ "om_new_latest_fresh",
672
+ "default",
673
+ expect.any(Function),
674
+ );
675
+ });
676
+
677
+ it("releases early event dedupe when debounced dispatch fails", async () => {
678
+ setDedupPassThroughMocks();
679
+ const enqueueMock = vi.fn();
680
+ setFeishuRuntime(
681
+ createFeishuMonitorRuntime({
682
+ createInboundDebouncer: <T>(params: { onError?: (err: unknown, items: T[]) => void }) => ({
683
+ enqueue: async (item: T) => {
684
+ enqueueMock(item);
685
+ params.onError?.(new Error("dispatch failed"), [item]);
686
+ },
687
+ flushKey: async () => {},
688
+ }),
689
+ }),
690
+ );
691
+ const onMessage = await setupDebounceMonitor();
692
+ const event = createTextEvent({ messageId: "om_retryable", text: "hello" });
693
+
694
+ await enqueueDebouncedMessage(onMessage, event);
695
+ expect(enqueueMock).toHaveBeenCalledTimes(1);
696
+
697
+ await enqueueDebouncedMessage(onMessage, event);
698
+ expect(enqueueMock).toHaveBeenCalledTimes(2);
699
+ expect(handleFeishuMessageMock).not.toHaveBeenCalled();
700
+ });
701
+
702
+ it("drops duplicate inbound events before they re-enter the debounce pipeline", async () => {
703
+ const onMessage = await setupDebounceMonitor();
704
+ const event = createTextEvent({ messageId: "om_duplicate", text: "hello" });
705
+
706
+ await enqueueDebouncedMessage(onMessage, event);
707
+ await vi.advanceTimersByTimeAsync(25);
708
+ await enqueueDebouncedMessage(onMessage, event);
709
+ await vi.advanceTimersByTimeAsync(25);
710
+
711
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
610
712
  });
611
713
  });
@@ -0,0 +1,190 @@
1
+ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import "./lifecycle.test-support.js";
4
+ import {
5
+ getFeishuLifecycleTestMocks,
6
+ resetFeishuLifecycleTestMocks,
7
+ } from "./lifecycle.test-support.js";
8
+ import {
9
+ createFeishuLifecycleConfig,
10
+ createFeishuLifecycleReplyDispatcher,
11
+ createFeishuTextMessageEvent,
12
+ expectFeishuReplyDispatcherSentFinalReplyOnce,
13
+ expectFeishuReplyPipelineDedupedAcrossReplay,
14
+ expectFeishuReplyPipelineDedupedAfterPostSendFailure,
15
+ installFeishuLifecycleReplyRuntime,
16
+ mockFeishuReplyOnceDispatch,
17
+ restoreFeishuLifecycleStateDir,
18
+ setFeishuLifecycleStateDir,
19
+ setupFeishuMessageReceiveLifecycleHandler,
20
+ } from "./test-support/lifecycle-test-support.js";
21
+
22
+ const {
23
+ createFeishuReplyDispatcherMock,
24
+ dispatchReplyFromConfigMock,
25
+ finalizeInboundContextMock,
26
+ resolveAgentRouteMock,
27
+ withReplyDispatcherMock,
28
+ } = getFeishuLifecycleTestMocks();
29
+
30
+ let lastRuntime = createRuntimeEnv();
31
+ let lifecycleCore: ReturnType<typeof installFeishuLifecycleReplyRuntime>;
32
+ const handleMessageMock = vi.fn();
33
+ const originalStateDir = process.env.OPENCLAW_STATE_DIR;
34
+ const lifecycleConfig = createFeishuLifecycleConfig({
35
+ accountId: "acct-lifecycle",
36
+ appId: "cli_test",
37
+ appSecret: "secret_test",
38
+ accountConfig: {
39
+ groupPolicy: "open",
40
+ groups: {
41
+ oc_group_1: {
42
+ requireMention: false,
43
+ groupSessionScope: "group_topic_sender",
44
+ replyInThread: "enabled",
45
+ },
46
+ },
47
+ },
48
+ });
49
+
50
+ async function setupLifecycleMonitor() {
51
+ lastRuntime = createRuntimeEnv();
52
+ return setupFeishuMessageReceiveLifecycleHandler({
53
+ runtime: lastRuntime,
54
+ core: lifecycleCore,
55
+ cfg: lifecycleConfig,
56
+ accountId: "acct-lifecycle",
57
+ handleMessage: handleMessageMock,
58
+ resolveDebounceText: ({ event }) => {
59
+ const parsed = JSON.parse(event.message.content) as { text?: string };
60
+ return parsed.text ?? "";
61
+ },
62
+ });
63
+ }
64
+
65
+ describe("Feishu reply-once lifecycle", () => {
66
+ beforeEach(() => {
67
+ vi.useRealTimers();
68
+ resetFeishuLifecycleTestMocks();
69
+ handleMessageMock.mockReset();
70
+ lastRuntime = createRuntimeEnv();
71
+ setFeishuLifecycleStateDir("openclaw-feishu-lifecycle");
72
+
73
+ createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
74
+
75
+ resolveAgentRouteMock.mockReturnValue({
76
+ agentId: "main",
77
+ channel: "feishu",
78
+ accountId: "acct-lifecycle",
79
+ sessionKey: "agent:main:feishu:group:oc_group_1",
80
+ mainSessionKey: "agent:main:main",
81
+ matchedBy: "default",
82
+ });
83
+
84
+ mockFeishuReplyOnceDispatch({
85
+ dispatchReplyFromConfigMock,
86
+ replyText: "reply once",
87
+ });
88
+
89
+ withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
90
+ handleMessageMock.mockImplementation(async ({ event }) => {
91
+ const reply = createFeishuReplyDispatcherMock({
92
+ accountId: "acct-lifecycle",
93
+ chatId: event.message.chat_id,
94
+ replyToMessageId: event.message.root_id ?? event.message.message_id,
95
+ replyInThread: true,
96
+ rootId: event.message.root_id,
97
+ });
98
+ try {
99
+ await withReplyDispatcherMock({
100
+ dispatcher: reply.dispatcher,
101
+ onSettled: () => reply.markDispatchIdle(),
102
+ run: () =>
103
+ dispatchReplyFromConfigMock({
104
+ ctx: {
105
+ AccountId: "acct-lifecycle",
106
+ MessageSid: event.message.message_id,
107
+ },
108
+ dispatcher: reply.dispatcher,
109
+ }),
110
+ });
111
+ } catch (err) {
112
+ lastRuntime?.error(`feishu[acct-lifecycle]: failed to dispatch message: ${String(err)}`);
113
+ }
114
+ });
115
+
116
+ lifecycleCore = installFeishuLifecycleReplyRuntime({
117
+ resolveAgentRouteMock,
118
+ finalizeInboundContextMock,
119
+ dispatchReplyFromConfigMock,
120
+ withReplyDispatcherMock,
121
+ storePath: "/tmp/feishu-lifecycle-sessions.json",
122
+ });
123
+ });
124
+
125
+ afterEach(() => {
126
+ vi.useRealTimers();
127
+ restoreFeishuLifecycleStateDir(originalStateDir);
128
+ });
129
+
130
+ it("routes a topic-bound inbound event and emits one reply across duplicate replay", async () => {
131
+ const onMessage = await setupLifecycleMonitor();
132
+ const event = createFeishuTextMessageEvent({
133
+ messageId: "om_lifecycle_once",
134
+ chatId: "oc_group_1",
135
+ rootId: "om_root_topic_1",
136
+ threadId: "omt_topic_1",
137
+ text: "hello from topic",
138
+ });
139
+
140
+ await expectFeishuReplyPipelineDedupedAcrossReplay({
141
+ handler: onMessage,
142
+ event,
143
+ dispatchReplyFromConfigMock,
144
+ createFeishuReplyDispatcherMock,
145
+ });
146
+
147
+ expect(lastRuntime?.error).not.toHaveBeenCalled();
148
+ expect(handleMessageMock).toHaveBeenCalledTimes(1);
149
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
150
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
151
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
152
+ expect.objectContaining({
153
+ accountId: "acct-lifecycle",
154
+ chatId: "oc_group_1",
155
+ replyToMessageId: "om_root_topic_1",
156
+ replyInThread: true,
157
+ rootId: "om_root_topic_1",
158
+ }),
159
+ );
160
+ expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
161
+ });
162
+
163
+ it("does not duplicate delivery when the first attempt fails after sending the reply", async () => {
164
+ const onMessage = await setupLifecycleMonitor();
165
+ const event = createFeishuTextMessageEvent({
166
+ messageId: "om_lifecycle_retry",
167
+ chatId: "oc_group_1",
168
+ rootId: "om_root_topic_1",
169
+ threadId: "omt_topic_1",
170
+ text: "hello from topic",
171
+ });
172
+
173
+ dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => {
174
+ await dispatcher.sendFinalReply({ text: "reply once" });
175
+ throw new Error("post-send failure");
176
+ });
177
+
178
+ await expectFeishuReplyPipelineDedupedAfterPostSendFailure({
179
+ handler: onMessage,
180
+ event,
181
+ dispatchReplyFromConfigMock,
182
+ runtimeErrorMock: lastRuntime?.error as ReturnType<typeof vi.fn>,
183
+ });
184
+
185
+ expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
186
+ expect(handleMessageMock).toHaveBeenCalledTimes(1);
187
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
188
+ expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
189
+ });
190
+ });