Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4058,15 +4058,26 @@ async def send_clarify(
"""Render a clarify prompt: numbered buttons per choice plus "✏️ Other (type answer)" (flips to
text-capture mode); without choices, plain question and the gateway text-intercept captures."""
def build():
text = f"❓ {_html.escape(question)}"
header = "❓ "
body = str(question)
keyboard = None
if choices:
# Full option text in the body (mobile truncates button labels); buttons keep numeric labels.
text += "\n\n" + "\n".join(f"{i + 1}. {_html.escape(str(c))}" for i, c in enumerate(choices))
body += "\n\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(choices))
# Telegram caps callback_data at 64 bytes; keep "cl:<id>:<idx>" short.
rows = [[InlineKeyboardButton(str(idx + 1), callback_data=f"cl:{clarify_id}:{idx}")] for idx in range(len(choices))]
rows.append([InlineKeyboardButton("✏️ Other (type answer)", callback_data=f"cl:{clarify_id}:other")])
keyboard = InlineKeyboardMarkup(rows)
# Budget the HTML-escaped rendering (escaping expands text), same as the exec-approval
# and slash-confirm cards — an unbudgeted question/choice set can exceed the 4096 cap
# and Telegram answers "Message is too long" instead of sending the prompt at all.
# ``_ea_fit``'s "..." suffix rides outside the budget it's given (by design, like
# ``_truncate_preview``), so the header AND that suffix are both reserved up front —
# same margin ``send_slash_confirm`` reserves for its own "..." above.
budget = (
self.MAX_MESSAGE_LENGTH - utf16_len(self._ea_escape(header))
- utf16_len(self._ea_escape("...")))
text = header + self._ea_escape(self._ea_fit(body, budget, escape=self._ea_escape))
return text, keyboard, lambda msg: self._clarify_state.__setitem__(clarify_id, session_key)
return await self._send_prompt(
"send_clarify", chat_id, metadata, build, parse_mode=ParseMode.HTML, thread_id=self._metadata_thread_id(metadata))
Expand Down
45 changes: 45 additions & 0 deletions tests/gateway/test_telegram_clarify_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,51 @@ async def test_html_escapes_question(self):
assert "<script>" not in kwargs["text"]
assert "&lt;script&gt;" in kwargs["text"]

@pytest.mark.asyncio
async def test_oversized_choice_set_fits_after_html_escaping(self):
"""The rendered card (question + escaped choices) must fit Telegram's 4096-char cap,
like the exec-approval and slash-confirm cards — mirrors
test_telegram_approval_buttons.test_oversized_escaped_approval_text_keeps_inline_keyboard.
Before the fix each choice was escaped individually with no total budget, so a long
question or many/long choices (both model-controlled) could exceed the cap and Telegram
would answer "Message is too long" instead of sending the prompt at all."""
from gateway.platforms.base import utf16_len

adapter = _make_adapter()
mock_msg = MagicMock()
mock_msg.message_id = 104
adapter._bot.send_message = AsyncMock(return_value=mock_msg)

await adapter.send_clarify(
chat_id="12345",
question="&" * 200, # escapes to 5x length ("&amp;")
choices=["<" * 500 for _ in range(10)], # each "<" escapes to "&lt;" (4x)
clarify_id="cid6",
session_key="sk6",
)

kwargs = adapter._bot.send_message.call_args[1]
assert utf16_len(kwargs["text"]) <= adapter.MAX_MESSAGE_LENGTH
assert kwargs["reply_markup"] is not None
assert "cid6" in adapter._clarify_state

@pytest.mark.asyncio
async def test_emoji_dense_clarify_card_fits_in_utf16_units(self):
"""Telegram counts UTF-16 code units (astral emoji = 2), like the adapter's chunker and
the exec-approval/slash-confirm budgeting."""
from gateway.platforms.base import utf16_len

adapter = _make_adapter()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=105))

await adapter.send_clarify(
chat_id="12345", question="😀" * 3000, choices=["ok"], clarify_id="cid7",
session_key="sk7")

kwargs = adapter._bot.send_message.call_args[1]
assert utf16_len(kwargs["text"]) <= adapter.MAX_MESSAGE_LENGTH
assert kwargs["reply_markup"] is not None


# ===========================================================================
# Callback dispatch — _handle_callback_query routing for cl:* prefixes
Expand Down