From 854c2ce30922200aedb96e0f609697433efd2ec6 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sat, 9 May 2026 08:14:23 -0700 Subject: [PATCH] fix(telegram): honor message.quote for partial-quote reply context When a Telegram user replies using the native quote feature to select only part of a prior message, _build_message_event was injecting the ENTIRE replied-to message into reply_to_text via message.reply_to_message.text/caption. python-telegram-bot exposes the user-selected substring as message.quote (TextQuote.text); we now prefer that and fall back to the full replied-to text only when no native quote is present. The agent-visible "[Replying to: \"...\"]" prefix can otherwise expand the user's narrow quote into the full prior message, causing the agent to act on unrelated actionable-looking text the user did not select (e.g. multi-item briefings where the user quotes one bullet but the prefix injects every bullet). Falls back cleanly when message.quote is absent (PTB <21 or replies that don't quote a substring). Fixes #22619 Co-Authored-By: Claude Opus 4.7 (1M context) --- gateway/platforms/telegram.py | 20 ++- tests/gateway/test_telegram_reply_quote.py | 144 +++++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_telegram_reply_quote.py diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index e680db61e67..0017edb8472 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -4026,12 +4026,28 @@ class TelegramAdapter(BasePlatformAdapter): chat_topic=chat_topic, ) - # Extract reply context if this message is a reply + # Extract reply context if this message is a reply. + # Prefer Telegram's native partial quote (message.quote, TextQuote) + # so a user replying to a single selected substring of a prior + # multi-section message doesn't get the whole replied-to message + # injected into the agent's context — which can cause the agent + # to act on unrelated actionable-looking text the user didn't + # quote (#22619). Fall back to the full replied-to message text + # / caption when no native quote is present. reply_to_id = None reply_to_text = None if message.reply_to_message: reply_to_id = str(message.reply_to_message.message_id) - reply_to_text = message.reply_to_message.text or message.reply_to_message.caption or None + quote = getattr(message, "quote", None) + quote_text = getattr(quote, "text", None) if quote is not None else None + if quote_text: + reply_to_text = quote_text + else: + reply_to_text = ( + message.reply_to_message.text + or message.reply_to_message.caption + or None + ) # Per-channel/topic ephemeral prompt from gateway.platforms.base import resolve_channel_prompt diff --git a/tests/gateway/test_telegram_reply_quote.py b/tests/gateway/test_telegram_reply_quote.py new file mode 100644 index 00000000000..d636f0df94a --- /dev/null +++ b/tests/gateway/test_telegram_reply_quote.py @@ -0,0 +1,144 @@ +"""Tests for Telegram native partial-quote handling in _build_message_event. + +When a Telegram user replies using Telegram's native quote feature to +select only part of a prior message, the adapter must use ``message.quote.text`` +(the user-selected substring) rather than ``message.reply_to_message.text`` +(the entire replied-to message). Otherwise the agent receives the full prior +message as ``reply_to_text``, which can cause it to act on unrelated +actionable-looking text the user did not quote (#22619). +""" + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +from gateway.config import PlatformConfig + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + + +_ensure_telegram_mock() + +from gateway.platforms.telegram import TelegramAdapter # noqa: E402 + + +def _make_adapter(): + return TelegramAdapter(PlatformConfig(enabled=True, token="***", extra={})) + + +def _make_message( + text="follow-up", + reply_to_text=None, + reply_to_caption=None, + reply_to_id=42, + quote_text=None, +): + chat = SimpleNamespace(id=111, type="private", title=None, full_name="Alice") + user = SimpleNamespace(id=42, full_name="Alice") + + reply_to_message = None + if reply_to_text is not None or reply_to_caption is not None: + reply_to_message = SimpleNamespace( + message_id=reply_to_id, + text=reply_to_text, + caption=reply_to_caption, + ) + + quote = None + if quote_text is not None: + quote = SimpleNamespace(text=quote_text) + + return SimpleNamespace( + chat=chat, + from_user=user, + text=text, + message_thread_id=None, + message_id=1001, + reply_to_message=reply_to_message, + quote=quote, + date=None, + forum_topic_created=None, + ) + + +def test_native_partial_quote_used_as_reply_to_text(): + """When ``message.quote`` is present, prefer the selected substring.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + msg = _make_message( + text="mark this one as done", + reply_to_text=( + "Briefing:\n- Item A: deploy fix\n- Item B: rotate keys\n- Item C: update docs" + ), + quote_text="Item B: rotate keys", + ) + + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.reply_to_text == "Item B: rotate keys" + assert event.reply_to_message_id == "42" + + +def test_full_reply_text_used_when_no_native_quote(): + """No ``message.quote`` → fall back to the whole replied-to message text.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + msg = _make_message( + text="thanks", + reply_to_text="Whole prior message body", + quote_text=None, + ) + + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.reply_to_text == "Whole prior message body" + assert event.reply_to_message_id == "42" + + +def test_caption_fallback_when_no_quote_and_no_text(): + """Replied-to media message: caption is used when text is absent.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + msg = _make_message( + text="see this", + reply_to_text=None, + reply_to_caption="Photo caption from earlier", + quote_text=None, + ) + + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.reply_to_text == "Photo caption from earlier" + + +def test_empty_quote_text_falls_back_to_full_reply(): + """Defensive: a present-but-empty quote.text shouldn't blank the prefix.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + msg = _make_message( + text="follow-up", + reply_to_text="Prior message body", + quote_text="", + ) + + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.reply_to_text == "Prior message body"