fix(telegram): clear generic callback buttons after click (#90169)

* fix(telegram): clear generic callback buttons

* test(telegram): simplify callback cleanup coverage

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Mark
2026-07-20 22:28:21 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent bce51057d8
commit d9ac878ea3
2 changed files with 88 additions and 0 deletions
@@ -31,6 +31,7 @@ import type { TelegramGetChat } from "./bot/types.js";
import { getTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js";
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
import { parseTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
import { isTelegramMessageNotModifiedError } from "./network-errors.js";
import {
hasTelegramQuestionCallbackPrefix,
parseTelegramQuestionCallbackData,
@@ -273,6 +274,20 @@ export function registerTelegramCallbackQueryHandler(
return;
}
const hasCallbackInlineKeyboard =
(callbackMessage.reply_markup?.inline_keyboard?.length ?? 0) > 0;
if (hasCallbackInlineKeyboard) {
try {
await actions.clearCallbackButtons();
} catch (editErr) {
if (
!isTelegramMessageNotModifiedError(editErr) &&
!isPermanentTelegramCallbackEditError(editErr)
) {
throw new TelegramRetryableCallbackError(editErr);
}
}
}
const syntheticMessage = buildSyntheticTextMessage({
base: withResolvedTelegramForumFlag(callbackMessage, isForum),
from: callback.from,
@@ -192,6 +192,28 @@ function requireValue<T>(value: T | null | undefined, label: string): T {
return value;
}
function makeGenericCallbackContext(params: { id: string; updateId?: number }) {
const data = "skip nightly build tonight";
return {
...(params.updateId === undefined ? {} : { update: { update_id: params.updateId } }),
callbackQuery: {
id: params.id,
data,
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 10,
reply_markup: {
inline_keyboard: [[{ text: "Skip tonight", callback_data: data }]],
},
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
}
function createDeferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
@@ -1947,6 +1969,57 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-slash-1");
});
it.each([
{ name: "clears buttons", id: "cbq-generic-clear-1", editError: undefined },
{
name: "continues after a permanent edit error",
id: "cbq-generic-clear-permanent-1",
editError: new Error("400: Bad Request: message can't be edited"),
},
])("routes generic callback_query payloads and $name", async ({ id, editError }) => {
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query");
if (editError) {
editMessageReplyMarkupSpy.mockRejectedValueOnce(editError);
}
await callbackHandler(makeGenericCallbackContext({ id }));
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 10, {
reply_markup: { inline_keyboard: [] },
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0];
expect(payload.Body).toContain("skip nightly build tonight");
expect(answerCallbackQuerySpy).toHaveBeenCalledWith(id);
});
it("retries generic callback_query button cleanup after transient edit failures", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query");
const ctx = makeGenericCallbackContext({ id: "cbq-generic-clear-retry-1", updateId: 779 });
editMessageReplyMarkupSpy.mockRejectedValueOnce(new Error("edit boom"));
await expect(
runTelegramMiddlewareChain({
ctx,
finalHandler: callbackHandler,
}),
).rejects.toThrow("edit boom");
expect(replySpy).not.toHaveBeenCalled();
await runTelegramMiddlewareChain({
ctx,
finalHandler: callbackHandler,
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(2);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0];
expect(payload.Body).toContain("skip nightly build tonight");
});
it("does not route opaque callback_query payloads as synthetic commands", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = requireValue(