Skip to content

Commit 77f1ea0

Browse files
committed
fix(telegram): retry failed approval callbacks
1 parent 8f3e229 commit 77f1ea0

3 files changed

Lines changed: 108 additions & 44 deletions

File tree

extensions/telegram/src/bot-handlers.runtime.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,10 +1384,7 @@ export const registerTelegramHandlers = ({
13841384
logVerbose(
13851385
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${errStr}`,
13861386
);
1387-
await replyToCallbackChat(
1388-
"❌ Failed to submit approval. Please try again or contact an admin.",
1389-
);
1390-
return;
1387+
throw new TelegramRetryableCallbackError(resolveErr);
13911388
}
13921389
try {
13931390
await clearCallbackButtons();

extensions/telegram/src/bot.create-telegram-bot.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const {
2626
middlewareUseSpy,
2727
onSpy,
2828
replySpy,
29+
resolveExecApprovalSpy,
2930
sendAnimationSpy,
3031
sendChatActionSpy,
3132
sendMessageSpy,
@@ -3106,6 +3107,72 @@ describe("createTelegramBot", () => {
31063107
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("plugin bind approval");
31073108
});
31083109

3110+
it("retries exec approval callbacks after a bubbled resolution failure", async () => {
3111+
loadConfig.mockReturnValue({
3112+
channels: {
3113+
telegram: {
3114+
dmPolicy: "open",
3115+
allowFrom: ["*"],
3116+
execApprovals: {
3117+
enabled: true,
3118+
approvers: ["9"],
3119+
target: "dm",
3120+
},
3121+
},
3122+
},
3123+
});
3124+
createTelegramBot({ token: "tok" });
3125+
const callbackHandler = getOnHandler("callback_query");
3126+
const middlewares = middlewareUseSpy.mock.calls
3127+
.map((call) => call[0])
3128+
.filter(
3129+
(fn): fn is (ctx: Record<string, unknown>, next: () => Promise<void>) => Promise<void> =>
3130+
typeof fn === "function",
3131+
);
3132+
const runMiddlewareChain = async (ctx: Record<string, unknown>) => {
3133+
let idx = -1;
3134+
const dispatch = async (i: number): Promise<void> => {
3135+
if (i <= idx) {
3136+
throw new Error("middleware dispatch called multiple times");
3137+
}
3138+
idx = i;
3139+
const fn = middlewares[i];
3140+
if (!fn) {
3141+
await callbackHandler(ctx);
3142+
return;
3143+
}
3144+
await fn(ctx, async () => dispatch(i + 1));
3145+
};
3146+
await dispatch(0);
3147+
};
3148+
3149+
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("approval boom"));
3150+
3151+
const ctx = {
3152+
update: { update_id: 8895 },
3153+
callbackQuery: {
3154+
id: "cbq-approval-retry-1",
3155+
data: "/approve 138e9b8c allow-once",
3156+
from: { id: 9, first_name: "Ada", username: "ada_bot" },
3157+
message: {
3158+
chat: { id: 1234, type: "private" },
3159+
date: 1736380800,
3160+
message_id: 231,
3161+
text: "Approval required.",
3162+
},
3163+
},
3164+
me: { username: "openclaw_bot" },
3165+
getFile: async () => ({ download: async () => new Uint8Array() }),
3166+
};
3167+
3168+
await expect(runMiddlewareChain(ctx)).rejects.toThrow("approval boom");
3169+
await runMiddlewareChain(ctx);
3170+
3171+
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(2);
3172+
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
3173+
expect(sendMessageSpy).not.toHaveBeenCalled();
3174+
});
3175+
31093176
it("retries model provider callbacks after a bubbled edit failure", async () => {
31103177
createTelegramBot({ token: "tok" });
31113178
const callbackHandler = getOnHandler("callback_query");

extensions/telegram/src/bot.test.ts

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ const loadConfig = getLoadConfigMock();
4141
const loadWebMedia = getLoadWebMediaMock();
4242
const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
4343
const PUZZLE_EMOJI = "\u{1F9E9}";
44-
const CROSS_MARK_EMOJI = "\u{274C}";
4544
const INFO_EMOJI = "\u{2139}\u{FE0F}";
4645
const CHECK_MARK_EMOJI = "\u{2705}";
4746
const THUMBS_UP_EMOJI = "\u{1F44D}";
@@ -501,9 +500,10 @@ describe("createTelegramBot", () => {
501500
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked");
502501
});
503502

504-
it("does not leak raw approval callback errors back into Telegram chat", async () => {
503+
it("keeps approval callback resolution failures out of Telegram chat before retry", async () => {
505504
onSpy.mockClear();
506505
sendMessageSpy.mockClear();
506+
editMessageReplyMarkupSpy.mockClear();
507507
resolveExecApprovalSpy.mockClear();
508508
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("gateway secret detail"));
509509

@@ -525,26 +525,27 @@ describe("createTelegramBot", () => {
525525
ctx: Record<string, unknown>,
526526
) => Promise<void>;
527527

528-
await callbackHandler({
529-
callbackQuery: {
530-
id: "cbq-approve-error",
531-
data: "/approve 138e9b8c allow-once",
532-
from: { id: 9, first_name: "Ada", username: "ada_bot" },
533-
message: {
534-
chat: { id: 1234, type: "private" },
535-
date: 1736380800,
536-
message_id: 25,
537-
text: "Approval required.",
528+
await expect(
529+
callbackHandler({
530+
callbackQuery: {
531+
id: "cbq-approve-error",
532+
data: "/approve 138e9b8c allow-once",
533+
from: { id: 9, first_name: "Ada", username: "ada_bot" },
534+
message: {
535+
chat: { id: 1234, type: "private" },
536+
date: 1736380800,
537+
message_id: 25,
538+
text: "Approval required.",
539+
},
538540
},
539-
},
540-
me: { username: "openclaw_bot" },
541-
getFile: async () => ({ download: async () => new Uint8Array() }),
542-
});
541+
me: { username: "openclaw_bot" },
542+
getFile: async () => ({ download: async () => new Uint8Array() }),
543+
}),
544+
).rejects.toThrow("gateway secret detail");
543545

544-
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
545-
expect(sendMessageSpy.mock.calls[0]?.[1]).toBe(
546-
`${CROSS_MARK_EMOJI} Failed to submit approval. Please try again or contact an admin.`,
547-
);
546+
expect(sendMessageSpy).not.toHaveBeenCalled();
547+
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
548+
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-error");
548549
});
549550

550551
it("allows exec approval callbacks from target-only Telegram recipients", async () => {
@@ -608,12 +609,13 @@ describe("createTelegramBot", () => {
608609
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-target");
609610
});
610611

611-
it("does not allow target-only recipients to use legacy plugin fallback ids", async () => {
612+
it("keeps legacy plugin fallback approval failures retryable for target-only recipients", async () => {
612613
onSpy.mockClear();
613614
editMessageReplyMarkupSpy.mockClear();
614615
editMessageTextSpy.mockClear();
615616
resolveExecApprovalSpy.mockClear();
616617
replySpy.mockClear();
618+
sendMessageSpy.mockClear();
617619
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id"));
618620

619621
loadConfig.mockReturnValue({
@@ -637,21 +639,23 @@ describe("createTelegramBot", () => {
637639
) => Promise<void>;
638640
expect(callbackHandler).toBeDefined();
639641

640-
await callbackHandler({
641-
callbackQuery: {
642-
id: "cbq-legacy-plugin-fallback-blocked",
643-
data: "/approve 138e9b8c allow-once",
644-
from: { id: 9, first_name: "Ada", username: "ada_bot" },
645-
message: {
646-
chat: { id: 1234, type: "private" },
647-
date: 1736380800,
648-
message_id: 25,
649-
text: "Legacy plugin approval required.",
642+
await expect(
643+
callbackHandler({
644+
callbackQuery: {
645+
id: "cbq-legacy-plugin-fallback-blocked",
646+
data: "/approve 138e9b8c allow-once",
647+
from: { id: 9, first_name: "Ada", username: "ada_bot" },
648+
message: {
649+
chat: { id: 1234, type: "private" },
650+
date: 1736380800,
651+
message_id: 25,
652+
text: "Legacy plugin approval required.",
653+
},
650654
},
651-
},
652-
me: { username: "openclaw_bot" },
653-
getFile: async () => ({ download: async () => new Uint8Array() }),
654-
});
655+
me: { username: "openclaw_bot" },
656+
getFile: async () => ({ download: async () => new Uint8Array() }),
657+
}),
658+
).rejects.toThrow("unknown or expired approval id");
655659

656660
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
657661
cfg: expect.objectContaining({
@@ -669,11 +673,7 @@ describe("createTelegramBot", () => {
669673
});
670674
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
671675
expect(replySpy).not.toHaveBeenCalled();
672-
expect(sendMessageSpy).toHaveBeenCalledWith(
673-
1234,
674-
`${CROSS_MARK_EMOJI} Failed to submit approval. Please try again or contact an admin.`,
675-
undefined,
676-
);
676+
expect(sendMessageSpy).not.toHaveBeenCalled();
677677
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-legacy-plugin-fallback-blocked");
678678
});
679679

0 commit comments

Comments
 (0)