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