diff --git a/src/bot/handlers/message.py b/src/bot/handlers/message.py index bbd24084..d83f4c4e 100644 --- a/src/bot/handlers/message.py +++ b/src/bot/handlers/message.py @@ -25,6 +25,7 @@ should_send_as_photo, validate_image_path, ) +from ..utils.quote_prompt import build_user_prompt logger = structlog.get_logger() @@ -298,7 +299,9 @@ async def handle_text_message( ) -> None: """Handle regular text messages as Claude prompts.""" user_id = update.effective_user.id - message_text = update.message.text + # Include reply/quote context so Claude sees the fragment the user is + # responding to, not just their new text. + message_text = build_user_prompt(update.message) settings: Settings = context.bot_data["settings"] # Get services diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 6d9719f0..f49cf53a 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -39,6 +39,7 @@ should_send_as_photo, validate_image_path, ) +from .utils.quote_prompt import build_user_prompt logger = structlog.get_logger() @@ -917,7 +918,9 @@ async def agentic_text( ) -> None: """Direct Claude passthrough. Simple progress. No suggestions.""" user_id = update.effective_user.id - message_text = update.message.text + # Include reply/quote context so Claude sees the fragment the user is + # responding to, not just their new text. + message_text = build_user_prompt(update.message) logger.info( "Agentic text message", diff --git a/src/bot/utils/quote_prompt.py b/src/bot/utils/quote_prompt.py new file mode 100644 index 00000000..5c1a8426 --- /dev/null +++ b/src/bot/utils/quote_prompt.py @@ -0,0 +1,77 @@ +"""Build prompts from Telegram messages, including reply/quote context. + +When a user replies to a message (especially with a highlighted fragment — +Telegram's partial-quote feature, Bot API 7.0+), the bot's default behaviour +of reading only `update.message.text` drops the quoted context. Claude then +has to guess what the user is referring to. + +This helper extracts the quoted fragment (partial quote has priority over +the full replied-to message) and renders it as a markdown blockquote above +the user's new text. +""" + +from typing import Any, Optional + + +def build_user_prompt(message: Any) -> str: + """Return the prompt text to send to Claude for a given user message. + + Shape, when reply/quote context is present:: + + > quoted fragment line 1 + > quoted fragment line 2 + + new user text + + When no reply/quote is present, returns just the user's text (or caption). + """ + user_text = _safe_str(getattr(message, "text", None)) or _safe_str( + getattr(message, "caption", None) + ) + quoted = _extract_quoted_text(message) + + if not quoted: + return user_text + + blockquote = "\n".join(f"> {line}" if line else ">" for line in quoted.split("\n")) + if not user_text: + return blockquote + + return f"{blockquote}\n\n{user_text}" + + +def _extract_quoted_text(message: Any) -> Optional[str]: + """Return the text the user is referring to, or None. + + Priority: + 1. `message.quote.text` — Telegram partial-quote (user highlighted a + fragment of the replied-to message). Bot API 7.0+. + 2. `message.reply_to_message.text` or `.caption` — plain reply with no + partial highlight; fall back to the whole replied message. + """ + quote = getattr(message, "quote", None) + if quote is not None: + quote_text = _safe_str(getattr(quote, "text", None)) + if quote_text: + return quote_text + + reply = getattr(message, "reply_to_message", None) + if reply is not None: + reply_text = _safe_str(getattr(reply, "text", None)) or _safe_str( + getattr(reply, "caption", None) + ) + if reply_text: + return reply_text + + return None + + +def _safe_str(value: Any) -> str: + """Return value if it's a string, else empty string. + + Guards against MagicMock attributes in tests that set up + `update.message` without specifying `text`/`caption`/`quote`/ + `reply_to_message` — those attributes return mock objects rather than + None, which would otherwise crash downstream string handling. + """ + return value if isinstance(value, str) else "" diff --git a/tests/unit/test_bot/test_quote_prompt.py b/tests/unit/test_bot/test_quote_prompt.py new file mode 100644 index 00000000..63aac2aa --- /dev/null +++ b/tests/unit/test_bot/test_quote_prompt.py @@ -0,0 +1,97 @@ +"""Tests for build_user_prompt — reply/quote context extraction.""" + +from types import SimpleNamespace + +from src.bot.utils.quote_prompt import build_user_prompt + + +def _make_message(text=None, caption=None, quote=None, reply_to_message=None): + return SimpleNamespace( + text=text, + caption=caption, + quote=quote, + reply_to_message=reply_to_message, + ) + + +def test_plain_message_returns_text_unchanged(): + msg = _make_message(text="hello") + assert build_user_prompt(msg) == "hello" + + +def test_message_without_text_or_caption_returns_empty_string(): + msg = _make_message() + assert build_user_prompt(msg) == "" + + +def test_partial_quote_takes_priority_over_reply_text(): + """When user highlights a fragment (Bot API 7.0+ partial quote), that + fragment — not the whole replied-to message — is put in the blockquote.""" + quote = SimpleNamespace(text="only this fragment") + reply = _make_message(text="the whole long original message") + msg = _make_message( + text="tell me more about it", quote=quote, reply_to_message=reply + ) + + result = build_user_prompt(msg) + + assert result == "> only this fragment\n\ntell me more about it" + assert "whole long original" not in result + + +def test_reply_without_partial_quote_uses_full_reply_text(): + reply = _make_message(text="original text") + msg = _make_message(text="my reply", reply_to_message=reply) + + assert build_user_prompt(msg) == "> original text\n\nmy reply" + + +def test_reply_to_media_uses_caption(): + """If the replied-to message is a photo/document (no `.text`), fall back + to its caption.""" + reply = _make_message(text=None, caption="photo caption") + msg = _make_message(text="what is this?", reply_to_message=reply) + + assert build_user_prompt(msg) == "> photo caption\n\nwhat is this?" + + +def test_multiline_quote_renders_every_line_with_prefix(): + reply = _make_message(text="first line\nsecond line\nthird line") + msg = _make_message(text="comment", reply_to_message=reply) + + assert build_user_prompt(msg) == ( + "> first line\n> second line\n> third line\n\ncomment" + ) + + +def test_blank_line_inside_quote_stays_as_bare_gt(): + """Markdown blockquote convention — blank lines in the quote stay as `>`.""" + reply = _make_message(text="para1\n\npara2") + msg = _make_message(text="x", reply_to_message=reply) + + assert build_user_prompt(msg) == "> para1\n>\n> para2\n\nx" + + +def test_reply_with_empty_text_and_caption_falls_through_to_plain(): + reply = _make_message(text=None, caption=None) + msg = _make_message(text="standalone", reply_to_message=reply) + + assert build_user_prompt(msg) == "standalone" + + +def test_quote_without_reply_to_message_still_works(): + """Edge: Telegram does send `quote` alongside `reply_to_message` in practice, + but the helper must not assume that.""" + quote = SimpleNamespace(text="fragment") + msg = _make_message(text="ack", quote=quote, reply_to_message=None) + + assert build_user_prompt(msg) == "> fragment\n\nack" + + +def test_user_text_only_quote_no_new_text_returns_quote_alone(): + """Edge: a reply with empty user text but a quoted fragment — still send + the quote so Claude has the context.""" + quote = SimpleNamespace(text="just the quote") + msg = _make_message(text="", quote=quote) + + assert build_user_prompt(msg) == "> just the quote"