@actagent/feishu 2026.6.2

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 (207) hide show
  1. package/README.md +11 -0
  2. package/actagent.plugin.json +224 -0
  3. package/api.ts +33 -0
  4. package/channel-entry.ts +21 -0
  5. package/channel-plugin-api.ts +2 -0
  6. package/contract-api.ts +17 -0
  7. package/index.ts +83 -0
  8. package/legacy-state-migrations-api.ts +2 -0
  9. package/npm-shrinkwrap.json +539 -0
  10. package/package.json +64 -0
  11. package/runtime-api.ts +58 -0
  12. package/runtime-setter-api.ts +3 -0
  13. package/secret-contract-api.ts +6 -0
  14. package/security-contract-api.ts +2 -0
  15. package/session-key-api.ts +2 -0
  16. package/setup-api.ts +4 -0
  17. package/setup-entry.test.ts +33 -0
  18. package/setup-entry.ts +25 -0
  19. package/skills/feishu-doc/SKILL.md +211 -0
  20. package/skills/feishu-doc/references/block-types.md +103 -0
  21. package/skills/feishu-drive/SKILL.md +97 -0
  22. package/skills/feishu-perm/SKILL.md +119 -0
  23. package/skills/feishu-wiki/SKILL.md +113 -0
  24. package/src/accounts.test.ts +481 -0
  25. package/src/accounts.ts +380 -0
  26. package/src/agent-config.ts +22 -0
  27. package/src/app-registration.test.ts +62 -0
  28. package/src/app-registration.ts +355 -0
  29. package/src/approval-auth.test.ts +25 -0
  30. package/src/approval-auth.ts +26 -0
  31. package/src/async.test.ts +68 -0
  32. package/src/async.ts +109 -0
  33. package/src/audio-preflight.runtime.ts +10 -0
  34. package/src/bitable.test.ts +174 -0
  35. package/src/bitable.ts +781 -0
  36. package/src/bot-content.ts +488 -0
  37. package/src/bot-group-name.test.ts +148 -0
  38. package/src/bot-runtime-api.ts +13 -0
  39. package/src/bot-sender-name.test.ts +68 -0
  40. package/src/bot-sender-name.ts +137 -0
  41. package/src/bot.broadcast.test.ts +643 -0
  42. package/src/bot.card-action.test.ts +647 -0
  43. package/src/bot.checkBotMentioned.test.ts +266 -0
  44. package/src/bot.helpers.test.ts +136 -0
  45. package/src/bot.stripBotMention.test.ts +127 -0
  46. package/src/bot.test.ts +3817 -0
  47. package/src/bot.ts +1788 -0
  48. package/src/card-action.ts +515 -0
  49. package/src/card-interaction.test.ts +132 -0
  50. package/src/card-interaction.ts +160 -0
  51. package/src/card-test-helpers.ts +55 -0
  52. package/src/card-ux-approval.ts +66 -0
  53. package/src/card-ux-launcher.test.ts +126 -0
  54. package/src/card-ux-launcher.ts +136 -0
  55. package/src/card-ux-shared.ts +34 -0
  56. package/src/channel-runtime-api.ts +17 -0
  57. package/src/channel.runtime.ts +48 -0
  58. package/src/channel.test.ts +1337 -0
  59. package/src/channel.ts +1401 -0
  60. package/src/chat-schema.ts +30 -0
  61. package/src/chat.test.ts +295 -0
  62. package/src/chat.ts +198 -0
  63. package/src/client-timeout.ts +44 -0
  64. package/src/client.test.ts +463 -0
  65. package/src/client.ts +263 -0
  66. package/src/comment-dispatcher-runtime-api.ts +7 -0
  67. package/src/comment-dispatcher.test.ts +186 -0
  68. package/src/comment-dispatcher.ts +108 -0
  69. package/src/comment-handler-runtime-api.ts +4 -0
  70. package/src/comment-handler.test.ts +588 -0
  71. package/src/comment-handler.ts +304 -0
  72. package/src/comment-reaction.test.ts +139 -0
  73. package/src/comment-reaction.ts +260 -0
  74. package/src/comment-shared.test.ts +184 -0
  75. package/src/comment-shared.ts +405 -0
  76. package/src/comment-target.ts +45 -0
  77. package/src/config-schema.test.ts +327 -0
  78. package/src/config-schema.ts +338 -0
  79. package/src/conversation-id.test.ts +19 -0
  80. package/src/conversation-id.ts +199 -0
  81. package/src/dedup-migrations.test.ts +90 -0
  82. package/src/dedup-migrations.ts +103 -0
  83. package/src/dedup.test.ts +95 -0
  84. package/src/dedup.ts +304 -0
  85. package/src/dedupe-key.ts +68 -0
  86. package/src/directory.static.ts +62 -0
  87. package/src/directory.test.ts +142 -0
  88. package/src/directory.ts +125 -0
  89. package/src/doc-schema.ts +183 -0
  90. package/src/doctor.test.ts +382 -0
  91. package/src/doctor.ts +876 -0
  92. package/src/docx-batch-insert.test.ts +117 -0
  93. package/src/docx-batch-insert.ts +223 -0
  94. package/src/docx-color-text.ts +154 -0
  95. package/src/docx-table-ops.test.ts +54 -0
  96. package/src/docx-table-ops.ts +316 -0
  97. package/src/docx-types.ts +39 -0
  98. package/src/docx.account-selection.test.ts +96 -0
  99. package/src/docx.test.ts +706 -0
  100. package/src/docx.ts +1598 -0
  101. package/src/drive-schema.ts +93 -0
  102. package/src/drive.test.ts +1240 -0
  103. package/src/drive.ts +830 -0
  104. package/src/dynamic-agent.test.ts +156 -0
  105. package/src/dynamic-agent.ts +144 -0
  106. package/src/event-types.ts +46 -0
  107. package/src/external-keys.test.ts +21 -0
  108. package/src/external-keys.ts +20 -0
  109. package/src/lifecycle.test-support.ts +223 -0
  110. package/src/media.test.ts +956 -0
  111. package/src/media.ts +1106 -0
  112. package/src/mention-target.types.ts +6 -0
  113. package/src/mention.ts +115 -0
  114. package/src/message-action-contract.ts +14 -0
  115. package/src/monitor-state-runtime-api.ts +8 -0
  116. package/src/monitor-transport-runtime-api.ts +11 -0
  117. package/src/monitor.account.ts +501 -0
  118. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +215 -0
  119. package/src/monitor.bot-identity.ts +87 -0
  120. package/src/monitor.bot-menu-handler.ts +164 -0
  121. package/src/monitor.bot-menu.lifecycle.test-support.ts +221 -0
  122. package/src/monitor.bot-menu.test.ts +200 -0
  123. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +265 -0
  124. package/src/monitor.card-action.lifecycle.test-support.ts +418 -0
  125. package/src/monitor.cleanup.test.ts +384 -0
  126. package/src/monitor.comment-notice-handler.ts +106 -0
  127. package/src/monitor.comment.test.ts +968 -0
  128. package/src/monitor.comment.ts +1386 -0
  129. package/src/monitor.lifecycle.test.ts +5 -0
  130. package/src/monitor.message-handler.ts +346 -0
  131. package/src/monitor.reaction.test.ts +770 -0
  132. package/src/monitor.startup.test.ts +232 -0
  133. package/src/monitor.startup.ts +76 -0
  134. package/src/monitor.state.defaults.test.ts +47 -0
  135. package/src/monitor.state.ts +171 -0
  136. package/src/monitor.synthetic-error.ts +19 -0
  137. package/src/monitor.test-mocks.ts +47 -0
  138. package/src/monitor.transport.ts +451 -0
  139. package/src/monitor.ts +104 -0
  140. package/src/monitor.webhook-e2e.test.ts +284 -0
  141. package/src/monitor.webhook-security.test.ts +394 -0
  142. package/src/monitor.webhook.test-helpers.ts +138 -0
  143. package/src/outbound-runtime-api.ts +2 -0
  144. package/src/outbound.test.ts +1255 -0
  145. package/src/outbound.ts +742 -0
  146. package/src/perm-schema.ts +53 -0
  147. package/src/perm.ts +171 -0
  148. package/src/pins.ts +109 -0
  149. package/src/policy.test.ts +224 -0
  150. package/src/policy.ts +322 -0
  151. package/src/post.test.ts +106 -0
  152. package/src/post.ts +276 -0
  153. package/src/presentation-card.ts +204 -0
  154. package/src/probe.test.ts +310 -0
  155. package/src/probe.ts +181 -0
  156. package/src/processing-claims.ts +60 -0
  157. package/src/qr-terminal.ts +2 -0
  158. package/src/reactions.ts +124 -0
  159. package/src/reasoning-preview.test.ts +114 -0
  160. package/src/reasoning-preview.ts +29 -0
  161. package/src/reply-dispatcher-runtime-api.ts +8 -0
  162. package/src/reply-dispatcher.test.ts +2009 -0
  163. package/src/reply-dispatcher.ts +865 -0
  164. package/src/runtime.ts +10 -0
  165. package/src/secret-contract.ts +146 -0
  166. package/src/secret-input.ts +2 -0
  167. package/src/security-audit-shared.ts +70 -0
  168. package/src/security-audit.test.ts +60 -0
  169. package/src/security-audit.ts +2 -0
  170. package/src/send-result.ts +81 -0
  171. package/src/send-target.test.ts +87 -0
  172. package/src/send-target.ts +36 -0
  173. package/src/send.reply-fallback.test.ts +418 -0
  174. package/src/send.test.ts +661 -0
  175. package/src/send.ts +860 -0
  176. package/src/sequential-key.test.ts +73 -0
  177. package/src/sequential-key.ts +29 -0
  178. package/src/sequential-queue.test.ts +184 -0
  179. package/src/sequential-queue.ts +90 -0
  180. package/src/session-conversation.ts +42 -0
  181. package/src/session-route.ts +49 -0
  182. package/src/setup-core.ts +52 -0
  183. package/src/setup-surface.test.ts +485 -0
  184. package/src/setup-surface.ts +620 -0
  185. package/src/streaming-card.test.ts +549 -0
  186. package/src/streaming-card.ts +611 -0
  187. package/src/subagent-hooks.test.ts +632 -0
  188. package/src/subagent-hooks.ts +414 -0
  189. package/src/targets.ts +98 -0
  190. package/src/test-support/lifecycle-test-support.ts +459 -0
  191. package/src/thread-bindings.test.ts +181 -0
  192. package/src/thread-bindings.ts +332 -0
  193. package/src/tool-account-routing.test.ts +419 -0
  194. package/src/tool-account.test.ts +45 -0
  195. package/src/tool-account.ts +98 -0
  196. package/src/tool-factory-test-harness.ts +83 -0
  197. package/src/tool-result.test.ts +33 -0
  198. package/src/tool-result.ts +17 -0
  199. package/src/tools-config.test.ts +52 -0
  200. package/src/tools-config.ts +29 -0
  201. package/src/types.ts +111 -0
  202. package/src/typing.test.ts +145 -0
  203. package/src/typing.ts +215 -0
  204. package/src/wiki-schema.ts +70 -0
  205. package/src/wiki.ts +271 -0
  206. package/subagent-hooks-api.ts +22 -0
  207. package/tsconfig.json +16 -0
