@openclaw/feishu 2026.3.1 → 2026.3.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.
@@ -3,12 +3,26 @@ import * as Lark from "@larksuiteoapi/node-sdk";
3
3
  import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
4
4
  import { resolveFeishuAccount } from "./accounts.js";
5
5
  import { raceWithTimeoutAndAbort } from "./async.js";
6
- import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
6
+ import {
7
+ handleFeishuMessage,
8
+ parseFeishuMessageEvent,
9
+ type FeishuMessageEvent,
10
+ type FeishuBotAddedEvent,
11
+ } from "./bot.js";
7
12
  import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
8
13
  import { createEventDispatcher } from "./client.js";
14
+ import {
15
+ hasRecordedMessage,
16
+ hasRecordedMessagePersistent,
17
+ tryRecordMessage,
18
+ tryRecordMessagePersistent,
19
+ warmupDedupFromDisk,
20
+ } from "./dedup.js";
21
+ import { isMentionForwardRequest } from "./mention.js";
9
22
  import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
10
23
  import { botOpenIds } from "./monitor.state.js";
11
24
  import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
25
+ import { getFeishuRuntime } from "./runtime.js";
12
26
  import { getMessageFeishu } from "./send.js";
13
27
  import type { ResolvedFeishuAccount } from "./types.js";
14
28
 
