@openclaw/feishu 2026.5.2 → 2026.5.3-beta.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 (224) hide show
  1. package/dist/accounts-Ba3-WP1z.js +423 -0
  2. package/dist/api.js +2280 -0
  3. package/dist/app-registration-B8qc1MCM.js +184 -0
  4. package/dist/audio-preflight.runtime-BPlzkO3l.js +7 -0
  5. package/dist/card-interaction-BfRLgvw_.js +96 -0
  6. package/dist/channel-CSD_Jt8I.js +1668 -0
  7. package/dist/channel-entry.js +22 -0
  8. package/dist/channel-plugin-api.js +2 -0
  9. package/dist/channel.runtime-DYsXcD36.js +700 -0
  10. package/dist/client-DBVoQL5w.js +157 -0
  11. package/dist/contract-api.js +9 -0
  12. package/dist/conversation-id-DWS3Ep2A.js +139 -0
  13. package/dist/directory.static-f3EeoRJd.js +44 -0
  14. package/dist/drive-C5eJLJr7.js +883 -0
  15. package/dist/index.js +68 -0
  16. package/dist/monitor-CT189QfR.js +60 -0
  17. package/dist/monitor.account-dJV2jO8C.js +4990 -0
  18. package/dist/monitor.state-DYM02ipp.js +100 -0
  19. package/dist/policy-D6c-wMPl.js +118 -0
  20. package/dist/probe-BNzzU_uR.js +149 -0
  21. package/dist/rolldown-runtime-DUslC3ob.js +14 -0
  22. package/dist/runtime-CG0DuRCy.js +8 -0
  23. package/dist/runtime-api.js +14 -0
  24. package/dist/secret-contract-Dm4Z_zQN.js +119 -0
  25. package/dist/secret-contract-api.js +2 -0
  26. package/dist/security-audit-DqJdocrN.js +11 -0
  27. package/dist/security-audit-shared-ByuMx9cJ.js +38 -0
  28. package/dist/security-contract-api.js +2 -0
  29. package/dist/send-DowxxbpH.js +1218 -0
  30. package/dist/session-conversation-B4nrW-vo.js +27 -0
  31. package/dist/session-key-api.js +2 -0
  32. package/dist/setup-api.js +2 -0
  33. package/dist/setup-entry.js +15 -0
  34. package/dist/subagent-hooks-C3UhPVLV.js +227 -0
  35. package/dist/subagent-hooks-api.js +23 -0
  36. package/dist/targets-JMFJRKSe.js +48 -0
  37. package/dist/thread-bindings-BmS6TLes.js +222 -0
  38. package/package.json +15 -6
  39. package/api.ts +0 -31
  40. package/channel-entry.ts +0 -20
  41. package/channel-plugin-api.ts +0 -1
  42. package/contract-api.ts +0 -16
  43. package/index.ts +0 -82
  44. package/runtime-api.ts +0 -55
  45. package/secret-contract-api.ts +0 -5
  46. package/security-contract-api.ts +0 -1
  47. package/session-key-api.ts +0 -1
  48. package/setup-api.ts +0 -3
  49. package/setup-entry.test.ts +0 -14
  50. package/setup-entry.ts +0 -13
  51. package/src/accounts.test.ts +0 -459
  52. package/src/accounts.ts +0 -326
  53. package/src/app-registration.ts +0 -331
  54. package/src/approval-auth.test.ts +0 -24
  55. package/src/approval-auth.ts +0 -25
  56. package/src/async.test.ts +0 -35
  57. package/src/async.ts +0 -104
  58. package/src/audio-preflight.runtime.ts +0 -9
  59. package/src/bitable.test.ts +0 -131
  60. package/src/bitable.ts +0 -762
  61. package/src/bot-content.ts +0 -474
  62. package/src/bot-group-name.test.ts +0 -108
  63. package/src/bot-runtime-api.ts +0 -12
  64. package/src/bot-sender-name.ts +0 -125
  65. package/src/bot.broadcast.test.ts +0 -463
  66. package/src/bot.card-action.test.ts +0 -577
  67. package/src/bot.checkBotMentioned.test.ts +0 -265
  68. package/src/bot.helpers.test.ts +0 -118
  69. package/src/bot.stripBotMention.test.ts +0 -126
  70. package/src/bot.test.ts +0 -3040
  71. package/src/bot.ts +0 -1559
  72. package/src/card-action.ts +0 -447
  73. package/src/card-interaction.test.ts +0 -129
  74. package/src/card-interaction.ts +0 -159
  75. package/src/card-test-helpers.ts +0 -47
  76. package/src/card-ux-approval.ts +0 -65
  77. package/src/card-ux-launcher.test.ts +0 -99
  78. package/src/card-ux-launcher.ts +0 -121
  79. package/src/card-ux-shared.ts +0 -33
  80. package/src/channel-runtime-api.ts +0 -16
  81. package/src/channel.runtime.ts +0 -47
  82. package/src/channel.test.ts +0 -959
  83. package/src/channel.ts +0 -1313
  84. package/src/chat-schema.ts +0 -25
  85. package/src/chat.test.ts +0 -196
  86. package/src/chat.ts +0 -188
  87. package/src/client.test.ts +0 -433
  88. package/src/client.ts +0 -290
  89. package/src/comment-dispatcher-runtime-api.ts +0 -6
  90. package/src/comment-dispatcher.test.ts +0 -169
  91. package/src/comment-dispatcher.ts +0 -107
  92. package/src/comment-handler-runtime-api.ts +0 -3
  93. package/src/comment-handler.test.ts +0 -486
  94. package/src/comment-handler.ts +0 -309
  95. package/src/comment-reaction.test.ts +0 -166
  96. package/src/comment-reaction.ts +0 -259
  97. package/src/comment-shared.test.ts +0 -182
  98. package/src/comment-shared.ts +0 -406
  99. package/src/comment-target.ts +0 -44
  100. package/src/config-schema.test.ts +0 -309
  101. package/src/config-schema.ts +0 -333
  102. package/src/conversation-id.test.ts +0 -18
  103. package/src/conversation-id.ts +0 -199
  104. package/src/dedup-runtime-api.ts +0 -1
  105. package/src/dedup.ts +0 -141
  106. package/src/directory.static.ts +0 -61
  107. package/src/directory.test.ts +0 -136
  108. package/src/directory.ts +0 -124
  109. package/src/doc-schema.ts +0 -182
  110. package/src/docx-batch-insert.test.ts +0 -91
  111. package/src/docx-batch-insert.ts +0 -223
  112. package/src/docx-color-text.ts +0 -154
  113. package/src/docx-table-ops.test.ts +0 -53
  114. package/src/docx-table-ops.ts +0 -316
  115. package/src/docx-types.ts +0 -38
  116. package/src/docx.account-selection.test.ts +0 -79
  117. package/src/docx.test.ts +0 -685
  118. package/src/docx.ts +0 -1616
  119. package/src/drive-schema.ts +0 -92
  120. package/src/drive.test.ts +0 -1219
  121. package/src/drive.ts +0 -829
  122. package/src/dynamic-agent.ts +0 -137
  123. package/src/event-types.ts +0 -45
  124. package/src/external-keys.test.ts +0 -20
  125. package/src/external-keys.ts +0 -19
  126. package/src/lifecycle.test-support.ts +0 -220
  127. package/src/media.test.ts +0 -900
  128. package/src/media.ts +0 -861
  129. package/src/mention-target.types.ts +0 -5
  130. package/src/mention.ts +0 -114
  131. package/src/message-action-contract.ts +0 -13
  132. package/src/monitor-state-runtime-api.ts +0 -7
  133. package/src/monitor-transport-runtime-api.ts +0 -7
  134. package/src/monitor.account.ts +0 -468
  135. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +0 -219
  136. package/src/monitor.bot-identity.ts +0 -86
  137. package/src/monitor.bot-menu-handler.ts +0 -165
  138. package/src/monitor.bot-menu.lifecycle.test-support.ts +0 -224
  139. package/src/monitor.bot-menu.test.ts +0 -178
  140. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +0 -264
  141. package/src/monitor.card-action.lifecycle.test-support.ts +0 -373
  142. package/src/monitor.cleanup.test.ts +0 -376
  143. package/src/monitor.comment-notice-handler.ts +0 -105
  144. package/src/monitor.comment.test.ts +0 -937
  145. package/src/monitor.comment.ts +0 -1386
  146. package/src/monitor.lifecycle.test.ts +0 -4
  147. package/src/monitor.message-handler.ts +0 -339
  148. package/src/monitor.reaction.lifecycle.test-support.ts +0 -68
  149. package/src/monitor.reaction.test.ts +0 -713
  150. package/src/monitor.startup.test.ts +0 -192
  151. package/src/monitor.startup.ts +0 -74
  152. package/src/monitor.state.defaults.test.ts +0 -46
  153. package/src/monitor.state.ts +0 -170
  154. package/src/monitor.synthetic-error.ts +0 -18
  155. package/src/monitor.test-mocks.ts +0 -45
  156. package/src/monitor.transport.ts +0 -424
  157. package/src/monitor.ts +0 -100
  158. package/src/monitor.webhook-e2e.test.ts +0 -272
  159. package/src/monitor.webhook-security.test.ts +0 -264
  160. package/src/monitor.webhook.test-helpers.ts +0 -116
  161. package/src/outbound-runtime-api.ts +0 -1
  162. package/src/outbound.test.ts +0 -935
  163. package/src/outbound.ts +0 -718
  164. package/src/perm-schema.ts +0 -52
  165. package/src/perm.ts +0 -170
  166. package/src/pins.ts +0 -108
  167. package/src/policy.test.ts +0 -334
  168. package/src/policy.ts +0 -236
  169. package/src/post.test.ts +0 -105
  170. package/src/post.ts +0 -275
  171. package/src/probe.test.ts +0 -275
  172. package/src/probe.ts +0 -166
  173. package/src/processing-claims.ts +0 -59
  174. package/src/qr-terminal.ts +0 -1
  175. package/src/reactions.ts +0 -123
  176. package/src/reasoning-preview.test.ts +0 -59
  177. package/src/reasoning-preview.ts +0 -20
  178. package/src/reply-dispatcher-runtime-api.ts +0 -7
  179. package/src/reply-dispatcher.test.ts +0 -1144
  180. package/src/reply-dispatcher.ts +0 -650
  181. package/src/runtime.ts +0 -9
  182. package/src/secret-contract.ts +0 -145
  183. package/src/secret-input.ts +0 -1
  184. package/src/security-audit-shared.ts +0 -69
  185. package/src/security-audit.test.ts +0 -61
  186. package/src/security-audit.ts +0 -1
  187. package/src/send-result.ts +0 -29
  188. package/src/send-target.test.ts +0 -80
  189. package/src/send-target.ts +0 -35
  190. package/src/send.reply-fallback.test.ts +0 -292
  191. package/src/send.test.ts +0 -550
  192. package/src/send.ts +0 -800
  193. package/src/sequential-key.test.ts +0 -72
  194. package/src/sequential-key.ts +0 -28
  195. package/src/sequential-queue.test.ts +0 -92
  196. package/src/sequential-queue.ts +0 -16
  197. package/src/session-conversation.ts +0 -42
  198. package/src/session-route.ts +0 -48
  199. package/src/setup-core.ts +0 -51
  200. package/src/setup-surface.test.ts +0 -174
  201. package/src/setup-surface.ts +0 -581
  202. package/src/streaming-card.test.ts +0 -190
  203. package/src/streaming-card.ts +0 -490
  204. package/src/subagent-hooks.test.ts +0 -603
  205. package/src/subagent-hooks.ts +0 -397
  206. package/src/targets.ts +0 -97
  207. package/src/test-support/lifecycle-test-support.ts +0 -453
  208. package/src/thread-bindings.test.ts +0 -143
  209. package/src/thread-bindings.ts +0 -330
  210. package/src/tool-account-routing.test.ts +0 -187
  211. package/src/tool-account.test.ts +0 -44
  212. package/src/tool-account.ts +0 -93
  213. package/src/tool-factory-test-harness.ts +0 -79
  214. package/src/tool-result.test.ts +0 -32
  215. package/src/tool-result.ts +0 -16
  216. package/src/tools-config.test.ts +0 -21
  217. package/src/tools-config.ts +0 -22
  218. package/src/types.ts +0 -104
  219. package/src/typing.test.ts +0 -144
  220. package/src/typing.ts +0 -214
  221. package/src/wiki-schema.ts +0 -55
  222. package/src/wiki.ts +0 -227
  223. package/subagent-hooks-api.ts +0 -31
  224. package/tsconfig.json +0 -16
