@openclaw/feishu 2026.3.13 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1827 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
package/src/bot.test.ts CHANGED
@@ -1,108 +1,543 @@
1
- import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
1
+ import type * as ConversationRuntime from "openclaw/plugin-sdk/conversation-runtime";
2
+ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
3
+ import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
2
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
5
+ import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
4
6
  import type { FeishuMessageEvent } from "./bot.js";
5
- import {
6
- buildBroadcastSessionKey,
7
- buildFeishuAgentBody,
8
- handleFeishuMessage,
9
- resolveBroadcastAgents,
10
- toMessageResourceType,
11
- } from "./bot.js";
7
+ import { handleFeishuMessage } from "./bot.js";
12
8
  import { setFeishuRuntime } from "./runtime.js";
13
9
 
10
+ type ConfiguredBindingRoute = ReturnType<typeof ConversationRuntime.resolveConfiguredBindingRoute>;
11
+ type BoundConversation = ReturnType<
12
+ ReturnType<typeof ConversationRuntime.getSessionBindingService>["resolveByConversation"]
13
+ >;
14
+ type BindingReadiness = Awaited<
15
+ ReturnType<typeof ConversationRuntime.ensureConfiguredBindingRouteReady>
16
+ >;
17
+ type ReplyDispatcher = Parameters<
18
+ PluginRuntime["channel"]["reply"]["withReplyDispatcher"]
19
+ >[0]["dispatcher"];
20
+ type DeepPartial<T> = {
21
+ [K in keyof T]?: T[K] extends (...args: never[]) => unknown
22
+ ? T[K]
23
+ : T[K] extends ReadonlyArray<unknown>
24
+ ? T[K]
25
+ : T[K] extends object
26
+ ? DeepPartial<T[K]>
27
+ : T[K];
28
+ };
29
+
30
+ function createReplyDispatcher(): ReplyDispatcher {
31
+ return {
32
+ sendToolResult: vi.fn(),
33
+ sendBlockReply: vi.fn(),
34
+ sendFinalReply: vi.fn(),
35
+ waitForIdle: vi.fn(),
36
+ getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
37
+ getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
38
+ markComplete: vi.fn(),
39
+ };
40
+ }
41
+
42
+ function createConfiguredFeishuRoute(): NonNullable<ConfiguredBindingRoute> {
43
+ return {
44
+ bindingResolution: {
45
+ conversation: {
46
+ channel: "feishu",
47
+ accountId: "default",
48
+ conversationId: "ou_sender_1",
49
+ },
50
+ compiledBinding: {
51
+ channel: "feishu",
52
+ accountPattern: "default",
53
+ binding: {
54
+ type: "acp",
55
+ agentId: "codex",
56
+ match: {
57
+ channel: "feishu",
58
+ accountId: "default",
59
+ peer: { kind: "direct", id: "ou_sender_1" },
60
+ },
61
+ },
62
+ bindingConversationId: "ou_sender_1",
63
+ target: {
64
+ conversationId: "ou_sender_1",
65
+ },
66
+ agentId: "codex",
67
+ provider: {
68
+ compileConfiguredBinding: () => ({ conversationId: "ou_sender_1" }),
69
+ matchInboundConversation: () => ({ conversationId: "ou_sender_1" }),
70
+ },
71
+ targetFactory: {
72
+ driverId: "acp",
73
+ materialize: () => ({
74
+ record: {
75
+ bindingId: "config:acp:feishu:default:ou_sender_1",
76
+ targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
77
+ targetKind: "session",
78
+ conversation: {
79
+ channel: "feishu",
80
+ accountId: "default",
81
+ conversationId: "ou_sender_1",
82
+ },
83
+ status: "active",
84
+ boundAt: 0,
85
+ metadata: { source: "config" },
86
+ },
87
+ statefulTarget: {
88
+ kind: "stateful",
89
+ driverId: "acp",
90
+ sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
91
+ agentId: "codex",
92
+ },
93
+ }),
94
+ },
95
+ },
96
+ match: {
97
+ conversationId: "ou_sender_1",
98
+ },
99
+ record: {
100
+ bindingId: "config:acp:feishu:default:ou_sender_1",
101
+ targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
102
+ targetKind: "session",
103
+ conversation: {
104
+ channel: "feishu",
105
+ accountId: "default",
106
+ conversationId: "ou_sender_1",
107
+ },
108
+ status: "active",
109
+ boundAt: 0,
110
+ metadata: { source: "config" },
111
+ },
112
+ statefulTarget: {
113
+ kind: "stateful",
114
+ driverId: "acp",
115
+ sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
116
+ agentId: "codex",
117
+ },
118
+ },
119
+ route: {
120
+ agentId: "codex",
121
+ channel: "feishu",
122
+ accountId: "default",
123
+ sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
124
+ mainSessionKey: "agent:codex:main",
125
+ lastRoutePolicy: "session",
126
+ matchedBy: "binding.channel",
127
+ } as ResolvedAgentRoute,
128
+ };
129
+ }
130
+
131
+ function createConfiguredBindingReadiness(ok: boolean, error?: string): BindingReadiness {
132
+ return (ok ? { ok: true } : { ok: false, error: error ?? "unknown error" }) as BindingReadiness;
133
+ }
134
+
135
+ function createBoundConversation(): NonNullable<BoundConversation> {
136
+ return {
137
+ bindingId: "default:oc_group_chat:topic:om_topic_root",
138
+ targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface",
139
+ targetKind: "session",
140
+ conversation: {
141
+ channel: "feishu",
142
+ accountId: "default",
143
+ conversationId: "oc_group_chat:topic:om_topic_root",
144
+ parentConversationId: "oc_group_chat",
145
+ },
146
+ status: "active",
147
+ boundAt: 0,
148
+ };
149
+ }
150
+
151
+ function buildDefaultResolveRoute(): ResolvedAgentRoute {
152
+ return {
153
+ agentId: "main",
154
+ channel: "feishu",
155
+ accountId: "default",
156
+ sessionKey: "agent:main:feishu:dm:ou-attacker",
157
+ mainSessionKey: "agent:main:main",
158
+ lastRoutePolicy: "session",
159
+ matchedBy: "default",
160
+ };
161
+ }
162
+
163
+ function _createUnboundConfiguredRoute(
164
+ route: NonNullable<ConfiguredBindingRoute>["route"],
165
+ ): ConfiguredBindingRoute {
166
+ return { bindingResolution: null, route };
167
+ }
168
+
169
+ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
170
+ return {
171
+ channel: {
172
+ routing: {
173
+ resolveAgentRoute: resolveAgentRouteMock,
174
+ },
175
+ session: {
176
+ readSessionUpdatedAt: readSessionUpdatedAtMock,
177
+ resolveStorePath: resolveStorePathMock,
178
+ recordInboundSession: vi.fn(async () => undefined),
179
+ },
180
+ reply: {
181
+ resolveEnvelopeFormatOptions:
182
+ resolveEnvelopeFormatOptionsMock as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
183
+ formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
184
+ finalizeInboundContext: finalizeInboundContextMock as never,
185
+ dispatchReplyFromConfig: vi.fn().mockResolvedValue({
186
+ queuedFinal: false,
187
+ counts: { final: 1 },
188
+ }),
189
+ withReplyDispatcher: withReplyDispatcherMock as never,
190
+ },
191
+ commands: {
192
+ shouldComputeCommandAuthorized: vi.fn(() => false),
193
+ resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
194
+ },
195
+ pairing: {
196
+ readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]),
197
+ upsertPairingRequest: vi.fn(),
198
+ buildPairingReply: vi.fn(),
199
+ },
200
+ turn: {
201
+ run: vi.fn(async (params) => {
202
+ const input = await params.adapter.ingest(params.raw);
203
+ const turn = await params.adapter.resolveTurn(input, {
204
+ kind: "message",
205
+ canStartAgentTurn: true,
206
+ });
207
+ return {
208
+ dispatchResult: await turn.runDispatch(),
209
+ };
210
+ }),
211
+ runPrepared: vi.fn(async (params) => ({
212
+ dispatchResult: await params.runDispatch(),
213
+ })),
214
+ },
215
+ ...overrides.channel,
216
+ },
217
+ ...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
218
+ ...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
219
+ } as unknown as PluginRuntime;
220
+ }
221
+
222
+ const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
223
+ mockResolveAgentRoute(params);
224
+ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSessionUpdatedAt"] = (
225
+ params,
226
+ ) => mockReadSessionUpdatedAt(params);
227
+ const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
228
+ mockResolveStorePath(params);
229
+ const resolveEnvelopeFormatOptionsMock = () => ({});
230
+ const finalizeInboundContextMock = (ctx: Record<string, unknown>) => ctx;
231
+ const withReplyDispatcherMock = async ({
232
+ run,
233
+ }: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
234
+
14
235
  const {
15
236
  mockCreateFeishuReplyDispatcher,
16
237
  mockSendMessageFeishu,
17
238
  mockGetMessageFeishu,
239
+ mockListFeishuThreadMessages,
18
240
  mockDownloadMessageResourceFeishu,
19
241
  mockCreateFeishuClient,
20
242
  mockResolveAgentRoute,
243
+ mockReadSessionUpdatedAt,
244
+ mockResolveStorePath,
245
+ mockResolveConfiguredBindingRoute,
246
+ mockEnsureConfiguredBindingRouteReady,
247
+ mockResolveBoundConversation,
248
+ mockTouchBinding,
249
+ mockResolveFeishuReasoningPreviewEnabled,
250
+ mockTranscribeFirstAudio,
21
251
  } = vi.hoisted(() => ({
22
252
  mockCreateFeishuReplyDispatcher: vi.fn(() => ({
23
- dispatcher: vi.fn(),
253
+ dispatcher: createReplyDispatcher(),
24
254
  replyOptions: {},
25
255
  markDispatchIdle: vi.fn(),
26
256
  })),
27
257
  mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
28
258
  mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
259
+ mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
29
260
  mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
30
261
  buffer: Buffer.from("video"),
31
262
  contentType: "video/mp4",
32
263
  fileName: "clip.mp4",
33
264
  }),
34
265
  mockCreateFeishuClient: vi.fn(),
35
- mockResolveAgentRoute: vi.fn(() => ({
36
- agentId: "main",
37
- channel: "feishu",
38
- accountId: "default",
39
- sessionKey: "agent:main:feishu:dm:ou-attacker",
40
- mainSessionKey: "agent:main:main",
41
- matchedBy: "default",
42
- })),
266
+ mockResolveAgentRoute: vi.fn((_params?: unknown) => buildDefaultResolveRoute()),
267
+ mockReadSessionUpdatedAt: vi.fn((_params?: unknown): number | undefined => undefined),
268
+ mockResolveStorePath: vi.fn((_params?: unknown) => "/tmp/feishu-sessions.json"),
269
+ mockResolveConfiguredBindingRoute: vi.fn(
270
+ ({
271
+ route,
272
+ }: {
273
+ route: NonNullable<ConfiguredBindingRoute>["route"];
274
+ }): ConfiguredBindingRoute => ({
275
+ bindingResolution: null,
276
+ route,
277
+ }),
278
+ ),
279
+ mockEnsureConfiguredBindingRouteReady: vi.fn(
280
+ async (_params?: unknown): Promise<BindingReadiness> => ({ ok: true }),
281
+ ),
282
+ mockResolveBoundConversation: vi.fn((_ref?: unknown) => null as BoundConversation),
283
+ mockTouchBinding: vi.fn(),
284
+ mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
285
+ mockTranscribeFirstAudio: vi.fn(),
43
286
  }));
