fix(feishu): dedupe redelivered text by stable retry identity (#93449)

Merged via squash.

Prepared head SHA: 230266c0ba
Co-authored-by: ZengWen-DT <290981215+ZengWen-DT@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
ZengWen-DT
2026-06-16 16:53:29 +08:00
committed by GitHub
parent d697ecf172
commit 9ed9d389e0
5 changed files with 225 additions and 21 deletions

View File

@@ -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> | 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 = {

View File

@@ -466,6 +466,7 @@ export async function handleFeishuMessage(params: {
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
processingClaimHeld?: boolean;
messageDedupeKey?: string;
}): Promise<void> {
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,

View File

@@ -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"]),
);
});
});

View File

@@ -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<FeishuMessageEvent, "message">;
type FeishuMessageDedupeInput = Pick<FeishuMessageEvent, "message" | "sender">;
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;
}

View File

@@ -28,6 +28,7 @@ type FeishuMessageReceiveHandlerContext = {
chatHistories?: Map<string, HistoryEntry[]>;
accountId?: string;
processingClaimHeld?: boolean;
messageDedupeKey?: string;
}) => Promise<void>;
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) {