package/src/bot.test.ts DELETED
@@ -1,3040 +0,0 @@
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";
4
- import { beforeEach, describe, expect, it, vi } from "vitest";
5
- import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
6
- import type { FeishuMessageEvent } from "./bot.js";
7
- import { handleFeishuMessage } from "./bot.js";
8
- import { setFeishuRuntime } from "./runtime.js";
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
-
235
- const {
236
- mockCreateFeishuReplyDispatcher,
237
- mockSendMessageFeishu,
238
- mockGetMessageFeishu,
239
- mockListFeishuThreadMessages,
240
- mockDownloadMessageResourceFeishu,
241
- mockCreateFeishuClient,
242
- mockResolveAgentRoute,
243
- mockReadSessionUpdatedAt,
244
- mockResolveStorePath,
245
- mockResolveConfiguredBindingRoute,
246
- mockEnsureConfiguredBindingRouteReady,
247
- mockResolveBoundConversation,
248
- mockTouchBinding,
249
- mockResolveFeishuReasoningPreviewEnabled,
250
- mockTranscribeFirstAudio,
251
- } = vi.hoisted(() => ({
252
- mockCreateFeishuReplyDispatcher: vi.fn(() => ({
253
- dispatcher: createReplyDispatcher(),
254
- replyOptions: {},
255
- markDispatchIdle: vi.fn(),
256
- })),
257
- mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
258
- mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
259
- mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
260
- mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
261
- buffer: Buffer.from("video"),
262
- contentType: "video/mp4",
263
- fileName: "clip.mp4",
264
- }),
265
- mockCreateFeishuClient: vi.fn(),
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(),
286
- }));
287
-
288
- vi.mock("./reply-dispatcher.js", () => ({
289
- createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
290
- }));
291
-
292
- vi.mock("./reasoning-preview.js", () => ({
293
- resolveFeishuReasoningPreviewEnabled: mockResolveFeishuReasoningPreviewEnabled,
294
- }));
295
-
296
- vi.mock("./send.js", () => ({
297
- sendMessageFeishu: mockSendMessageFeishu,
298
- getMessageFeishu: mockGetMessageFeishu,
299
- listFeishuThreadMessages: mockListFeishuThreadMessages,
300
- }));
301
-
302
- vi.mock("./media.js", () => ({
303
- downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
304
- }));
305
-
306
- vi.mock("./audio-preflight.runtime.js", () => ({
307
- transcribeFirstAudio: mockTranscribeFirstAudio,
308
- }));
309
-
310
- vi.mock("./client.js", () => ({
311
- createFeishuClient: mockCreateFeishuClient,
312
- }));
313
-
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
- );
318
- return {
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,
351
- }),
352
- };
353
- });
354
-
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;
371
- await handleFeishuMessage({
372
- cfg,
373
- event: params.event,
374
- runtime,
375
- });
376
- return runtime;
377
- }
378
-
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
- },
527
- },
528
- });
529
-
530
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
531
- expect.objectContaining({ allowReasoningPreview: true }),
532
- );
533
- });
534
- });
535
-
536
- describe("handleFeishuMessage command authorization", () => {
537
- const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({
538
- ...ctx,
539
- CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
540
- }));
541
- const mockDispatchReplyFromConfig = vi
542
- .fn()
543
- .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
544
- const mockWithReplyDispatcher = vi.fn(
545
- async ({
546
- dispatcher,
547
- run,
548
- onSettled,
549
- }: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
550
- try {
551
- return await run();
552
- } finally {
553
- dispatcher.markComplete();
554
- try {
555
- await dispatcher.waitForIdle();
556
- } finally {
557
- await onSettled?.();
558
- }
559
- }
560
- },
561
- );
562
- const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
563
- const mockShouldComputeCommandAuthorized = vi.fn(() => true);
564
- const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
565
- const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
566
- const mockBuildPairingReply = vi.fn(() => "Pairing response");
567
- const mockEnqueueSystemEvent = vi.fn();
568
- const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
569
- id: "inbound-clip.mp4",
570
- path: "/tmp/inbound-clip.mp4",
571
- size: Buffer.byteLength("video"),
572
- contentType: "video/mp4",
573
- });
574
-
575
- beforeEach(() => {
576
- vi.clearAllMocks();
577
- mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
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());
597
- mockCreateFeishuClient.mockReturnValue({
598
- contact: {
599
- user: {
600
- get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
601
- },
602
- },
603
- });
604
- mockEnqueueSystemEvent.mockReset();
605
- setFeishuRuntime(
606
- createFeishuBotRuntime({
607
- system: {
608
- enqueueSystemEvent: mockEnqueueSystemEvent,
609
- },
610
- channel: {
611
- reply: {
612
- resolveEnvelopeFormatOptions:
613
- resolveEnvelopeFormatOptionsMock as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
614
- formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
615
- finalizeInboundContext: mockFinalizeInboundContext as never,
616
- dispatchReplyFromConfig: mockDispatchReplyFromConfig,
617
- withReplyDispatcher: mockWithReplyDispatcher as never,
618
- },
619
- commands: {
620
- shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
621
- resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
622
- },
623
- pairing: {
624
- readAllowFromStore: mockReadAllowFromStore,
625
- upsertPairingRequest: mockUpsertPairingRequest,
626
- buildPairingReply: mockBuildPairingReply,
627
- },
628
- media: {
629
- saveMediaBuffer: mockSaveMediaBuffer,
630
- },
631
- },
632
- media: {
633
- detectMime: vi.fn(async () => "application/octet-stream"),
634
- },
635
- }),
636
- );
637
- });
638
-
639
- it("does not enqueue inbound preview text as system events", async () => {
640
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
641
-
642
- const cfg: ClawdbotConfig = {
643
- channels: {
644
- feishu: {
645
- dmPolicy: "open",
646
- },
647
- },
648
- } as ClawdbotConfig;
649
-
650
- const event: FeishuMessageEvent = {
651
- sender: {
652
- sender_id: {
653
- open_id: "ou-attacker",
654
- },
655
- },
656
- message: {
657
- message_id: "msg-no-system-preview",
658
- chat_id: "oc-dm",
659
- chat_type: "p2p",
660
- message_type: "text",
661
- content: JSON.stringify({ text: "hi there" }),
662
- },
663
- };
664
-
665
- await dispatchMessage({ cfg, event });
666
-
667
- expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
668
- });
669
-
670
- it("blocks open DMs when a restrictive allowlist does not match", async () => {
671
- const cfg: ClawdbotConfig = {
672
- commands: { useAccessGroups: true },
673
- channels: {
674
- feishu: {
675
- dmPolicy: "open",
676
- allowFrom: ["ou-admin"],
677
- },
678
- },
679
- } as ClawdbotConfig;
680
-
681
- const event: FeishuMessageEvent = {
682
- sender: {
683
- sender_id: {
684
- open_id: "ou-attacker",
685
- },
686
- },
687
- message: {
688
- message_id: "msg-auth-bypass-regression",
689
- chat_id: "oc-dm",
690
- chat_type: "p2p",
691
- message_type: "text",
692
- content: JSON.stringify({ text: "/status" }),
693
- },
694
- };
695
-
696
- await dispatchMessage({ cfg, event });
697
-
698
- expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
699
- expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
700
- });
701
-
702
- it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
703
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
704
- mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
705
-
706
- const cfg: ClawdbotConfig = {
707
- commands: { useAccessGroups: true },
708
- channels: {
709
- feishu: {
710
- dmPolicy: "pairing",
711
- allowFrom: [],
712
- },
713
- },
714
- } as ClawdbotConfig;
715
-
716
- const event: FeishuMessageEvent = {
717
- sender: {
718
- sender_id: {
719
- open_id: "ou-attacker",
720
- },
721
- },
722
- message: {
723
- message_id: "msg-read-store-non-command",
724
- chat_id: "oc-dm",
725
- chat_type: "p2p",
726
- message_type: "text",
727
- content: JSON.stringify({ text: "hello there" }),
728
- },
729
- };
730
-
731
- await dispatchMessage({ cfg, event });
732
-
733
- expect(mockReadAllowFromStore).toHaveBeenCalledWith({
734
- channel: "feishu",
735
- accountId: "default",
736
- });
737
- expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
738
- expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
739
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
740
- });
741
-
742
- it("skips sender-name lookup when resolveSenderNames is false", async () => {
743
- const cfg: ClawdbotConfig = {
744
- channels: {
745
- feishu: {
746
- dmPolicy: "open",
747
- allowFrom: ["*"],
748
- resolveSenderNames: false,
749
- },
750
- },
751
- } as ClawdbotConfig;
752
-
753
- const event: FeishuMessageEvent = {
754
- sender: {
755
- sender_id: {
756
- open_id: "ou-attacker",
757
- },
758
- },
759
- message: {
760
- message_id: "msg-skip-sender-lookup",
761
- chat_id: "oc-dm",
762
- chat_type: "p2p",
763
- message_type: "text",
764
- content: JSON.stringify({ text: "hello" }),
765
- },
766
- };
767
-
768
- await dispatchMessage({ cfg, event });
769
-
770
- expect(mockCreateFeishuClient).not.toHaveBeenCalled();
771
- });
772
-
773
- it("propagates parent/root message ids into inbound context for reply reconstruction", async () => {
774
- mockGetMessageFeishu.mockResolvedValueOnce({
775
- messageId: "om_parent_001",
776
- chatId: "oc-group",
777
- content: "quoted content",
778
- contentType: "text",
779
- });
780
-
781
- const cfg: ClawdbotConfig = {
782
- channels: {
783
- feishu: {
784
- enabled: true,
785
- dmPolicy: "open",
786
- },
787
- },
788
- } as ClawdbotConfig;
789
-
790
- const event: FeishuMessageEvent = {
791
- sender: {
792
- sender_id: {
793
- open_id: "ou-replier",
794
- },
795
- },
796
- message: {
797
- message_id: "om_reply_001",
798
- root_id: "om_root_001",
799
- parent_id: "om_parent_001",
800
- chat_id: "oc-dm",
801
- chat_type: "p2p",
802
- message_type: "text",
803
- content: JSON.stringify({ text: "reply text" }),
804
- },
805
- };
806
-
807
- await dispatchMessage({ cfg, event });
808
-
809
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
810
- expect.objectContaining({
811
- ReplyToId: "om_parent_001",
812
- RootMessageId: "om_root_001",
813
- ReplyToBody: "quoted content",
814
- }),
815
- );
816
- });
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
-
889
- it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
890
- const cfg: ClawdbotConfig = {
891
- channels: {
892
- feishu: {
893
- dmPolicy: "pairing",
894
- },
895
- },
896
- } as ClawdbotConfig;
897
-
898
- const event: FeishuMessageEvent = {
899
- sender: {
900
- sender_id: {
901
- user_id: "u_mobile_only",
902
- },
903
- },
904
- message: {
905
- message_id: "msg-pairing-chat-reply",
906
- chat_id: "oc_dm_chat_1",
907
- chat_type: "p2p",
908
- message_type: "text",
909
- content: JSON.stringify({ text: "hello" }),
910
- },
911
- };
912
-
913
- mockReadAllowFromStore.mockResolvedValue([]);
914
- mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
915
-
916
- await dispatchMessage({ cfg, event });
917
-
918
- expect(mockSendMessageFeishu).toHaveBeenCalledWith(
919
- expect.objectContaining({
920
- to: "chat:oc_dm_chat_1",
921
- }),
922
- );
923
- });
924
- it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
925
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
926
- mockReadAllowFromStore.mockResolvedValue([]);
927
- mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
928
-
929
- const cfg: ClawdbotConfig = {
930
- channels: {
931
- feishu: {
932
- dmPolicy: "pairing",
933
- allowFrom: [],
934
- },
935
- },
936
- } as ClawdbotConfig;
937
-
938
- const event: FeishuMessageEvent = {
939
- sender: {
940
- sender_id: {
941
- open_id: "ou-unapproved",
942
- },
943
- },
944
- message: {
945
- message_id: "msg-pairing-flow",
946
- chat_id: "oc-dm",
947
- chat_type: "p2p",
948
- message_type: "text",
949
- content: JSON.stringify({ text: "hello" }),
950
- },
951
- };
952
-
953
- await dispatchMessage({ cfg, event });
954
-
955
- expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
956
- channel: "feishu",
957
- accountId: "default",
958
- id: "ou-unapproved",
959
- meta: { name: undefined },
960
- });
961
- expect(mockSendMessageFeishu).toHaveBeenCalledWith(
962
- expect.objectContaining({
963
- to: "chat:oc-dm",
964
- text: expect.stringContaining("Your Feishu user id: ou-unapproved"),
965
- accountId: "default",
966
- }),
967
- );
968
- expect(mockSendMessageFeishu).toHaveBeenCalledWith(
969
- expect.objectContaining({
970
- to: "chat:oc-dm",
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"),
979
- accountId: "default",
980
- }),
981
- );
982
- expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
983
- expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
984
- });
985
-
986
- it("computes group command authorization from group allowFrom", async () => {
987
- mockShouldComputeCommandAuthorized.mockReturnValue(true);
988
- mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
989
-
990
- const cfg: ClawdbotConfig = {
991
- commands: { useAccessGroups: true },
992
- channels: {
993
- feishu: {
994
- groups: {
995
- "oc-group": {
996
- requireMention: false,
997
- },
998
- },
999
- },
1000
- },
1001
- } as ClawdbotConfig;
1002
-
1003
- const event: FeishuMessageEvent = {
1004
- sender: {
1005
- sender_id: {
1006
- open_id: "ou-attacker",
1007
- },
1008
- },
1009
- message: {
1010
- message_id: "msg-group-command-auth",
1011
- chat_id: "oc-group",
1012
- chat_type: "group",
1013
- message_type: "text",
1014
- content: JSON.stringify({ text: "/status" }),
1015
- },
1016
- };
1017
-
1018
- await dispatchMessage({ cfg, event });
1019
-
1020
- expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
1021
- useAccessGroups: true,
1022
- authorizers: [{ configured: false, allowed: false }],
1023
- });
1024
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1025
- expect.objectContaining({
1026
- ChatType: "group",
1027
- CommandAuthorized: false,
1028
- SenderId: "ou-attacker",
1029
- }),
1030
- );
1031
- });
1032
-
1033
- it("normalizes group mention-prefixed slash commands before command-auth probing", async () => {
1034
- mockShouldComputeCommandAuthorized.mockReturnValue(true);
1035
-
1036
- const cfg: ClawdbotConfig = {
1037
- channels: {
1038
- feishu: {
1039
- groups: {
1040
- "oc-group": {
1041
- requireMention: false,
1042
- },
1043
- },
1044
- },
1045
- },
1046
- } as ClawdbotConfig;
1047
-
1048
- const event: FeishuMessageEvent = {
1049
- sender: {
1050
- sender_id: {
1051
- open_id: "ou-attacker",
1052
- },
1053
- },
1054
- message: {
1055
- message_id: "msg-group-mention-command-probe",
1056
- chat_id: "oc-group",
1057
- chat_type: "group",
1058
- message_type: "text",
1059
- content: JSON.stringify({ text: "@_user_1/model" }),
1060
- mentions: [{ key: "@_user_1", id: { open_id: "ou-bot" }, name: "Bot", tenant_key: "" }],
1061
- },
1062
- };
1063
-
1064
- await dispatchMessage({ cfg, event });
1065
-
1066
- expect(mockShouldComputeCommandAuthorized).toHaveBeenCalledWith("/model", cfg);
1067
- });
1068
-
1069
- it("falls back to top-level allowFrom for group command authorization", async () => {
1070
- mockShouldComputeCommandAuthorized.mockReturnValue(true);
1071
- mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
1072
-
1073
- const cfg: ClawdbotConfig = {
1074
- commands: { useAccessGroups: true },
1075
- channels: {
1076
- feishu: {
1077
- allowFrom: ["ou-admin"],
1078
- groups: {
1079
- "oc-group": {
1080
- requireMention: false,
1081
- },
1082
- },
1083
- },
1084
- },
1085
- } as ClawdbotConfig;
1086
-
1087
- const event: FeishuMessageEvent = {
1088
- sender: {
1089
- sender_id: {
1090
- open_id: "ou-admin",
1091
- },
1092
- },
1093
- message: {
1094
- message_id: "msg-group-command-fallback",
1095
- chat_id: "oc-group",
1096
- chat_type: "group",
1097
- message_type: "text",
1098
- content: JSON.stringify({ text: "/status" }),
1099
- },
1100
- };
1101
-
1102
- await dispatchMessage({ cfg, event });
1103
-
1104
- expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
1105
- useAccessGroups: true,
1106
- authorizers: [{ configured: true, allowed: true }],
1107
- });
1108
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1109
- expect.objectContaining({
1110
- ChatType: "group",
1111
- CommandAuthorized: true,
1112
- SenderId: "ou-admin",
1113
- }),
1114
- );
1115
- });
1116
-
1117
- it("allows group sender when global groupSenderAllowFrom includes sender", async () => {
1118
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1119
-
1120
- const cfg: ClawdbotConfig = {
1121
- channels: {
1122
- feishu: {
1123
- groupPolicy: "open",
1124
- groupSenderAllowFrom: ["ou-allowed"],
1125
- groups: {
1126
- "oc-group": {
1127
- requireMention: false,
1128
- },
1129
- },
1130
- },
1131
- },
1132
- } as ClawdbotConfig;
1133
-
1134
- const event: FeishuMessageEvent = {
1135
- sender: {
1136
- sender_id: {
1137
- open_id: "ou-allowed",
1138
- },
1139
- },
1140
- message: {
1141
- message_id: "msg-global-group-sender-allow",
1142
- chat_id: "oc-group",
1143
- chat_type: "group",
1144
- message_type: "text",
1145
- content: JSON.stringify({ text: "hello" }),
1146
- },
1147
- };
1148
-
1149
- await dispatchMessage({ cfg, event });
1150
-
1151
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1152
- expect.objectContaining({
1153
- ChatType: "group",
1154
- SenderId: "ou-allowed",
1155
- }),
1156
- );
1157
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1158
- });
1159
-
1160
- it("blocks group sender when global groupSenderAllowFrom excludes sender", async () => {
1161
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1162
-
1163
- const cfg: ClawdbotConfig = {
1164
- channels: {
1165
- feishu: {
1166
- groupPolicy: "open",
1167
- groupSenderAllowFrom: ["ou-allowed"],
1168
- groups: {
1169
- "oc-group": {
1170
- requireMention: false,
1171
- },
1172
- },
1173
- },
1174
- },
1175
- } as ClawdbotConfig;
1176
-
1177
- const event: FeishuMessageEvent = {
1178
- sender: {
1179
- sender_id: {
1180
- open_id: "ou-blocked",
1181
- },
1182
- },
1183
- message: {
1184
- message_id: "msg-global-group-sender-block",
1185
- chat_id: "oc-group",
1186
- chat_type: "group",
1187
- message_type: "text",
1188
- content: JSON.stringify({ text: "hello" }),
1189
- },
1190
- };
1191
-
1192
- await dispatchMessage({ cfg, event });
1193
-
1194
- expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1195
- expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1196
- });
1197
-
1198
- it("prefers per-group allowFrom over global groupSenderAllowFrom", async () => {
1199
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1200
-
1201
- const cfg: ClawdbotConfig = {
1202
- channels: {
1203
- feishu: {
1204
- groupPolicy: "open",
1205
- groupSenderAllowFrom: ["ou-global"],
1206
- groups: {
1207
- "oc-group": {
1208
- allowFrom: ["ou-group-only"],
1209
- requireMention: false,
1210
- },
1211
- },
1212
- },
1213
- },
1214
- } as ClawdbotConfig;
1215
-
1216
- const event: FeishuMessageEvent = {
1217
- sender: {
1218
- sender_id: {
1219
- open_id: "ou-global",
1220
- },
1221
- },
1222
- message: {
1223
- message_id: "msg-per-group-precedence",
1224
- chat_id: "oc-group",
1225
- chat_type: "group",
1226
- message_type: "text",
1227
- content: JSON.stringify({ text: "hello" }),
1228
- },
1229
- };
1230
-
1231
- await dispatchMessage({ cfg, event });
1232
-
1233
- expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1234
- expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1235
- });
1236
-
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
-
1248
- const cfg: ClawdbotConfig = {
1249
- channels: {
1250
- feishu: {
1251
- groupPolicy: "open",
1252
- groupSenderAllowFrom: ["ou-allowed"],
1253
- contextVisibility: "allowlist",
1254
- groups: {
1255
- "oc-group": {
1256
- requireMention: false,
1257
- },
1258
- },
1259
- },
1260
- },
1261
- } as ClawdbotConfig;
1262
-
1263
- const event: FeishuMessageEvent = {
1264
- sender: {
1265
- sender_id: {
1266
- open_id: "ou-allowed",
1267
- },
1268
- },
1269
- message: {
1270
- message_id: "msg-group-quoted-filter",
1271
- parent_id: "om_parent_blocked",
1272
- chat_id: "oc-group",
1273
- chat_type: "group",
1274
- message_type: "text",
1275
- content: JSON.stringify({ text: "hello" }),
1276
- },
1277
- };
1278
-
1279
- await dispatchMessage({ cfg, event });
1280
-
1281
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1282
- expect.objectContaining({
1283
- ReplyToId: "om_parent_blocked",
1284
- ReplyToBody: undefined,
1285
- }),
1286
- );
1287
- });
1288
-
1289
- it("keeps quoted group context from non-allowlisted senders in default all mode", async () => {
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
- });
1299
-
1300
- const cfg: ClawdbotConfig = {
1301
- channels: {
1302
- feishu: {
1303
- groupPolicy: "open",
1304
- groupSenderAllowFrom: ["ou-allowed"],
1305
- groups: {
1306
- "oc-group": {
1307
- requireMention: false,
1308
- },
1309
- },
1310
- },
1311
- },
1312
- } as ClawdbotConfig;
1313
-
1314
- const event: FeishuMessageEvent = {
1315
- sender: {
1316
- sender_id: {
1317
- open_id: "ou-allowed",
1318
- },
1319
- },
1320
- message: {
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",
1678
- image_key: "img_thumb_payload",
1679
- file_name: "clip.mp4",
1680
- }),
1681
- },
1682
- };
1683
-
1684
- await dispatchMessage({ cfg, event });
1685
-
1686
- expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
1687
- expect.objectContaining({
1688
- messageId: "msg-video-inbound",
1689
- fileKey: "file_video_payload",
1690
- type: "file",
1691
- }),
1692
- );
1693
- expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
1694
- expect.any(Buffer),
1695
- "video/mp4",
1696
- "inbound",
1697
- expect.any(Number),
1698
- "clip.mp4",
1699
- );
1700
- });
1701
-
1702
- it("uses media message_type file_key (not thumbnail image_key) for inbound mobile video download", async () => {
1703
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1704
-
1705
- const cfg: ClawdbotConfig = {
1706
- channels: {
1707
- feishu: {
1708
- dmPolicy: "open",
1709
- },
1710
- },
1711
- } as ClawdbotConfig;
1712
-
1713
- const event: FeishuMessageEvent = {
1714
- sender: {
1715
- sender_id: {
1716
- open_id: "ou-sender",
1717
- },
1718
- },
1719
- message: {
1720
- message_id: "msg-media-inbound",
1721
- chat_id: "oc-dm",
1722
- chat_type: "p2p",
1723
- message_type: "media",
1724
- content: JSON.stringify({
1725
- file_key: "file_media_payload",
1726
- image_key: "img_media_thumb",
1727
- file_name: "mobile.mp4",
1728
- }),
1729
- },
1730
- };
1731
-
1732
- await dispatchMessage({ cfg, event });
1733
-
1734
- expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
1735
- expect.objectContaining({
1736
- messageId: "msg-media-inbound",
1737
- fileKey: "file_media_payload",
1738
- type: "file",
1739
- }),
1740
- );
1741
- expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
1742
- expect.any(Buffer),
1743
- "video/mp4",
1744
- "inbound",
1745
- expect.any(Number),
1746
- "clip.mp4",
1747
- );
1748
- });
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
-
1795
- it("downloads embedded media tags from post messages as files", async () => {
1796
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1797
-
1798
- const cfg: ClawdbotConfig = {
1799
- channels: {
1800
- feishu: {
1801
- dmPolicy: "open",
1802
- },
1803
- },
1804
- } as ClawdbotConfig;
1805
-
1806
- const event: FeishuMessageEvent = {
1807
- sender: {
1808
- sender_id: {
1809
- open_id: "ou-sender",
1810
- },
1811
- },
1812
- message: {
1813
- message_id: "msg-post-media",
1814
- chat_id: "oc-dm",
1815
- chat_type: "p2p",
1816
- message_type: "post",
1817
- content: JSON.stringify({
1818
- title: "Rich text",
1819
- content: [
1820
- [
1821
- {
1822
- tag: "media",
1823
- file_key: "file_post_media_payload",
1824
- file_name: "embedded.mov",
1825
- },
1826
- ],
1827
- ],
1828
- }),
1829
- },
1830
- };
1831
-
1832
- await dispatchMessage({ cfg, event });
1833
-
1834
- expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
1835
- expect.objectContaining({
1836
- messageId: "msg-post-media",
1837
- fileKey: "file_post_media_payload",
1838
- type: "file",
1839
- }),
1840
- );
1841
- expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
1842
- expect.any(Buffer),
1843
- "video/mp4",
1844
- "inbound",
1845
- expect.any(Number),
1846
- );
1847
- });
1848
-
1849
- it("includes message_id in BodyForAgent on its own line", async () => {
1850
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1851
-
1852
- const cfg: ClawdbotConfig = {
1853
- channels: {
1854
- feishu: {
1855
- dmPolicy: "open",
1856
- },
1857
- },
1858
- } as ClawdbotConfig;
1859
-
1860
- const event: FeishuMessageEvent = {
1861
- sender: {
1862
- sender_id: {
1863
- open_id: "ou-msgid",
1864
- },
1865
- },
1866
- message: {
1867
- message_id: "msg-message-id-line",
1868
- chat_id: "oc-dm",
1869
- chat_type: "p2p",
1870
- message_type: "text",
1871
- content: JSON.stringify({ text: "hello" }),
1872
- },
1873
- };
1874
-
1875
- await dispatchMessage({ cfg, event });
1876
-
1877
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1878
- expect.objectContaining({
1879
- BodyForAgent: "[message_id: msg-message-id-line]\nou-msgid: hello",
1880
- }),
1881
- );
1882
- });
1883
-
1884
- it("expands merge_forward content from API sub-messages", async () => {
1885
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1886
- const mockGetMerged = vi.fn().mockResolvedValue({
1887
- code: 0,
1888
- data: {
1889
- items: [
1890
- {
1891
- message_id: "container",
1892
- msg_type: "merge_forward",
1893
- body: { content: JSON.stringify({ text: "Merged and Forwarded Message" }) },
1894
- },
1895
- {
1896
- message_id: "sub-2",
1897
- upper_message_id: "container",
1898
- msg_type: "file",
1899
- body: { content: JSON.stringify({ file_name: "report.pdf" }) },
1900
- create_time: "2000",
1901
- },
1902
- {
1903
- message_id: "sub-1",
1904
- upper_message_id: "container",
1905
- msg_type: "text",
1906
- body: { content: JSON.stringify({ text: "alpha" }) },
1907
- create_time: "1000",
1908
- },
1909
- ],
1910
- },
1911
- });
1912
- mockCreateFeishuClient.mockReturnValue({
1913
- contact: {
1914
- user: {
1915
- get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
1916
- },
1917
- },
1918
- im: {
1919
- message: {
1920
- get: mockGetMerged,
1921
- },
1922
- },
1923
- } as unknown as PluginRuntime);
1924
-
1925
- const cfg: ClawdbotConfig = {
1926
- channels: {
1927
- feishu: {
1928
- dmPolicy: "open",
1929
- },
1930
- },
1931
- } as ClawdbotConfig;
1932
-
1933
- const event: FeishuMessageEvent = {
1934
- sender: {
1935
- sender_id: {
1936
- open_id: "ou-merge",
1937
- },
1938
- },
1939
- message: {
1940
- message_id: "msg-merge-forward",
1941
- chat_id: "oc-dm",
1942
- chat_type: "p2p",
1943
- message_type: "merge_forward",
1944
- content: JSON.stringify({ text: "Merged and Forwarded Message" }),
1945
- },
1946
- };
1947
-
1948
- await dispatchMessage({ cfg, event });
1949
-
1950
- expect(mockGetMerged).toHaveBeenCalledWith({
1951
- path: { message_id: "msg-merge-forward" },
1952
- });
1953
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1954
- expect.objectContaining({
1955
- BodyForAgent: expect.stringContaining(
1956
- "[Merged and Forwarded Messages]\n- alpha\n- [File: report.pdf]",
1957
- ),
1958
- }),
1959
- );
1960
- });
1961
-
1962
- it("falls back when merge_forward API returns no sub-messages", async () => {
1963
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
1964
- mockCreateFeishuClient.mockReturnValue({
1965
- contact: {
1966
- user: {
1967
- get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
1968
- },
1969
- },
1970
- im: {
1971
- message: {
1972
- get: vi.fn().mockResolvedValue({ code: 0, data: { items: [] } }),
1973
- },
1974
- },
1975
- });
1976
-
1977
- const cfg: ClawdbotConfig = {
1978
- channels: {
1979
- feishu: {
1980
- dmPolicy: "open",
1981
- },
1982
- },
1983
- } as ClawdbotConfig;
1984
-
1985
- const event: FeishuMessageEvent = {
1986
- sender: {
1987
- sender_id: {
1988
- open_id: "ou-merge-empty",
1989
- },
1990
- },
1991
- message: {
1992
- message_id: "msg-merge-empty",
1993
- chat_id: "oc-dm",
1994
- chat_type: "p2p",
1995
- message_type: "merge_forward",
1996
- content: JSON.stringify({ text: "Merged and Forwarded Message" }),
1997
- },
1998
- };
1999
-
2000
- await dispatchMessage({ cfg, event });
2001
-
2002
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2003
- expect.objectContaining({
2004
- BodyForAgent: expect.stringContaining("[Merged and Forwarded Message - could not fetch]"),
2005
- }),
2006
- );
2007
- });
2008
-
2009
- it("dispatches once and appends permission notice to the main agent body", async () => {
2010
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2011
- mockCreateFeishuClient.mockReturnValue({
2012
- contact: {
2013
- user: {
2014
- get: vi.fn().mockRejectedValue({
2015
- response: {
2016
- data: {
2017
- code: 99991672,
2018
- msg: "permission denied https://open.feishu.cn/app/cli_test",
2019
- },
2020
- },
2021
- }),
2022
- },
2023
- },
2024
- });
2025
-
2026
- const cfg: ClawdbotConfig = {
2027
- channels: {
2028
- feishu: {
2029
- appId: "cli_test",
2030
- appSecret: "sec_test", // pragma: allowlist secret
2031
- groups: {
2032
- "oc-group": {
2033
- requireMention: false,
2034
- },
2035
- },
2036
- },
2037
- },
2038
- } as ClawdbotConfig;
2039
-
2040
- const event: FeishuMessageEvent = {
2041
- sender: {
2042
- sender_id: {
2043
- open_id: "ou-perm",
2044
- },
2045
- },
2046
- message: {
2047
- message_id: "msg-perm-1",
2048
- chat_id: "oc-group",
2049
- chat_type: "group",
2050
- message_type: "text",
2051
- content: JSON.stringify({ text: "hello group" }),
2052
- },
2053
- };
2054
-
2055
- await dispatchMessage({ cfg, event });
2056
-
2057
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
2058
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2059
- expect.objectContaining({
2060
- BodyForAgent: expect.stringContaining(
2061
- "Permission grant URL: https://open.feishu.cn/app/cli_test",
2062
- ),
2063
- }),
2064
- );
2065
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2066
- expect.objectContaining({
2067
- BodyForAgent: expect.stringContaining("ou-perm: hello group"),
2068
- }),
2069
- );
2070
- });
2071
-
2072
- it("ignores stale non-existent contact scope permission errors", async () => {
2073
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2074
- mockCreateFeishuClient.mockReturnValue({
2075
- contact: {
2076
- user: {
2077
- get: vi.fn().mockRejectedValue({
2078
- response: {
2079
- data: {
2080
- code: 99991672,
2081
- msg: "permission denied: contact:contact.base:readonly https://open.feishu.cn/app/cli_scope_bug",
2082
- },
2083
- },
2084
- }),
2085
- },
2086
- },
2087
- });
2088
-
2089
- const cfg: ClawdbotConfig = {
2090
- channels: {
2091
- feishu: {
2092
- appId: "cli_scope_bug",
2093
- appSecret: "sec_scope_bug", // pragma: allowlist secret
2094
- groups: {
2095
- "oc-group": {
2096
- requireMention: false,
2097
- },
2098
- },
2099
- },
2100
- },
2101
- } as ClawdbotConfig;
2102
-
2103
- const event: FeishuMessageEvent = {
2104
- sender: {
2105
- sender_id: {
2106
- open_id: "ou-perm-scope",
2107
- },
2108
- },
2109
- message: {
2110
- message_id: "msg-perm-scope-1",
2111
- chat_id: "oc-group",
2112
- chat_type: "group",
2113
- message_type: "text",
2114
- content: JSON.stringify({ text: "hello group" }),
2115
- },
2116
- };
2117
-
2118
- await dispatchMessage({ cfg, event });
2119
-
2120
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
2121
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2122
- expect.objectContaining({
2123
- BodyForAgent: expect.not.stringContaining("Permission grant URL"),
2124
- }),
2125
- );
2126
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2127
- expect.objectContaining({
2128
- BodyForAgent: expect.stringContaining("ou-perm-scope: hello group"),
2129
- }),
2130
- );
2131
- });
2132
-
2133
- it("routes group sessions by sender when groupSessionScope=group_sender", async () => {
2134
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2135
-
2136
- const cfg: ClawdbotConfig = {
2137
- channels: {
2138
- feishu: {
2139
- groups: {
2140
- "oc-group": {
2141
- requireMention: false,
2142
- groupSessionScope: "group_sender",
2143
- },
2144
- },
2145
- },
2146
- },
2147
- } as ClawdbotConfig;
2148
-
2149
- const event: FeishuMessageEvent = {
2150
- sender: { sender_id: { open_id: "ou-scope-user" } },
2151
- message: {
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",
2190
- chat_id: "oc-group",
2191
- chat_type: "group",
2192
- root_id: "om_root_topic",
2193
- message_type: "text",
2194
- content: JSON.stringify({ text: "topic sender scope" }),
2195
- },
2196
- };
2197
-
2198
- await dispatchMessage({ cfg, event });
2199
-
2200
- expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2201
- expect.objectContaining({
2202
- peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
2203
- parentPeer: { kind: "group", id: "oc-group" },
2204
- }),
2205
- );
2206
- });
2207
-
2208
- it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
2209
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2210
-
2211
- const cfg: ClawdbotConfig = {
2212
- channels: {
2213
- feishu: {
2214
- groups: {
2215
- "oc-group": {
2216
- requireMention: false,
2217
- groupSessionScope: "group_topic_sender",
2218
- },
2219
- },
2220
- },
2221
- },
2222
- } as ClawdbotConfig;
2223
-
2224
- const event: FeishuMessageEvent = {
2225
- sender: { sender_id: { open_id: "ou-topic-user" } },
2226
- message: {
2227
- message_id: "msg-scope-topic-thread-id",
2228
- chat_id: "oc-group",
2229
- chat_type: "group",
2230
- root_id: "om_root_topic",
2231
- thread_id: "omt_topic_1",
2232
- message_type: "text",
2233
- content: JSON.stringify({ text: "topic sender scope" }),
2234
- },
2235
- };
2236
-
2237
- await dispatchMessage({ cfg, event });
2238
-
2239
- expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2240
- expect.objectContaining({
2241
- peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
2242
- parentPeer: { kind: "group", id: "oc-group" },
2243
- }),
2244
- );
2245
- });
2246
-
2247
- it("uses thread_id as the canonical topic key in Feishu topic groups", async () => {
2248
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2249
-
2250
- const cfg: ClawdbotConfig = {
2251
- channels: {
2252
- feishu: {
2253
- groups: {
2254
- "oc-group": {
2255
- requireMention: false,
2256
- groupSessionScope: "group_topic",
2257
- },
2258
- },
2259
- },
2260
- },
2261
- } as ClawdbotConfig;
2262
-
2263
- const topicStarter: FeishuMessageEvent = {
2264
- sender: { sender_id: { open_id: "ou-topic-user" } },
2265
- message: {
2266
- message_id: "om_topic_starter_message",
2267
- chat_id: "oc-group",
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",
2281
- thread_id: "omt_topic_1",
2282
- message_type: "text",
2283
- content: JSON.stringify({ text: "topic reply" }),
2284
- },
2285
- };
2286
-
2287
- await dispatchMessage({ cfg, event: topicStarter });
2288
- await dispatchMessage({ cfg, event: topicReply });
2289
-
2290
- expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
2291
- 1,
2292
- expect.objectContaining({
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" },
2301
- parentPeer: { kind: "group", id: "oc-group" },
2302
- }),
2303
- );
2304
- });
2305
-
2306
- it("uses thread_id as topic key when root_id is missing", async () => {
2307
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2308
-
2309
- const cfg: ClawdbotConfig = {
2310
- channels: {
2311
- feishu: {
2312
- groups: {
2313
- "oc-group": {
2314
- requireMention: false,
2315
- groupSessionScope: "group_topic_sender",
2316
- },
2317
- },
2318
- },
2319
- },
2320
- } as ClawdbotConfig;
2321
-
2322
- const event: FeishuMessageEvent = {
2323
- sender: { sender_id: { open_id: "ou-topic-user" } },
2324
- message: {
2325
- message_id: "msg-scope-topic-thread-only",
2326
- chat_id: "oc-group",
2327
- chat_type: "group",
2328
- thread_id: "omt_topic_1",
2329
- message_type: "text",
2330
- content: JSON.stringify({ text: "topic sender scope" }),
2331
- },
2332
- };
2333
-
2334
- await dispatchMessage({ cfg, event });
2335
-
2336
- expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2337
- expect.objectContaining({
2338
- peer: { kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
2339
- parentPeer: { kind: "group", id: "oc-group" },
2340
- }),
2341
- );
2342
- });
2343
-
2344
- it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
2345
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2346
-
2347
- const cfg: ClawdbotConfig = {
2348
- channels: {
2349
- feishu: {
2350
- topicSessionMode: "enabled",
2351
- groups: {
2352
- "oc-group": {
2353
- requireMention: false,
2354
- },
2355
- },
2356
- },
2357
- },
2358
- } as ClawdbotConfig;
2359
-
2360
- const event: FeishuMessageEvent = {
2361
- sender: { sender_id: { open_id: "ou-legacy" } },
2362
- message: {
2363
- message_id: "msg-legacy-topic-mode",
2364
- chat_id: "oc-group",
2365
- chat_type: "group",
2366
- root_id: "om_root_legacy",
2367
- message_type: "text",
2368
- content: JSON.stringify({ text: "legacy topic mode" }),
2369
- },
2370
- };
2371
-
2372
- await dispatchMessage({ cfg, event });
2373
-
2374
- expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2375
- expect.objectContaining({
2376
- peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
2377
- parentPeer: { kind: "group", id: "oc-group" },
2378
- }),
2379
- );
2380
- });
2381
-
2382
- it("maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist", async () => {
2383
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2384
-
2385
- const cfg: ClawdbotConfig = {
2386
- channels: {
2387
- feishu: {
2388
- topicSessionMode: "enabled",
2389
- groups: {
2390
- "oc-group": {
2391
- requireMention: false,
2392
- },
2393
- },
2394
- },
2395
- },
2396
- } as ClawdbotConfig;
2397
-
2398
- const event: FeishuMessageEvent = {
2399
- sender: { sender_id: { open_id: "ou-legacy-thread-id" } },
2400
- message: {
2401
- message_id: "msg-legacy-topic-thread-id",
2402
- chat_id: "oc-group",
2403
- chat_type: "group",
2404
- root_id: "om_root_legacy",
2405
- thread_id: "omt_topic_legacy",
2406
- message_type: "text",
2407
- content: JSON.stringify({ text: "legacy topic mode" }),
2408
- },
2409
- };
2410
-
2411
- await dispatchMessage({ cfg, event });
2412
-
2413
- expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2414
- expect.objectContaining({
2415
- peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
2416
- parentPeer: { kind: "group", id: "oc-group" },
2417
- }),
2418
- );
2419
- });
2420
-
2421
- it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
2422
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2423
-
2424
- const cfg: ClawdbotConfig = {
2425
- channels: {
2426
- feishu: {
2427
- groups: {
2428
- "oc-group": {
2429
- requireMention: false,
2430
- groupSessionScope: "group_topic",
2431
- replyInThread: "enabled",
2432
- },
2433
- },
2434
- },
2435
- },
2436
- } as ClawdbotConfig;
2437
-
2438
- const event: FeishuMessageEvent = {
2439
- sender: { sender_id: { open_id: "ou-topic-init" } },
2440
- message: {
2441
- message_id: "msg-new-topic-root",
2442
- chat_id: "oc-group",
2443
- chat_type: "group",
2444
- message_type: "text",
2445
- content: JSON.stringify({ text: "create topic" }),
2446
- },
2447
- };
2448
-
2449
- await dispatchMessage({ cfg, event });
2450
-
2451
- expect(mockResolveAgentRoute).toHaveBeenCalledWith(
2452
- expect.objectContaining({
2453
- peer: { kind: "group", id: "oc-group:topic:msg-new-topic-root" },
2454
- parentPeer: { kind: "group", id: "oc-group" },
2455
- }),
2456
- );
2457
- });
2458
-
2459
- it("keeps topic session key stable after first turn creates a thread", async () => {
2460
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2461
-
2462
- const cfg: ClawdbotConfig = {
2463
- channels: {
2464
- feishu: {
2465
- groups: {
2466
- "oc-group": {
2467
- requireMention: false,
2468
- groupSessionScope: "group_topic",
2469
- replyInThread: "enabled",
2470
- },
2471
- },
2472
- },
2473
- },
2474
- } as ClawdbotConfig;
2475
-
2476
- const firstTurn: FeishuMessageEvent = {
2477
- sender: { sender_id: { open_id: "ou-topic-init" } },
2478
- message: {
2479
- message_id: "msg-topic-first",
2480
- chat_id: "oc-group",
2481
- chat_type: "group",
2482
- message_type: "text",
2483
- content: JSON.stringify({ text: "create topic" }),
2484
- },
2485
- };
2486
- const secondTurn: FeishuMessageEvent = {
2487
- sender: { sender_id: { open_id: "ou-topic-init" } },
2488
- message: {
2489
- message_id: "msg-topic-second",
2490
- chat_id: "oc-group",
2491
- chat_type: "group",
2492
- root_id: "msg-topic-first",
2493
- thread_id: "omt_topic_created",
2494
- message_type: "text",
2495
- content: JSON.stringify({ text: "follow up in same topic" }),
2496
- },
2497
- };
2498
-
2499
- await dispatchMessage({ cfg, event: firstTurn });
2500
- await dispatchMessage({ cfg, event: secondTurn });
2501
-
2502
- expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
2503
- 1,
2504
- expect.objectContaining({
2505
- peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
2506
- }),
2507
- );
2508
- expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
2509
- 2,
2510
- expect.objectContaining({
2511
- peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
2512
- }),
2513
- );
2514
- });
2515
-
2516
- it("replies to the topic root when handling a message inside an existing topic", async () => {
2517
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2518
-
2519
- const cfg: ClawdbotConfig = {
2520
- channels: {
2521
- feishu: {
2522
- groups: {
2523
- "oc-group": {
2524
- requireMention: false,
2525
- replyInThread: "enabled",
2526
- },
2527
- },
2528
- },
2529
- },
2530
- } as ClawdbotConfig;
2531
-
2532
- const event: FeishuMessageEvent = {
2533
- sender: { sender_id: { open_id: "ou-topic-user" } },
2534
- message: {
2535
- message_id: "om_child_message",
2536
- root_id: "om_root_topic",
2537
- chat_id: "oc-group",
2538
- chat_type: "group",
2539
- message_type: "text",
2540
- content: JSON.stringify({ text: "reply inside topic" }),
2541
- },
2542
- };
2543
-
2544
- await dispatchMessage({ cfg, event });
2545
-
2546
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
2547
- expect.objectContaining({
2548
- replyToMessageId: "om_root_topic",
2549
- rootId: "om_root_topic",
2550
- }),
2551
- );
2552
- });
2553
-
2554
- it("replies to triggering message in normal group even when root_id is present (#32980)", async () => {
2555
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2556
-
2557
- const cfg: ClawdbotConfig = {
2558
- channels: {
2559
- feishu: {
2560
- groups: {
2561
- "oc-group": {
2562
- requireMention: false,
2563
- groupSessionScope: "group",
2564
- },
2565
- },
2566
- },
2567
- },
2568
- } as ClawdbotConfig;
2569
-
2570
- const event: FeishuMessageEvent = {
2571
- sender: { sender_id: { open_id: "ou-normal-user" } },
2572
- message: {
2573
- message_id: "om_quote_reply",
2574
- root_id: "om_original_msg",
2575
- chat_id: "oc-group",
2576
- chat_type: "group",
2577
- message_type: "text",
2578
- content: JSON.stringify({ text: "hello in normal group" }),
2579
- },
2580
- };
2581
-
2582
- await dispatchMessage({ cfg, event });
2583
-
2584
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
2585
- expect.objectContaining({
2586
- replyToMessageId: "om_quote_reply",
2587
- rootId: "om_original_msg",
2588
- }),
2589
- );
2590
- });
2591
-
2592
- it("replies to topic root in topic-mode group with root_id", async () => {
2593
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2594
-
2595
- const cfg: ClawdbotConfig = {
2596
- channels: {
2597
- feishu: {
2598
- groups: {
2599
- "oc-group": {
2600
- requireMention: false,
2601
- groupSessionScope: "group_topic",
2602
- },
2603
- },
2604
- },
2605
- },
2606
- } as ClawdbotConfig;
2607
-
2608
- const event: FeishuMessageEvent = {
2609
- sender: { sender_id: { open_id: "ou-topic-user" } },
2610
- message: {
2611
- message_id: "om_topic_reply",
2612
- root_id: "om_topic_root",
2613
- chat_id: "oc-group",
2614
- chat_type: "group",
2615
- message_type: "text",
2616
- content: JSON.stringify({ text: "hello in topic group" }),
2617
- },
2618
- };
2619
-
2620
- await dispatchMessage({ cfg, event });
2621
-
2622
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
2623
- expect.objectContaining({
2624
- replyToMessageId: "om_topic_root",
2625
- rootId: "om_topic_root",
2626
- }),
2627
- );
2628
- });
2629
-
2630
- it("replies to topic root in topic-sender group with root_id", async () => {
2631
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2632
-
2633
- const cfg: ClawdbotConfig = {
2634
- channels: {
2635
- feishu: {
2636
- groups: {
2637
- "oc-group": {
2638
- requireMention: false,
2639
- groupSessionScope: "group_topic_sender",
2640
- },
2641
- },
2642
- },
2643
- },
2644
- } as ClawdbotConfig;
2645
-
2646
- const event: FeishuMessageEvent = {
2647
- sender: { sender_id: { open_id: "ou-topic-sender-user" } },
2648
- message: {
2649
- message_id: "om_topic_sender_reply",
2650
- root_id: "om_topic_sender_root",
2651
- chat_id: "oc-group",
2652
- chat_type: "group",
2653
- message_type: "text",
2654
- content: JSON.stringify({ text: "hello in topic sender group" }),
2655
- },
2656
- };
2657
-
2658
- await dispatchMessage({ cfg, event });
2659
-
2660
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
2661
- expect.objectContaining({
2662
- replyToMessageId: "om_topic_sender_root",
2663
- rootId: "om_topic_sender_root",
2664
- }),
2665
- );
2666
- });
2667
-
2668
- it("forces thread replies when inbound message contains thread_id", async () => {
2669
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2670
-
2671
- const cfg: ClawdbotConfig = {
2672
- channels: {
2673
- feishu: {
2674
- groups: {
2675
- "oc-group": {
2676
- requireMention: false,
2677
- groupSessionScope: "group",
2678
- replyInThread: "disabled",
2679
- },
2680
- },
2681
- },
2682
- },
2683
- } as ClawdbotConfig;
2684
-
2685
- const event: FeishuMessageEvent = {
2686
- sender: { sender_id: { open_id: "ou-thread-reply" } },
2687
- message: {
2688
- message_id: "msg-thread-reply",
2689
- chat_id: "oc-group",
2690
- chat_type: "group",
2691
- thread_id: "omt_topic_thread_reply",
2692
- message_type: "text",
2693
- content: JSON.stringify({ text: "thread content" }),
2694
- },
2695
- };
2696
-
2697
- await dispatchMessage({ cfg, event });
2698
-
2699
- expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
2700
- expect.objectContaining({
2701
- replyInThread: true,
2702
- threadReply: true,
2703
- }),
2704
- );
2705
- });
2706
-
2707
- it("bootstraps topic thread context only for a new thread session", async () => {
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
- ]);
2734
-
2735
- const cfg: ClawdbotConfig = {
2736
- channels: {
2737
- feishu: {
2738
- groups: {
2739
- "oc-group": {
2740
- requireMention: false,
2741
- groupSessionScope: "group_topic",
2742
- },
2743
- },
2744
- },
2745
- },
2746
- } as ClawdbotConfig;
2747
-
2748
- const event: FeishuMessageEvent = {
2749
- sender: { sender_id: { open_id: "ou-topic-user" } },
2750
- message: {
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" }),
2757
- },
2758
- };
2759
-
2760
- await dispatchMessage({ cfg, event });
2761
-
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
- }),
2770
- );
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
- }),
2778
- );
2779
- });
2780
-
2781
- it("skips topic thread bootstrap when the thread session already exists", async () => {
2782
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2783
- mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
2784
-
2785
- const cfg: ClawdbotConfig = {
2786
- channels: {
2787
- feishu: {
2788
- groups: {
2789
- "oc-group": {
2790
- requireMention: false,
2791
- groupSessionScope: "group_topic",
2792
- },
2793
- },
2794
- },
2795
- },
2796
- } as ClawdbotConfig;
2797
-
2798
- const event: FeishuMessageEvent = {
2799
- sender: { sender_id: { open_id: "ou-topic-user" } },
2800
- message: {
2801
- message_id: "om_topic_followup",
2802
- root_id: "om_topic_root",
2803
- chat_id: "oc-group",
2804
- chat_type: "group",
2805
- message_type: "text",
2806
- content: JSON.stringify({ text: "current turn" }),
2807
- },
2808
- };
2809
-
2810
- await dispatchMessage({ cfg, event });
2811
-
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
- }),
2821
- );
2822
- });
2823
-
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
-
2852
- const cfg: ClawdbotConfig = {
2853
- channels: {
2854
- feishu: {
2855
- groups: {
2856
- "oc-group": {
2857
- requireMention: false,
2858
- groupSessionScope: "group_topic_sender",
2859
- },
2860
- },
2861
- },
2862
- },
2863
- } as ClawdbotConfig;
2864
-
2865
- const event: FeishuMessageEvent = {
2866
- sender: {
2867
- sender_id: {
2868
- open_id: "ou-topic-user",
2869
- user_id: "user_topic_1",
2870
- },
2871
- },
2872
- message: {
2873
- message_id: "om_topic_followup_mixed_ids",
2874
- root_id: "om_topic_root",
2875
- chat_id: "oc-group",
2876
- chat_type: "group",
2877
- message_type: "text",
2878
- content: JSON.stringify({ text: "current turn" }),
2879
- },
2880
- };
2881
-
2882
- await dispatchMessage({ cfg, event });
2883
-
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
- );
2892
- });
2893
-
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
-
2932
- const cfg: ClawdbotConfig = {
2933
- channels: {
2934
- feishu: {
2935
- groupPolicy: "open",
2936
- groupSenderAllowFrom: ["ou-allowed"],
2937
- contextVisibility: "allowlist",
2938
- groups: {
2939
- "oc-group": {
2940
- requireMention: false,
2941
- groupSessionScope: "group_topic",
2942
- },
2943
- },
2944
- },
2945
- },
2946
- } as ClawdbotConfig;
2947
-
2948
- const event: FeishuMessageEvent = {
2949
- sender: { sender_id: { open_id: "ou-allowed" } },
2950
- message: {
2951
- message_id: "om_topic_followup_allowlisted",
2952
- root_id: "om_topic_root",
2953
- thread_id: "omt_topic_1",
2954
- chat_id: "oc-group",
2955
- chat_type: "group",
2956
- message_type: "text",
2957
- content: JSON.stringify({ text: "current turn" }),
2958
- },
2959
- };
2960
-
2961
- await dispatchMessage({ cfg, event });
2962
-
2963
- expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
2964
- expect.objectContaining({
2965
- ThreadStarterBody: "assistant reply",
2966
- ThreadHistoryBody: "assistant reply\n\nallowed follow-up",
2967
- }),
2968
- );
2969
- });
2970
-
2971
- it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
2972
- mockShouldComputeCommandAuthorized.mockReturnValue(false);
2973
-
2974
- const cfg: ClawdbotConfig = {
2975
- channels: {
2976
- feishu: {
2977
- dmPolicy: "open",
2978
- },
2979
- },
2980
- } as ClawdbotConfig;
2981
-
2982
- const event: FeishuMessageEvent = {
2983
- sender: {
2984
- sender_id: {
2985
- open_id: "ou-image-dedup",
2986
- },
2987
- },
2988
- message: {
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
- }),
2996
- },
2997
- };
2998
-
2999
- await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
3000
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
3001
- });
3002
-
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
-
3010
- const cfg: ClawdbotConfig = {
3011
- channels: {
3012
- feishu: {
3013
- dmPolicy: "open",
3014
- allowFrom: ["*"],
3015
- },
3016
- },
3017
- } as ClawdbotConfig;
3018
-
3019
- const event: FeishuMessageEvent = {
3020
- sender: {
3021
- sender_id: {
3022
- open_id: "ou-empty-text-sender",
3023
- },
3024
- },
3025
- message: {
3026
- message_id: "msg-empty-text-74634",
3027
- chat_id: "oc-dm",
3028
- chat_type: "p2p",
3029
- message_type: "text",
3030
- // Feishu encodes empty text as {"text":""}
3031
- content: JSON.stringify({ text: "" }),
3032
- },
3033
- };
3034
-
3035
- await dispatchMessage({ cfg, event });
3036
-
3037
- // No reply should be dispatched: empty message is silently skipped
3038
- expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
3039
- });
3040
- });