@@ -17,7 +31,7 @@ const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
17
31
  export type FeishuReactionCreatedEvent = {
18
32
  message_id: string;
19
33
  chat_id?: string;
20
- chat_type?: "p2p" | "group";
34
+ chat_type?: "p2p" | "group" | "private";
21
35
  reaction_type?: { emoji_type?: string };
22
36
  operator_type?: string;
23
37
  user_id?: { open_id?: string };
@@ -93,7 +107,8 @@ export async function resolveReactionSyntheticEvent(
93
107
 
94
108
  const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
95
109
  const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
96
- const syntheticChatType: "p2p" | "group" = event.chat_type ?? "p2p";
110
+ const syntheticChatType: "p2p" | "group" | "private" =
111
+ event.chat_type === "group" ? "group" : "p2p";
97
112
  return {
98
113
  sender: {
99
114
  sender_id: { open_id: senderId },
@@ -119,33 +134,261 @@ type RegisterEventHandlersContext = {
119
134
  fireAndForget?: boolean;
120
135
  };
121
136
 
137
+ /**
138
+ * Per-chat serial queue that ensures messages from the same chat are processed
139
+ * in arrival order while allowing different chats to run concurrently.
140
+ */
141
+ function createChatQueue() {
142
+ const queues = new Map<string, Promise<void>>();
143
+ return (chatId: string, task: () => Promise<void>): Promise<void> => {
144
+ const prev = queues.get(chatId) ?? Promise.resolve();
145
+ const next = prev.then(task, task);
146
+ queues.set(chatId, next);
147
+ void next.finally(() => {
148
+ if (queues.get(chatId) === next) {
149
+ queues.delete(chatId);
150
+ }
151
+ });
152
+ return next;
153
+ };
154
+ }
155
+
156
+ function mergeFeishuDebounceMentions(
157
+ entries: FeishuMessageEvent[],
158
+ ): FeishuMessageEvent["message"]["mentions"] | undefined {
159
+ const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
160
+ for (const entry of entries) {
161
+ for (const mention of entry.message.mentions ?? []) {
162
+ const stableId =
163
+ mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
164
+ const mentionName = mention.name?.trim();
165
+ const mentionKey = mention.key?.trim();
166
+ const fallback =
167
+ mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
168
+ const key = stableId || fallback;
169
+ if (!key || merged.has(key)) {
170
+ continue;
171
+ }
172
+ merged.set(key, mention);
173
+ }
174
+ }
175
+ if (merged.size === 0) {
176
+ return undefined;
177
+ }
178
+ return Array.from(merged.values());
179
+ }
180
+
181
+ function dedupeFeishuDebounceEntriesByMessageId(
182
+ entries: FeishuMessageEvent[],
183
+ ): FeishuMessageEvent[] {
184
+ const seen = new Set<string>();
185
+ const deduped: FeishuMessageEvent[] = [];
186
+ for (const entry of entries) {
187
+ const messageId = entry.message.message_id?.trim();
188
+ if (!messageId) {
189
+ deduped.push(entry);
190
+ continue;
191
+ }
192
+ if (seen.has(messageId)) {
193
+ continue;
194
+ }
195
+ seen.add(messageId);
196
+ deduped.push(entry);
197
+ }
198
+ return deduped;
199
+ }
200
+
201
+ function resolveFeishuDebounceMentions(params: {
202
+ entries: FeishuMessageEvent[];
203
+ botOpenId?: string;
204
+ }): FeishuMessageEvent["message"]["mentions"] | undefined {
205
+ const { entries, botOpenId } = params;
206
+ if (entries.length === 0) {
207
+ return undefined;
208
+ }
209
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
210
+ const entry = entries[index];
211
+ if (isMentionForwardRequest(entry, botOpenId)) {
212
+ // Keep mention-forward semantics scoped to a single source message.
213
+ return mergeFeishuDebounceMentions([entry]);
214
+ }
215
+ }
216
+ const merged = mergeFeishuDebounceMentions(entries);
217
+ if (!merged) {
218
+ return undefined;
219
+ }
220
+ const normalizedBotOpenId = botOpenId?.trim();
221
+ if (!normalizedBotOpenId) {
222
+ return undefined;
223
+ }
224
+ const botMentions = merged.filter(
225
+ (mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
226
+ );
227
+ return botMentions.length > 0 ? botMentions : undefined;
228
+ }
229
+
122
230
  function registerEventHandlers(
123
231
  eventDispatcher: Lark.EventDispatcher,
124
232
  context: RegisterEventHandlersContext,
125
233
  ): void {
126
234
  const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
235
+ const core = getFeishuRuntime();
236
+ const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
237
+ cfg,
238
+ channel: "feishu",
239
+ });
127
240
  const log = runtime?.log ?? console.log;
128
241
  const error = runtime?.error ?? console.error;
242
+ const enqueue = createChatQueue();
243
+ const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
244
+ const chatId = event.message.chat_id?.trim() || "unknown";
245
+ const task = () =>
246
+ handleFeishuMessage({
247
+ cfg,
248
+ event,
249
+ botOpenId: botOpenIds.get(accountId),
250
+ runtime,
251
+ chatHistories,
252
+ accountId,
253
+ });
254
+ await enqueue(chatId, task);
255
+ };
256
+ const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
257
+ const senderId =
258
+ event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
259
+ return senderId || undefined;
260
+ };
261
+ const resolveDebounceText = (event: FeishuMessageEvent): string => {
262
+ const botOpenId = botOpenIds.get(accountId);
263
+ const parsed = parseFeishuMessageEvent(event, botOpenId);
264
+ return parsed.content.trim();
265
+ };
266
+ const recordSuppressedMessageIds = async (
267
+ entries: FeishuMessageEvent[],
268
+ dispatchMessageId?: string,
269
+ ) => {
270
+ const keepMessageId = dispatchMessageId?.trim();
271
+ const suppressedIds = new Set(
272
+ entries
273
+ .map((entry) => entry.message.message_id?.trim())
274
+ .filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
275
+ );
276
+ if (suppressedIds.size === 0) {
277
+ return;
278
+ }
279
+ for (const messageId of suppressedIds) {
280
+ // Keep in-memory dedupe in sync with handleFeishuMessage's keying.
281
+ tryRecordMessage(`${accountId}:${messageId}`);
282
+ try {
283
+ await tryRecordMessagePersistent(messageId, accountId, log);
284
+ } catch (err) {
285
+ error(
286
+ `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
287
+ );
288
+ }
289
+ }
290
+ };
291
+ const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
292
+ const messageId = entry.message.message_id?.trim();
293
+ if (!messageId) {
294
+ return false;
295
+ }
296
+ const memoryKey = `${accountId}:${messageId}`;
297
+ if (hasRecordedMessage(memoryKey)) {
298
+ return true;
299
+ }
300
+ return hasRecordedMessagePersistent(messageId, accountId, log);
301
+ };
302
+ const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
303
+ debounceMs: inboundDebounceMs,
304
+ buildKey: (event) => {
305
+ const chatId = event.message.chat_id?.trim();
306
+ const senderId = resolveSenderDebounceId(event);
307
+ if (!chatId || !senderId) {
308
+ return null;
309
+ }
310
+ const rootId = event.message.root_id?.trim();
311
+ const threadKey = rootId ? `thread:${rootId}` : "chat";
312
+ return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
313
+ },
314
+ shouldDebounce: (event) => {
315
+ if (event.message.message_type !== "text") {
316
+ return false;
317
+ }
318
+ const text = resolveDebounceText(event);
319
+ if (!text) {
320
+ return false;
321
+ }
322
+ return !core.channel.text.hasControlCommand(text, cfg);
323
+ },
324
+ onFlush: async (entries) => {
325
+ const last = entries.at(-1);
326
+ if (!last) {
327
+ return;
328
+ }
329
+ if (entries.length === 1) {
330
+ await dispatchFeishuMessage(last);
331
+ return;
332
+ }
333
+ const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
334
+ const freshEntries: FeishuMessageEvent[] = [];
335
+ for (const entry of dedupedEntries) {
336
+ if (!(await isMessageAlreadyProcessed(entry))) {
337
+ freshEntries.push(entry);
338
+ }
339
+ }
340
+ const dispatchEntry = freshEntries.at(-1);
341
+ if (!dispatchEntry) {
342
+ return;
343
+ }
344
+ await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
345
+ const combinedText = freshEntries
346
+ .map((entry) => resolveDebounceText(entry))
347
+ .filter(Boolean)
348
+ .join("\n");
349
+ const mergedMentions = resolveFeishuDebounceMentions({
350
+ entries: freshEntries,
351
+ botOpenId: botOpenIds.get(accountId),
352
+ });
353
+ if (!combinedText.trim()) {
354
+ await dispatchFeishuMessage({
355
+ ...dispatchEntry,
356
+ message: {
357
+ ...dispatchEntry.message,
358
+ mentions: mergedMentions ?? dispatchEntry.message.mentions,
359
+ },
360
+ });
361
+ return;
362
+ }
363
+ await dispatchFeishuMessage({
364
+ ...dispatchEntry,
365
+ message: {
366
+ ...dispatchEntry.message,
367
+ message_type: "text",
368
+ content: JSON.stringify({ text: combinedText }),
369
+ mentions: mergedMentions ?? dispatchEntry.message.mentions,
370
+ },
371
+ });
372
+ },
373
+ onError: (err) => {
374
+ error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
375
+ },
376
+ });
129
377
 
130
378
  eventDispatcher.register({
131
379
  "im.message.receive_v1": async (data) => {
132
- try {
380
+ const processMessage = async () => {
133
381
  const event = data as unknown as FeishuMessageEvent;
134
- const promise = handleFeishuMessage({
135
- cfg,
136
- event,
137
- botOpenId: botOpenIds.get(accountId),
138
- runtime,
139
- chatHistories,
140
- accountId,
382
+ await inboundDebouncer.enqueue(event);
383
+ };
384
+ if (fireAndForget) {
385
+ void processMessage().catch((err) => {
386
+ error(`feishu[${accountId}]: error handling message: ${String(err)}`);
141
387
  });
142
- if (fireAndForget) {
143
- promise.catch((err) => {
144
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
145
- });
146
- } else {
147
- await promise;
148
- }
388
+ return;
389
+ }
390
+ try {
391
+ await processMessage();
149
392
  } catch (err) {
150
393
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
151
394
  }
@@ -268,6 +511,11 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
268
511
  throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
269
512
  }
270
513
 
514
+ const warmupCount = await warmupDedupFromDisk(accountId, log);
515
+ if (warmupCount > 0) {
516
+ log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
517
+ }
518
+
271
519
  const eventDispatcher = createEventDispatcher(account);
272
520
  const chatHistories = new Map<string, HistoryEntry[]>();
273
521
 
@@ -1,6 +1,41 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
- import { describe, expect, it, vi } from "vitest";
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
4
+ import {
5
+ createInboundDebouncer,
6
+ resolveInboundDebounceMs,
7
+ } from "../../../src/auto-reply/inbound-debounce.js";
8
+ import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
9
+ import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
10
+ import * as dedup from "./dedup.js";
11
+ import { monitorSingleAccount } from "./monitor.account.js";
3
12
  import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
13
+ import { setFeishuRuntime } from "./runtime.js";
14
+ import type { ResolvedFeishuAccount } from "./types.js";
15
+
16
+ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: unknown }) => {}));
17
+ const createEventDispatcherMock = vi.hoisted(() => vi.fn());
18
+ const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
19
+ const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
20
+
21
+ let handlers: Record<string, (data: unknown) => Promise<void>> = {};
22
+
23
+ vi.mock("./client.js", () => ({
24
+ createEventDispatcher: createEventDispatcherMock,
25
+ }));
26
+
27
+ vi.mock("./bot.js", async () => {
28
+ const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
29
+ return {
30
+ ...actual,
31
+ handleFeishuMessage: handleFeishuMessageMock,
32
+ };
33
+ });
34
+
35
+ vi.mock("./monitor.transport.js", () => ({
36
+ monitorWebSocket: monitorWebSocketMock,
37
+ monitorWebhook: monitorWebhookMock,
38
+ }));
4
39
 
5
40
  const cfg = {} as ClawdbotConfig;
6
41
 
@@ -16,6 +51,100 @@ function makeReactionEvent(
16
51
  };
17
52
  }
18
53
 
54
+ type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
55
+
56
+ function buildDebounceConfig(): ClawdbotConfig {
57
+ return {
58
+ messages: {
59
+ inbound: {
60
+ debounceMs: 0,
61
+ byChannel: {
62
+ feishu: 20,
63
+ },
64
+ },
65
+ },
66
+ channels: {
67
+ feishu: {
68
+ enabled: true,
69
+ },
70
+ },
71
+ } as ClawdbotConfig;
72
+ }
73
+
74
+ function buildDebounceAccount(): ResolvedFeishuAccount {
75
+ return {
76
+ accountId: "default",
77
+ enabled: true,
78
+ configured: true,
79
+ appId: "cli_test",
80
+ appSecret: "secret_test",
81
+ domain: "feishu",
82
+ config: {
83
+ enabled: true,
84
+ connectionMode: "websocket",
85
+ },
86
+ } as ResolvedFeishuAccount;
87
+ }
88
+
89
+ function createTextEvent(params: {
90
+ messageId: string;
91
+ text: string;
92
+ senderId?: string;
93
+ mentions?: FeishuMention[];
94
+ }): FeishuMessageEvent {
95
+ const senderId = params.senderId ?? "ou_sender";
96
+ return {
97
+ sender: {
98
+ sender_id: { open_id: senderId },
99
+ sender_type: "user",
100
+ },
101
+ message: {
102
+ message_id: params.messageId,
103
+ chat_id: "oc_group_1",
104
+ chat_type: "group",
105
+ message_type: "text",
106
+ content: JSON.stringify({ text: params.text }),
107
+ mentions: params.mentions,
108
+ },
109
+ };
110
+ }
111
+
112
+ async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>> {
113
+ const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
114
+ handlers = registered;
115
+ });
116
+ createEventDispatcherMock.mockReturnValue({ register });
117
+
118
+ await monitorSingleAccount({
119
+ cfg: buildDebounceConfig(),
120
+ account: buildDebounceAccount(),
121
+ runtime: {
122
+ log: vi.fn(),
123
+ error: vi.fn(),
124
+ exit: vi.fn(),
125
+ } as RuntimeEnv,
126
+ botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" },
127
+ });
128
+
129
+ const onMessage = handlers["im.message.receive_v1"];
130
+ if (!onMessage) {
131
+ throw new Error("missing im.message.receive_v1 handler");
132
+ }
133
+ return onMessage;
134
+ }
135
+
136
+ function getFirstDispatchedEvent(): FeishuMessageEvent {
137
+ const firstCall = handleFeishuMessageMock.mock.calls[0];
138
+ if (!firstCall) {
139
+ throw new Error("missing dispatch call");
140
+ }
141
+ const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined;
142
+ if (!firstParams?.event) {
143
+ throw new Error("missing dispatched event payload");
144
+ }
145
+ return firstParams.event;
146
+ }
147
+
19
148
  describe("resolveReactionSyntheticEvent", () => {
20
149
  it("filters app self-reactions", async () => {
21
150
  const event = makeReactionEvent({ operator_type: "app" });
@@ -233,3 +362,217 @@ describe("resolveReactionSyntheticEvent", () => {
233
362
  );
234
363
  });
235
364
  });
365
+
366
+ describe("Feishu inbound debounce regressions", () => {
367
+ beforeEach(() => {
368
+ vi.useFakeTimers();
369
+ handlers = {};
370
+ handleFeishuMessageMock.mockClear();
371
+ setFeishuRuntime(
372
+ createPluginRuntimeMock({
373
+ channel: {
374
+ debounce: {
375
+ createInboundDebouncer,
376
+ resolveInboundDebounceMs,
377
+ },
378
+ text: {
379
+ hasControlCommand,
380
+ },
381
+ },
382
+ }),
383
+ );
384
+ });
385
+
386
+ afterEach(() => {
387
+ vi.useRealTimers();
388
+ vi.restoreAllMocks();
389
+ });
390
+
391
+ it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
392
+ vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
393
+ vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
394
+ vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
395
+ vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
396
+ const onMessage = await setupDebounceMonitor();
397
+
398
+ await onMessage(
399
+ createTextEvent({
400
+ messageId: "om_1",
401
+ text: "first",
402
+ mentions: [
403
+ {
404
+ key: "@_user_1",
405
+ id: { open_id: "ou_user_a" },
406
+ name: "user-a",
407
+ },
408
+ ],
409
+ }),
410
+ );
411
+ await Promise.resolve();
412
+ await Promise.resolve();
413
+ await onMessage(
414
+ createTextEvent({
415
+ messageId: "om_2",
416
+ text: "@bot second",
417
+ mentions: [
418
+ {
419
+ key: "@_user_1",
420
+ id: { open_id: "ou_bot" },
421
+ name: "bot",
422
+ },
423
+ ],
424
+ }),
425
+ );
426
+ await Promise.resolve();
427
+ await Promise.resolve();
428
+ await vi.advanceTimersByTimeAsync(25);
429
+
430
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
431
+ const dispatched = getFirstDispatchedEvent();
432
+ const mergedMentions = dispatched.message.mentions ?? [];
433
+ expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
434
+ expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
435
+ });
436
+
437
+ it("does not synthesize mention-forward intent across separate messages", async () => {
438
+ vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
439
+ vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
440
+ vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
441
+ vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
442
+ const onMessage = await setupDebounceMonitor();
443
+
444
+ await onMessage(
445
+ createTextEvent({
446
+ messageId: "om_user_mention",
447
+ text: "@alice first",
448
+ mentions: [
449
+ {
450
+ key: "@_user_1",
451
+ id: { open_id: "ou_alice" },
452
+ name: "alice",
453
+ },
454
+ ],
455
+ }),
456
+ );
457
+ await Promise.resolve();
458
+ await Promise.resolve();
459
+ await onMessage(
460
+ createTextEvent({
461
+ messageId: "om_bot_mention",
462
+ text: "@bot second",
463
+ mentions: [
464
+ {
465
+ key: "@_user_1",
466
+ id: { open_id: "ou_bot" },
467
+ name: "bot",
468
+ },
469
+ ],
470
+ }),
471
+ );
472
+ await Promise.resolve();
473
+ await Promise.resolve();
474
+ await vi.advanceTimersByTimeAsync(25);
475
+
476
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
477
+ const dispatched = getFirstDispatchedEvent();
478
+ const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
479
+ expect(parsed.mentionedBot).toBe(true);
480
+ expect(parsed.mentionTargets).toBeUndefined();
481
+ const mergedMentions = dispatched.message.mentions ?? [];
482
+ expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true);
483
+ });
484
+
485
+ it("preserves bot mention signal when the latest merged message has no mentions", async () => {
486
+ vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
487
+ vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
488
+ vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
489
+ vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
490
+ const onMessage = await setupDebounceMonitor();
491
+
492
+ await onMessage(
493
+ createTextEvent({
494
+ messageId: "om_bot_first",
495
+ text: "@bot first",
496
+ mentions: [
497
+ {
498
+ key: "@_user_1",
499
+ id: { open_id: "ou_bot" },
500
+ name: "bot",
501
+ },
502
+ ],
503
+ }),
504
+ );
505
+ await Promise.resolve();
506
+ await Promise.resolve();
507
+ await onMessage(
508
+ createTextEvent({
509
+ messageId: "om_plain_second",
510
+ text: "plain follow-up",
511
+ }),
512
+ );
513
+ await Promise.resolve();
514
+ await Promise.resolve();
515
+ await vi.advanceTimersByTimeAsync(25);
516
+
517
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
518
+ const dispatched = getFirstDispatchedEvent();
519
+ const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
520
+ expect(parsed.mentionedBot).toBe(true);
521
+ });
522
+
523
+ it("excludes previously processed retries from combined debounce text", async () => {
524
+ vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
525
+ vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
526
+ vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
527
+ vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
528
+ async (messageId) => messageId === "om_old",
529
+ );
530
+ const onMessage = await setupDebounceMonitor();
531
+
532
+ await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
533
+ await Promise.resolve();
534
+ await Promise.resolve();
535
+ await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" }));
536
+ await Promise.resolve();
537
+ await Promise.resolve();
538
+ await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
539
+ await Promise.resolve();
540
+ await Promise.resolve();
541
+ await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" }));
542
+ await Promise.resolve();
543
+ await Promise.resolve();
544
+ await vi.advanceTimersByTimeAsync(25);
545
+
546
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
547
+ const dispatched = getFirstDispatchedEvent();
548
+ expect(dispatched.message.message_id).toBe("om_new_2");
549
+ const combined = JSON.parse(dispatched.message.content) as { text?: string };
550
+ expect(combined.text).toBe("first\nsecond");
551
+ });
552
+
553
+ it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
554
+ const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
555
+ vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
556
+ vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
557
+ vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
558
+ async (messageId) => messageId === "om_old",
559
+ );
560
+ const onMessage = await setupDebounceMonitor();
561
+
562
+ await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
563
+ await Promise.resolve();
564
+ await Promise.resolve();
565
+ await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
566
+ await Promise.resolve();
567
+ await Promise.resolve();
568
+ await vi.advanceTimersByTimeAsync(25);
569
+
570
+ expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
571
+ const dispatched = getFirstDispatchedEvent();
572
+ expect(dispatched.message.message_id).toBe("om_new");
573
+ const combined = JSON.parse(dispatched.message.content) as { text?: string };
574
+ expect(combined.text).toBe("fresh");
575
+ expect(recordSpy).toHaveBeenCalledWith("default:om_old");
576
+ expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
577
+ });
578
+ });