diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 1cf1dc6c3c3f..c7dc258378df 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -8,6 +8,7 @@ import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; import { parseMergeForwardContent } from "./bot-content.js"; import type { FeishuMessageEvent } from "./bot.js"; import { handleFeishuMessage } from "./bot.js"; +import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js"; import { createFeishuMessageReceiveHandler } from "./monitor.message-handler.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -4166,6 +4167,70 @@ describe("handleFeishuMessage command authorization", () => { }); describe("createFeishuMessageReceiveHandler media dedupe", () => { + it("preserves the original dispatch dedupe key when debounce merges text content", async () => { + const handleMessage = vi.fn(async () => undefined); + const core = { + channel: { + debounce: { + resolveInboundDebounceMs: vi.fn(() => 10), + createInboundDebouncer: vi.fn( + (options: { onFlush: (entries: FeishuMessageEvent[]) => Promise | void }) => { + const entries: FeishuMessageEvent[] = []; + return { + enqueue: async (event: FeishuMessageEvent) => { + entries.push(event); + if (entries.length === 2) { + await options.onFlush(entries); + } + }, + }; + }, + ), + }, + commands: { + isControlCommandMessage: vi.fn(() => false), + }, + }, + } as unknown as PluginRuntime; + const createTextEvent = (messageId: string, createTime: string, text: string) => + ({ + sender: { sender_id: { open_id: "ou-text-debounce" } }, + message: { + message_id: messageId, + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text }), + create_time: createTime, + }, + }) satisfies FeishuMessageEvent; + const last = createTextEvent("msg-text-last", "1710000001000", "second"); + const handler = createFeishuMessageReceiveHandler({ + cfg: { channels: { feishu: { dmPolicy: "open" } } } as ClawdbotConfig, + channelRuntime: core.channel, + accountId: "receive-text-debounce", + chatHistories: new Map(), + handleMessage, + resolveDebounceText: ({ event }) => + (JSON.parse(event.message.content) as { text: string }).text, + hasProcessedMessage: vi.fn(async () => false), + recordProcessedMessage: vi.fn(async () => true), + }); + + await handler(createTextEvent("msg-text-first", "1710000000000", "first")); + await handler(last); + + const call = mockCallArg<{ + event?: FeishuMessageEvent; + messageDedupeKey?: string; + }>(handleMessage, 0, 0); + expect(call.event?.message.content).toBe(JSON.stringify({ text: "first\nsecond" })); + expect(call.messageDedupeKey).toBe(resolveFeishuMessageDedupeKey(last)); + expect(resolveFeishuMessageDedupeKey(call.event as FeishuMessageEvent)).not.toBe( + call.messageDedupeKey, + ); + }); + it("keeps same-id media variants distinct at receive time", async () => { const handleMessage = vi.fn(async () => undefined); const core = { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 8053f472c78d..abcf535ce436 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -466,6 +466,7 @@ export async function handleFeishuMessage(params: { chatHistories?: Map; accountId?: string; processingClaimHeld?: boolean; + messageDedupeKey?: string; }): Promise { const { cfg, @@ -477,6 +478,7 @@ export async function handleFeishuMessage(params: { chatHistories, accountId, processingClaimHeld = false, + messageDedupeKey: messageDedupeKeyOverride, } = params; // Resolve account with merged config @@ -487,7 +489,7 @@ export async function handleFeishuMessage(params: { const error = runtime?.error ?? console.error; const messageId = event.message.message_id; - const messageDedupeKey = resolveFeishuMessageDedupeKey(event); + const messageDedupeKey = messageDedupeKeyOverride ?? resolveFeishuMessageDedupeKey(event); if ( !(await finalizeFeishuMessageProcessing({ messageId: messageDedupeKey, diff --git a/extensions/feishu/src/monitor.message-handler.ts b/extensions/feishu/src/monitor.message-handler.ts index e584ac61809a..7961e648d861 100644 --- a/extensions/feishu/src/monitor.message-handler.ts +++ b/extensions/feishu/src/monitor.message-handler.ts @@ -28,6 +28,7 @@ type FeishuMessageReceiveHandlerContext = { chatHistories?: Map; accountId?: string; processingClaimHeld?: boolean; + messageDedupeKey?: string; }) => Promise; resolveDebounceText: (params: { event: FeishuMessageEvent; @@ -184,7 +185,7 @@ export function createFeishuMessageReceiveHandler({ }, }); - const dispatchFeishuMessage = async (event: FeishuMessageEvent) => { + const dispatchFeishuMessage = async (event: FeishuMessageEvent, messageDedupeKey?: string) => { const sequentialKey = resolveSequentialKey({ accountId, event, @@ -202,6 +203,7 @@ export function createFeishuMessageReceiveHandler({ chatHistories, accountId, processingClaimHeld: true, + messageDedupeKey, }); await enqueue(sequentialKey, task); }; @@ -266,7 +268,7 @@ export function createFeishuMessageReceiveHandler({ return; } if (entries.length === 1) { - await dispatchFeishuMessage(last); + await dispatchFeishuMessage(last, resolveFeishuMessageDedupeKey(last)); return; } const dedupedEntries = dedupeFeishuDebounceEntriesByDedupeKey(entries); @@ -280,10 +282,8 @@ export function createFeishuMessageReceiveHandler({ if (!dispatchEntry) { return; } - await recordSuppressedMessageIds( - dedupedEntries, - resolveFeishuMessageDedupeKey(dispatchEntry), - ); + const dispatchDedupeKey = resolveFeishuMessageDedupeKey(dispatchEntry); + await recordSuppressedMessageIds(dedupedEntries, dispatchDedupeKey); const combinedText = freshEntries .map((entry) => resolveDebounceText(entry)) .filter(Boolean) @@ -292,19 +292,22 @@ export function createFeishuMessageReceiveHandler({ entries: freshEntries, botOpenId: getBotOpenId(accountId), }); - await dispatchFeishuMessage({ - ...dispatchEntry, - message: { - ...dispatchEntry.message, - ...(combinedText.trim() - ? { - message_type: "text", - content: JSON.stringify({ text: combinedText }), - } - : {}), - mentions: mergedMentions ?? dispatchEntry.message.mentions, + await dispatchFeishuMessage( + { + ...dispatchEntry, + message: { + ...dispatchEntry.message, + ...(combinedText.trim() + ? { + message_type: "text", + content: JSON.stringify({ text: combinedText }), + } + : {}), + mentions: mergedMentions ?? dispatchEntry.message.mentions, + }, }, - }); + dispatchDedupeKey, + ); }, onError: (err, entries) => { for (const entry of entries) {