fix(telegram): fall back buttons to inline text when keyboard disabled
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user