@@ -0,0 +1,643 @@
1
+ // Feishu tests cover bot.broadcast plugin behavior.
2
+ import type { EnvelopeFormatOptions } from "actagent/plugin-sdk/channel-inbound";
3
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { ACTAgentBotConfig, PluginRuntime } from "../runtime-api.js";
5
+ import type { FeishuMessageEvent } from "./bot.js";
6
+ import { clearGroupNameCache, handleFeishuMessage } from "./bot.js";
7
+ import { setFeishuRuntime } from "./runtime.js";
8
+
9
+ const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } =
10
+ vi.hoisted(() => ({
11
+ mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({
12
+ dispatcher: {
13
+ sendToolResult: vi.fn(),
14
+ sendBlockReply: vi.fn(),
15
+ sendFinalReply: vi.fn(),
16
+ waitForIdle: vi.fn(),
17
+ getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
18
+ getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
19
+ markComplete: vi.fn(),
20
+ },
21
+ replyOptions: {},
22
+ markDispatchIdle: vi.fn(),
23
+ ensureNoVisibleReplyFallback: vi.fn(),
24
+ })),
25
+ mockCreateFeishuClient: vi.fn(),
26
+ mockResolveAgentRoute: vi.fn(),
27
+ }));
28
+
29
+ vi.mock("./reply-dispatcher.js", () => ({
30
+ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
31
+ }));
32
+
33
+ vi.mock("./client.js", () => ({
34
+ createFeishuClient: mockCreateFeishuClient,
35
+ }));
36
+
37
+ function createRuntimeEnv() {
38
+ return {
39
+ log: vi.fn(),
40
+ error: vi.fn(),
41
+ writeStdout: vi.fn(),
42
+ writeJson: vi.fn(),
43
+ exit: vi.fn((code: number): never => {
44
+ throw new Error(`exit ${code}`);
45
+ }),
46
+ };
47
+ }
48
+
49
+ describe("broadcast dispatch", () => {
50
+ const finalizeInboundContextCalls: Array<Record<string, unknown>> = [];
51
+ const mockGetChatInfo = vi.fn();
52
+ const mockFinalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"] = (
53
+ ctx,
54
+ ) => {
55
+ finalizeInboundContextCalls.push(ctx);
56
+ return {
57
+ ...ctx,
58
+ CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
59
+ CommandTurn: {
60
+ kind: "normal",
61
+ source: "message",
62
+ authorized: false,
63
+ },
64
+ };
65
+ };
66
+ const mockDispatchReplyFromConfig = vi
67
+ .fn()
68
+ .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
69
+ const mockWithReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"] = async ({
70
+ dispatcher,
71
+ run,
72
+ onSettled,
73
+ }) => {
74
+ try {
75
+ return await run();
76
+ } finally {
77
+ dispatcher.markComplete();
78
+ try {
79
+ await dispatcher.waitForIdle();
80
+ } finally {
81
+ await onSettled?.();
82
+ }
83
+ }
84
+ };
85
+ const resolveEnvelopeFormatOptionsMock: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"] =
86
+ () => ({}) satisfies EnvelopeFormatOptions;
87
+ const mockShouldComputeCommandAuthorized = vi.fn(() => false);
88
+ const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
89
+ path: "/tmp/inbound-clip.mp4",
90
+ contentType: "video/mp4",
91
+ });
92
+ const runtimeStub = {
93
+ system: {
94
+ enqueueSystemEvent: vi.fn(),
95
+ },
96
+ channel: {
97
+ routing: {
98
+ resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params),
99
+ },
100
+ session: {
101
+ resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
102
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
103
+ },
104
+ reply: {
105
+ resolveEnvelopeFormatOptions: resolveEnvelopeFormatOptionsMock,
106
+ formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
107
+ finalizeInboundContext:
108
+ mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
109
+ dispatchReplyFromConfig: mockDispatchReplyFromConfig,
110
+ withReplyDispatcher:
111
+ mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
112
+ },
113
+ commands: {
114
+ shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
115
+ resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
116
+ },
117
+ media: {
118
+ saveMediaBuffer: mockSaveMediaBuffer,
119
+ },
120
+ inbound: {
121
+ run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["inbound"]["run"]>[0]) => {
122
+ const input = await params.adapter.ingest(params.raw);
123
+ if (!input) {
124
+ return {
125
+ admission: { kind: "drop" as const, reason: "ingest-null" },
126
+ dispatched: false,
127
+ };
128
+ }
129
+ const eventClass = {
130
+ kind: "message" as const,
131
+ canStartAgentTurn: true,
132
+ };
133
+ const turn = await params.adapter.resolveTurn(input, eventClass, {});
134
+ if (!("runDispatch" in turn)) {
135
+ throw new Error("feishu broadcast test runtime only supports prepared turns");
136
+ }
137
+ await turn.recordInboundSession({
138
+ storePath: turn.storePath,
139
+ sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
140
+ ctx: turn.ctxPayload,
141
+ groupResolution: turn.record?.groupResolution,
142
+ createIfMissing: turn.record?.createIfMissing,
143
+ updateLastRoute: turn.record?.updateLastRoute,
144
+ onRecordError: turn.record?.onRecordError ?? (() => undefined),
145
+ });
146
+ return {
147
+ admission: { kind: "dispatch" as const },
148
+ dispatched: true,
149
+ ctxPayload: turn.ctxPayload,
150
+ routeSessionKey: turn.routeSessionKey,
151
+ dispatchResult: await turn.runDispatch(),
152
+ };
153
+ }),
154
+ },
155
+ pairing: {
156
+ readAllowFromStore: vi.fn().mockResolvedValue([]),
157
+ upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }),
158
+ buildPairingReply: vi.fn(() => "Pairing response"),
159
+ },
160
+ },
161
+ media: {
162
+ detectMime: vi.fn(async () => "application/octet-stream"),
163
+ },
164
+ } as unknown as PluginRuntime;
165
+
166
+ afterAll(() => {
167
+ vi.doUnmock("./reply-dispatcher.js");
168
+ vi.doUnmock("./client.js");
169
+ vi.resetModules();
170
+ });
171
+
172
+ function createBroadcastConfig(): ACTAgentBotConfig {
173
+ return {
174
+ broadcast: { "oc-broadcast-group": ["susan", "main"] },
175
+ agents: { list: [{ id: "main" }, { id: "susan" }] },
176
+ channels: {
177
+ feishu: {
178
+ appId: "cli_test",
179
+ appSecret: "sec_test", // pragma: allowlist secret
180
+ groups: {
181
+ "oc-broadcast-group": {
182
+ requireMention: true,
183
+ },
184
+ },
185
+ },
186
+ },
187
+ };
188
+ }
189
+
190
+ function createBroadcastEvent(options: {
191
+ messageId: string;
192
+ text: string;
193
+ botMentioned?: boolean;
194
+ }): FeishuMessageEvent {
195
+ return {
196
+ sender: { sender_id: { open_id: "ou-sender" } },
197
+ message: {
198
+ message_id: options.messageId,
199
+ chat_id: "oc-broadcast-group",
200
+ chat_type: "group",
201
+ message_type: "text",
202
+ content: JSON.stringify({ text: options.text }),
203
+ ...(options.botMentioned
204
+ ? {
205
+ mentions: [
206
+ {
207
+ key: "@_user_1",
208
+ id: { open_id: "bot-open-id" },
209
+ name: "Bot",
210
+ tenant_key: "",
211
+ },
212
+ ],
213
+ }
214
+ : {}),
215
+ },
216
+ };
217
+ }
218
+
219
+ beforeEach(() => {
220
+ vi.clearAllMocks();
221
+ clearGroupNameCache();
222
+ finalizeInboundContextCalls.length = 0;
223
+ mockResolveAgentRoute.mockReturnValue({
224
+ agentId: "main",
225
+ channel: "feishu",
226
+ accountId: "default",
227
+ sessionKey: "agent:main:feishu:group:oc-broadcast-group",
228
+ mainSessionKey: "agent:main:main",
229
+ lastRoutePolicy: "session",
230
+ matchedBy: "default",
231
+ });
232
+ mockCreateFeishuReplyDispatcher.mockReturnValue({
233
+ dispatcher: {
234
+ sendToolResult: vi.fn(),
235
+ sendBlockReply: vi.fn(),
236
+ sendFinalReply: vi.fn(),
237
+ waitForIdle: vi.fn(),
238
+ getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
239
+ getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
240
+ markComplete: vi.fn(),
241
+ },
242
+ replyOptions: {},
243
+ markDispatchIdle: vi.fn(),
244
+ ensureNoVisibleReplyFallback: vi.fn(),
245
+ });
246
+ mockCreateFeishuClient.mockReturnValue({
247
+ contact: {
248
+ user: {
249
+ get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
250
+ },
251
+ },
252
+ im: {
253
+ chat: {
254
+ get: mockGetChatInfo.mockResolvedValue({
255
+ code: 0,
256
+ data: { name: "Broadcast Team" },
257
+ }),
258
+ },
259
+ },
260
+ });
261
+ setFeishuRuntime(runtimeStub);
262
+ });
263
+
264
+ it("dispatches to all broadcast agents when bot is mentioned", async () => {
265
+ const cfg = createBroadcastConfig();
266
+ const event = createBroadcastEvent({
267
+ messageId: "msg-broadcast-mentioned",
268
+ text: "hello @bot",
269
+ botMentioned: true,
270
+ });
271
+
272
+ await handleFeishuMessage({
273
+ cfg,
274
+ event,
275
+ botOpenId: "bot-open-id",
276
+ runtime: createRuntimeEnv(),
277
+ });
278
+
279
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
280
+ const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey);
281
+ expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
282
+ expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
283
+ const recordCalls = (
284
+ runtimeStub.channel.session.recordInboundSession as unknown as {
285
+ mock: {
286
+ calls: Array<
287
+ [
288
+ {
289
+ updateLastRoute?: {
290
+ sessionKey?: unknown;
291
+ channel?: unknown;
292
+ to?: unknown;
293
+ };
294
+ },
295
+ ]
296
+ >;
297
+ };
298
+ }
299
+ ).mock.calls;
300
+ expect(
301
+ recordCalls
302
+ .map(([call]) => ({
303
+ sessionKey: call.updateLastRoute?.["sessionKey"],
304
+ channel: call.updateLastRoute?.["channel"],
305
+ to: call.updateLastRoute?.["to"],
306
+ }))
307
+ .toSorted((left, right) => String(left.sessionKey).localeCompare(String(right.sessionKey))),
308
+ ).toEqual([
309
+ {
310
+ sessionKey: "agent:main:feishu:group:oc-broadcast-group",
311
+ channel: "feishu",
312
+ to: "chat:oc-broadcast-group",
313
+ },
314
+ {
315
+ sessionKey: "agent:susan:feishu:group:oc-broadcast-group",
316
+ channel: "feishu",
317
+ to: "chat:oc-broadcast-group",
318
+ },
319
+ ]);
320
+ expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
321
+ expect(
322
+ finalizeInboundContextCalls
323
+ .map((call) => ({
324
+ sessionKey: call.SessionKey,
325
+ groupSubject: call.GroupSubject,
326
+ conversationLabel: call.ConversationLabel,
327
+ }))
328
+ .toSorted((left, right) => String(left.sessionKey).localeCompare(String(right.sessionKey))),
329
+ ).toEqual([
330
+ {
331
+ sessionKey: "agent:main:feishu:group:oc-broadcast-group",
332
+ groupSubject: "Broadcast Team",
333
+ conversationLabel: "Broadcast Team",
334
+ },
335
+ {
336
+ sessionKey: "agent:susan:feishu:group:oc-broadcast-group",
337
+ groupSubject: "Broadcast Team",
338
+ conversationLabel: "Broadcast Team",
339
+ },
340
+ ]);
341
+ expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
342
+ const dispatcherParams = mockCreateFeishuReplyDispatcher.mock.calls.at(0)?.[0] as
343
+ | { agentId?: string }
344
+ | undefined;
345
+ expect(dispatcherParams?.agentId).toBe("main");
346
+ });
347
+
348
+ it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
349
+ mockDispatchReplyFromConfig
350
+ .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
351
+ .mockResolvedValueOnce({
352
+ queuedFinal: false,
353
+ counts: { final: 0 },
354
+ noVisibleReplyFallbackEligible: true,
355
+ });
356
+ const ensureNoVisibleReplyFallback = vi.fn();
357
+ mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
358
+ dispatcher: {
359
+ sendToolResult: vi.fn(),
360
+ sendBlockReply: vi.fn(),
361
+ sendFinalReply: vi.fn(),
362
+ waitForIdle: vi.fn(),
363
+ getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
364
+ getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
365
+ markComplete: vi.fn(),
366
+ },
367
+ replyOptions: {},
368
+ markDispatchIdle: vi.fn(),
369
+ ensureNoVisibleReplyFallback,
370
+ });
371
+ const cfg = createBroadcastConfig();
372
+ const event = createBroadcastEvent({
373
+ messageId: "msg-broadcast-zero-final",
374
+ text: "hello @bot",
375
+ botMentioned: true,
376
+ });
377
+
378
+ await handleFeishuMessage({
379
+ cfg,
380
+ event,
381
+ botOpenId: "bot-open-id",
382
+ runtime: createRuntimeEnv(),
383
+ });
384
+
385
+ expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
386
+ "broadcast-dispatch-complete-no-visible-reply",
387
+ );
388
+ });
389
+
390
+ it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
391
+ mockDispatchReplyFromConfig
392
+ .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
393
+ .mockResolvedValueOnce({
394
+ queuedFinal: true,
395
+ counts: { final: 1 },
396
+ });
397
+ const ensureNoVisibleReplyFallback = vi.fn();
398
+ mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
399
+ dispatcher: {
400
+ sendToolResult: vi.fn(),
401
+ sendBlockReply: vi.fn(),
402
+ sendFinalReply: vi.fn(),
403
+ waitForIdle: vi.fn(),
404
+ getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
405
+ getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 1 })),
406
+ markComplete: vi.fn(),
407
+ },
408
+ replyOptions: {},
409
+ markDispatchIdle: vi.fn(),
410
+ ensureNoVisibleReplyFallback,
411
+ });
412
+ const cfg = createBroadcastConfig();
413
+ const event = createBroadcastEvent({
414
+ messageId: "msg-broadcast-final-failed",
415
+ text: "hello @bot",
416
+ botMentioned: true,
417
+ });
418
+
419
+ await handleFeishuMessage({
420
+ cfg,
421
+ event,
422
+ botOpenId: "bot-open-id",
423
+ runtime: createRuntimeEnv(),
424
+ });
425
+
426
+ expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
427
+ "broadcast-dispatch-complete-no-visible-reply",
428
+ );
429
+ });
430
+
431
+ it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
432
+ mockDispatchReplyFromConfig
433
+ .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
434
+ .mockResolvedValueOnce({
435
+ queuedFinal: false,
436
+ counts: { final: 0 },
437
+ sourceReplyDeliveryMode: "message_tool_only",
438
+ noVisibleReplyFallbackEligible: true,
439
+ });
440
+ const ensureNoVisibleReplyFallback = vi.fn();
441
+ mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
442
+ dispatcher: {
443
+ sendToolResult: vi.fn(),
444
+ sendBlockReply: vi.fn(),
445
+ sendFinalReply: vi.fn(),
446
+ waitForIdle: vi.fn(),
447
+ getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
448
+ getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
449
+ markComplete: vi.fn(),
450
+ },
451
+ replyOptions: {},
452
+ markDispatchIdle: vi.fn(),
453
+ ensureNoVisibleReplyFallback,
454
+ });
455
+ const cfg = createBroadcastConfig();
456
+ const event = createBroadcastEvent({
457
+ messageId: "msg-broadcast-source-suppressed",
458
+ text: "hello @bot",
459
+ botMentioned: true,
460
+ });
461
+
462
+ await handleFeishuMessage({
463
+ cfg,
464
+ event,
465
+ botOpenId: "bot-open-id",
466
+ runtime: createRuntimeEnv(),
467
+ });
468
+
469
+ expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
470
+ });
471
+
472
+ it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
473
+ const cfg = createBroadcastConfig();
474
+ const event = createBroadcastEvent({
475
+ messageId: "msg-broadcast-not-mentioned",
476
+ text: "hello everyone",
477
+ });
478
+
479
+ await handleFeishuMessage({
480
+ cfg,
481
+ event,
482
+ botOpenId: "ou_known_bot",
483
+ runtime: createRuntimeEnv(),
484
+ });
485
+
486
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
487
+ expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
488
+ expect(mockGetChatInfo).not.toHaveBeenCalled();
489
+ });
490
+
491
+ it("skips broadcast dispatch when bot identity is unknown (requireMention=true)", async () => {
492
+ const cfg = createBroadcastConfig();
493
+ const event = createBroadcastEvent({
494
+ messageId: "msg-broadcast-unknown-bot-id",
495
+ text: "hello everyone",
496
+ });
497
+
498
+ await handleFeishuMessage({
499
+ cfg,
500
+ event,
501
+ runtime: createRuntimeEnv(),
502
+ });
503
+
504
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
505
+ expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
506
+ expect(mockGetChatInfo).not.toHaveBeenCalled();
507
+ });
508
+
509
+ it("preserves single-agent dispatch when no broadcast config", async () => {
510
+ const cfg: ACTAgentBotConfig = {
511
+ channels: {
512
+ feishu: {
513
+ appId: "cli_test",
514
+ appSecret: "sec_test", // pragma: allowlist secret
515
+ groups: {
516
+ "oc-broadcast-group": {
517
+ requireMention: false,
518
+ },
519
+ },
520
+ },
521
+ },
522
+ };
523
+
524
+ const event: FeishuMessageEvent = {
525
+ sender: { sender_id: { open_id: "ou-sender" } },
526
+ message: {
527
+ message_id: "msg-no-broadcast",
528
+ chat_id: "oc-broadcast-group",
529
+ chat_type: "group",
530
+ message_type: "text",
531
+ content: JSON.stringify({ text: "hello" }),
532
+ },
533
+ };
534
+
535
+ await handleFeishuMessage({
536
+ cfg,
537
+ event,
538
+ runtime: createRuntimeEnv(),
539
+ });
540
+
541
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
542
+ expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
543
+ expect(finalizeInboundContextCalls).toHaveLength(1);
544
+ expect(finalizeInboundContextCalls[0]?.SessionKey).toBe(
545
+ "agent:main:feishu:group:oc-broadcast-group",
546
+ );
547
+ expect(finalizeInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team");
548
+ expect(finalizeInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team");
549
+ expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
550
+ });
551
+
552
+ it("cross-account broadcast dedup: second account skips dispatch", async () => {
553
+ const cfg: ACTAgentBotConfig = {
554
+ broadcast: { "oc-broadcast-group": ["susan", "main"] },
555
+ agents: { list: [{ id: "main" }, { id: "susan" }] },
556
+ channels: {
557
+ feishu: {
558
+ appId: "cli_test",
559
+ appSecret: "sec_test", // pragma: allowlist secret
560
+ groups: {
561
+ "oc-broadcast-group": {
562
+ requireMention: false,
563
+ },
564
+ },
565
+ },
566
+ },
567
+ };
568
+
569
+ const event: FeishuMessageEvent = {
570
+ sender: { sender_id: { open_id: "ou-sender" } },
571
+ message: {
572
+ message_id: "msg-multi-account-dedup",
573
+ chat_id: "oc-broadcast-group",
574
+ chat_type: "group",
575
+ message_type: "text",
576
+ content: JSON.stringify({ text: "hello" }),
577
+ },
578
+ };
579
+
580
+ await handleFeishuMessage({
581
+ cfg,
582
+ event,
583
+ runtime: createRuntimeEnv(),
584
+ accountId: "account-A",
585
+ });
586
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
587
+
588
+ mockDispatchReplyFromConfig.mockClear();
589
+ mockGetChatInfo.mockClear();
590
+ finalizeInboundContextCalls.length = 0;
591
+
592
+ await handleFeishuMessage({
593
+ cfg,
594
+ event,
595
+ runtime: createRuntimeEnv(),
596
+ accountId: "account-B",
597
+ });
598
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
599
+ expect(mockGetChatInfo).not.toHaveBeenCalled();
600
+ });
601
+
602
+ it("skips unknown agents not in agents.list", async () => {
603
+ const cfg: ACTAgentBotConfig = {
604
+ broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] },
605
+ agents: { list: [{ id: "main" }, { id: "susan" }] },
606
+ channels: {
607
+ feishu: {
608
+ appId: "cli_test",
609
+ appSecret: "sec_test", // pragma: allowlist secret
610
+ groups: {
611
+ "oc-broadcast-group": {
612
+ requireMention: false,
613
+ },
614
+ },
615
+ },
616
+ },
617
+ };
618
+
619
+ const event: FeishuMessageEvent = {
620
+ sender: { sender_id: { open_id: "ou-sender" } },
621
+ message: {
622
+ message_id: "msg-broadcast-unknown-agent",
623
+ chat_id: "oc-broadcast-group",
624
+ chat_type: "group",
625
+ message_type: "text",
626
+ content: JSON.stringify({ text: "hello" }),
627
+ },
628
+ };
629
+
630
+ await handleFeishuMessage({
631
+ cfg,
632
+ event,
633
+ runtime: createRuntimeEnv(),
634
+ });
635
+
636
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
637
+ const sessionKey =
638
+ typeof finalizeInboundContextCalls[0]?.SessionKey === "string"
639
+ ? finalizeInboundContextCalls[0].SessionKey
640
+ : "";
641
+ expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
642
+ });
643
+ });