From 06503cd0fc434d88df9e7a4730f089aab4924319 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 23 Apr 2026 05:23:10 +0000 Subject: [PATCH] fix(telegram): keep callback_data under Telegram's 64-byte cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``InlineKeyboardButton(label, callback_data=label)`` fails Telegram's API when the label exceeds 64 bytes UTF-8. An LLM-generated long option (realistic in multilingual flows) used to 400 the ``send_message`` call silently — user got nothing, agent heard a successful retry-then-drop. Decouple display from wire: button text keeps the full label, callback_data gets truncated at a UTF-8 char boundary. Tap echoes the prefix back as the user message; the LLM understands a prefix of its own option just fine, and the display the user saw was always the full string. Locks: helper boundary behavior (ASCII, CJK, short labels pass through) and end-to-end ``_build_keyboard`` integration with an over-cap label. Made-with: Cursor --- nanobot/channels/telegram.py | 10 ++++++- tests/channels/test_telegram_channel.py | 35 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 40ba71ad..38c3fe89 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -1205,11 +1205,19 @@ class TelegramChannel(BaseChannel): if not buttons or not self.config.inline_keyboards: return None keyboard = [ - [InlineKeyboardButton(label, callback_data=label) for label in row] + [InlineKeyboardButton(label, callback_data=self._safe_callback_data(label)) for label in row] for row in buttons ] return InlineKeyboardMarkup(keyboard) + @staticmethod + def _safe_callback_data(label: str) -> str: + # Telegram caps callback_data at 64 bytes UTF-8; truncate at a char boundary so the keyboard still sends. + encoded = label.encode("utf-8") + if len(encoded) <= 64: + return label + return encoded[:64].decode("utf-8", errors="ignore") + @staticmethod def _buttons_as_text(buttons: list[list[str]]) -> str: # Buttons are semantic options; when we can't render a keyboard, the user still needs to see them. diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index a9fad504..175844b2 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -1619,6 +1619,41 @@ def test_build_keyboard_respects_inline_keyboards_flag() -> None: assert rows[0][0].callback_data == "Yes" +def test_safe_callback_data_truncates_at_utf8_boundary() -> None: + # Telegram's 64-byte callback_data cap is a hard API limit; silent 400s were the bug. + short = "Yes" + assert TelegramChannel._safe_callback_data(short) == short + + long_ascii = "a" * 100 + out = TelegramChannel._safe_callback_data(long_ascii) + assert len(out.encode("utf-8")) <= 64 + assert long_ascii.startswith(out) + + # Multibyte labels must not split a codepoint mid-byte. + long_cjk = "同意并继续下一步,我已阅读并同意了服务条款以及隐私政策" + assert len(long_cjk.encode("utf-8")) > 64 + out = TelegramChannel._safe_callback_data(long_cjk) + assert len(out.encode("utf-8")) <= 64 + assert long_cjk.startswith(out) + out.encode("utf-8").decode("utf-8") # must round-trip cleanly + + +def test_build_keyboard_uses_safe_callback_data_for_long_labels() -> None: + # Pins the integration so a long-label payload survives ``send_message`` instead of 400ing. + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", inline_keyboards=True), + MessageBus(), + ) + long_label = "Approve and continue to the next step with the updated terms of service" + assert len(long_label.encode("utf-8")) > 64 + + markup = channel._build_keyboard([[long_label]]) + btn = markup.inline_keyboard[0][0] + assert btn.text == long_label # display preserved + assert len(btn.callback_data.encode("utf-8")) <= 64 + assert long_label.startswith(btn.callback_data) + + def test_buttons_as_text_format_preserves_rows_and_labels() -> None: # Canonical shape: one row per line, labels bracketed. Layout survives the fallback. assert TelegramChannel._buttons_as_text([["Yes", "No"], ["Cancel"]]) == "[Yes] [No]\n[Cancel]"