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
5 changes: 4 additions & 1 deletion src/bot/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
should_send_as_photo,
validate_image_path,
)
from ..utils.quote_prompt import build_user_prompt

logger = structlog.get_logger()

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/bot/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
should_send_as_photo,
validate_image_path,
)
from .utils.quote_prompt import build_user_prompt

logger = structlog.get_logger()

Expand Down Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions src/bot/utils/quote_prompt.py
Original file line number Diff line number Diff line change
@@ -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 ""
97 changes: 97 additions & 0 deletions tests/unit/test_bot/test_quote_prompt.py
Original file line number Diff line number Diff line change
@@ -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"