@openclaw/feishu 2026.3.13 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1827 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
@@ -0,0 +1,453 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
3
+ import { expect, vi, type Mock } from "vitest";
4
+ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
5
+ import { setFeishuRuntime } from "../runtime.js";
6
+ import type { ResolvedFeishuAccount } from "../types.js";
7
+
8
+ const FEISHU_LIFECYCLE_WAIT_TIMEOUT_MS = 10_000;
9
+
10
+ type InboundDebouncerParams<T> = {
11
+ onFlush?: (items: T[]) => Promise<void>;
12
+ onError?: (err: unknown, items: T[]) => void;
13
+ };
14
+ type UnknownMock = Mock<(...args: unknown[]) => unknown>;
15
+ type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
16
+ type FeishuDispatchReplyCounts = {
17
+ final: number;
18
+ block?: number;
19
+ tool?: number;
20
+ };
21
+ type FeishuDispatchReplyContext = Record<string, unknown> & {
22
+ SessionKey?: string;
23
+ };
24
+ type FeishuDispatchReplyDispatcher = {
25
+ sendFinalReply: (payload: { text: string }) => unknown;
26
+ };
27
+ type FeishuDispatchReplyMock = Mock<
28
+ (args: {
29
+ ctx: FeishuDispatchReplyContext;
30
+ dispatcher: FeishuDispatchReplyDispatcher;
31
+ }) => Promise<{ queuedFinal: boolean; counts: FeishuDispatchReplyCounts }>
32
+ >;
33
+ type FeishuLifecycleReplyDispatcher = {
34
+ dispatcher: {
35
+ sendToolResult: UnknownMock;
36
+ sendBlockReply: UnknownMock;
37
+ sendFinalReply: AsyncUnknownMock;
38
+ waitForIdle: AsyncUnknownMock;
39
+ getQueuedCounts: UnknownMock;
40
+ markComplete: UnknownMock;
41
+ };
42
+ replyOptions: Record<string, never>;
43
+ markDispatchIdle: UnknownMock;
44
+ };
45
+
46
+ export function setFeishuLifecycleStateDir(prefix: string) {
47
+ process.env.OPENCLAW_STATE_DIR = `/tmp/${prefix}-${randomUUID()}`;
48
+ }
49
+
50
+ export function restoreFeishuLifecycleStateDir(originalStateDir: string | undefined) {
51
+ if (originalStateDir === undefined) {
52
+ delete process.env.OPENCLAW_STATE_DIR;
53
+ return;
54
+ }
55
+ process.env.OPENCLAW_STATE_DIR = originalStateDir;
56
+ }
57
+
58
+ const FEISHU_PREFETCHED_BOT_OPEN_ID_SOURCE = {
59
+ kind: "prefetched",
60
+ botOpenId: "ou_bot_1",
61
+ botName: "Bot",
62
+ } as const;
63
+
64
+ export function createFeishuLifecycleReplyDispatcher(): FeishuLifecycleReplyDispatcher {
65
+ return {
66
+ dispatcher: {
67
+ sendToolResult: vi.fn(() => false),
68
+ sendBlockReply: vi.fn(() => false),
69
+ sendFinalReply: vi.fn(async () => true),
70
+ waitForIdle: vi.fn(async () => {}),
71
+ getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
72
+ markComplete: vi.fn(),
73
+ },
74
+ replyOptions: {},
75
+ markDispatchIdle: vi.fn(),
76
+ };
77
+ }
78
+
79
+ function createImmediateInboundDebounce() {
80
+ return {
81
+ resolveInboundDebounceMs: vi.fn(() => 0),
82
+ createInboundDebouncer: <T>(params: InboundDebouncerParams<T>) => ({
83
+ enqueue: async (item: T) => {
84
+ try {
85
+ await params.onFlush?.([item]);
86
+ } catch (err) {
87
+ params.onError?.(err, [item]);
88
+ }
89
+ },
90
+ flushKey: async () => {},
91
+ }),
92
+ };
93
+ }
94
+
95
+ function installFeishuLifecycleRuntime(params: {
96
+ resolveAgentRoute: PluginRuntime["channel"]["routing"]["resolveAgentRoute"];
97
+ finalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"];
98
+ dispatchReplyFromConfig: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"];
99
+ withReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"];
100
+ resolveStorePath: PluginRuntime["channel"]["session"]["resolveStorePath"];
101
+ hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
102
+ shouldComputeCommandAuthorized?: PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"];
103
+ resolveCommandAuthorizedFromAuthorizers?: PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"];
104
+ readAllowFromStore?: PluginRuntime["channel"]["pairing"]["readAllowFromStore"];
105
+ upsertPairingRequest?: PluginRuntime["channel"]["pairing"]["upsertPairingRequest"];
106
+ buildPairingReply?: PluginRuntime["channel"]["pairing"]["buildPairingReply"];
107
+ detectMime?: PluginRuntime["media"]["detectMime"];
108
+ }): PluginRuntime {
109
+ const runtime = createPluginRuntimeMock({
110
+ channel: {
111
+ debounce: createImmediateInboundDebounce(),
112
+ text: {
113
+ hasControlCommand: params.hasControlCommand ?? vi.fn(() => false),
114
+ },
115
+ routing: {
116
+ resolveAgentRoute: params.resolveAgentRoute,
117
+ },
118
+ reply: {
119
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
120
+ formatAgentEnvelope: vi.fn((value: { body: string }) => value.body),
121
+ finalizeInboundContext: params.finalizeInboundContext,
122
+ dispatchReplyFromConfig: params.dispatchReplyFromConfig,
123
+ withReplyDispatcher: params.withReplyDispatcher,
124
+ },
125
+ commands: {
126
+ shouldComputeCommandAuthorized: params.shouldComputeCommandAuthorized ?? vi.fn(() => false),
127
+ resolveCommandAuthorizedFromAuthorizers:
128
+ params.resolveCommandAuthorizedFromAuthorizers ?? vi.fn(() => false),
129
+ },
130
+ session: {
131
+ readSessionUpdatedAt: vi.fn(),
132
+ resolveStorePath: params.resolveStorePath,
133
+ },
134
+ pairing: {
135
+ readAllowFromStore: params.readAllowFromStore ?? vi.fn().mockResolvedValue([]),
136
+ upsertPairingRequest: params.upsertPairingRequest ?? vi.fn(),
137
+ buildPairingReply: params.buildPairingReply ?? vi.fn(),
138
+ },
139
+ },
140
+ media: {
141
+ detectMime: params.detectMime ?? vi.fn(async () => "text/plain"),
142
+ },
143
+ }) as unknown as PluginRuntime;
144
+ setFeishuRuntime(runtime);
145
+ return runtime;
146
+ }
147
+
148
+ export function installFeishuLifecycleReplyRuntime(params: {
149
+ resolveAgentRouteMock: unknown;
150
+ finalizeInboundContextMock: unknown;
151
+ dispatchReplyFromConfigMock: unknown;
152
+ withReplyDispatcherMock: unknown;
153
+ storePath: string;
154
+ }): PluginRuntime {
155
+ return installFeishuLifecycleRuntime({
156
+ resolveAgentRoute:
157
+ params.resolveAgentRouteMock as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
158
+ finalizeInboundContext:
159
+ params.finalizeInboundContextMock as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
160
+ dispatchReplyFromConfig:
161
+ params.dispatchReplyFromConfigMock as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
162
+ withReplyDispatcher:
163
+ params.withReplyDispatcherMock as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
164
+ resolveStorePath: vi.fn(() => params.storePath),
165
+ });
166
+ }
167
+
168
+ export function mockFeishuReplyOnceDispatch(params: {
169
+ dispatchReplyFromConfigMock: FeishuDispatchReplyMock;
170
+ replyText: string;
171
+ shouldSendFinalReply?: (ctx: unknown) => boolean;
172
+ }) {
173
+ params.dispatchReplyFromConfigMock.mockImplementation(async ({ ctx, dispatcher }) => {
174
+ const shouldSendFinalReply = params.shouldSendFinalReply?.(ctx) ?? true;
175
+ if (shouldSendFinalReply && typeof dispatcher?.sendFinalReply === "function") {
176
+ await dispatcher.sendFinalReply({ text: params.replyText });
177
+ }
178
+ return {
179
+ queuedFinal: false,
180
+ counts: { final: shouldSendFinalReply ? 1 : 0 },
181
+ };
182
+ });
183
+ }
184
+
185
+ export function createFeishuLifecycleConfig(params: {
186
+ accountId: string;
187
+ appId: string;
188
+ appSecret: string;
189
+ channelConfig?: Record<string, unknown>;
190
+ accountConfig?: Record<string, unknown>;
191
+ extraConfig?: Record<string, unknown>;
192
+ }): ClawdbotConfig {
193
+ const extraConfig = params.extraConfig ?? {};
194
+ return {
195
+ ...extraConfig,
196
+ channels: {
197
+ ...(extraConfig.channels as Record<string, unknown> | undefined),
198
+ feishu: {
199
+ enabled: true,
200
+ requireMention: false,
201
+ resolveSenderNames: false,
202
+ ...params.channelConfig,
203
+ accounts: {
204
+ [params.accountId]: {
205
+ enabled: true,
206
+ appId: params.appId,
207
+ appSecret: params.appSecret, // pragma: allowlist secret
208
+ connectionMode: "websocket",
209
+ requireMention: false,
210
+ resolveSenderNames: false,
211
+ ...params.accountConfig,
212
+ },
213
+ },
214
+ },
215
+ },
216
+ messages: {
217
+ inbound: {
218
+ debounceMs: 0,
219
+ byChannel: {
220
+ feishu: 0,
221
+ },
222
+ },
223
+ },
224
+ } as ClawdbotConfig;
225
+ }
226
+
227
+ export function createFeishuLifecycleFixture(params: {
228
+ accountId: string;
229
+ appId: string;
230
+ appSecret: string;
231
+ channelConfig?: Record<string, unknown>;
232
+ accountConfig?: Record<string, unknown>;
233
+ extraConfig?: Record<string, unknown>;
234
+ }) {
235
+ return {
236
+ cfg: createFeishuLifecycleConfig(params),
237
+ account: createResolvedFeishuLifecycleAccount({
238
+ accountId: params.accountId,
239
+ appId: params.appId,
240
+ appSecret: params.appSecret,
241
+ config: {
242
+ ...params.channelConfig,
243
+ ...params.accountConfig,
244
+ },
245
+ }),
246
+ };
247
+ }
248
+
249
+ export function createResolvedFeishuLifecycleAccount(params: {
250
+ accountId: string;
251
+ appId: string;
252
+ appSecret: string;
253
+ config: Record<string, unknown>;
254
+ }): ResolvedFeishuAccount {
255
+ return {
256
+ accountId: params.accountId,
257
+ selectionSource: "config",
258
+ enabled: true,
259
+ configured: true,
260
+ appId: params.appId,
261
+ appSecret: params.appSecret, // pragma: allowlist secret
262
+ domain: "feishu",
263
+ config: {
264
+ enabled: true,
265
+ connectionMode: "websocket",
266
+ requireMention: false,
267
+ resolveSenderNames: false,
268
+ ...params.config,
269
+ },
270
+ } as unknown as ResolvedFeishuAccount;
271
+ }
272
+
273
+ export function createFeishuTextMessageEvent(params: {
274
+ messageId: string;
275
+ chatId: string;
276
+ text: string;
277
+ chatType?: "group" | "p2p";
278
+ senderOpenId?: string;
279
+ rootId?: string;
280
+ threadId?: string;
281
+ }) {
282
+ return {
283
+ sender: {
284
+ sender_id: { open_id: params.senderOpenId ?? "ou_sender_1" },
285
+ sender_type: "user",
286
+ },
287
+ message: {
288
+ message_id: params.messageId,
289
+ ...(params.rootId ? { root_id: params.rootId } : {}),
290
+ ...(params.threadId ? { thread_id: params.threadId } : {}),
291
+ chat_id: params.chatId,
292
+ chat_type: params.chatType ?? "group",
293
+ message_type: "text",
294
+ content: JSON.stringify({ text: params.text }),
295
+ create_time: "1710000000000",
296
+ },
297
+ };
298
+ }
299
+
300
+ async function expectFeishuLifecycleEventually(
301
+ assertion: () => void | Promise<void>,
302
+ timeoutMs: number,
303
+ ) {
304
+ try {
305
+ await assertion();
306
+ } catch {
307
+ await vi.waitFor(assertion, { timeout: timeoutMs });
308
+ }
309
+ }
310
+
311
+ async function replayFeishuLifecycleEvent(params: {
312
+ handler: (data: unknown) => Promise<void>;
313
+ event: unknown;
314
+ waitForFirst: () => void | Promise<void>;
315
+ waitForSecond?: () => void | Promise<void>;
316
+ waitTimeoutMs?: number;
317
+ }) {
318
+ const waitTimeoutMs = params.waitTimeoutMs ?? FEISHU_LIFECYCLE_WAIT_TIMEOUT_MS;
319
+ await params.handler(params.event);
320
+ await expectFeishuLifecycleEventually(params.waitForFirst, waitTimeoutMs);
321
+ await params.handler(params.event);
322
+ await expectFeishuLifecycleEventually(params.waitForSecond ?? params.waitForFirst, waitTimeoutMs);
323
+ }
324
+
325
+ export async function runFeishuLifecycleSequence(
326
+ deliveries: Array<() => Promise<void>>,
327
+ waits: Array<() => void | Promise<void>>,
328
+ ) {
329
+ for (const [index, deliver] of deliveries.entries()) {
330
+ await deliver();
331
+ await expectFeishuLifecycleEventually(
332
+ waits[index] ?? waits.at(-1) ?? (() => {}),
333
+ FEISHU_LIFECYCLE_WAIT_TIMEOUT_MS,
334
+ );
335
+ }
336
+ }
337
+
338
+ export async function expectFeishuSingleEffectAcrossReplay(params: {
339
+ handler: (data: unknown) => Promise<void>;
340
+ event: unknown;
341
+ effectMock: ReturnType<typeof vi.fn>;
342
+ effectCount?: number;
343
+ }) {
344
+ const effectCount = params.effectCount ?? 1;
345
+ await replayFeishuLifecycleEvent({
346
+ handler: params.handler,
347
+ event: params.event,
348
+ waitForFirst: () => {
349
+ expect(params.effectMock).toHaveBeenCalledTimes(effectCount);
350
+ },
351
+ });
352
+ }
353
+
354
+ export async function expectFeishuReplyPipelineDedupedAcrossReplay(params: {
355
+ handler: (data: unknown) => Promise<void>;
356
+ event: unknown;
357
+ dispatchReplyFromConfigMock: ReturnType<typeof vi.fn>;
358
+ createFeishuReplyDispatcherMock: ReturnType<typeof vi.fn>;
359
+ waitTimeoutMs?: number;
360
+ }) {
361
+ const waitTimeoutMs = params.waitTimeoutMs ?? FEISHU_LIFECYCLE_WAIT_TIMEOUT_MS;
362
+ await replayFeishuLifecycleEvent({
363
+ handler: params.handler,
364
+ event: params.event,
365
+ waitTimeoutMs,
366
+ waitForFirst: () => {
367
+ expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
368
+ },
369
+ waitForSecond: () => {
370
+ expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
371
+ expect(params.createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
372
+ },
373
+ });
374
+ }
375
+
376
+ export async function expectFeishuReplyPipelineDedupedAfterPostSendFailure(params: {
377
+ handler: (data: unknown) => Promise<void>;
378
+ event: unknown;
379
+ dispatchReplyFromConfigMock: ReturnType<typeof vi.fn>;
380
+ runtimeErrorMock: ReturnType<typeof vi.fn>;
381
+ waitTimeoutMs?: number;
382
+ }) {
383
+ const waitTimeoutMs = params.waitTimeoutMs ?? FEISHU_LIFECYCLE_WAIT_TIMEOUT_MS;
384
+ await replayFeishuLifecycleEvent({
385
+ handler: params.handler,
386
+ event: params.event,
387
+ waitTimeoutMs,
388
+ waitForFirst: () => {
389
+ expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
390
+ expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1);
391
+ },
392
+ waitForSecond: () => {
393
+ expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
394
+ expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1);
395
+ },
396
+ });
397
+ }
398
+
399
+ export function expectFeishuReplyDispatcherSentFinalReplyOnce(params: {
400
+ createFeishuReplyDispatcherMock: ReturnType<typeof vi.fn>;
401
+ }) {
402
+ const dispatcher = params.createFeishuReplyDispatcherMock.mock.results[0]?.value.dispatcher as {
403
+ sendFinalReply: ReturnType<typeof vi.fn>;
404
+ };
405
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
406
+ }
407
+
408
+ async function loadMonitorSingleAccount() {
409
+ const module = await import("../monitor.account.js");
410
+ return module.monitorSingleAccount;
411
+ }
412
+
413
+ export async function setupFeishuLifecycleHandler(params: {
414
+ createEventDispatcherMock: {
415
+ mockReturnValue: (value: unknown) => unknown;
416
+ mockReturnValueOnce: (value: unknown) => unknown;
417
+ };
418
+ onRegister: (registered: Record<string, (data: unknown) => Promise<void>>) => void;
419
+ runtime: RuntimeEnv;
420
+ cfg: ClawdbotConfig;
421
+ account: ResolvedFeishuAccount;
422
+ handlerKey: string;
423
+ missingHandlerMessage: string;
424
+ once?: boolean;
425
+ }): Promise<(data: unknown) => Promise<void>> {
426
+ const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
427
+ params.onRegister(registered);
428
+ });
429
+ if (params.once) {
430
+ params.createEventDispatcherMock.mockReturnValueOnce({ register });
431
+ } else {
432
+ params.createEventDispatcherMock.mockReturnValue({ register });
433
+ }
434
+
435
+ const monitorSingleAccount = await loadMonitorSingleAccount();
436
+ await monitorSingleAccount({
437
+ cfg: params.cfg,
438
+ account: params.account,
439
+ runtime: params.runtime,
440
+ botOpenIdSource: FEISHU_PREFETCHED_BOT_OPEN_ID_SOURCE,
441
+ fireAndForget: false,
442
+ });
443
+
444
+ const handlers: Record<string, (data: unknown) => Promise<void>> = {};
445
+ for (const [key, value] of Object.entries(register.mock.calls[0]?.[0] ?? {})) {
446
+ handlers[key] = value as (data: unknown) => Promise<void>;
447
+ }
448
+ const handler = handlers[params.handlerKey];
449
+ if (!handler) {
450
+ throw new Error(params.missingHandlerMessage);
451
+ }
452
+ return handler;
453
+ }
@@ -0,0 +1,143 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2
+ import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { __testing, createFeishuThreadBindingManager } from "./thread-bindings.js";
5
+
6
+ const baseCfg = {
7
+ session: { mainKey: "main", scope: "per-sender" },
8
+ } satisfies OpenClawConfig;
9
+
10
+ describe("Feishu thread bindings", () => {
11
+ beforeEach(() => {
12
+ __testing.resetFeishuThreadBindingsForTests();
13
+ });
14
+
15
+ it("registers current-placement adapter capabilities for Feishu", () => {
16
+ createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
17
+
18
+ expect(
19
+ getSessionBindingService().getCapabilities({
20
+ channel: "feishu",
21
+ accountId: "default",
22
+ }),
23
+ ).toEqual({
24
+ adapterAvailable: true,
25
+ bindSupported: true,
26
+ unbindSupported: true,
27
+ placements: ["current"],
28
+ });
29
+ });
30
+
31
+ it("binds and resolves a Feishu topic conversation", async () => {
32
+ createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
33
+
34
+ const binding = await getSessionBindingService().bind({
35
+ targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
36
+ targetKind: "session",
37
+ conversation: {
38
+ channel: "feishu",
39
+ accountId: "default",
40
+ conversationId: "oc_group_chat:topic:om_topic_root",
41
+ parentConversationId: "oc_group_chat",
42
+ },
43
+ placement: "current",
44
+ metadata: {
45
+ agentId: "codex",
46
+ label: "codex-main",
47
+ },
48
+ });
49
+
50
+ expect(binding.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root");
51
+ expect(
52
+ getSessionBindingService().resolveByConversation({
53
+ channel: "feishu",
54
+ accountId: "default",
55
+ conversationId: "oc_group_chat:topic:om_topic_root",
56
+ }),
57
+ )?.toMatchObject({
58
+ targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
59
+ metadata: expect.objectContaining({
60
+ agentId: "codex",
61
+ label: "codex-main",
62
+ }),
63
+ });
64
+ });
65
+
66
+ it("clears account-scoped bindings when the manager stops", async () => {
67
+ const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
68
+
69
+ await getSessionBindingService().bind({
70
+ targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
71
+ targetKind: "session",
72
+ conversation: {
73
+ channel: "feishu",
74
+ accountId: "default",
75
+ conversationId: "oc_group_chat:topic:om_topic_root",
76
+ parentConversationId: "oc_group_chat",
77
+ },
78
+ placement: "current",
79
+ metadata: {
80
+ agentId: "codex",
81
+ },
82
+ });
83
+
84
+ manager.stop();
85
+
86
+ expect(
87
+ getSessionBindingService().resolveByConversation({
88
+ channel: "feishu",
89
+ accountId: "default",
90
+ conversationId: "oc_group_chat:topic:om_topic_root",
91
+ }),
92
+ ).toBeNull();
93
+ });
94
+
95
+ it("preserves delivery routing metadata when rebinding the same conversation", async () => {
96
+ const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
97
+
98
+ manager.bindConversation({
99
+ conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
100
+ parentConversationId: "oc_group_chat",
101
+ targetKind: "subagent",
102
+ targetSessionKey: "agent:main:subagent:child",
103
+ metadata: {
104
+ agentId: "codex",
105
+ label: "child",
106
+ boundBy: "system",
107
+ deliveryTo: "user:ou_sender_1",
108
+ deliveryThreadId: "om_topic_root",
109
+ },
110
+ });
111
+
112
+ await getSessionBindingService().bind({
113
+ targetSessionKey: "agent:main:subagent:child",
114
+ targetKind: "subagent",
115
+ conversation: {
116
+ channel: "feishu",
117
+ accountId: "default",
118
+ conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
119
+ parentConversationId: "oc_group_chat",
120
+ },
121
+ placement: "current",
122
+ metadata: {
123
+ label: "child",
124
+ },
125
+ });
126
+
127
+ expect(
128
+ getSessionBindingService().resolveByConversation({
129
+ channel: "feishu",
130
+ accountId: "default",
131
+ conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
132
+ }),
133
+ ).toMatchObject({
134
+ metadata: expect.objectContaining({
135
+ agentId: "codex",
136
+ label: "child",
137
+ boundBy: "system",
138
+ deliveryTo: "user:ou_sender_1",
139
+ deliveryThreadId: "om_topic_root",
140
+ }),
141
+ });
142
+ });
143
+ });