fix(telegram): keep callback_data under Telegram's 64-byte cap
``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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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]"
|
||||
|
||||
Reference in New Issue
Block a user