@openclaw/feishu 2026.3.13 → 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 +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 +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 +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 +32 -94
  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 +375 -26
  91. package/src/media.ts +434 -88
  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.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +11 -9
  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 +220 -60
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +65 -7
  122. package/src/monitor.webhook-security.test.ts +122 -0
  123. package/src/monitor.webhook.test-helpers.ts +44 -26
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +616 -37
  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 +14 -9
  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 +4 -34
  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 +660 -29
  142. package/src/reply-dispatcher.ts +407 -154
  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 +77 -2
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +399 -86
  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
@@ -0,0 +1,178 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ClawdbotConfig } from "../runtime-api.js";
3
+ import { expectFirstSentCardUsesFillWidthOnly } from "./card-test-helpers.js";
4
+ import { createFeishuBotMenuHandler } from "./monitor.bot-menu-handler.js";
5
+
6
+ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {}));
7
+ const parseFeishuMessageEventMock = vi.hoisted(() => vi.fn());
8
+ const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" })));
9
+ const getMessageFeishuMock = vi.hoisted(() => vi.fn());
10
+
11
+ const originalStateDir = process.env.OPENCLAW_STATE_DIR;
12
+
13
+ vi.mock("./bot.js", () => {
14
+ return {
15
+ handleFeishuMessage: handleFeishuMessageMock,
16
+ parseFeishuMessageEvent: parseFeishuMessageEventMock,
17
+ };
18
+ });
19
+
20
+ vi.mock("./send.js", () => {
21
+ return {
22
+ sendCardFeishu: sendCardFeishuMock,
23
+ getMessageFeishu: getMessageFeishuMock,
24
+ };
25
+ });
26
+
27
+ function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
28
+ return {
29
+ event_key: params.eventKey,
30
+ timestamp: params.timestamp,
31
+ operator: {
32
+ operator_id: {
33
+ open_id: "ou_user1",
34
+ user_id: "user_1",
35
+ union_id: "union_1",
36
+ },
37
+ },
38
+ };
39
+ }
40
+
41
+ async function registerHandlers() {
42
+ return createFeishuBotMenuHandler({
43
+ cfg: {} as ClawdbotConfig,
44
+ accountId: "default",
45
+ runtime: {
46
+ log: vi.fn(),
47
+ error: vi.fn(),
48
+ exit: vi.fn(),
49
+ },
50
+ chatHistories: new Map(),
51
+ fireAndForget: true,
52
+ getBotOpenId: () => "ou_bot",
53
+ getBotName: () => "Bot",
54
+ });
55
+ }
56
+
57
+ describe("Feishu bot menu handler", () => {
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ process.env.OPENCLAW_STATE_DIR = `/tmp/openclaw-feishu-bot-menu-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
61
+ });
62
+
63
+ afterEach(() => {
64
+ if (originalStateDir === undefined) {
65
+ delete process.env.OPENCLAW_STATE_DIR;
66
+ return;
67
+ }
68
+ process.env.OPENCLAW_STATE_DIR = originalStateDir;
69
+ });
70
+
71
+ it("opens the quick-action launcher card at the webhook/event layer", async () => {
72
+ const onBotMenu = await registerHandlers();
73
+
74
+ await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000000" }));
75
+
76
+ expect(sendCardFeishuMock).toHaveBeenCalledWith(
77
+ expect.objectContaining({
78
+ to: "user:ou_user1",
79
+ card: expect.objectContaining({
80
+ config: expect.objectContaining({
81
+ width_mode: "fill",
82
+ }),
83
+ header: expect.objectContaining({
84
+ title: expect.objectContaining({ content: "Quick actions" }),
85
+ }),
86
+ }),
87
+ }),
88
+ );
89
+ expect(handleFeishuMessageMock).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("does not block bot-menu handling on quick-action launcher send", async () => {
93
+ const onBotMenu = await registerHandlers();
94
+ let resolveSend: (() => void) | undefined;
95
+ sendCardFeishuMock.mockImplementationOnce(
96
+ () =>
97
+ new Promise((resolve) => {
98
+ resolveSend = () => resolve({ messageId: "m1", chatId: "c1" });
99
+ }),
100
+ );
101
+
102
+ const pending = onBotMenu(
103
+ createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000001" }),
104
+ );
105
+ let settled = false;
106
+ void pending.finally(() => {
107
+ settled = true;
108
+ });
109
+
110
+ await vi.waitFor(() => {
111
+ expect(settled).toBe(true);
112
+ });
113
+
114
+ resolveSend?.();
115
+ await pending;
116
+ });
117
+
118
+ it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => {
119
+ const onBotMenu = await registerHandlers();
120
+
121
+ await onBotMenu(createBotMenuEvent({ eventKey: "custom-key", timestamp: "1700000000002" }));
122
+
123
+ expect(handleFeishuMessageMock).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ event: expect.objectContaining({
126
+ message: expect.objectContaining({
127
+ content: '{"text":"/menu custom-key"}',
128
+ }),
129
+ }),
130
+ }),
131
+ );
132
+ expect(sendCardFeishuMock).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("falls back to the legacy /menu path when launcher rendering fails", async () => {
136
+ const onBotMenu = await registerHandlers();
137
+ sendCardFeishuMock.mockRejectedValueOnce(new Error("boom"));
138
+
139
+ await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000003" }));
140
+
141
+ await vi.waitFor(() => {
142
+ expect(handleFeishuMessageMock).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ event: expect.objectContaining({
145
+ message: expect.objectContaining({
146
+ content: '{"text":"/menu quick-actions"}',
147
+ }),
148
+ }),
149
+ }),
150
+ );
151
+ });
152
+ expectFirstSentCardUsesFillWidthOnly(sendCardFeishuMock);
153
+ });
154
+
155
+ it("reopens replay for explicit retryable fallback failures", async () => {
156
+ const onBotMenu = await registerHandlers();
157
+ sendCardFeishuMock
158
+ .mockImplementationOnce(async () => {
159
+ throw new Error("boom");
160
+ })
161
+ .mockImplementationOnce(async () => {
162
+ throw new Error("boom");
163
+ });
164
+ handleFeishuMessageMock
165
+ .mockRejectedValueOnce(
166
+ Object.assign(new Error("retry me"), {
167
+ name: "FeishuRetryableSyntheticEventError",
168
+ }),
169
+ )
170
+ .mockResolvedValueOnce(undefined);
171
+
172
+ await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" }));
173
+ await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" }));
174
+
175
+ expect(sendCardFeishuMock).toHaveBeenCalledTimes(2);
176
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
177
+ });
178
+ });
@@ -0,0 +1,264 @@
1
+ import "./lifecycle.test-support.js";
2
+ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
5
+ import { FeishuConfigSchema } from "./config-schema.js";
6
+ import {
7
+ getFeishuLifecycleTestMocks,
8
+ resetFeishuLifecycleTestMocks,
9
+ } from "./lifecycle.test-support.js";
10
+ import {
11
+ createFeishuTextMessageEvent,
12
+ createFeishuLifecycleReplyDispatcher,
13
+ installFeishuLifecycleReplyRuntime,
14
+ mockFeishuReplyOnceDispatch,
15
+ restoreFeishuLifecycleStateDir,
16
+ runFeishuLifecycleSequence,
17
+ setFeishuLifecycleStateDir,
18
+ setupFeishuLifecycleHandler,
19
+ } from "./test-support/lifecycle-test-support.js";
20
+ import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
21
+
22
+ const {
23
+ createEventDispatcherMock,
24
+ createFeishuReplyDispatcherMock,
25
+ dispatchReplyFromConfigMock,
26
+ finalizeInboundContextMock,
27
+ resolveAgentRouteMock,
28
+ resolveBoundConversationMock,
29
+ withReplyDispatcherMock,
30
+ } = getFeishuLifecycleTestMocks();
31
+
32
+ let handlersByAccount = new Map<string, Record<string, (data: unknown) => Promise<void>>>();
33
+ let runtimesByAccount = new Map<string, RuntimeEnv>();
34
+ const originalStateDir = process.env.OPENCLAW_STATE_DIR;
35
+
36
+ function createLifecycleConfig(): ClawdbotConfig {
37
+ return {
38
+ broadcast: {
39
+ oc_broadcast_group: ["susan", "main"],
40
+ },
41
+ agents: {
42
+ list: [{ id: "main" }, { id: "susan" }],
43
+ },
44
+ channels: {
45
+ feishu: {
46
+ enabled: true,
47
+ groupPolicy: "open",
48
+ requireMention: false,
49
+ resolveSenderNames: false,
50
+ accounts: {
51
+ "account-A": {
52
+ enabled: true,
53
+ appId: "cli_a",
54
+ appSecret: "secret_a", // pragma: allowlist secret
55
+ connectionMode: "websocket",
56
+ groupPolicy: "open",
57
+ requireMention: false,
58
+ resolveSenderNames: false,
59
+ groups: {
60
+ oc_broadcast_group: {
61
+ requireMention: false,
62
+ },
63
+ },
64
+ },
65
+ "account-B": {
66
+ enabled: true,
67
+ appId: "cli_b",
68
+ appSecret: "secret_b", // pragma: allowlist secret
69
+ connectionMode: "websocket",
70
+ groupPolicy: "open",
71
+ requireMention: false,
72
+ resolveSenderNames: false,
73
+ groups: {
74
+ oc_broadcast_group: {
75
+ requireMention: false,
76
+ },
77
+ },
78
+ },
79
+ },
80
+ },
81
+ },
82
+ messages: {
83
+ inbound: {
84
+ debounceMs: 0,
85
+ byChannel: {
86
+ feishu: 0,
87
+ },
88
+ },
89
+ },
90
+ } as ClawdbotConfig;
91
+ }
92
+
93
+ function createLifecycleAccount(accountId: "account-A" | "account-B"): ResolvedFeishuAccount {
94
+ const config: FeishuConfig = FeishuConfigSchema.parse({
95
+ enabled: true,
96
+ connectionMode: "websocket",
97
+ groupPolicy: "open",
98
+ requireMention: false,
99
+ resolveSenderNames: false,
100
+ groups: {
101
+ oc_broadcast_group: {
102
+ requireMention: false,
103
+ },
104
+ },
105
+ });
106
+ return {
107
+ accountId,
108
+ selectionSource: "explicit",
109
+ enabled: true,
110
+ configured: true,
111
+ appId: accountId === "account-A" ? "cli_a" : "cli_b",
112
+ appSecret: accountId === "account-A" ? "secret_a" : "secret_b", // pragma: allowlist secret
113
+ domain: "feishu",
114
+ config,
115
+ };
116
+ }
117
+
118
+ async function setupLifecycleMonitor(accountId: "account-A" | "account-B") {
119
+ const runtime = createNonExitingRuntimeEnv();
120
+ runtimesByAccount.set(accountId, runtime);
121
+ return setupFeishuLifecycleHandler({
122
+ createEventDispatcherMock,
123
+ onRegister: (registered) => {
124
+ handlersByAccount.set(accountId, registered);
125
+ },
126
+ runtime,
127
+ cfg: createLifecycleConfig(),
128
+ account: createLifecycleAccount(accountId),
129
+ handlerKey: "im.message.receive_v1",
130
+ missingHandlerMessage: `missing im.message.receive_v1 handler for ${accountId}`,
131
+ once: true,
132
+ });
133
+ }
134
+
135
+ describe("Feishu broadcast reply-once lifecycle", () => {
136
+ beforeEach(() => {
137
+ vi.useRealTimers();
138
+ resetFeishuLifecycleTestMocks();
139
+ handlersByAccount = new Map();
140
+ runtimesByAccount = new Map();
141
+ setFeishuLifecycleStateDir("openclaw-feishu-broadcast");
142
+
143
+ createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
144
+
145
+ resolveBoundConversationMock.mockReturnValue(null);
146
+ resolveAgentRouteMock.mockReturnValue({
147
+ agentId: "main",
148
+ channel: "feishu",
149
+ accountId: "account-A",
150
+ sessionKey: "agent:main:feishu:group:oc_broadcast_group",
151
+ mainSessionKey: "agent:main:main",
152
+ matchedBy: "default",
153
+ });
154
+
155
+ mockFeishuReplyOnceDispatch({
156
+ dispatchReplyFromConfigMock,
157
+ replyText: "broadcast reply once",
158
+ shouldSendFinalReply: (ctx) =>
159
+ typeof (ctx as { SessionKey?: string } | undefined)?.SessionKey === "string" &&
160
+ (ctx as { SessionKey: string }).SessionKey.includes("agent:main:"),
161
+ });
162
+
163
+ withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
164
+
165
+ installFeishuLifecycleReplyRuntime({
166
+ resolveAgentRouteMock,
167
+ finalizeInboundContextMock,
168
+ dispatchReplyFromConfigMock,
169
+ withReplyDispatcherMock,
170
+ storePath: "/tmp/feishu-broadcast-sessions.json",
171
+ });
172
+ });
173
+
174
+ afterEach(() => {
175
+ vi.useRealTimers();
176
+ restoreFeishuLifecycleStateDir(originalStateDir);
177
+ });
178
+
179
+ it("uses one active reply path when the same broadcast event reaches two accounts", async () => {
180
+ const onMessageA = await setupLifecycleMonitor("account-A");
181
+ const onMessageB = await setupLifecycleMonitor("account-B");
182
+ const event = createFeishuTextMessageEvent({
183
+ messageId: "om_broadcast_once",
184
+ chatId: "oc_broadcast_group",
185
+ text: "hello broadcast",
186
+ });
187
+
188
+ await runFeishuLifecycleSequence(
189
+ [() => onMessageA(event), () => onMessageB(event)],
190
+ [
191
+ () => {
192
+ expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0);
193
+ },
194
+ () => {
195
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
196
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
197
+ },
198
+ ],
199
+ );
200
+
201
+ expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled();
202
+ expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled();
203
+
204
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
205
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
206
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
207
+ expect.objectContaining({
208
+ accountId: "account-a",
209
+ chatId: "oc_broadcast_group",
210
+ replyToMessageId: "om_broadcast_once",
211
+ }),
212
+ );
213
+
214
+ const sessionKeys = finalizeInboundContextMock.mock.calls.map(
215
+ (call) => (call[0] as { SessionKey?: string }).SessionKey,
216
+ );
217
+ expect(sessionKeys).toContain("agent:main:feishu:group:oc_broadcast_group");
218
+ expect(sessionKeys).toContain("agent:susan:feishu:group:oc_broadcast_group");
219
+
220
+ const activeDispatcher = createFeishuReplyDispatcherMock.mock.results[0]?.value.dispatcher as {
221
+ sendFinalReply: ReturnType<typeof vi.fn>;
222
+ };
223
+ expect(activeDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
224
+ });
225
+
226
+ it("does not duplicate delivery after a post-send failure on the first account", async () => {
227
+ const onMessageA = await setupLifecycleMonitor("account-A");
228
+ const onMessageB = await setupLifecycleMonitor("account-B");
229
+ const event = createFeishuTextMessageEvent({
230
+ messageId: "om_broadcast_retry",
231
+ chatId: "oc_broadcast_group",
232
+ text: "hello broadcast",
233
+ });
234
+
235
+ dispatchReplyFromConfigMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
236
+ if (typeof ctx?.SessionKey === "string" && ctx.SessionKey.includes("agent:susan:")) {
237
+ return { queuedFinal: false, counts: { final: 0 } };
238
+ }
239
+ await dispatcher.sendFinalReply({ text: "broadcast reply once" });
240
+ throw new Error("post-send failure");
241
+ });
242
+
243
+ await runFeishuLifecycleSequence(
244
+ [() => onMessageA(event), () => onMessageB(event)],
245
+ [
246
+ () => {
247
+ expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0);
248
+ },
249
+ () => {
250
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
251
+ },
252
+ ],
253
+ );
254
+
255
+ expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled();
256
+ expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled();
257
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
258
+
259
+ const activeDispatcher = createFeishuReplyDispatcherMock.mock.results[0]?.value.dispatcher as {
260
+ sendFinalReply: ReturnType<typeof vi.fn>;
261
+ };
262
+ expect(activeDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
263
+ });
264
+ });