@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,4 @@
1
+ import "./monitor.acp-init-failure.lifecycle.test-support.js";
2
+ import "./monitor.bot-menu.lifecycle.test-support.js";
3
+ import "./monitor.broadcast.reply-once.lifecycle.test-support.js";
4
+ import "./monitor.card-action.lifecycle.test-support.js";
@@ -0,0 +1,339 @@
1
+ import type { ClawdbotConfig, HistoryEntry, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
2
+ import type { FeishuMessageEvent } from "./event-types.js";
3
+ import { isMentionForwardRequest } from "./mention.js";
4
+ import {
5
+ releaseFeishuMessageProcessing,
6
+ tryBeginFeishuMessageProcessing,
7
+ } from "./processing-claims.js";
8
+ import { createSequentialQueue } from "./sequential-queue.js";
9
+ import type { FeishuChatType } from "./types.js";
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ function readString(value: unknown): string | undefined {
16
+ return typeof value === "string" ? value : undefined;
17
+ }
18
+
19
+ type FeishuMessageReceiveHandlerContext = {
20
+ cfg: ClawdbotConfig;
21
+ core: PluginRuntime;
22
+ accountId: string;
23
+ runtime?: RuntimeEnv;
24
+ chatHistories: Map<string, HistoryEntry[]>;
25
+ fireAndForget?: boolean;
26
+ handleMessage: (params: {
27
+ cfg: ClawdbotConfig;
28
+ event: FeishuMessageEvent;
29
+ botOpenId?: string;
30
+ botName?: string;
31
+ runtime?: RuntimeEnv;
32
+ chatHistories?: Map<string, HistoryEntry[]>;
33
+ accountId?: string;
34
+ processingClaimHeld?: boolean;
35
+ }) => Promise<void>;
36
+ resolveDebounceText: (params: {
37
+ event: FeishuMessageEvent;
38
+ botOpenId?: string;
39
+ botName?: string;
40
+ }) => string;
41
+ hasProcessedMessage: (
42
+ messageId: string | undefined | null,
43
+ namespace: string,
44
+ log?: (...args: unknown[]) => void,
45
+ ) => Promise<boolean>;
46
+ recordProcessedMessage: (
47
+ messageId: string | undefined | null,
48
+ namespace: string,
49
+ log?: (...args: unknown[]) => void,
50
+ ) => Promise<boolean>;
51
+ getBotOpenId?: (accountId: string) => string | undefined;
52
+ getBotName?: (accountId: string) => string | undefined;
53
+ resolveSequentialKey?: (params: {
54
+ accountId: string;
55
+ event: FeishuMessageEvent;
56
+ botOpenId?: string;
57
+ botName?: string;
58
+ }) => string;
59
+ };
60
+
61
+ function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
62
+ return value === "group" || value === "topic_group" || value === "private" || value === "p2p"
63
+ ? value
64
+ : undefined;
65
+ }
66
+
67
+ function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null {
68
+ if (!isRecord(value)) {
69
+ return null;
70
+ }
71
+ const sender = value.sender;
72
+ const message = value.message;
73
+ if (!isRecord(sender) || !isRecord(message)) {
74
+ return null;
75
+ }
76
+ const senderId = sender.sender_id;
77
+ if (!isRecord(senderId)) {
78
+ return null;
79
+ }
80
+ const messageId = readString(message.message_id);
81
+ const chatId = readString(message.chat_id);
82
+ const chatType = normalizeFeishuChatType(message.chat_type);
83
+ const messageType = readString(message.message_type);
84
+ const content = readString(message.content);
85
+ if (!messageId || !chatId || !chatType || !messageType || !content) {
86
+ return null;
87
+ }
88
+ return value as FeishuMessageEvent;
89
+ }
90
+
91
+ function mergeFeishuDebounceMentions(
92
+ entries: FeishuMessageEvent[],
93
+ ): FeishuMessageEvent["message"]["mentions"] | undefined {
94
+ const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
95
+ for (const entry of entries) {
96
+ for (const mention of entry.message.mentions ?? []) {
97
+ const stableId =
98
+ mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
99
+ const mentionName = mention.name?.trim();
100
+ const mentionKey = mention.key?.trim();
101
+ const fallback =
102
+ mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
103
+ const key = stableId || fallback;
104
+ if (!key || merged.has(key)) {
105
+ continue;
106
+ }
107
+ merged.set(key, mention);
108
+ }
109
+ }
110
+ return merged.size > 0 ? Array.from(merged.values()) : undefined;
111
+ }
112
+
113
+ function dedupeFeishuDebounceEntriesByMessageId(
114
+ entries: FeishuMessageEvent[],
115
+ ): FeishuMessageEvent[] {
116
+ const seen = new Set<string>();
117
+ const deduped: FeishuMessageEvent[] = [];
118
+ for (const entry of entries) {
119
+ const messageId = entry.message.message_id?.trim();
120
+ if (!messageId) {
121
+ deduped.push(entry);
122
+ continue;
123
+ }
124
+ if (seen.has(messageId)) {
125
+ continue;
126
+ }
127
+ seen.add(messageId);
128
+ deduped.push(entry);
129
+ }
130
+ return deduped;
131
+ }
132
+
133
+ function resolveFeishuDebounceMentions(params: {
134
+ entries: FeishuMessageEvent[];
135
+ botOpenId?: string;
136
+ }): FeishuMessageEvent["message"]["mentions"] | undefined {
137
+ const { entries, botOpenId } = params;
138
+ if (entries.length === 0) {
139
+ return undefined;
140
+ }
141
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
142
+ const entry = entries[index];
143
+ if (isMentionForwardRequest(entry, botOpenId)) {
144
+ return mergeFeishuDebounceMentions([entry]);
145
+ }
146
+ }
147
+ const merged = mergeFeishuDebounceMentions(entries);
148
+ if (!merged) {
149
+ return undefined;
150
+ }
151
+ const normalizedBotOpenId = botOpenId?.trim();
152
+ if (!normalizedBotOpenId) {
153
+ return undefined;
154
+ }
155
+ const botMentions = merged.filter(
156
+ (mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
157
+ );
158
+ return botMentions.length > 0 ? botMentions : undefined;
159
+ }
160
+
161
+ export function createFeishuMessageReceiveHandler({
162
+ cfg,
163
+ core,
164
+ accountId,
165
+ runtime,
166
+ chatHistories,
167
+ fireAndForget,
168
+ handleMessage,
169
+ resolveDebounceText: resolveText,
170
+ hasProcessedMessage,
171
+ recordProcessedMessage,
172
+ getBotOpenId = () => undefined,
173
+ getBotName = () => undefined,
174
+ resolveSequentialKey = ({ accountId, event }) =>
175
+ `feishu:${accountId}:${event.message.chat_id?.trim() || "unknown"}`,
176
+ }: FeishuMessageReceiveHandlerContext): (data: unknown) => Promise<void> {
177
+ const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
178
+ cfg,
179
+ channel: "feishu",
180
+ });
181
+ const log = runtime?.log ?? console.log;
182
+ const error = runtime?.error ?? console.error;
183
+ const enqueue = createSequentialQueue();
184
+
185
+ const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
186
+ const sequentialKey = resolveSequentialKey({
187
+ accountId,
188
+ event,
189
+ botOpenId: getBotOpenId(accountId),
190
+ botName: getBotName(accountId),
191
+ });
192
+ const task = () =>
193
+ handleMessage({
194
+ cfg,
195
+ event,
196
+ botOpenId: getBotOpenId(accountId),
197
+ botName: getBotName(accountId),
198
+ runtime,
199
+ chatHistories,
200
+ accountId,
201
+ processingClaimHeld: true,
202
+ });
203
+ await enqueue(sequentialKey, task);
204
+ };
205
+
206
+ const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
207
+ const senderId =
208
+ event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
209
+ return senderId || undefined;
210
+ };
211
+
212
+ const resolveDebounceText = (event: FeishuMessageEvent): string => {
213
+ return resolveText({
214
+ event,
215
+ botOpenId: getBotOpenId(accountId),
216
+ botName: getBotName(accountId),
217
+ }).trim();
218
+ };
219
+
220
+ const recordSuppressedMessageIds = async (
221
+ entries: FeishuMessageEvent[],
222
+ dispatchMessageId?: string,
223
+ ) => {
224
+ const keepMessageId = dispatchMessageId?.trim();
225
+ const suppressedIds = new Set(
226
+ entries
227
+ .map((entry) => entry.message.message_id?.trim())
228
+ .filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
229
+ );
230
+ for (const messageId of suppressedIds) {
231
+ try {
232
+ await recordProcessedMessage(messageId, accountId, log);
233
+ } catch (err) {
234
+ error(
235
+ `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
236
+ );
237
+ }
238
+ }
239
+ };
240
+
241
+ const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
242
+ debounceMs: inboundDebounceMs,
243
+ buildKey: (event) => {
244
+ const chatId = event.message.chat_id?.trim();
245
+ const senderId = resolveSenderDebounceId(event);
246
+ if (!chatId || !senderId) {
247
+ return null;
248
+ }
249
+ const rootId = event.message.root_id?.trim();
250
+ const threadKey = rootId ? `thread:${rootId}` : "chat";
251
+ return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
252
+ },
253
+ shouldDebounce: (event) => {
254
+ if (event.message.message_type !== "text") {
255
+ return false;
256
+ }
257
+ const text = resolveDebounceText(event);
258
+ return Boolean(text) && !core.channel.text.hasControlCommand(text, cfg);
259
+ },
260
+ onFlush: async (entries) => {
261
+ const last = entries.at(-1);
262
+ if (!last) {
263
+ return;
264
+ }
265
+ if (entries.length === 1) {
266
+ await dispatchFeishuMessage(last);
267
+ return;
268
+ }
269
+ const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
270
+ const freshEntries: FeishuMessageEvent[] = [];
271
+ for (const entry of dedupedEntries) {
272
+ if (!(await hasProcessedMessage(entry.message.message_id, accountId, log))) {
273
+ freshEntries.push(entry);
274
+ }
275
+ }
276
+ const dispatchEntry = freshEntries.at(-1);
277
+ if (!dispatchEntry) {
278
+ return;
279
+ }
280
+ await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
281
+ const combinedText = freshEntries
282
+ .map((entry) => resolveDebounceText(entry))
283
+ .filter(Boolean)
284
+ .join("\n");
285
+ const mergedMentions = resolveFeishuDebounceMentions({
286
+ entries: freshEntries,
287
+ botOpenId: getBotOpenId(accountId),
288
+ });
289
+ await dispatchFeishuMessage({
290
+ ...dispatchEntry,
291
+ message: {
292
+ ...dispatchEntry.message,
293
+ ...(combinedText.trim()
294
+ ? {
295
+ message_type: "text",
296
+ content: JSON.stringify({ text: combinedText }),
297
+ }
298
+ : {}),
299
+ mentions: mergedMentions ?? dispatchEntry.message.mentions,
300
+ },
301
+ });
302
+ },
303
+ onError: (err, entries) => {
304
+ for (const entry of entries) {
305
+ releaseFeishuMessageProcessing(entry.message.message_id, accountId);
306
+ }
307
+ error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
308
+ },
309
+ });
310
+
311
+ return async (data) => {
312
+ const event = parseFeishuMessageEventPayload(data);
313
+ if (!event) {
314
+ error(`feishu[${accountId}]: ignoring malformed message event payload`);
315
+ return;
316
+ }
317
+ const messageId = event.message?.message_id?.trim();
318
+ if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
319
+ log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
320
+ return;
321
+ }
322
+ const processMessage = async () => {
323
+ await inboundDebouncer.enqueue(event);
324
+ };
325
+ if (fireAndForget) {
326
+ void processMessage().catch((err) => {
327
+ releaseFeishuMessageProcessing(messageId, accountId);
328
+ error(`feishu[${accountId}]: error handling message: ${String(err)}`);
329
+ });
330
+ return;
331
+ }
332
+ try {
333
+ await processMessage();
334
+ } catch (err) {
335
+ releaseFeishuMessageProcessing(messageId, accountId);
336
+ error(`feishu[${accountId}]: error handling message: ${String(err)}`);
337
+ }
338
+ };
339
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ClawdbotConfig } from "../runtime-api.js";
3
+ import {
4
+ resolveReactionSyntheticEvent,
5
+ type FeishuReactionCreatedEvent,
6
+ } from "./monitor.account.js";
7
+
8
+ const cfg = {} as ClawdbotConfig;
9
+
10
+ function makeReactionEvent(
11
+ overrides: Partial<FeishuReactionCreatedEvent> = {},
12
+ ): FeishuReactionCreatedEvent {
13
+ return {
14
+ message_id: "om_msg1",
15
+ reaction_type: { emoji_type: "THUMBSUP" },
16
+ operator_type: "user",
17
+ user_id: { open_id: "ou_user1" },
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("Feishu reaction lifecycle", () => {
23
+ it("builds a created synthetic interaction payload", async () => {
24
+ const result = await resolveReactionSyntheticEvent({
25
+ cfg,
26
+ accountId: "default",
27
+ event: makeReactionEvent({ user_id: { open_id: "ou_user1", user_id: "on_user1" } }),
28
+ botOpenId: "ou_bot",
29
+ fetchMessage: async () => ({
30
+ messageId: "om_msg1",
31
+ chatId: "oc_group_1",
32
+ chatType: "group",
33
+ senderOpenId: "ou_bot",
34
+ senderType: "app",
35
+ content: "hello",
36
+ contentType: "text",
37
+ }),
38
+ uuid: () => "fixed-uuid",
39
+ });
40
+
41
+ expect(result?.sender.sender_id).toEqual({ open_id: "ou_user1", user_id: "on_user1" });
42
+ expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
43
+ });
44
+
45
+ it("builds a deleted synthetic interaction payload", async () => {
46
+ const result = await resolveReactionSyntheticEvent({
47
+ cfg,
48
+ accountId: "default",
49
+ event: makeReactionEvent(),
50
+ botOpenId: "ou_bot",
51
+ fetchMessage: async () => ({
52
+ messageId: "om_msg1",
53
+ chatId: "oc_group_1",
54
+ chatType: "group",
55
+ senderOpenId: "ou_bot",
56
+ senderType: "app",
57
+ content: "hello",
58
+ contentType: "text",
59
+ }),
60
+ uuid: () => "fixed-uuid",
61
+ action: "deleted",
62
+ });
63
+
64
+ expect(result?.message.content).toBe(
65
+ '{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
66
+ );
67
+ });
68
+ });
@@ -1,15 +1,18 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
4
1
  import {
5
2
  createInboundDebouncer,
6
3
  resolveInboundDebounceMs,
7
- } from "../../../src/auto-reply/inbound-debounce.js";
8
- import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
4
+ } from "openclaw/plugin-sdk/channel-inbound-debounce";
5
+ import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
6
+ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
9
9
  import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
10
10
  import * as dedup from "./dedup.js";
11
- import { monitorSingleAccount } from "./monitor.account.js";
12
- import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
11
+ import {
12
+ monitorSingleAccount,
13
+ resolveReactionSyntheticEvent,
14
+ type FeishuReactionCreatedEvent,
15
+ } from "./monitor.account.js";
13
16
  import { setFeishuRuntime } from "./runtime.js";
14
17
  import type { ResolvedFeishuAccount } from "./types.js";
15
18
 
@@ -17,6 +20,7 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?:
17
20
  const createEventDispatcherMock = vi.hoisted(() => vi.fn());
18
21
  const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
19
22
  const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
23
+ const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
20
24
 
21
25
  let handlers: Record<string, (data: unknown) => Promise<void>> = {};
22
26
 
@@ -37,6 +41,10 @@ vi.mock("./monitor.transport.js", () => ({
37
41
  monitorWebhook: monitorWebhookMock,
38
42
  }));
39
43
 
44
+ vi.mock("./thread-bindings.js", () => ({
45
+ createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
46
+ }));
47
+
40
48
  const cfg = {} as ClawdbotConfig;
41
49
 
42
50
  function makeReactionEvent(
@@ -167,11 +175,7 @@ async function setupDebounceMonitor(params?: {
167
175
  await monitorSingleAccount({
168
176
  cfg: buildDebounceConfig(),
169
177
  account: buildDebounceAccount(),
170
- runtime: {
171
- log: vi.fn(),
172
- error: vi.fn(),
173
- exit: vi.fn(),
174
- } as RuntimeEnv,
178
+ runtime: createNonExitingRuntimeEnv(),
175
179
  botOpenIdSource: {
176
180
  kind: "prefetched",
177
181
  botOpenId: params?.botOpenId ?? "ou_bot",
@@ -225,6 +229,24 @@ function createMention(params: { openId: string; name: string; key?: string }):
225
229
  };
226
230
  }
227
231
 
232
+ function createFeishuMonitorRuntime(params?: {
233
+ createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
234
+ resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
235
+ hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
236
+ }): PluginRuntime {
237
+ return {
238
+ channel: {
239
+ debounce: {
240
+ createInboundDebouncer: params?.createInboundDebouncer ?? createInboundDebouncer,
241
+ resolveInboundDebounceMs: params?.resolveInboundDebounceMs ?? resolveInboundDebounceMs,
242
+ },
243
+ text: {
244
+ hasControlCommand: params?.hasControlCommand ?? hasControlCommand,
245
+ },
246
+ },
247
+ } as unknown as PluginRuntime;
248
+ }
249
+
228
250
  async function enqueueDebouncedMessage(
229
251
  onMessage: (data: unknown) => Promise<void>,
230
252
  event: FeishuMessageEvent,
@@ -419,24 +441,68 @@ describe("resolveReactionSyntheticEvent", () => {
419
441
  });
420
442
  });
421
443
 
444
+ describe("monitorSingleAccount lifecycle", () => {
445
+ beforeEach(() => {
446
+ createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
447
+ stop: vi.fn(),
448
+ }));
449
+ createEventDispatcherMock.mockReset().mockReturnValue({
450
+ register: vi.fn(),
451
+ });
452
+ });
453
+
454
+ it("stops the Feishu thread binding manager when the monitor exits", async () => {
455
+ setFeishuRuntime(createFeishuMonitorRuntime());
456
+
457
+ await monitorSingleAccount({
458
+ cfg: buildDebounceConfig(),
459
+ account: buildDebounceAccount(),
460
+ runtime: createNonExitingRuntimeEnv(),
461
+ botOpenIdSource: {
462
+ kind: "prefetched",
463
+ botOpenId: "ou_bot",
464
+ },
465
+ });
466
+
467
+ const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
468
+ | { stop: ReturnType<typeof vi.fn> }
469
+ | undefined;
470
+ expect(manager?.stop).toHaveBeenCalledTimes(1);
471
+ });
472
+
473
+ it("stops the Feishu thread binding manager when setup fails before transport starts", async () => {
474
+ setFeishuRuntime(createFeishuMonitorRuntime());
475
+ createEventDispatcherMock.mockReturnValue({
476
+ get register() {
477
+ throw new Error("register failed");
478
+ },
479
+ });
480
+
481
+ await expect(
482
+ monitorSingleAccount({
483
+ cfg: buildDebounceConfig(),
484
+ account: buildDebounceAccount(),
485
+ runtime: createNonExitingRuntimeEnv(),
486
+ botOpenIdSource: {
487
+ kind: "prefetched",
488
+ botOpenId: "ou_bot",
489
+ },
490
+ }),
491
+ ).rejects.toThrow("register failed");
492
+
493
+ const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
494
+ | { stop: ReturnType<typeof vi.fn> }
495
+ | undefined;
496
+ expect(manager?.stop).toHaveBeenCalledTimes(1);
497
+ });
498
+ });
499
+
422
500
  describe("Feishu inbound debounce regressions", () => {
423
501
  beforeEach(() => {
424
502
  vi.useFakeTimers();
425
503
  handlers = {};
426
504
  handleFeishuMessageMock.mockClear();
427
- setFeishuRuntime(
428
- createPluginRuntimeMock({
429
- channel: {
430
- debounce: {
431
- createInboundDebouncer,
432
- resolveInboundDebounceMs,
433
- },
434
- text: {
435
- hasControlCommand,
436
- },
437
- },
438
- }),
439
- );
505
+ setFeishuRuntime(createFeishuMonitorRuntime());
440
506
  });
441
507
 
442
508
  afterEach(() => {
@@ -585,47 +651,41 @@ describe("Feishu inbound debounce regressions", () => {
585
651
  it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
586
652
  vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
587
653
  const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
588
- setStaleRetryMocks();
654
+ setStaleRetryMocks("om_old_latest_fresh");
589
655
  const onMessage = await setupDebounceMonitor();
590
656
 
591
- await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
657
+ await onMessage(createTextEvent({ messageId: "om_new_latest_fresh", text: "fresh" }));
592
658
  await Promise.resolve();
593
659
  await Promise.resolve();
594
- await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
660
+ await onMessage(createTextEvent({ messageId: "om_old_latest_fresh", text: "stale" }));
595
661
  await Promise.resolve();
596
662
  await Promise.resolve();
597
663
  await vi.advanceTimersByTimeAsync(25);
598
664
 
599
665
  const dispatched = expectSingleDispatchedEvent();
600
- expect(dispatched.message.message_id).toBe("om_new");
666
+ expect(dispatched.message.message_id).toBe("om_new_latest_fresh");
601
667
  const combined = JSON.parse(dispatched.message.content) as { text?: string };
602
668
  expect(combined.text).toBe("fresh");
603
- expect(recordSpy).toHaveBeenCalledWith("om_old", "default", expect.any(Function));
604
- expect(recordSpy).not.toHaveBeenCalledWith("om_new", "default", expect.any(Function));
669
+ expect(recordSpy).toHaveBeenCalledWith("om_old_latest_fresh", "default", expect.any(Function));
670
+ expect(recordSpy).not.toHaveBeenCalledWith(
671
+ "om_new_latest_fresh",
672
+ "default",
673
+ expect.any(Function),
674
+ );
605
675
  });
606
676
 
607
677
  it("releases early event dedupe when debounced dispatch fails", async () => {
608
678
  setDedupPassThroughMocks();
609
679
  const enqueueMock = vi.fn();
610
680
  setFeishuRuntime(
611
- createPluginRuntimeMock({
612
- channel: {
613
- debounce: {
614
- createInboundDebouncer: <T>(params: {
615
- onError?: (err: unknown, items: T[]) => void;
616
- }) => ({
617
- enqueue: async (item: T) => {
618
- enqueueMock(item);
619
- params.onError?.(new Error("dispatch failed"), [item]);
620
- },
621
- flushKey: async () => {},
622
- }),
623
- resolveInboundDebounceMs,
624
- },
625
- text: {
626
- hasControlCommand,
681
+ createFeishuMonitorRuntime({
682
+ createInboundDebouncer: <T>(params: { onError?: (err: unknown, items: T[]) => void }) => ({
683
+ enqueue: async (item: T) => {
684
+ enqueueMock(item);
685
+ params.onError?.(new Error("dispatch failed"), [item]);
627
686
  },
628
- },
687
+ flushKey: async () => {},
688
+ }),
629
689
  }),
630
690
  );
631
691
  const onMessage = await setupDebounceMonitor();
@@ -1,5 +1,6 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
1
+ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { ClawdbotConfig } from "../runtime-api.js";
3
4
  import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
4
5
 
5
6
  const probeFeishuMock = vi.hoisted(() => vi.fn());
@@ -39,9 +40,12 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
39
40
  }
40
41
 
41
42
  async function waitForStartedAccount(started: string[], accountId: string) {
42
- for (let i = 0; i < 10 && !started.includes(accountId); i += 1) {
43
- await Promise.resolve();
44
- }
43
+ await vi.waitFor(
44
+ () => {
45
+ expect(started).toContain(accountId);
46
+ },
47
+ { timeout: 10_000 },
48
+ );
45
49
  }
46
50
 
47
51
  afterEach(() => {
@@ -73,9 +77,7 @@ describe("Feishu monitor startup preflight", () => {
73
77
  });
74
78
 
75
79
  try {
76
- await Promise.resolve();
77
- await Promise.resolve();
78
-
80
+ await waitForStartedAccount(started, "alpha");
79
81
  expect(started).toEqual(["alpha"]);
80
82
  expect(maxInFlight).toBe(1);
81
83
  } finally {
@@ -134,7 +136,7 @@ describe("Feishu monitor startup preflight", () => {
134
136
  });
135
137
 
136
138
  const abortController = new AbortController();
137
- const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
139
+ const runtime = createNonExitingRuntimeEnv();
138
140
  const monitorPromise = monitorFeishuProvider({
139
141
  config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
140
142
  runtime,
@@ -176,7 +178,7 @@ describe("Feishu monitor startup preflight", () => {
176
178
  });
177
179
 
178
180
  try {
179
- await Promise.resolve();
181
+ await waitForStartedAccount(started, "alpha");
180
182
  expect(started).toEqual(["alpha"]);
181
183
 
182
184
  abortController.abort();