fix(feishu): preserve debounce dedupe identity

This commit is contained in:
Vincent Koc
2026-06-16 16:28:34 +08:00
parent 4ec7f0f9e8
commit e42a516678
3 changed files with 89 additions and 19 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

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