From a78d922acff561859ea31adfdf42064c20ca9d90 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 13 Apr 2026 17:24:44 +0100 Subject: [PATCH] fix(telegram): retry failed model callbacks --- .../telegram/src/bot-handlers.runtime.ts | 52 +++++++------ .../src/bot.create-telegram-bot.test.ts | 73 +++++++++++++++++++ 2 files changed, 102 insertions(+), 23 deletions(-) diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 132da683c00..7164fec224a 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -671,6 +671,13 @@ export const registerTelegramHandlers = ({ }, }; + class TelegramRetryableCallbackError extends Error { + constructor(public readonly cause: unknown) { + super(String(cause)); + this.name = "TelegramRetryableCallbackError"; + } + } + const resolveTelegramEventAuthorizationContext = async (params: { chatId: number; isGroup: boolean; @@ -1436,18 +1443,22 @@ export const registerTelegramHandlers = ({ // Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back) const modelCallback = parseModelCallbackData(data); if (modelCallback) { - const sessionState = resolveTelegramSessionState({ - chatId, - isGroup, - isForum, - messageThreadId, - resolvedThreadId, - senderId, - }); - const modelData = await telegramDeps.buildModelsProviderData( - runtimeCfg, - sessionState.agentId, - ); + let sessionState: ReturnType; + let modelData: Awaited>; + try { + // Retry only the callback preflight that happens before any visible chat mutation. + sessionState = resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + }); + modelData = await telegramDeps.buildModelsProviderData(runtimeCfg, sessionState.agentId); + } catch (err) { + throw new TelegramRetryableCallbackError(err); + } const { byProvider, providers } = modelData; const editMessageWithButtons = async ( @@ -1511,15 +1522,7 @@ export const registerTelegramHandlers = ({ const safePage = Math.max(1, Math.min(page, totalPages)); // Resolve current model from session (prefer overrides) - const currentSessionState = resolveTelegramSessionState({ - chatId, - isGroup, - isForum, - messageThreadId, - resolvedThreadId, - senderId, - }); - const currentModel = currentSessionState.model; + const currentModel = sessionState.model; const buttons = buildModelsKeyboard({ provider, @@ -1533,8 +1536,8 @@ export const registerTelegramHandlers = ({ provider, total: models.length, cfg, - agentDir: resolveAgentDir(cfg, currentSessionState.agentId), - sessionEntry: currentSessionState.sessionEntry, + agentDir: resolveAgentDir(cfg, sessionState.agentId), + sessionEntry: sessionState.sessionEntry, }); await editMessageWithButtons(text, buttons); return; @@ -1635,6 +1638,9 @@ export const registerTelegramHandlers = ({ }); } catch (err) { runtime.error?.(danger(`callback handler failed: ${String(err)}`)); + if (err instanceof TelegramRetryableCallbackError) { + throw err.cause; + } } }); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 8c2efddbda7..6698294c049 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -9,6 +9,7 @@ const { botCtorSpy, commandSpy, dispatchReplyWithBufferedBlockDispatcher, + editMessageTextSpy, enqueueSystemEventSpy, getLoadWebMediaMock, getChatSpy, @@ -2859,4 +2860,76 @@ describe("createTelegramBot", () => { expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(2); expect(enqueueSystemEventSpy.mock.calls.at(-1)?.[0]).toContain("Telegram reaction added:"); }); + + it("retries model callback updates after a bubbled preflight failure", async () => { + loadConfig.mockReturnValue({ + agents: { + defaults: { + model: "openai/gpt-5.4", + }, + }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + const buildModelsProviderDataMock = + telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType; + buildModelsProviderDataMock.mockClear(); + editMessageTextSpy.mockClear(); + + createTelegramBot({ token: "tok" }); + const callbackHandler = getOnHandler("callback_query"); + const middlewares = middlewareUseSpy.mock.calls + .map((call) => call[0]) + .filter( + (fn): fn is (ctx: Record, next: () => Promise) => Promise => + typeof fn === "function", + ); + const runMiddlewareChain = async (ctx: Record) => { + let idx = -1; + const dispatch = async (i: number): Promise => { + if (i <= idx) { + throw new Error("middleware dispatch called multiple times"); + } + idx = i; + const fn = middlewares[i]; + if (!fn) { + await callbackHandler(ctx); + return; + } + await fn(ctx, async () => dispatch(i + 1)); + }; + await dispatch(0); + }; + + const ctx = { + update: { update_id: 666 }, + callbackQuery: { + id: "cbq-model-retry-1", + data: "mdl_prov", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 18, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }; + + buildModelsProviderDataMock.mockImplementationOnce(async () => { + throw new Error("providers boom"); + }); + await expect(runMiddlewareChain(ctx)).rejects.toThrow("providers boom"); + await runMiddlewareChain(ctx); + + expect(buildModelsProviderDataMock).toHaveBeenCalledTimes(2); + expect(editMessageTextSpy).toHaveBeenCalledTimes(1); + expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain("Select a provider:"); + }); });