44
287
 
45
288
  vi.mock("./reply-dispatcher.js", () => ({
46
289
  createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
47
290
  }));
48
291
 
292
+ vi.mock("./reasoning-preview.js", () => ({
293
+ resolveFeishuReasoningPreviewEnabled: mockResolveFeishuReasoningPreviewEnabled,
294
+ }));
295
+
49
296
  vi.mock("./send.js", () => ({
50
297
  sendMessageFeishu: mockSendMessageFeishu,
51
298
  getMessageFeishu: mockGetMessageFeishu,
299
+ listFeishuThreadMessages: mockListFeishuThreadMessages,
52
300
  }));
53
301
 
54
302
  vi.mock("./media.js", () => ({
55
303
  downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
56
304
  }));
57
305
 
306
+ vi.mock("./audio-preflight.runtime.js", () => ({
307
+ transcribeFirstAudio: mockTranscribeFirstAudio,
308
+ }));
309
+
58
310
  vi.mock("./client.js", () => ({
59
311
  createFeishuClient: mockCreateFeishuClient,
60
312
  }));
61
313
 
62
- function createRuntimeEnv(): RuntimeEnv {
314
+ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
315
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
316
+ "openclaw/plugin-sdk/conversation-runtime",
317
+ );
63
318
  return {
64
- log: vi.fn(),
65
- error: vi.fn(),
66
- exit: vi.fn((code: number): never => {
67
- throw new Error(`exit ${code}`);
319
+ ...actual,
320
+ resolveConfiguredBindingRoute: (params: unknown) =>
321
+ mockResolveConfiguredBindingRoute(params as { route: ResolvedAgentRoute }),
322
+ resolveRuntimeConversationBindingRoute: (params: {
323
+ route: ResolvedAgentRoute;
324
+ conversation: Parameters<
325
+ ReturnType<typeof actual.getSessionBindingService>["resolveByConversation"]
326
+ >[0];
327
+ }) => {
328
+ const bindingRecord = mockResolveBoundConversation(params.conversation);
329
+ const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
330
+ if (!bindingRecord || !boundSessionKey) {
331
+ return { bindingRecord: null, route: params.route };
332
+ }
333
+ mockTouchBinding(bindingRecord.bindingId);
334
+ return {
335
+ bindingRecord,
336
+ boundSessionKey,
337
+ boundAgentId: params.route.agentId,
338
+ route: {
339
+ ...params.route,
340
+ sessionKey: boundSessionKey,
341
+ lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
342
+ matchedBy: "binding.channel",
343
+ },
344
+ };
345
+ },
346
+ ensureConfiguredBindingRouteReady: (params: unknown) =>
347
+ mockEnsureConfiguredBindingRouteReady(params),
348
+ getSessionBindingService: () => ({
349
+ resolveByConversation: mockResolveBoundConversation,
350
+ touch: mockTouchBinding,
68
351
  }),
69
- } as RuntimeEnv;
70
- }
352
+ };
353
+ });
71
354
 
72
355
  async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
356
+ const runtime = createRuntimeEnv();
357
+ const feishuConfig = params.cfg.channels?.feishu;
358
+ const cfg =
359
+ feishuConfig?.dmPolicy === "open" && feishuConfig.allowFrom === undefined
360
+ ? ({
361
+ ...params.cfg,
362
+ channels: {
363
+ ...params.cfg.channels,
364
+ feishu: {
365
+ ...feishuConfig,
366
+ allowFrom: ["*"],
367
+ },
368
+ },
369
+ } as ClawdbotConfig)
370
+ : params.cfg;
73
371
  await handleFeishuMessage({
74
- cfg: params.cfg,
372
+ cfg,
75
373
  event: params.event,
76
- runtime: createRuntimeEnv(),
374
+ runtime,
77
375
  });
376
+ return runtime;
78
377
  }
79
378
 
