Skip to content

Commit 14a8388

Browse files
authored
Merge pull request chigwell#154 from nikit34/feat/reply-quote
feat: surface reply quotes (quote_text + offset) in message reads
2 parents ab935d8 + 506e5a1 commit 14a8388

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

‎telegram_mcp/tools/messages.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,29 @@ def _link_urls(msg):
8787
return out
8888

8989

90+
def get_reply_quote(msg) -> Optional[dict]:
91+
"""Quoted fragment when a reply targets only *part* of the replied-to message.
92+
93+
Telegram lets you select a span of another message and reply to just that
94+
span. Telethon exposes it on msg.reply_to as quote_text (the selected text)
95+
and quote_offset (its UTF-16 character offset inside the original message).
96+
Returns {"text": ..., "offset": ...} for such a partial-quote reply, or None
97+
for a plain whole-message reply (or no reply at all). Independent of
98+
reply_to_msg_id so a cross-chat quote reply still surfaces its quote.
99+
"""
100+
reply = getattr(msg, "reply_to", None)
101+
if reply is None:
102+
return None
103+
quote_text = getattr(reply, "quote_text", None)
104+
if not quote_text:
105+
return None
106+
quote = {"text": sanitize_user_content(quote_text)}
107+
offset = getattr(reply, "quote_offset", None)
108+
if offset is not None:
109+
quote["offset"] = offset
110+
return quote
111+
112+
90113
def message_to_dict(msg) -> dict:
91114
"""API-complete but compact Telethon message view (omit empty fields).
92115
@@ -123,6 +146,9 @@ def message_to_dict(msg) -> dict:
123146
)
124147
if reply_to_id:
125148
d["reply_to"] = reply_to_id
149+
reply_quote = get_reply_quote(msg)
150+
if reply_quote:
151+
d["reply_quote"] = reply_quote # reply to a selected span of the original
126152

127153
fwd = getattr(msg, "fwd_from", None)
128154
if fwd is not None:
@@ -184,6 +210,12 @@ def format_message_line(msg) -> str:
184210
)
185211
if reply_to_id:
186212
parts.append(f"reply to {reply_to_id}")
213+
reply_quote = get_reply_quote(msg)
214+
if reply_quote:
215+
preview = reply_quote["text"].replace("\n", " ")
216+
if len(preview) > 60:
217+
preview = preview[:60] + "…"
218+
parts.append(f'quoting "{preview}"')
187219

188220
flags = []
189221
media_label = get_media_label(msg)
@@ -785,6 +817,9 @@ async def list_messages(
785817
reply_to_id = getattr(msg.reply_to, "reply_to_msg_id", None) if msg.reply_to else None
786818
if reply_to_id:
787819
record["reply_to"] = reply_to_id
820+
reply_quote = get_reply_quote(msg)
821+
if reply_quote:
822+
record["reply_quote"] = reply_quote
788823
engagement = get_engagement_dict(msg)
789824
if engagement:
790825
record["engagement"] = engagement
@@ -855,6 +890,9 @@ async def get_message_context(
855890
record["grouped_id"] = grouped_id
856891

857892
# Check if this message is a reply and get the replied message
893+
reply_quote = get_reply_quote(msg)
894+
if reply_quote:
895+
record["reply_quote"] = reply_quote
858896
if msg.reply_to and msg.reply_to.reply_to_msg_id:
859897
record["reply_to"] = msg.reply_to.reply_to_msg_id
860898
try:
@@ -1319,6 +1357,9 @@ async def search_messages(
13191357
}
13201358
if msg.reply_to and msg.reply_to.reply_to_msg_id:
13211359
record["reply_to"] = msg.reply_to.reply_to_msg_id
1360+
reply_quote = get_reply_quote(msg)
1361+
if reply_quote:
1362+
record["reply_quote"] = reply_quote
13221363
records.append(record)
13231364
return format_tool_result(records)
13241365
except Exception as e:
@@ -1435,6 +1476,9 @@ async def get_pinned_messages(chat_id: Union[int, str], account: str = None) ->
14351476
}
14361477
if msg.reply_to and msg.reply_to.reply_to_msg_id:
14371478
record["reply_to"] = msg.reply_to.reply_to_msg_id
1479+
reply_quote = get_reply_quote(msg)
1480+
if reply_quote:
1481+
record["reply_quote"] = reply_quote
14381482
records.append(record)
14391483

14401484
return format_tool_result(records)

‎tests/test_reply_quote.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for reply-quote extraction (reply to a selected part of a message)."""
2+
3+
from datetime import datetime, timezone
4+
from types import SimpleNamespace
5+
6+
from telegram_mcp.tools.messages import (
7+
get_reply_quote,
8+
message_to_dict,
9+
format_message_line,
10+
)
11+
12+
13+
def _reply(**overrides):
14+
"""Fake Telethon MessageReplyHeader."""
15+
base = {
16+
"reply_to_msg_id": 100,
17+
"quote_text": None,
18+
"quote_offset": None,
19+
}
20+
base.update(overrides)
21+
return SimpleNamespace(**base)
22+
23+
24+
def _msg(**overrides):
25+
"""Minimal fake Telethon Message; getattr defaults cover unset media/flags."""
26+
base = {
27+
"id": 200,
28+
"sender": None,
29+
"sender_id": 42,
30+
"date": datetime(2024, 1, 1, tzinfo=timezone.utc),
31+
"message": "the reply body",
32+
"reply_to": None,
33+
}
34+
base.update(overrides)
35+
return SimpleNamespace(**base)
36+
37+
38+
def test_get_reply_quote_returns_text_and_offset():
39+
msg = _msg(reply_to=_reply(quote_text="the important part", quote_offset=15))
40+
assert get_reply_quote(msg) == {"text": "the important part", "offset": 15}
41+
42+
43+
def test_get_reply_quote_none_for_plain_reply():
44+
# A whole-message reply carries reply_to_msg_id but no quote_text.
45+
msg = _msg(reply_to=_reply())
46+
assert get_reply_quote(msg) is None
47+
48+
49+
def test_get_reply_quote_none_when_not_a_reply():
50+
assert get_reply_quote(_msg(reply_to=None)) is None
51+
52+
53+
def test_get_reply_quote_offset_zero_is_kept():
54+
# offset 0 is a valid position (quote at the very start), must not be dropped.
55+
msg = _msg(reply_to=_reply(quote_text="start span", quote_offset=0))
56+
assert get_reply_quote(msg) == {"text": "start span", "offset": 0}
57+
58+
59+
def test_get_reply_quote_without_offset():
60+
msg = _msg(reply_to=_reply(quote_text="span", quote_offset=None))
61+
assert get_reply_quote(msg) == {"text": "span"}
62+
63+
64+
def test_get_reply_quote_survives_cross_chat_reply_without_msg_id():
65+
# Cross-chat quote reply: reply_to_msg_id may be absent, quote still present.
66+
msg = _msg(reply_to=_reply(reply_to_msg_id=None, quote_text="from elsewhere", quote_offset=3))
67+
assert get_reply_quote(msg) == {"text": "from elsewhere", "offset": 3}
68+
69+
70+
def test_message_to_dict_includes_reply_quote():
71+
msg = _msg(reply_to=_reply(quote_text="quoted span", quote_offset=7))
72+
d = message_to_dict(msg)
73+
assert d["reply_to"] == 100
74+
assert d["reply_quote"] == {"text": "quoted span", "offset": 7}
75+
76+
77+
def test_message_to_dict_omits_reply_quote_for_plain_reply():
78+
d = message_to_dict(_msg(reply_to=_reply()))
79+
assert d["reply_to"] == 100
80+
assert "reply_quote" not in d
81+
82+
83+
def test_format_message_line_shows_quote_preview():
84+
msg = _msg(reply_to=_reply(quote_text="a short quoted span", quote_offset=2))
85+
line = format_message_line(msg)
86+
assert "reply to 100" in line
87+
assert 'quoting "a short quoted span"' in line
88+
89+
90+
def test_format_message_line_truncates_long_quote_and_flattens_newlines():
91+
long_quote = "x" * 80 + "\nmore"
92+
msg = _msg(reply_to=_reply(quote_text=long_quote, quote_offset=0))
93+
line = format_message_line(msg)
94+
assert "\n" not in line.split("quoting", 1)[1]
95+
assert "…" in line

0 commit comments

Comments
 (0)