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/dedupe-key.test.ts b/extensions/feishu/src/dedupe-key.test.ts new file mode 100644 index 000000000000..a41d1b98ac1f --- /dev/null +++ b/extensions/feishu/src/dedupe-key.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js"; +import type { FeishuMessageEvent } from "./event-types.js"; + +function textEvent(overrides: { + messageId: string; + createTime?: string; + senderOpenId?: string; + chatId?: string; + text?: string; +}): FeishuMessageEvent { + return { + sender: { sender_id: { open_id: overrides.senderOpenId ?? "ou-user" } }, + message: { + message_id: overrides.messageId, + chat_id: overrides.chatId ?? "oc-dm", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: overrides.text ?? "hello" }), + create_time: overrides.createTime, + }, + }; +} + +describe("resolveFeishuMessageDedupeKey", () => { + it("collapses redelivered text with a fresh message_id but identical sender/chat/create_time/content (#46778)", () => { + const first = resolveFeishuMessageDedupeKey( + textEvent({ messageId: "om_first", createTime: "1710000000000" }), + ); + const retry = resolveFeishuMessageDedupeKey( + textEvent({ messageId: "om_second", createTime: "1710000000000" }), + ); + expect(first).toBeDefined(); + expect(retry).toBe(first); + }); + + it("keeps genuine repeat sends distinct via create_time", () => { + const a = resolveFeishuMessageDedupeKey( + textEvent({ messageId: "om_a", createTime: "1710000000000" }), + ); + const b = resolveFeishuMessageDedupeKey( + textEvent({ messageId: "om_b", createTime: "1710000001000" }), + ); + expect(a).not.toBe(b); + }); + + it("does not collide across senders, chats, or content", () => { + const base = textEvent({ messageId: "om_1", createTime: "1710000000000" }); + const otherSender = textEvent({ + messageId: "om_2", + createTime: "1710000000000", + senderOpenId: "ou-other", + }); + const otherChat = textEvent({ messageId: "om_3", createTime: "1710000000000", chatId: "oc-2" }); + const otherText = textEvent({ messageId: "om_4", createTime: "1710000000000", text: "bye" }); + const baseKey = resolveFeishuMessageDedupeKey(base); + expect(resolveFeishuMessageDedupeKey(otherSender)).not.toBe(baseKey); + expect(resolveFeishuMessageDedupeKey(otherChat)).not.toBe(baseKey); + expect(resolveFeishuMessageDedupeKey(otherText)).not.toBe(baseKey); + }); + + it("falls back to message_id for text without a stable retry anchor", () => { + const key = resolveFeishuMessageDedupeKey(textEvent({ messageId: "om_no_time" })); + expect(key).toBe("om_no_time"); + }); + + it("falls back to message_id for malformed create_time", () => { + const key = resolveFeishuMessageDedupeKey( + textEvent({ messageId: "om_bad_time", createTime: "1710000000000ms" }), + ); + expect(key).toBe("om_bad_time"); + }); + + it("keeps media keyed by message_id plus media key", () => { + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-user" } }, + message: { + message_id: "om_media", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "image", + content: JSON.stringify({ image_key: "img_123" }), + create_time: "1710000000000", + }, + }; + expect(resolveFeishuMessageDedupeKey(event)).toBe( + JSON.stringify(["om_media", "image_key:img_123"]), + ); + }); +}); diff --git a/extensions/feishu/src/dedupe-key.ts b/extensions/feishu/src/dedupe-key.ts index 0b61d616bc39..4e657a9b653b 100644 --- a/extensions/feishu/src/dedupe-key.ts +++ b/extensions/feishu/src/dedupe-key.ts @@ -1,10 +1,12 @@ // Feishu plugin module implements dedupe key behavior. +import { createHash } from "node:crypto"; +import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; import { asNullableRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { FeishuMessageEvent } from "./event-types.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { parsePostContent } from "./post.js"; -type FeishuMessageDedupeInput = Pick; +type FeishuMessageDedupeInput = Pick; function readExternalKey(value: unknown): string | undefined { return normalizeFeishuExternalKey(typeof value === "string" ? value : ""); @@ -57,6 +59,42 @@ function resolveMessageMediaParts(messageType: string, content: string): string[ } } +function resolveSenderIdentity(event: FeishuMessageDedupeInput): string | undefined { + const senderId = event.sender?.sender_id; + return ( + senderId?.open_id?.trim() || + senderId?.union_id?.trim() || + senderId?.user_id?.trim() || + undefined + ); +} + +// Feishu can redeliver the same logical text message with a fresh message_id +// (retry/reconnect), defeating message_id-based dedupe (#46778). For text we key +// on a stable retry identity instead: same sender + chat + create_time + content +// is the same logical message. create_time is the message's own server timestamp +// and stays fixed across redeliveries, so genuine repeat sends (which get a new +// create_time) keep distinct keys and are never suppressed. Falls back to +// message_id when any field is missing so behavior is unchanged then. +function resolveTextRetryDedupeKey(event: FeishuMessageDedupeInput): string | undefined { + const createTime = event.message.create_time?.trim(); + const chatId = event.message.chat_id?.trim(); + const senderId = resolveSenderIdentity(event); + if ( + !createTime || + parseStrictNonNegativeInteger(createTime) === undefined || + !chatId || + !senderId + ) { + return undefined; + } + const contentHash = createHash("sha256") + .update(event.message.content, "utf8") + .digest("hex") + .slice(0, 32); + return JSON.stringify(["text-retry", senderId, chatId, createTime, contentHash]); +} + export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput): string | undefined { const messageId = event.message.message_id?.trim(); if (!messageId) { @@ -64,5 +102,11 @@ export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput): } const messageType = event.message.message_type.trim(); const mediaParts = resolveMessageMediaParts(messageType, event.message.content); - return mediaParts.length > 0 ? buildMediaDedupeKey(messageId, mediaParts) : messageId; + if (mediaParts.length > 0) { + return buildMediaDedupeKey(messageId, mediaParts); + } + if (messageType === "text") { + return resolveTextRetryDedupeKey(event) ?? messageId; + } + return messageId; } 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) {