fix(feishu): make bot menu retries explicit

This commit is contained in:
Vincent Koc
2026-04-13 16:17:07 +01:00
parent 8dbe1b4f5a
commit 9c7cb6b67d
3 changed files with 75 additions and 2 deletions

View File

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

View File

@@ -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<typeof vi.fn>,
waitTimeoutMs: 5_000,
});
expect(sendCardFeishuMock).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
});
});

View File

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