mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
90
extensions/feishu/src/dedupe-key.test.ts
Normal file
90
extensions/feishu/src/dedupe-key.test.ts
Normal 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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user