From 9c7cb6b67def4daa06fd43f0e7af3daaf3e1758e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 13 Apr 2026 16:17:07 +0100 Subject: [PATCH] fix(feishu): make bot menu retries explicit --- extensions/feishu/src/monitor.account.ts | 27 +++++++++++++++++-- .../src/monitor.bot-menu.lifecycle.test.ts | 26 ++++++++++++++++++ .../feishu/src/monitor.bot-menu.test.ts | 24 +++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 3f4b89fa0f0..45885460480 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -37,6 +37,25 @@ import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js"; const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500; +export class FeishuRetryableSyntheticEventError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "FeishuRetryableSyntheticEventError"; + } +} + +function isFeishuRetryableSyntheticEventError( + error: unknown, +): error is FeishuRetryableSyntheticEventError { + return ( + error instanceof FeishuRetryableSyntheticEventError || + (typeof error === "object" && + error !== null && + "name" in error && + error.name === "FeishuRetryableSyntheticEventError") + ); +} + export type FeishuReactionCreatedEvent = { message_id: string; chat_id?: string; @@ -781,8 +800,12 @@ function registerEventHandlers( } return await handleLegacyMenu(); }) - .catch((err) => { - releaseFeishuMessageProcessing(syntheticMessageId, accountId); + .catch(async (err) => { + if (isFeishuRetryableSyntheticEventError(err)) { + releaseFeishuMessageProcessing(syntheticMessageId, accountId); + } else { + await recordProcessedFeishuMessage(syntheticMessageId, accountId, log); + } throw err; }); if (fireAndForget) { diff --git a/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts index 4f97be7fe2f..5f55a8737bd 100644 --- a/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts @@ -8,6 +8,7 @@ import { createResolvedFeishuLifecycleAccount, expectFeishuReplyDispatcherSentFinalReplyOnce, expectFeishuReplyPipelineDedupedAcrossReplay, + expectFeishuReplyPipelineDedupedAfterPostSendFailure, expectFeishuSingleEffectAcrossReplay, installFeishuLifecycleReplyRuntime, mockFeishuReplyOnceDispatch, @@ -189,4 +190,29 @@ describe("Feishu bot-menu lifecycle", () => { expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock }); }); + + it("does not duplicate delivery when launcher fallback hits a post-send failure", async () => { + const onBotMenu = await setupLifecycleMonitor(); + const event = createBotMenuEvent({ + eventKey: "quick-actions", + timestamp: "1700000000002", + }); + sendCardFeishuMock.mockRejectedValueOnce(new Error("boom")); + dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => { + await dispatcher.sendFinalReply({ text: "menu reply once" }); + throw new Error("post-send failure"); + }); + + await expectFeishuReplyPipelineDedupedAfterPostSendFailure({ + handler: onBotMenu, + event, + dispatchReplyFromConfigMock, + runtimeErrorMock: lastRuntime?.error as ReturnType, + waitTimeoutMs: 5_000, + }); + + expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock }); + }); }); diff --git a/extensions/feishu/src/monitor.bot-menu.test.ts b/extensions/feishu/src/monitor.bot-menu.test.ts index df54b148386..e68e8fe0b7f 100644 --- a/extensions/feishu/src/monitor.bot-menu.test.ts +++ b/extensions/feishu/src/monitor.bot-menu.test.ts @@ -231,4 +231,28 @@ describe("Feishu bot menu handler", () => { expect(sentCard?.config?.wide_screen_mode).toBeUndefined(); expect(sentCard?.config?.enable_forward).toBeUndefined(); }); + + it("reopens replay for explicit retryable fallback failures", async () => { + const onBotMenu = await registerHandlers(); + sendCardFeishuMock + .mockImplementationOnce(async () => { + throw new Error("boom"); + }) + .mockImplementationOnce(async () => { + throw new Error("boom"); + }); + handleFeishuMessageMock + .mockRejectedValueOnce( + Object.assign(new Error("retry me"), { + name: "FeishuRetryableSyntheticEventError", + }), + ) + .mockResolvedValueOnce(undefined); + + await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" })); + await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000004" })); + + expect(sendCardFeishuMock).toHaveBeenCalledTimes(2); + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + }); });