80
- describe("buildFeishuAgentBody", () => {
81
- it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
82
- const body = buildFeishuAgentBody({
83
- ctx: {
84
- content: "hello world",
85
- senderName: "Sender Name",
86
- senderOpenId: "ou-sender",
87
- messageId: "msg-42",
88
- mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
89
- },
90
- quotedContent: "previous message",
91
- permissionErrorForAgent: {
92
- code: 99991672,
93
- message: "permission denied",
94
- grantUrl: "https://open.feishu.cn/app/cli_test",
379
+ describe("handleFeishuMessage ACP routing", () => {
380
+ beforeEach(() => {
381
+ vi.clearAllMocks();
382
+ mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
383
+ ({
384
+ route,
385
+ }: {
386
+ route: NonNullable<ConfiguredBindingRoute>["route"];
387
+ }): ConfiguredBindingRoute => ({
388
+ bindingResolution: null,
389
+ route,
390
+ }),
391
+ );
392
+ mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
393
+ mockResolveBoundConversation.mockReset().mockReturnValue(null);
394
+ mockTouchBinding.mockReset();
395
+ mockResolveFeishuReasoningPreviewEnabled.mockReset().mockReturnValue(false);
396
+ mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
397
+ mockResolveAgentRoute.mockReset().mockReturnValue({
398
+ ...buildDefaultResolveRoute(),
399
+ sessionKey: "agent:main:feishu:direct:ou_sender_1",
400
+ });
401
+ mockSendMessageFeishu
402
+ .mockReset()
403
+ .mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" });
404
+ mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({
405
+ dispatcher: createReplyDispatcher(),
406
+ replyOptions: {},
407
+ markDispatchIdle: vi.fn(),
408
+ });
409
+
410
+ setFeishuRuntime(createFeishuBotRuntime());
411
+ });
412
+
413
+ it("ensures configured ACP routes for Feishu DMs", async () => {
414
+ mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
415
+
416
+ await dispatchMessage({
417
+ cfg: {
418
+ session: { mainKey: "main", scope: "per-sender" },
419
+ channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
420
+ },
421
+ event: {
422
+ sender: { sender_id: { open_id: "ou_sender_1" } },
423
+ message: {
424
+ message_id: "msg-1",
425
+ chat_id: "oc_dm",
426
+ chat_type: "p2p",
427
+ message_type: "text",
428
+ content: JSON.stringify({ text: "hello" }),
429
+ },
430
+ },
431
+ });
432
+
433
+ expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
434
+ expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
435
+ });
436
+
437
+ it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
438
+ mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
439
+ mockEnsureConfiguredBindingRouteReady.mockResolvedValue(
440
+ createConfiguredBindingReadiness(false, "runtime unavailable"),
441
+ );
442
+
443
+ await dispatchMessage({
444
+ cfg: {
445
+ session: { mainKey: "main", scope: "per-sender" },
446
+ channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
447
+ },
448
+ event: {
449
+ sender: { sender_id: { open_id: "ou_sender_1" } },
450
+ message: {
451
+ message_id: "msg-2",
452
+ chat_id: "oc_dm",
453
+ chat_type: "p2p",
454
+ message_type: "text",
455
+ content: JSON.stringify({ text: "hello" }),
456
+ },
457
+ },
458
+ });
459
+
460
+ expect(mockSendMessageFeishu).toHaveBeenCalledWith(
461
+ expect.objectContaining({
462
+ to: "chat:oc_dm",
463
+ text: expect.stringContaining("runtime unavailable"),
464
+ }),
465
+ );
466
+ });
467
+
468
+ it("routes Feishu topic messages through active bound conversations", async () => {
469
+ mockResolveBoundConversation.mockReturnValue(createBoundConversation());
470
+
471
+ await dispatchMessage({
472
+ cfg: {
473
+ session: { mainKey: "main", scope: "per-sender" },
474
+ channels: {
475
+ feishu: {
476
+ enabled: true,
477
+ allowFrom: ["ou_sender_1"],
478
+ groups: {
479
+ oc_group_chat: {
480
+ allow: true,
481
+ requireMention: false,
482
+ groupSessionScope: "group_topic",
483
+ },
484
+ },
485
+ },
486
+ },
487
+ },
488
+ event: {
489
+ sender: { sender_id: { open_id: "ou_sender_1" } },
490
+ message: {
491
+ message_id: "msg-3",
492
+ chat_id: "oc_group_chat",
493
+ chat_type: "group",
494
+ message_type: "text",
495
+ root_id: "om_topic_root",
496
+ content: JSON.stringify({ text: "hello topic" }),
497
+ },
498
+ },
499
+ });
500
+
501
+ expect(mockResolveBoundConversation).toHaveBeenCalledWith(
502
+ expect.objectContaining({
503
+ channel: "feishu",
504
+ conversationId: "oc_group_chat:topic:om_topic_root",
505
+ }),
506
+ );
507
+ expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
508
+ });
509
+
510
+ it("passes reasoning preview permission from session state into the dispatcher", async () => {
511
+ mockResolveFeishuReasoningPreviewEnabled.mockReturnValue(true);
512
+
513
+ await dispatchMessage({
514
+ cfg: {
515
+ session: { mainKey: "main", scope: "per-sender" },
516
+ channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
517
+ },
518
+ event: {
519
+ sender: { sender_id: { open_id: "ou_sender_1" } },
520
+ message: {
521
+ message_id: "msg-reasoning",
522
+ chat_id: "oc_dm",
523
+ chat_type: "p2p",
524
+ message_type: "text",
525
+ content: JSON.stringify({ text: "hello" }),
526
+ },
95
527
  },
96
528
  });
97
529
 
98
- expect(body).toBe(
99
- '[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]',
530
+ expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
531
+ expect.objectContaining({ allowReasoningPreview: true }),
100
532
  );
101
533
  });
102
534
  });
103
535
 
104
536
  describe("handleFeishuMessage command authorization", () => {
105
- const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
537
+ const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({
538
+ ...ctx,
539
+ CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
540
+ }));
106
541
  const mockDispatchReplyFromConfig = vi
107
542
  .fn()
108
543
  .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
@@ -140,14 +575,25 @@ describe("handleFeishuMessage command authorization", () => {
140
575
  beforeEach(() => {
141
576
  vi.clearAllMocks();
142
577
  mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
143
- mockResolveAgentRoute.mockReturnValue({
144
- agentId: "main",
145
- channel: "feishu",
146
- accountId: "default",
147
- sessionKey: "agent:main:feishu:dm:ou-attacker",
148
- mainSessionKey: "agent:main:main",
149
- matchedBy: "default",
150
- });
578
+ mockGetMessageFeishu.mockReset().mockResolvedValue(null);
579
+ mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
580
+ mockReadSessionUpdatedAt.mockReturnValue(undefined);
581
+ mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
582
+ mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
583
+ ({
584
+ route,
585
+ }: {
586
+ route: NonNullable<ConfiguredBindingRoute>["route"];
587
+ }): ConfiguredBindingRoute => ({
588
+ bindingResolution: null,
589
+ route,
590
+ }),
591
+ );
592
+ mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
593
+ mockResolveBoundConversation.mockReset().mockReturnValue(null);
594
+ mockTouchBinding.mockReset();
595
+ mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
596
+ mockResolveAgentRoute.mockReturnValue(buildDefaultResolveRoute());
151
597
  mockCreateFeishuClient.mockReturnValue({
152
598
  contact: {
153
599
  user: {
@@ -157,39 +603,31 @@ describe("handleFeishuMessage command authorization", () => {
157
603
  });
158
604
  mockEnqueueSystemEvent.mockReset();
159
605
  setFeishuRuntime(
160
- createPluginRuntimeMock({
606
+ createFeishuBotRuntime({
161
607
  system: {
162
608
  enqueueSystemEvent: mockEnqueueSystemEvent,
163
609
  },
164
610
  channel: {
165
- routing: {
166
- resolveAgentRoute:
167
- mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
168
- },
169
611
  reply: {
170
- resolveEnvelopeFormatOptions: vi.fn(
171
- () => ({}),
172
- ) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
612
+ resolveEnvelopeFormatOptions:
613
+ resolveEnvelopeFormatOptionsMock as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
173
614
  formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
174
- finalizeInboundContext:
175
- mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
615
+ finalizeInboundContext: mockFinalizeInboundContext as never,
176
616
  dispatchReplyFromConfig: mockDispatchReplyFromConfig,
177
- withReplyDispatcher:
178
- mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
617
+ withReplyDispatcher: mockWithReplyDispatcher as never,
179
618
  },
180
619
  commands: {
181
620
  shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
182
621
  resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
183
622
  },
184
- media: {
185
- saveMediaBuffer:
186
- mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
187
- },
188
623
  pairing: {
189
624
  readAllowFromStore: mockReadAllowFromStore,
190
625
  upsertPairingRequest: mockUpsertPairingRequest,
191
626
  buildPairingReply: mockBuildPairingReply,
192
627
  },
628
+ media: {
629
+ saveMediaBuffer: mockSaveMediaBuffer,
630
+ },
193
631
  },
194
632
  media: {
195
633
  detectMime: vi.fn(async () => "application/octet-stream"),
@@ -229,7 +667,7 @@ describe("handleFeishuMessage command authorization", () => {
229
667
  expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
230
668
  });
231
669
 
232
- it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
670
+ it("blocks open DMs when a restrictive allowlist does not match", async () => {
233
671
  const cfg: ClawdbotConfig = {
234
672
  commands: { useAccessGroups: true },
235
673
  channels: {
@@ -257,18 +695,8 @@ describe("handleFeishuMessage command authorization", () => {
257
695
 
258
696
  await dispatchMessage({ cfg, event });
259
697
 
260
- expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
261
- useAccessGroups: true,
262
- authorizers: [{ configured: true, allowed: false }],
263
- });
264
- expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
265
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
266
- expect.objectContaining({
267
- CommandAuthorized: false,
268
- SenderId: "ou-attacker",
269
- Surface: "feishu",
270
- }),
271
- );
698
+ expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
699
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
272
700
  });
273
701
 
274
702
  it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
@@ -387,6 +815,77 @@ describe("handleFeishuMessage command authorization", () => {
387
815
  );
388
816
  });
389
817
 
818
+ it("uses message create_time as Timestamp instead of Date.now()", async () => {
819
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
820
+
821
+ const cfg: ClawdbotConfig = {
822
+ channels: {
823
+ feishu: {
824
+ dmPolicy: "open",
825
+ },
826
+ },
827
+ } as ClawdbotConfig;
828
+
829
+ const event: FeishuMessageEvent = {
830
+ sender: {
831
+ sender_id: {
832
+ open_id: "ou-attacker",
833
+ },
834
+ },
835
+ message: {
836
+ message_id: "msg-create-time",
837
+ chat_id: "oc-dm",
838
+ chat_type: "p2p",
839
+ message_type: "text",
840
+ content: JSON.stringify({ text: "delete this" }),
841
+ create_time: "1700000000000",
842
+ },
843
+ };
844
+
845
+ await dispatchMessage({ cfg, event });
846
+
847
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
848
+ expect.objectContaining({
849
+ Timestamp: 1700000000000,
850
+ }),
851
+ );
852
+ });
853
+
854
+ it("falls back to Date.now() when create_time is absent", async () => {
855
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
856
+
857
+ const cfg: ClawdbotConfig = {
858
+ channels: {
859
+ feishu: {
860
+ dmPolicy: "open",
861
+ },
862
+ },
863
+ } as ClawdbotConfig;
864
+
865
+ const event: FeishuMessageEvent = {
866
+ sender: {
867
+ sender_id: {
868
+ open_id: "ou-attacker",
869
+ },
870
+ },
871
+ message: {
872
+ message_id: "msg-no-create-time",
873
+ chat_id: "oc-dm",
874
+ chat_type: "p2p",
875
+ message_type: "text",
876
+ content: JSON.stringify({ text: "hello" }),
877
+ },
878
+ };
879
+
880
+ const before = Date.now();
881
+ await dispatchMessage({ cfg, event });
882
+ const after = Date.now();
883
+
884
+ const call = mockFinalizeInboundContext.mock.calls[0]?.[0] as { Timestamp: number };
885
+ expect(call.Timestamp).toBeGreaterThanOrEqual(before);
886
+ expect(call.Timestamp).toBeLessThanOrEqual(after);
887
+ });
888
+
390
889
  it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
391
890
  const cfg: ClawdbotConfig = {
392
891
  channels: {
@@ -469,7 +968,14 @@ describe("handleFeishuMessage command authorization", () => {
469
968
  expect(mockSendMessageFeishu).toHaveBeenCalledWith(
470
969
  expect.objectContaining({
471
970
  to: "chat:oc-dm",
472
- text: expect.stringContaining("Pairing code: ABCDEFGH"),
971
+ text: expect.stringContaining("Pairing code:"),
972
+ accountId: "default",
973
+ }),
974
+ );
975
+ expect(mockSendMessageFeishu).toHaveBeenCalledWith(
976
+ expect.objectContaining({
977
+ to: "chat:oc-dm",
978
+ text: expect.stringContaining("ABCDEFGH"),
473
979
  accountId: "default",
474
980
  }),
475
981
  );
@@ -728,13 +1234,26 @@ describe("handleFeishuMessage command authorization", () => {
728
1234
  expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
729
1235
  });
730
1236
 
731
- it("drops message when groupConfig.enabled is false", async () => {
1237
+ it("drops quoted group context from senders outside the group sender allowlist in allowlist mode", async () => {
1238
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1239
+ mockGetMessageFeishu.mockResolvedValueOnce({
1240
+ messageId: "om_parent_blocked",
1241
+ chatId: "oc-group",
1242
+ senderId: "ou-blocked",
1243
+ senderType: "user",
1244
+ content: "blocked quoted content",
1245
+ contentType: "text",
1246
+ });
1247
+
732
1248
  const cfg: ClawdbotConfig = {
733
1249
  channels: {
734
1250
  feishu: {
1251
+ groupPolicy: "open",
1252
+ groupSenderAllowFrom: ["ou-allowed"],
1253
+ contextVisibility: "allowlist",
735
1254
  groups: {
736
- "oc-disabled-group": {
737
- enabled: false,
1255
+ "oc-group": {
1256
+ requireMention: false,
738
1257
  },
739
1258
  },
740
1259
  },
@@ -743,11 +1262,14 @@ describe("handleFeishuMessage command authorization", () => {
743
1262
 
744
1263
  const event: FeishuMessageEvent = {
745
1264
  sender: {
746
- sender_id: { open_id: "ou-sender" },
1265
+ sender_id: {
1266
+ open_id: "ou-allowed",
1267
+ },
747
1268
  },
748
1269
  message: {
749
- message_id: "msg-disabled-group",
750
- chat_id: "oc-disabled-group",
1270
+ message_id: "msg-group-quoted-filter",
1271
+ parent_id: "om_parent_blocked",
1272
+ chat_id: "oc-group",
751
1273
  chat_type: "group",
752
1274
  message_type: "text",
753
1275
  content: JSON.stringify({ text: "hello" }),
@@ -756,17 +1278,35 @@ describe("handleFeishuMessage command authorization", () => {
756
1278
 
757
1279
  await dispatchMessage({ cfg, event });
758
1280
 
759
- expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
760
- expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1281
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1282
+ expect.objectContaining({
1283
+ ReplyToId: "om_parent_blocked",
1284
+ ReplyToBody: undefined,
1285
+ }),
1286
+ );
761
1287
  });
762
1288
 
763
- it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
1289
+ it("keeps quoted group context from non-allowlisted senders in default all mode", async () => {
764
1290
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1291
+ mockGetMessageFeishu.mockResolvedValueOnce({
1292
+ messageId: "om_parent_visible",
1293
+ chatId: "oc-group",
1294
+ senderId: "ou-blocked",
1295
+ senderType: "user",
1296
+ content: "visible quoted content",
1297
+ contentType: "text",
1298
+ });
765
1299
 
766
1300
  const cfg: ClawdbotConfig = {
767
1301
  channels: {
768
1302
  feishu: {
769
- dmPolicy: "open",
1303
+ groupPolicy: "open",
1304
+ groupSenderAllowFrom: ["ou-allowed"],
1305
+ groups: {
1306
+ "oc-group": {
1307
+ requireMention: false,
1308
+ },
1309
+ },
770
1310
  },
771
1311
  },
772
1312
  } as ClawdbotConfig;
@@ -774,16 +1314,367 @@ describe("handleFeishuMessage command authorization", () => {
774
1314
  const event: FeishuMessageEvent = {
775
1315
  sender: {
776
1316
  sender_id: {
777
- open_id: "ou-sender",
1317
+ open_id: "ou-allowed",
778
1318
  },
779
1319
  },
780
1320
  message: {
781
- message_id: "msg-video-inbound",
782
- chat_id: "oc-dm",
783
- chat_type: "p2p",
784
- message_type: "video",
785
- content: JSON.stringify({
786
- file_key: "file_video_payload",
1321
+ message_id: "msg-group-quoted-visible",
1322
+ parent_id: "om_parent_visible",
1323
+ chat_id: "oc-group",
1324
+ chat_type: "group",
1325
+ message_type: "text",
1326
+ content: JSON.stringify({ text: "hello" }),
1327
+ },
1328
+ };
1329
+
1330
+ await dispatchMessage({ cfg, event });
1331
+
1332
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1333
+ expect.objectContaining({
1334
+ ReplyToId: "om_parent_visible",
1335
+ ReplyToBody: "visible quoted content",
1336
+ }),
1337
+ );
1338
+ });
1339
+
1340
+ it("dispatches group image message when groupPolicy is open (requireMention defaults to false)", async () => {
1341
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1342
+
1343
+ const cfg: ClawdbotConfig = {
1344
+ channels: {
1345
+ feishu: {
1346
+ groupPolicy: "open",
1347
+ // requireMention is NOT set — should default to false for open policy
1348
+ },
1349
+ },
1350
+ } as ClawdbotConfig;
1351
+
1352
+ const event: FeishuMessageEvent = {
1353
+ sender: {
1354
+ sender_id: { open_id: "ou-sender" },
1355
+ },
1356
+ message: {
1357
+ message_id: "msg-group-image-open",
1358
+ chat_id: "oc-group-open",
1359
+ chat_type: "group",
1360
+ message_type: "image",
1361
+ content: JSON.stringify({ image_key: "img_v3_test" }),
1362
+ },
1363
+ };
1364
+
1365
+ await dispatchMessage({ cfg, event });
1366
+
1367
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1368
+ });
1369
+
1370
+ it("drops group image message when groupPolicy is open but requireMention is explicitly true", async () => {
1371
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1372
+
1373
+ const cfg: ClawdbotConfig = {
1374
+ channels: {
1375
+ feishu: {
1376
+ groupPolicy: "open",
1377
+ requireMention: true, // explicit override — user opts into mention-required even for open
1378
+ },
1379
+ },
1380
+ } as ClawdbotConfig;
1381
+
1382
+ const event: FeishuMessageEvent = {
1383
+ sender: {
1384
+ sender_id: { open_id: "ou-sender" },
1385
+ },
1386
+ message: {
1387
+ message_id: "msg-group-image-open-explicit-mention",
1388
+ chat_id: "oc-group-open",
1389
+ chat_type: "group",
1390
+ message_type: "image",
1391
+ content: JSON.stringify({ image_key: "img_v3_test" }),
1392
+ },
1393
+ };
1394
+
1395
+ await dispatchMessage({ cfg, event });
1396
+
1397
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1398
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1399
+ });
1400
+
1401
+ it("drops group image message when groupPolicy is allowlist and requireMention is not set (defaults to true)", async () => {
1402
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1403
+
1404
+ const cfg: ClawdbotConfig = {
1405
+ channels: {
1406
+ feishu: {
1407
+ groupPolicy: "allowlist",
1408
+ // requireMention not set — for non-open policy defaults to true
1409
+ groups: {
1410
+ "oc-allowlist-group": {
1411
+ allow: true,
1412
+ },
1413
+ },
1414
+ },
1415
+ },
1416
+ } as ClawdbotConfig;
1417
+
1418
+ const event: FeishuMessageEvent = {
1419
+ sender: {
1420
+ sender_id: { open_id: "ou-sender" },
1421
+ },
1422
+ message: {
1423
+ message_id: "msg-group-image-allowlist",
1424
+ chat_id: "oc-allowlist-group",
1425
+ chat_type: "group",
1426
+ message_type: "image",
1427
+ content: JSON.stringify({ image_key: "img_v3_test" }),
1428
+ },
1429
+ };
1430
+
1431
+ await dispatchMessage({ cfg, event });
1432
+
1433
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1434
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1435
+ });
1436
+
1437
+ it("admits group when chat_id is explicitly configured under groups, even with empty groupAllowFrom (#67687)", async () => {
1438
+ // Regression for #67687: a group that only sets `groups.<chat_id>.requireMention=false`
1439
+ // (and leaves `groupAllowFrom` empty) should still be admitted under the schema-default
1440
+ // `groupPolicy="allowlist"`. The group's explicit presence in `channels.feishu.groups`
1441
+ // is the operator's allowlist signal, and the per-group `requireMention` override should
1442
+ // then control mention gating for inbound text events.
1443
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1444
+
1445
+ const cfg: ClawdbotConfig = {
1446
+ channels: {
1447
+ feishu: {
1448
+ groupPolicy: "allowlist",
1449
+ // groupAllowFrom intentionally omitted -> empty []
1450
+ groups: {
1451
+ "oc-explicit-group": {
1452
+ requireMention: false,
1453
+ },
1454
+ },
1455
+ },
1456
+ },
1457
+ } as ClawdbotConfig;
1458
+
1459
+ const event: FeishuMessageEvent = {
1460
+ sender: {
1461
+ sender_id: { open_id: "ou-sender" },
1462
+ },
1463
+ message: {
1464
+ message_id: "msg-explicit-group-67687",
1465
+ chat_id: "oc-explicit-group",
1466
+ chat_type: "group",
1467
+ message_type: "text",
1468
+ content: JSON.stringify({ text: "hello bot" }),
1469
+ },
1470
+ };
1471
+
1472
+ await dispatchMessage({ cfg, event });
1473
+
1474
+ // Group must be admitted: the inbound finalize/dispatch path runs.
1475
+ expect(mockFinalizeInboundContext).toHaveBeenCalled();
1476
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalled();
1477
+ });
1478
+
1479
+ it("does not let explicit group config override disabled group policy", async () => {
1480
+ const cfg: ClawdbotConfig = {
1481
+ channels: {
1482
+ feishu: {
1483
+ groupPolicy: "disabled",
1484
+ groups: {
1485
+ "oc-disabled-policy-group": {
1486
+ requireMention: false,
1487
+ },
1488
+ },
1489
+ },
1490
+ },
1491
+ } as ClawdbotConfig;
1492
+
1493
+ const event: FeishuMessageEvent = {
1494
+ sender: {
1495
+ sender_id: { open_id: "ou-sender" },
1496
+ },
1497
+ message: {
1498
+ message_id: "msg-disabled-policy-group",
1499
+ chat_id: "oc-disabled-policy-group",
1500
+ chat_type: "group",
1501
+ message_type: "text",
1502
+ content: JSON.stringify({ text: "hello bot" }),
1503
+ },
1504
+ };
1505
+
1506
+ await dispatchMessage({ cfg, event });
1507
+
1508
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1509
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1510
+ });
1511
+
1512
+ it("does not treat wildcard group defaults as allowlist admission", async () => {
1513
+ const cfg: ClawdbotConfig = {
1514
+ channels: {
1515
+ feishu: {
1516
+ groupPolicy: "allowlist",
1517
+ groups: {
1518
+ "*": {
1519
+ requireMention: false,
1520
+ },
1521
+ },
1522
+ },
1523
+ },
1524
+ } as ClawdbotConfig;
1525
+
1526
+ const event: FeishuMessageEvent = {
1527
+ sender: {
1528
+ sender_id: { open_id: "ou-sender" },
1529
+ },
1530
+ message: {
1531
+ message_id: "msg-wildcard-group-default",
1532
+ chat_id: "oc-wildcard-only",
1533
+ chat_type: "group",
1534
+ message_type: "text",
1535
+ content: JSON.stringify({ text: "hello bot" }),
1536
+ },
1537
+ };
1538
+
1539
+ await dispatchMessage({ cfg, event });
1540
+
1541
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1542
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1543
+ });
1544
+
1545
+ it("drops message when groupConfig.enabled is false", async () => {
1546
+ const cfg: ClawdbotConfig = {
1547
+ channels: {
1548
+ feishu: {
1549
+ groups: {
1550
+ "oc-disabled-group": {
1551
+ enabled: false,
1552
+ },
1553
+ },
1554
+ },
1555
+ },
1556
+ } as ClawdbotConfig;
1557
+
1558
+ const event: FeishuMessageEvent = {
1559
+ sender: {
1560
+ sender_id: { open_id: "ou-sender" },
1561
+ },
1562
+ message: {
1563
+ message_id: "msg-disabled-group",
1564
+ chat_id: "oc-disabled-group",
1565
+ chat_type: "group",
1566
+ message_type: "text",
1567
+ content: JSON.stringify({ text: "hello" }),
1568
+ },
1569
+ };
1570
+
1571
+ await dispatchMessage({ cfg, event });
1572
+
1573
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1574
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1575
+ });
1576
+
1577
+ it("transcribes inbound audio before building the agent turn", async () => {
1578
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1579
+ mockDownloadMessageResourceFeishu.mockResolvedValueOnce({
1580
+ buffer: Buffer.from("voice"),
1581
+ contentType: "audio/ogg",
1582
+ fileName: "voice.ogg",
1583
+ });
1584
+ mockSaveMediaBuffer.mockResolvedValueOnce({
1585
+ id: "inbound-voice.ogg",
1586
+ path: "/tmp/inbound-voice.ogg",
1587
+ size: Buffer.byteLength("voice"),
1588
+ contentType: "audio/ogg",
1589
+ });
1590
+ mockTranscribeFirstAudio.mockResolvedValueOnce("voice transcript");
1591
+
1592
+ const cfg: ClawdbotConfig = {
1593
+ channels: {
1594
+ feishu: {
1595
+ dmPolicy: "open",
1596
+ },
1597
+ },
1598
+ } as ClawdbotConfig;
1599
+
1600
+ const event: FeishuMessageEvent = {
1601
+ sender: {
1602
+ sender_id: {
1603
+ open_id: "ou-voice",
1604
+ },
1605
+ },
1606
+ message: {
1607
+ message_id: "msg-audio-inbound",
1608
+ chat_id: "oc-dm",
1609
+ chat_type: "p2p",
1610
+ message_type: "audio",
1611
+ content: JSON.stringify({
1612
+ file_key: "file_audio_payload",
1613
+ duration: 1200,
1614
+ }),
1615
+ },
1616
+ };
1617
+
1618
+ await dispatchMessage({ cfg, event });
1619
+
1620
+ expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
1621
+ expect.objectContaining({
1622
+ messageId: "msg-audio-inbound",
1623
+ fileKey: "file_audio_payload",
1624
+ type: "file",
1625
+ }),
1626
+ );
1627
+ expect(mockTranscribeFirstAudio).toHaveBeenCalledWith({
1628
+ ctx: {
1629
+ MediaPaths: ["/tmp/inbound-voice.ogg"],
1630
+ MediaTypes: ["audio/ogg"],
1631
+ ChatType: "direct",
1632
+ },
1633
+ cfg: expect.objectContaining({
1634
+ channels: expect.objectContaining({
1635
+ feishu: expect.objectContaining({ dmPolicy: "open" }),
1636
+ }),
1637
+ }),
1638
+ });
1639
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1640
+ expect.objectContaining({
1641
+ BodyForAgent: "[message_id: msg-audio-inbound]\nou-voice: voice transcript",
1642
+ RawBody: "voice transcript",
1643
+ CommandBody: "voice transcript",
1644
+ Transcript: "voice transcript",
1645
+ MediaPaths: ["/tmp/inbound-voice.ogg"],
1646
+ MediaTypes: ["audio/ogg"],
1647
+ MediaTranscribedIndexes: [0],
1648
+ }),
1649
+ );
1650
+ const finalized = mockFinalizeInboundContext.mock.calls[0]?.[0];
1651
+ expect(finalized.BodyForAgent).not.toContain("file_audio_payload");
1652
+ });
1653
+
1654
+ it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
1655
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1656
+
1657
+ const cfg: ClawdbotConfig = {
1658
+ channels: {
1659
+ feishu: {
1660
+ dmPolicy: "open",
1661
+ },
1662
+ },
1663
+ } as ClawdbotConfig;
1664
+
1665
+ const event: FeishuMessageEvent = {
1666
+ sender: {
1667
+ sender_id: {
1668
+ open_id: "ou-sender",
1669
+ },
1670
+ },
1671
+ message: {
1672
+ message_id: "msg-video-inbound",
1673
+ chat_id: "oc-dm",
1674
+ chat_type: "p2p",
1675
+ message_type: "video",
1676
+ content: JSON.stringify({
1677
+ file_key: "file_video_payload",
787
1678
  image_key: "img_thumb_payload",
788
1679
  file_name: "clip.mp4",
789
1680
  }),
@@ -856,6 +1747,51 @@ describe("handleFeishuMessage command authorization", () => {
856
1747
  );
857
1748
  });
858
1749
 
1750
+ it("falls back to the message payload filename when download metadata omits it", async () => {
1751
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1752
+ mockDownloadMessageResourceFeishu.mockResolvedValueOnce({
1753
+ buffer: Buffer.from("video"),
1754
+ contentType: "video/mp4",
1755
+ });
1756
+
1757
+ const cfg: ClawdbotConfig = {
1758
+ channels: {
1759
+ feishu: {
1760
+ dmPolicy: "open",
1761
+ },
1762
+ },
1763
+ } as ClawdbotConfig;
1764
+
1765
+ const event: FeishuMessageEvent = {
1766
+ sender: {
1767
+ sender_id: {
1768
+ open_id: "ou-sender",
1769
+ },
1770
+ },
1771
+ message: {
1772
+ message_id: "msg-media-payload-name",
1773
+ chat_id: "oc-dm",
1774
+ chat_type: "p2p",
1775
+ message_type: "media",
1776
+ content: JSON.stringify({
1777
+ file_key: "file_media_payload",
1778
+ image_key: "img_media_thumb",
1779
+ file_name: "payload-name.mp4",
1780
+ }),
1781
+ },
1782
+ };
1783
+
1784
+ await dispatchMessage({ cfg, event });
1785
+
1786
+ expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
1787
+ expect.any(Buffer),
1788
+ "video/mp4",
1789
+ "inbound",
1790
+ expect.any(Number),
1791
+ "payload-name.mp4",
1792
+ );
1793
+ });
1794
+
859
1795
  it("downloads embedded media tags from post messages as files", async () => {
860
1796
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
861
1797
 
@@ -984,7 +1920,7 @@ describe("handleFeishuMessage command authorization", () => {
984
1920
  get: mockGetMerged,
985
1921
  },
986
1922
  },
987
- });
1923
+ } as unknown as PluginRuntime);
988
1924
 
989
1925
  const cfg: ClawdbotConfig = {
990
1926
  channels: {
@@ -1213,11 +2149,49 @@ describe("handleFeishuMessage command authorization", () => {
1213
2149
  const event: FeishuMessageEvent = {
1214
2150
  sender: { sender_id: { open_id: "ou-scope-user" } },
1215
2151
  message: {
1216
- message_id: "msg-scope-group-sender",
2152
+ message_id: "msg-scope-group-sender",
2153
+ chat_id: "oc-group",
2154
+ chat_type: "group",
2155
+ message_type: "text",
2156
+ content: JSON.stringify({ text: "group sender scope" }),
2157
+ },
2158
+ };
2159
+
2160
+ await dispatchMessage({ cfg, event });
2161
+
2162
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2163
+ expect.objectContaining({
2164
+ peer: { kind: "group", id: "oc-group:sender:ou-scope-user" },
2165
+ parentPeer: null,
2166
+ }),
2167
+ );
2168
+ });
2169
+
2170
+ it("routes topic sessions and parentPeer when groupSessionScope=group_topic_sender", async () => {
2171
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
2172
+
2173
+ const cfg: ClawdbotConfig = {
2174
+ channels: {
2175
+ feishu: {
2176
+ groups: {
2177
+ "oc-group": {
2178
+ requireMention: false,
2179
+ groupSessionScope: "group_topic_sender",
2180
+ },
2181
+ },
2182
+ },
2183
+ },
2184
+ } as ClawdbotConfig;
2185
+
2186
+ const event: FeishuMessageEvent = {
2187
+ sender: { sender_id: { open_id: "ou-topic-user" } },
2188
+ message: {
2189
+ message_id: "msg-scope-topic-sender",
1217
2190
  chat_id: "oc-group",
1218
2191
  chat_type: "group",
2192
+ root_id: "om_root_topic",
1219
2193
  message_type: "text",
1220
- content: JSON.stringify({ text: "group sender scope" }),
2194
+ content: JSON.stringify({ text: "topic sender scope" }),
1221
2195
  },
1222
2196
  };
1223
2197
 
@@ -1225,13 +2199,13 @@ describe("handleFeishuMessage command authorization", () => {
1225
2199
 
1226
2200
  expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1227
2201
  expect.objectContaining({
1228
- peer: { kind: "group", id: "oc-group:sender:ou-scope-user" },
1229
- parentPeer: null,
2202
+ peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
2203
+ parentPeer: { kind: "group", id: "oc-group" },
1230
2204
  }),
1231
2205
  );
1232
2206
  });
1233
2207
 
1234
- it("routes topic sessions and parentPeer when groupSessionScope=group_topic_sender", async () => {
2208
+ it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
1235
2209
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1236
2210
 
1237
2211
  const cfg: ClawdbotConfig = {
@@ -1250,10 +2224,11 @@ describe("handleFeishuMessage command authorization", () => {
1250
2224
  const event: FeishuMessageEvent = {
1251
2225
  sender: { sender_id: { open_id: "ou-topic-user" } },
1252
2226
  message: {
1253
- message_id: "msg-scope-topic-sender",
2227
+ message_id: "msg-scope-topic-thread-id",
1254
2228
  chat_id: "oc-group",
1255
2229
  chat_type: "group",
1256
2230
  root_id: "om_root_topic",
2231
+ thread_id: "omt_topic_1",
1257
2232
  message_type: "text",
1258
2233
  content: JSON.stringify({ text: "topic sender scope" }),
1259
2234
  },
@@ -1269,7 +2244,7 @@ describe("handleFeishuMessage command authorization", () => {
1269
2244
  );
1270
2245
  });
1271
2246
 
1272
- it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
2247
+ it("uses thread_id as the canonical topic key in Feishu topic groups", async () => {
1273
2248
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1274
2249
 
1275
2250
  const cfg: ClawdbotConfig = {
@@ -1278,31 +2253,51 @@ describe("handleFeishuMessage command authorization", () => {
1278
2253
  groups: {
1279
2254
  "oc-group": {
1280
2255
  requireMention: false,
1281
- groupSessionScope: "group_topic_sender",
2256
+ groupSessionScope: "group_topic",
1282
2257
  },
1283
2258
  },
1284
2259
  },
1285
2260
  },
1286
2261
  } as ClawdbotConfig;
1287
2262
 
1288
- const event: FeishuMessageEvent = {
2263
+ const topicStarter: FeishuMessageEvent = {
1289
2264
  sender: { sender_id: { open_id: "ou-topic-user" } },
1290
2265
  message: {
1291
- message_id: "msg-scope-topic-thread-id",
2266
+ message_id: "om_topic_starter_message",
1292
2267
  chat_id: "oc-group",
1293
- chat_type: "group",
1294
- root_id: "om_root_topic",
2268
+ chat_type: "topic_group",
2269
+ root_id: "omt_topic_1",
2270
+ message_type: "text",
2271
+ content: JSON.stringify({ text: "topic starter" }),
2272
+ },
2273
+ };
2274
+ const topicReply: FeishuMessageEvent = {
2275
+ sender: { sender_id: { open_id: "ou-topic-user" } },
2276
+ message: {
2277
+ message_id: "om_topic_reply_message",
2278
+ chat_id: "oc-group",
2279
+ chat_type: "topic_group",
2280
+ root_id: "om_topic_starter_message",
1295
2281
  thread_id: "omt_topic_1",
1296
2282
  message_type: "text",
1297
- content: JSON.stringify({ text: "topic sender scope" }),
2283
+ content: JSON.stringify({ text: "topic reply" }),
1298
2284
  },
1299
2285
  };
1300
2286
 
1301
- await dispatchMessage({ cfg, event });
2287
+ await dispatchMessage({ cfg, event: topicStarter });
2288
+ await dispatchMessage({ cfg, event: topicReply });
1302
2289
 
1303
- expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2290
+ expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
2291
+ 1,
1304
2292
  expect.objectContaining({
1305
- peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
2293
+ peer: { kind: "group", id: "oc-group:topic:omt_topic_1" },
2294
+ parentPeer: { kind: "group", id: "oc-group" },
2295
+ }),
2296
+ );
2297
+ expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
2298
+ 2,
2299
+ expect.objectContaining({
2300
+ peer: { kind: "group", id: "oc-group:topic:omt_topic_1" },
1306
2301
  parentPeer: { kind: "group", id: "oc-group" },
1307
2302
  }),
1308
2303
  );
@@ -1709,280 +2704,241 @@ describe("handleFeishuMessage command authorization", () => {
1709
2704
  );
1710
2705
  });
1711
2706
 
1712
- it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
2707
+ it("bootstraps topic thread context only for a new thread session", async () => {
1713
2708
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
2709
+ mockGetMessageFeishu.mockResolvedValue({
2710
+ messageId: "om_topic_root",
2711
+ chatId: "oc-group",
2712
+ content: "root starter",
2713
+ contentType: "text",
2714
+ threadId: "omt_topic_1",
2715
+ });
2716
+ mockListFeishuThreadMessages.mockResolvedValue([
2717
+ {
2718
+ messageId: "om_bot_reply",
2719
+ senderId: "app_1",
2720
+ senderType: "app",
2721
+ content: "assistant reply",
2722
+ contentType: "text",
2723
+ createTime: 1710000000000,
2724
+ },
2725
+ {
2726
+ messageId: "om_follow_up",
2727
+ senderId: "ou-topic-user",
2728
+ senderType: "user",
2729
+ content: "follow-up question",
2730
+ contentType: "text",
2731
+ createTime: 1710000001000,
2732
+ },
2733
+ ]);
1714
2734
 
1715
2735
  const cfg: ClawdbotConfig = {
1716
2736
  channels: {
1717
2737
  feishu: {
1718
- dmPolicy: "open",
2738
+ groups: {
2739
+ "oc-group": {
2740
+ requireMention: false,
2741
+ groupSessionScope: "group_topic",
2742
+ },
2743
+ },
1719
2744
  },
1720
2745
  },
1721
2746
  } as ClawdbotConfig;
1722
2747
 
1723
2748
  const event: FeishuMessageEvent = {
1724
- sender: {
1725
- sender_id: {
1726
- open_id: "ou-image-dedup",
1727
- },
1728
- },
2749
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1729
2750
  message: {
1730
- message_id: "msg-image-dedup",
1731
- chat_id: "oc-dm",
1732
- chat_type: "p2p",
1733
- message_type: "image",
1734
- content: JSON.stringify({
1735
- image_key: "img_dedup_payload",
1736
- }),
2751
+ message_id: "om_topic_followup_existing_session",
2752
+ root_id: "om_topic_root",
2753
+ chat_id: "oc-group",
2754
+ chat_type: "group",
2755
+ message_type: "text",
2756
+ content: JSON.stringify({ text: "current turn" }),
1737
2757
  },
1738
2758
  };
1739
2759
 
1740
- await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
1741
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1742
- });
1743
- });
1744
-
1745
- describe("toMessageResourceType", () => {
1746
- it("maps image to image", () => {
1747
- expect(toMessageResourceType("image")).toBe("image");
1748
- });
1749
-
1750
- it("maps audio to file", () => {
1751
- expect(toMessageResourceType("audio")).toBe("file");
1752
- });
1753
-
1754
- it("maps video/file/sticker to file", () => {
1755
- expect(toMessageResourceType("video")).toBe("file");
1756
- expect(toMessageResourceType("file")).toBe("file");
1757
- expect(toMessageResourceType("sticker")).toBe("file");
1758
- });
1759
- });
1760
-
1761
- describe("resolveBroadcastAgents", () => {
1762
- it("returns agent list when broadcast config has the peerId", () => {
1763
- const cfg = { broadcast: { oc_group123: ["susan", "main"] } } as unknown as ClawdbotConfig;
1764
- expect(resolveBroadcastAgents(cfg, "oc_group123")).toEqual(["susan", "main"]);
1765
- });
1766
-
1767
- it("returns null when no broadcast config", () => {
1768
- const cfg = {} as ClawdbotConfig;
1769
- expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
1770
- });
1771
-
1772
- it("returns null when peerId not in broadcast", () => {
1773
- const cfg = { broadcast: { oc_other: ["susan"] } } as unknown as ClawdbotConfig;
1774
- expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
1775
- });
1776
-
1777
- it("returns null when agent list is empty", () => {
1778
- const cfg = { broadcast: { oc_group123: [] } } as unknown as ClawdbotConfig;
1779
- expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
1780
- });
1781
- });
2760
+ await dispatchMessage({ cfg, event });
1782
2761
 
1783
- describe("buildBroadcastSessionKey", () => {
1784
- it("replaces agent ID prefix in session key", () => {
1785
- expect(buildBroadcastSessionKey("agent:main:feishu:group:oc_group123", "main", "susan")).toBe(
1786
- "agent:susan:feishu:group:oc_group123",
2762
+ expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
2763
+ storePath: "/tmp/feishu-sessions.json",
2764
+ sessionKey: "agent:main:feishu:dm:ou-attacker",
2765
+ });
2766
+ expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
2767
+ expect.objectContaining({
2768
+ rootMessageId: "om_topic_root",
2769
+ }),
1787
2770
  );
1788
- });
1789
-
1790
- it("handles compound peer IDs", () => {
1791
- expect(
1792
- buildBroadcastSessionKey(
1793
- "agent:main:feishu:group:oc_group123:sender:ou_user1",
1794
- "main",
1795
- "susan",
1796
- ),
1797
- ).toBe("agent:susan:feishu:group:oc_group123:sender:ou_user1");
1798
- });
1799
-
1800
- it("returns base key unchanged when prefix does not match", () => {
1801
- expect(buildBroadcastSessionKey("custom:key:format", "main", "susan")).toBe(
1802
- "custom:key:format",
2771
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2772
+ expect.objectContaining({
2773
+ ThreadStarterBody: "root starter",
2774
+ ThreadHistoryBody: "assistant reply\n\nfollow-up question",
2775
+ ThreadLabel: "Feishu thread in oc-group",
2776
+ MessageThreadId: "om_topic_root",
2777
+ }),
1803
2778
  );
1804
2779
  });
1805
- });
1806
-
1807
- describe("broadcast dispatch", () => {
1808
- const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
1809
- const mockDispatchReplyFromConfig = vi
1810
- .fn()
1811
- .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
1812
- const mockWithReplyDispatcher = vi.fn(
1813
- async ({
1814
- dispatcher,
1815
- run,
1816
- onSettled,
1817
- }: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
1818
- try {
1819
- return await run();
1820
- } finally {
1821
- dispatcher.markComplete();
1822
- try {
1823
- await dispatcher.waitForIdle();
1824
- } finally {
1825
- await onSettled?.();
1826
- }
1827
- }
1828
- },
1829
- );
1830
- const mockShouldComputeCommandAuthorized = vi.fn(() => false);
1831
- const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
1832
- path: "/tmp/inbound-clip.mp4",
1833
- contentType: "video/mp4",
1834
- });
1835
2780
 
1836
- beforeEach(() => {
1837
- vi.clearAllMocks();
1838
- mockResolveAgentRoute.mockReturnValue({
1839
- agentId: "main",
1840
- channel: "feishu",
1841
- accountId: "default",
1842
- sessionKey: "agent:main:feishu:group:oc-broadcast-group",
1843
- mainSessionKey: "agent:main:main",
1844
- matchedBy: "default",
1845
- });
1846
- mockCreateFeishuClient.mockReturnValue({
1847
- contact: {
1848
- user: {
1849
- get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
1850
- },
1851
- },
1852
- });
1853
- setFeishuRuntime({
1854
- system: {
1855
- enqueueSystemEvent: vi.fn(),
1856
- },
1857
- channel: {
1858
- routing: {
1859
- resolveAgentRoute: mockResolveAgentRoute,
1860
- },
1861
- reply: {
1862
- resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
1863
- formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
1864
- finalizeInboundContext: mockFinalizeInboundContext,
1865
- dispatchReplyFromConfig: mockDispatchReplyFromConfig,
1866
- withReplyDispatcher: mockWithReplyDispatcher,
1867
- },
1868
- commands: {
1869
- shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
1870
- resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
1871
- },
1872
- media: {
1873
- saveMediaBuffer: mockSaveMediaBuffer,
1874
- },
1875
- pairing: {
1876
- readAllowFromStore: vi.fn().mockResolvedValue([]),
1877
- upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }),
1878
- buildPairingReply: vi.fn(() => "Pairing response"),
1879
- },
1880
- },
1881
- media: {
1882
- detectMime: vi.fn(async () => "application/octet-stream"),
1883
- },
1884
- } as unknown as PluginRuntime);
1885
- });
2781
+ it("skips topic thread bootstrap when the thread session already exists", async () => {
2782
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
2783
+ mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
1886
2784
 
1887
- it("dispatches to all broadcast agents when bot is mentioned", async () => {
1888
2785
  const cfg: ClawdbotConfig = {
1889
- broadcast: { "oc-broadcast-group": ["susan", "main"] },
1890
- agents: { list: [{ id: "main" }, { id: "susan" }] },
1891
2786
  channels: {
1892
2787
  feishu: {
1893
2788
  groups: {
1894
- "oc-broadcast-group": {
1895
- requireMention: true,
2789
+ "oc-group": {
2790
+ requireMention: false,
2791
+ groupSessionScope: "group_topic",
1896
2792
  },
1897
2793
  },
1898
2794
  },
1899
2795
  },
1900
- } as unknown as ClawdbotConfig;
2796
+ } as ClawdbotConfig;
1901
2797
 
1902
2798
  const event: FeishuMessageEvent = {
1903
- sender: { sender_id: { open_id: "ou-sender" } },
2799
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1904
2800
  message: {
1905
- message_id: "msg-broadcast-mentioned",
1906
- chat_id: "oc-broadcast-group",
2801
+ message_id: "om_topic_followup",
2802
+ root_id: "om_topic_root",
2803
+ chat_id: "oc-group",
1907
2804
  chat_type: "group",
1908
2805
  message_type: "text",
1909
- content: JSON.stringify({ text: "hello @bot" }),
1910
- mentions: [
1911
- { key: "@_user_1", id: { open_id: "bot-open-id" }, name: "Bot", tenant_key: "" },
1912
- ],
2806
+ content: JSON.stringify({ text: "current turn" }),
1913
2807
  },
1914
2808
  };
1915
2809
 
1916
- await handleFeishuMessage({
1917
- cfg,
1918
- event,
1919
- botOpenId: "bot-open-id",
1920
- runtime: createRuntimeEnv(),
1921
- });
1922
-
1923
- // Both agents should get dispatched
1924
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
1925
-
1926
- // Verify session keys for both agents
1927
- const sessionKeys = mockFinalizeInboundContext.mock.calls.map(
1928
- (call: unknown[]) => (call[0] as { SessionKey: string }).SessionKey,
1929
- );
1930
- expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
1931
- expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
2810
+ await dispatchMessage({ cfg, event });
1932
2811
 
1933
- // Active agent (mentioned) gets the real Feishu reply dispatcher
1934
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
1935
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1936
- expect.objectContaining({ agentId: "main" }),
2812
+ expect(mockGetMessageFeishu).not.toHaveBeenCalled();
2813
+ expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
2814
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2815
+ expect.objectContaining({
2816
+ ThreadStarterBody: undefined,
2817
+ ThreadHistoryBody: undefined,
2818
+ ThreadLabel: "Feishu thread in oc-group",
2819
+ MessageThreadId: "om_topic_root",
2820
+ }),
1937
2821
  );
1938
2822
  });
1939
2823
 
1940
- it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
2824
+ it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => {
2825
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
2826
+ mockGetMessageFeishu.mockResolvedValue({
2827
+ messageId: "om_topic_root",
2828
+ chatId: "oc-group",
2829
+ content: "root starter",
2830
+ contentType: "text",
2831
+ threadId: "omt_topic_1",
2832
+ });
2833
+ mockListFeishuThreadMessages.mockResolvedValue([
2834
+ {
2835
+ messageId: "om_bot_reply",
2836
+ senderId: "app_1",
2837
+ senderType: "app",
2838
+ content: "assistant reply",
2839
+ contentType: "text",
2840
+ createTime: 1710000000000,
2841
+ },
2842
+ {
2843
+ messageId: "om_follow_up",
2844
+ senderId: "user_topic_1",
2845
+ senderType: "user",
2846
+ content: "follow-up question",
2847
+ contentType: "text",
2848
+ createTime: 1710000001000,
2849
+ },
2850
+ ]);
2851
+
1941
2852
  const cfg: ClawdbotConfig = {
1942
- broadcast: { "oc-broadcast-group": ["susan", "main"] },
1943
- agents: { list: [{ id: "main" }, { id: "susan" }] },
1944
2853
  channels: {
1945
2854
  feishu: {
1946
2855
  groups: {
1947
- "oc-broadcast-group": {
1948
- requireMention: true,
2856
+ "oc-group": {
2857
+ requireMention: false,
2858
+ groupSessionScope: "group_topic_sender",
1949
2859
  },
1950
2860
  },
1951
2861
  },
1952
2862
  },
1953
- } as unknown as ClawdbotConfig;
2863
+ } as ClawdbotConfig;
1954
2864
 
1955
2865
  const event: FeishuMessageEvent = {
1956
- sender: { sender_id: { open_id: "ou-sender" } },
2866
+ sender: {
2867
+ sender_id: {
2868
+ open_id: "ou-topic-user",
2869
+ user_id: "user_topic_1",
2870
+ },
2871
+ },
1957
2872
  message: {
1958
- message_id: "msg-broadcast-not-mentioned",
1959
- chat_id: "oc-broadcast-group",
2873
+ message_id: "om_topic_followup_mixed_ids",
2874
+ root_id: "om_topic_root",
2875
+ chat_id: "oc-group",
1960
2876
  chat_type: "group",
1961
2877
  message_type: "text",
1962
- content: JSON.stringify({ text: "hello everyone" }),
2878
+ content: JSON.stringify({ text: "current turn" }),
1963
2879
  },
1964
2880
  };
1965
2881
 
1966
- await handleFeishuMessage({
1967
- cfg,
1968
- event,
1969
- runtime: createRuntimeEnv(),
1970
- });
2882
+ await dispatchMessage({ cfg, event });
1971
2883
 
1972
- // No dispatch: requireMention=true and bot not mentioned → returns early.
1973
- // The mentioned bot's handler (on another account or same account with
1974
- // matching botOpenId) will handle broadcast dispatch for all agents.
1975
- expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1976
- expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
2884
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2885
+ expect.objectContaining({
2886
+ ThreadStarterBody: "root starter",
2887
+ ThreadHistoryBody: "assistant reply\n\nfollow-up question",
2888
+ ThreadLabel: "Feishu thread in oc-group",
2889
+ MessageThreadId: "om_topic_root",
2890
+ }),
2891
+ );
1977
2892
  });
1978
2893
 
1979
- it("preserves single-agent dispatch when no broadcast config", async () => {
2894
+ it("filters topic bootstrap context to allowlisted group senders", async () => {
2895
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
2896
+ mockGetMessageFeishu.mockResolvedValue({
2897
+ messageId: "om_topic_root",
2898
+ chatId: "oc-group",
2899
+ senderId: "ou-blocked",
2900
+ senderType: "user",
2901
+ content: "blocked root starter",
2902
+ contentType: "text",
2903
+ threadId: "omt_topic_1",
2904
+ });
2905
+ mockListFeishuThreadMessages.mockResolvedValue([
2906
+ {
2907
+ messageId: "om_blocked_reply",
2908
+ senderId: "ou-blocked",
2909
+ senderType: "user",
2910
+ content: "blocked follow-up",
2911
+ contentType: "text",
2912
+ createTime: 1710000000000,
2913
+ },
2914
+ {
2915
+ messageId: "om_bot_reply",
2916
+ senderId: "app_1",
2917
+ senderType: "app",
2918
+ content: "assistant reply",
2919
+ contentType: "text",
2920
+ createTime: 1710000001000,
2921
+ },
2922
+ {
2923
+ messageId: "om_allowed_reply",
2924
+ senderId: "ou-allowed",
2925
+ senderType: "user",
2926
+ content: "allowed follow-up",
2927
+ contentType: "text",
2928
+ createTime: 1710000002000,
2929
+ },
2930
+ ]);
2931
+
1980
2932
  const cfg: ClawdbotConfig = {
1981
2933
  channels: {
1982
2934
  feishu: {
2935
+ groupPolicy: "open",
2936
+ groupSenderAllowFrom: ["ou-allowed"],
2937
+ contextVisibility: "allowlist",
1983
2938
  groups: {
1984
- "oc-broadcast-group": {
2939
+ "oc-group": {
1985
2940
  requireMention: false,
2941
+ groupSessionScope: "group_topic",
1986
2942
  },
1987
2943
  },
1988
2944
  },
@@ -1990,118 +2946,95 @@ describe("broadcast dispatch", () => {
1990
2946
  } as ClawdbotConfig;
1991
2947
 
1992
2948
  const event: FeishuMessageEvent = {
1993
- sender: { sender_id: { open_id: "ou-sender" } },
2949
+ sender: { sender_id: { open_id: "ou-allowed" } },
1994
2950
  message: {
1995
- message_id: "msg-no-broadcast",
1996
- chat_id: "oc-broadcast-group",
2951
+ message_id: "om_topic_followup_allowlisted",
2952
+ root_id: "om_topic_root",
2953
+ thread_id: "omt_topic_1",
2954
+ chat_id: "oc-group",
1997
2955
  chat_type: "group",
1998
2956
  message_type: "text",
1999
- content: JSON.stringify({ text: "hello" }),
2957
+ content: JSON.stringify({ text: "current turn" }),
2000
2958
  },
2001
2959
  };
2002
2960
 
2003
- await handleFeishuMessage({
2004
- cfg,
2005
- event,
2006
- runtime: createRuntimeEnv(),
2007
- });
2961
+ await dispatchMessage({ cfg, event });
2008
2962
 
2009
- // Single dispatch (no broadcast)
2010
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
2011
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
2012
2963
  expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2013
2964
  expect.objectContaining({
2014
- SessionKey: "agent:main:feishu:group:oc-broadcast-group",
2965
+ ThreadStarterBody: "assistant reply",
2966
+ ThreadHistoryBody: "assistant reply\n\nallowed follow-up",
2015
2967
  }),
2016
2968
  );
2017
2969
  });
2018
2970
 
2019
- it("cross-account broadcast dedup: second account skips dispatch", async () => {
2971
+ it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
2972
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
2973
+
2020
2974
  const cfg: ClawdbotConfig = {
2021
- broadcast: { "oc-broadcast-group": ["susan", "main"] },
2022
- agents: { list: [{ id: "main" }, { id: "susan" }] },
2023
2975
  channels: {
2024
2976
  feishu: {
2025
- groups: {
2026
- "oc-broadcast-group": {
2027
- requireMention: false,
2028
- },
2029
- },
2977
+ dmPolicy: "open",
2030
2978
  },
2031
2979
  },
2032
- } as unknown as ClawdbotConfig;
2980
+ } as ClawdbotConfig;
2033
2981
 
2034
2982
  const event: FeishuMessageEvent = {
2035
- sender: { sender_id: { open_id: "ou-sender" } },
2983
+ sender: {
2984
+ sender_id: {
2985
+ open_id: "ou-image-dedup",
2986
+ },
2987
+ },
2036
2988
  message: {
2037
- message_id: "msg-multi-account-dedup",
2038
- chat_id: "oc-broadcast-group",
2039
- chat_type: "group",
2040
- message_type: "text",
2041
- content: JSON.stringify({ text: "hello" }),
2989
+ message_id: "msg-image-dedup",
2990
+ chat_id: "oc-dm",
2991
+ chat_type: "p2p",
2992
+ message_type: "image",
2993
+ content: JSON.stringify({
2994
+ image_key: "img_dedup_payload",
2995
+ }),
2042
2996
  },
2043
2997
  };
2044
2998
 
2045
- // First account handles broadcast normally
2046
- await handleFeishuMessage({
2047
- cfg,
2048
- event,
2049
- runtime: createRuntimeEnv(),
2050
- accountId: "account-A",
2051
- });
2052
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
2053
-
2054
- mockDispatchReplyFromConfig.mockClear();
2055
- mockFinalizeInboundContext.mockClear();
2056
-
2057
- // Second account: same message ID, different account.
2058
- // Per-account dedup passes (different namespace), but cross-account
2059
- // broadcast dedup blocks dispatch.
2060
- await handleFeishuMessage({
2061
- cfg,
2062
- event,
2063
- runtime: createRuntimeEnv(),
2064
- accountId: "account-B",
2065
- });
2066
- expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
2999
+ await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
3000
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
2067
3001
  });
2068
3002
 
2069
- it("skips unknown agents not in agents.list", async () => {
3003
+ it("skips empty-text messages with no media to prevent blank user turns in session (#74634)", async () => {
3004
+ // Feishu can deliver { "text": "" } events (empty-text or media-stripped
3005
+ // messages). Writing blank user content to the session causes downstream
3006
+ // LLM providers such as MiniMax to reject requests with "messages must not
3007
+ // be empty". The handler should drop such events before queuing a reply.
3008
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
3009
+
2070
3010
  const cfg: ClawdbotConfig = {
2071
- broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] },
2072
- agents: { list: [{ id: "main" }, { id: "susan" }] },
2073
3011
  channels: {
2074
3012
  feishu: {
2075
- groups: {
2076
- "oc-broadcast-group": {
2077
- requireMention: false,
2078
- },
2079
- },
3013
+ dmPolicy: "open",
3014
+ allowFrom: ["*"],
2080
3015
  },
2081
3016
  },
2082
- } as unknown as ClawdbotConfig;
3017
+ } as ClawdbotConfig;
2083
3018
 
2084
3019
  const event: FeishuMessageEvent = {
2085
- sender: { sender_id: { open_id: "ou-sender" } },
3020
+ sender: {
3021
+ sender_id: {
3022
+ open_id: "ou-empty-text-sender",
3023
+ },
3024
+ },
2086
3025
  message: {
2087
- message_id: "msg-broadcast-unknown-agent",
2088
- chat_id: "oc-broadcast-group",
2089
- chat_type: "group",
3026
+ message_id: "msg-empty-text-74634",
3027
+ chat_id: "oc-dm",
3028
+ chat_type: "p2p",
2090
3029
  message_type: "text",
2091
- content: JSON.stringify({ text: "hello" }),
3030
+ // Feishu encodes empty text as {"text":""}
3031
+ content: JSON.stringify({ text: "" }),
2092
3032
  },
2093
3033
  };
2094
3034
 
2095
- await handleFeishuMessage({
2096
- cfg,
2097
- event,
2098
- runtime: createRuntimeEnv(),
2099
- });
3035
+ await dispatchMessage({ cfg, event });
2100
3036
 
2101
- // Only susan should get dispatched (unknown-agent skipped)
2102
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
2103
- const sessionKey = (mockFinalizeInboundContext.mock.calls[0]?.[0] as { SessionKey: string })
2104
- .SessionKey;
2105
- expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
3037
+ // No reply should be dispatched: empty message is silently skipped
3038
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
2106
3039
  });
2107
3040
  });