From 6bc2983ab11baea7fe67c4599e466bce5116f51a Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Thu, 23 Apr 2026 05:14:49 +0000 Subject: [PATCH] fix(telegram): fall back buttons to inline text when keyboard disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttons are semantic options, not a separate channel protocol: a user who taps "Yes" and a user who types "yes" arrive at the agent as the same string. Dropping ``msg.buttons`` when ``inline_keyboards=False`` was the worst of both worlds — the agent got told "Message sent with N button(s)" while the user saw a question with no options. Splice the labels into the message text instead. The LLM produces the same ``message(buttons=...)`` call regardless of channel; the channel layer picks the richest rendering it can afford — native keyboard when enabled, bracketed inline text otherwise. Layout is preserved (one row per line). Other channels can adopt the same helper incrementally. Locks: canonical ``_buttons_as_text`` format, flag-off send-path splices labels, flag-on send-path keeps content clean and rides ``reply_markup``. Made-with: Cursor --- nanobot/channels/telegram.py | 14 +++++- tests/channels/test_telegram_channel.py | 61 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index d55c1a02..40ba71ad 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -520,8 +520,13 @@ class TelegramChannel(BaseChannel): # Send text content if msg.content and msg.content != "[empty message]": render_as_blockquote = bool(msg.metadata.get("_tool_hint")) - chunks = split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN) - reply_markup = self._build_keyboard(msg.buttons) if getattr(msg, 'buttons', None) else None + buttons = getattr(msg, "buttons", None) or [] + reply_markup = self._build_keyboard(buttons) if buttons else None + text = msg.content + # Fallback: no native keyboard → splice labels into the message so the choices survive. + if buttons and reply_markup is None: + text = f"{text}\n\n{self._buttons_as_text(buttons)}" + chunks = split_message(text, TELEGRAM_MAX_MESSAGE_LEN) for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) await self._send_text( @@ -1205,6 +1210,11 @@ class TelegramChannel(BaseChannel): ] return InlineKeyboardMarkup(keyboard) + @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. + return "\n".join(" ".join(f"[{label}]" for label in row) for row in buttons if row) + async def _on_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle inline keyboard button clicks (callback queries).""" if not update.callback_query or not update.effective_user: diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 5480b878..a9fad504 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -1617,3 +1617,64 @@ def test_build_keyboard_respects_inline_keyboards_flag() -> None: assert [[b.text for b in row] for row in rows] == [["Yes", "No"], ["Cancel"]] # callback_data mirrors label so _on_callback_query can echo the tap back. assert rows[0][0].callback_data == "Yes" + + +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]" + assert TelegramChannel._buttons_as_text([["Only"]]) == "[Only]" + assert TelegramChannel._buttons_as_text([[], ["A"]]) == "[A]" # empty rows skipped + + +@pytest.mark.asyncio +async def test_send_falls_back_buttons_to_inline_text_when_flag_off() -> None: + """Buttons are semantic options; with ``inline_keyboards=False`` we must + splice labels into the text so users still see the choices. Silent-drop + was the pre-fallback bug — the agent got a success reply while the user + saw a question with no options.""" + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=False), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + await channel.send( + OutboundMessage( + channel="telegram", + chat_id="123", + content="Proceed?", + buttons=[["Yes", "No"], ["Cancel"]], + ) + ) + + assert len(channel._app.bot.sent_messages) == 1 + sent = channel._app.bot.sent_messages[0] + assert sent.get("reply_markup") is None + assert "Proceed?" in sent["text"] + assert "[Yes] [No]" in sent["text"] + assert "[Cancel]" in sent["text"] + + +@pytest.mark.asyncio +async def test_send_uses_native_keyboard_when_flag_on() -> None: + """With the flag on, the content stays clean and buttons ride in ``reply_markup``.""" + from telegram import InlineKeyboardMarkup + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=True), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + await channel.send( + OutboundMessage( + channel="telegram", + chat_id="123", + content="Proceed?", + buttons=[["Yes", "No"]], + ) + ) + + sent = channel._app.bot.sent_messages[0] + assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup) + assert "[Yes]" not in sent["text"] # native keyboard owns the rendering