diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b04802a..d9b17ee64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,7 @@ Docs: https://docs.openclaw.ai - macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc. - Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526) - Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606) +- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548) - BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204. - Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc. - Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc. diff --git a/extensions/feishu/src/dedup.ts b/extensions/feishu/src/dedup.ts index b0fa4ce16..ffeb5b769 100644 --- a/extensions/feishu/src/dedup.ts +++ b/extensions/feishu/src/dedup.ts @@ -1,11 +1,16 @@ import os from "node:os"; import path from "node:path"; -import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk"; +import { + createDedupeCache, + createPersistentDedupe, + readJsonFileWithFallback, +} from "openclaw/plugin-sdk"; // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects. const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; const MEMORY_MAX_SIZE = 1_000; const FILE_MAX_ENTRIES = 10_000; +type PersistentDedupeData = Record; const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE }); @@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean { return !memoryDedupe.check(messageId); } +export function hasRecordedMessage(messageId: string): boolean { + const trimmed = messageId.trim(); + if (!trimmed) { + return false; + } + return memoryDedupe.peek(trimmed); +} + export async function tryRecordMessagePersistent( messageId: string, namespace = "global", @@ -52,3 +65,27 @@ export async function tryRecordMessagePersistent( }, }); } + +export async function hasRecordedMessagePersistent( + messageId: string, + namespace = "global", + log?: (...args: unknown[]) => void, +): Promise { + const trimmed = messageId.trim(); + if (!trimmed) { + return false; + } + const now = Date.now(); + const filePath = resolveNamespaceFilePath(namespace); + try { + const { value } = await readJsonFileWithFallback(filePath, {}); + const seenAt = value[trimmed]; + if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) { + return false; + } + return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS; + } catch (error) { + log?.(`feishu-dedup: persistent peek failed: ${String(error)}`); + return false; + } +} diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index e23fd8269..7c86814b1 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -3,12 +3,25 @@ import * as Lark from "@larksuiteoapi/node-sdk"; import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk"; import { resolveFeishuAccount } from "./accounts.js"; import { raceWithTimeoutAndAbort } from "./async.js"; -import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; +import { + handleFeishuMessage, + parseFeishuMessageEvent, + type FeishuMessageEvent, + type FeishuBotAddedEvent, +} from "./bot.js"; import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; import { createEventDispatcher } from "./client.js"; +import { + hasRecordedMessage, + hasRecordedMessagePersistent, + tryRecordMessage, + tryRecordMessagePersistent, +} from "./dedup.js"; +import { isMentionForwardRequest } from "./mention.js"; import { fetchBotOpenIdForMonitor } from "./monitor.startup.js"; import { botOpenIds } from "./monitor.state.js"; import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js"; +import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu } from "./send.js"; import type { ResolvedFeishuAccount } from "./types.js"; @@ -120,33 +133,238 @@ type RegisterEventHandlersContext = { fireAndForget?: boolean; }; +function mergeFeishuDebounceMentions( + entries: FeishuMessageEvent[], +): FeishuMessageEvent["message"]["mentions"] | undefined { + const merged = new Map[number]>(); + for (const entry of entries) { + for (const mention of entry.message.mentions ?? []) { + const stableId = + mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim(); + const mentionName = mention.name?.trim(); + const mentionKey = mention.key?.trim(); + const fallback = + mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey; + const key = stableId || fallback; + if (!key || merged.has(key)) { + continue; + } + merged.set(key, mention); + } + } + if (merged.size === 0) { + return undefined; + } + return Array.from(merged.values()); +} + +function dedupeFeishuDebounceEntriesByMessageId( + entries: FeishuMessageEvent[], +): FeishuMessageEvent[] { + const seen = new Set(); + const deduped: FeishuMessageEvent[] = []; + for (const entry of entries) { + const messageId = entry.message.message_id?.trim(); + if (!messageId) { + deduped.push(entry); + continue; + } + if (seen.has(messageId)) { + continue; + } + seen.add(messageId); + deduped.push(entry); + } + return deduped; +} + +function resolveFeishuDebounceMentions(params: { + entries: FeishuMessageEvent[]; + botOpenId?: string; +}): FeishuMessageEvent["message"]["mentions"] | undefined { + const { entries, botOpenId } = params; + if (entries.length === 0) { + return undefined; + } + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (isMentionForwardRequest(entry, botOpenId)) { + // Keep mention-forward semantics scoped to a single source message. + return mergeFeishuDebounceMentions([entry]); + } + } + const merged = mergeFeishuDebounceMentions(entries); + if (!merged) { + return undefined; + } + const normalizedBotOpenId = botOpenId?.trim(); + if (!normalizedBotOpenId) { + return undefined; + } + const botMentions = merged.filter( + (mention) => mention.id.open_id?.trim() === normalizedBotOpenId, + ); + return botMentions.length > 0 ? botMentions : undefined; +} + function registerEventHandlers( eventDispatcher: Lark.EventDispatcher, context: RegisterEventHandlersContext, ): void { const { cfg, accountId, runtime, chatHistories, fireAndForget } = context; + const core = getFeishuRuntime(); + const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({ + cfg, + channel: "feishu", + }); const log = runtime?.log ?? console.log; const error = runtime?.error ?? console.error; + const dispatchFeishuMessage = async (event: FeishuMessageEvent) => { + await handleFeishuMessage({ + cfg, + event, + botOpenId: botOpenIds.get(accountId), + runtime, + chatHistories, + accountId, + }); + }; + const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => { + const senderId = + event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim(); + return senderId || undefined; + }; + const resolveDebounceText = (event: FeishuMessageEvent): string => { + const botOpenId = botOpenIds.get(accountId); + const parsed = parseFeishuMessageEvent(event, botOpenId); + return parsed.content.trim(); + }; + const recordSuppressedMessageIds = async ( + entries: FeishuMessageEvent[], + dispatchMessageId?: string, + ) => { + const keepMessageId = dispatchMessageId?.trim(); + const suppressedIds = new Set( + entries + .map((entry) => entry.message.message_id?.trim()) + .filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)), + ); + if (suppressedIds.size === 0) { + return; + } + for (const messageId of suppressedIds) { + // Keep in-memory dedupe in sync with handleFeishuMessage's keying. + tryRecordMessage(`${accountId}:${messageId}`); + try { + await tryRecordMessagePersistent(messageId, accountId, log); + } catch (err) { + error( + `feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`, + ); + } + } + }; + const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise => { + const messageId = entry.message.message_id?.trim(); + if (!messageId) { + return false; + } + const memoryKey = `${accountId}:${messageId}`; + if (hasRecordedMessage(memoryKey)) { + return true; + } + return hasRecordedMessagePersistent(messageId, accountId, log); + }; + const inboundDebouncer = core.channel.debounce.createInboundDebouncer({ + debounceMs: inboundDebounceMs, + buildKey: (event) => { + const chatId = event.message.chat_id?.trim(); + const senderId = resolveSenderDebounceId(event); + if (!chatId || !senderId) { + return null; + } + const rootId = event.message.root_id?.trim(); + const threadKey = rootId ? `thread:${rootId}` : "chat"; + return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`; + }, + shouldDebounce: (event) => { + if (event.message.message_type !== "text") { + return false; + } + const text = resolveDebounceText(event); + if (!text) { + return false; + } + return !core.channel.text.hasControlCommand(text, cfg); + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) { + return; + } + if (entries.length === 1) { + await dispatchFeishuMessage(last); + return; + } + const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries); + const freshEntries: FeishuMessageEvent[] = []; + for (const entry of dedupedEntries) { + if (!(await isMessageAlreadyProcessed(entry))) { + freshEntries.push(entry); + } + } + const dispatchEntry = freshEntries.at(-1); + if (!dispatchEntry) { + return; + } + await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id); + const combinedText = freshEntries + .map((entry) => resolveDebounceText(entry)) + .filter(Boolean) + .join("\n"); + const mergedMentions = resolveFeishuDebounceMentions({ + entries: freshEntries, + botOpenId: botOpenIds.get(accountId), + }); + if (!combinedText.trim()) { + await dispatchFeishuMessage({ + ...dispatchEntry, + message: { + ...dispatchEntry.message, + mentions: mergedMentions ?? dispatchEntry.message.mentions, + }, + }); + return; + } + await dispatchFeishuMessage({ + ...dispatchEntry, + message: { + ...dispatchEntry.message, + message_type: "text", + content: JSON.stringify({ text: combinedText }), + mentions: mergedMentions ?? dispatchEntry.message.mentions, + }, + }); + }, + onError: (err) => { + error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`); + }, + }); eventDispatcher.register({ "im.message.receive_v1": async (data) => { - try { + const processMessage = async () => { const event = data as unknown as FeishuMessageEvent; - const promise = handleFeishuMessage({ - cfg, - event, - botOpenId: botOpenIds.get(accountId), - runtime, - chatHistories, - accountId, + await inboundDebouncer.enqueue(event); + }; + if (fireAndForget) { + void processMessage().catch((err) => { + error(`feishu[${accountId}]: error handling message: ${String(err)}`); }); - if (fireAndForget) { - promise.catch((err) => { - error(`feishu[${accountId}]: error handling message: ${String(err)}`); - }); - } else { - await promise; - } + return; + } + try { + await processMessage(); } catch (err) { error(`feishu[${accountId}]: error handling message: ${String(err)}`); } diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 900c8520e..837867282 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -1,6 +1,40 @@ -import type { ClawdbotConfig } from "openclaw/plugin-sdk"; -import { describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../../../src/auto-reply/inbound-debounce.js"; +import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js"; +import * as dedup from "./dedup.js"; +import { monitorSingleAccount } from "./monitor.account.js"; import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js"; +import { setFeishuRuntime } from "./runtime.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: unknown }) => {})); +const createEventDispatcherMock = vi.hoisted(() => vi.fn()); +const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {})); +const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {})); + +let handlers: Record Promise> = {}; + +vi.mock("./client.js", () => ({ + createEventDispatcher: createEventDispatcherMock, +})); + +vi.mock("./bot.js", async () => { + const actual = await vi.importActual("./bot.js"); + return { + ...actual, + handleFeishuMessage: handleFeishuMessageMock, + }; +}); + +vi.mock("./monitor.transport.js", () => ({ + monitorWebSocket: monitorWebSocketMock, + monitorWebhook: monitorWebhookMock, +})); const cfg = {} as ClawdbotConfig; @@ -16,6 +50,100 @@ function makeReactionEvent( }; } +type FeishuMention = NonNullable[number]; + +function buildDebounceConfig(): ClawdbotConfig { + return { + messages: { + inbound: { + debounceMs: 0, + byChannel: { + feishu: 20, + }, + }, + }, + channels: { + feishu: { + enabled: true, + }, + }, + } as ClawdbotConfig; +} + +function buildDebounceAccount(): ResolvedFeishuAccount { + return { + accountId: "default", + enabled: true, + configured: true, + appId: "cli_test", + appSecret: "secret_test", + domain: "feishu", + config: { + enabled: true, + connectionMode: "websocket", + }, + } as ResolvedFeishuAccount; +} + +function createTextEvent(params: { + messageId: string; + text: string; + senderId?: string; + mentions?: FeishuMention[]; +}): FeishuMessageEvent { + const senderId = params.senderId ?? "ou_sender"; + return { + sender: { + sender_id: { open_id: senderId }, + sender_type: "user", + }, + message: { + message_id: params.messageId, + chat_id: "oc_group_1", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: params.text }), + mentions: params.mentions, + }, + }; +} + +async function setupDebounceMonitor(): Promise<(data: unknown) => Promise> { + const register = vi.fn((registered: Record Promise>) => { + handlers = registered; + }); + createEventDispatcherMock.mockReturnValue({ register }); + + await monitorSingleAccount({ + cfg: buildDebounceConfig(), + account: buildDebounceAccount(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as RuntimeEnv, + botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" }, + }); + + const onMessage = handlers["im.message.receive_v1"]; + if (!onMessage) { + throw new Error("missing im.message.receive_v1 handler"); + } + return onMessage; +} + +function getFirstDispatchedEvent(): FeishuMessageEvent { + const firstCall = handleFeishuMessageMock.mock.calls[0]; + if (!firstCall) { + throw new Error("missing dispatch call"); + } + const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined; + if (!firstParams?.event) { + throw new Error("missing dispatched event payload"); + } + return firstParams.event; +} + describe("resolveReactionSyntheticEvent", () => { it("filters app self-reactions", async () => { const event = makeReactionEvent({ operator_type: "app" }); @@ -233,3 +361,215 @@ describe("resolveReactionSyntheticEvent", () => { ); }); }); + +describe("Feishu inbound debounce regressions", () => { + beforeEach(() => { + vi.useFakeTimers(); + handlers = {}; + handleFeishuMessageMock.mockClear(); + setFeishuRuntime({ + channel: { + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + text: { + hasControlCommand, + }, + }, + } as unknown as PluginRuntime); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => { + vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); + vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); + vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false); + const onMessage = await setupDebounceMonitor(); + + await onMessage( + createTextEvent({ + messageId: "om_1", + text: "first", + mentions: [ + { + key: "@_user_1", + id: { open_id: "ou_user_a" }, + name: "user-a", + }, + ], + }), + ); + await Promise.resolve(); + await Promise.resolve(); + await onMessage( + createTextEvent({ + messageId: "om_2", + text: "@bot second", + mentions: [ + { + key: "@_user_1", + id: { open_id: "ou_bot" }, + name: "bot", + }, + ], + }), + ); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(25); + + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + const dispatched = getFirstDispatchedEvent(); + const mergedMentions = dispatched.message.mentions ?? []; + expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true); + expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false); + }); + + it("does not synthesize mention-forward intent across separate messages", async () => { + vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); + vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); + vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false); + const onMessage = await setupDebounceMonitor(); + + await onMessage( + createTextEvent({ + messageId: "om_user_mention", + text: "@alice first", + mentions: [ + { + key: "@_user_1", + id: { open_id: "ou_alice" }, + name: "alice", + }, + ], + }), + ); + await Promise.resolve(); + await Promise.resolve(); + await onMessage( + createTextEvent({ + messageId: "om_bot_mention", + text: "@bot second", + mentions: [ + { + key: "@_user_1", + id: { open_id: "ou_bot" }, + name: "bot", + }, + ], + }), + ); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(25); + + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + const dispatched = getFirstDispatchedEvent(); + const parsed = parseFeishuMessageEvent(dispatched, "ou_bot"); + expect(parsed.mentionedBot).toBe(true); + expect(parsed.mentionTargets).toBeUndefined(); + const mergedMentions = dispatched.message.mentions ?? []; + expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true); + }); + + it("preserves bot mention signal when the latest merged message has no mentions", async () => { + vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); + vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); + vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false); + const onMessage = await setupDebounceMonitor(); + + await onMessage( + createTextEvent({ + messageId: "om_bot_first", + text: "@bot first", + mentions: [ + { + key: "@_user_1", + id: { open_id: "ou_bot" }, + name: "bot", + }, + ], + }), + ); + await Promise.resolve(); + await Promise.resolve(); + await onMessage( + createTextEvent({ + messageId: "om_plain_second", + text: "plain follow-up", + }), + ); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(25); + + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + const dispatched = getFirstDispatchedEvent(); + const parsed = parseFeishuMessageEvent(dispatched, "ou_bot"); + expect(parsed.mentionedBot).toBe(true); + }); + + it("excludes previously processed retries from combined debounce text", async () => { + vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); + vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); + vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( + async (messageId) => messageId === "om_old", + ); + const onMessage = await setupDebounceMonitor(); + + await onMessage(createTextEvent({ messageId: "om_old", text: "stale" })); + await Promise.resolve(); + await Promise.resolve(); + await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" })); + await Promise.resolve(); + await Promise.resolve(); + await onMessage(createTextEvent({ messageId: "om_old", text: "stale" })); + await Promise.resolve(); + await Promise.resolve(); + await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" })); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(25); + + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + const dispatched = getFirstDispatchedEvent(); + expect(dispatched.message.message_id).toBe("om_new_2"); + const combined = JSON.parse(dispatched.message.content) as { text?: string }; + expect(combined.text).toBe("first\nsecond"); + }); + + it("uses latest fresh message id when debounce batch ends with stale retry", async () => { + const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); + vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); + vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( + async (messageId) => messageId === "om_old", + ); + const onMessage = await setupDebounceMonitor(); + + await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" })); + await Promise.resolve(); + await Promise.resolve(); + await onMessage(createTextEvent({ messageId: "om_old", text: "stale" })); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(25); + + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + const dispatched = getFirstDispatchedEvent(); + expect(dispatched.message.message_id).toBe("om_new"); + const combined = JSON.parse(dispatched.message.content) as { text?: string }; + expect(combined.text).toBe("fresh"); + expect(recordSpy).toHaveBeenCalledWith("default:om_old"); + expect(recordSpy).not.toHaveBeenCalledWith("default:om_new"); + }); +});