Skip to content

Commit 81ad14b

Browse files
authored
Merge pull request chigwell#254 from oviron/feat/full-message-records
Return the full message record from list, search and context tools
2 parents bdf58bb + 10c0319 commit 81ad14b

3 files changed

Lines changed: 205 additions & 104 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,11 @@ Aliases live in `${XDG_STATE_HOME:-~/.local/state}/telegram-mcp/aliases.json` (o
8585

8686
The engine is chosen per call via the `engine` argument, or otherwise defaults to `TELEGRAM_TRANSCRIBE_ENGINE` (`groq`, `telegram`, `openai` or `whisper`). Results are cached by `(chat_id, message_id, engine)` in a local SQLite file so repeat reads and repeat listings never re-transcribe the same message. Concurrent requests for the same uncached recording are collapsed too: the second one waits for the first and returns its transcript, so a burst of callers costs one paid call, not one per caller. Every transcript is returned with a `note` marking it as a machine transcript, not a verbatim quote — treat it as a paraphrase, not exact wording.
8787

88-
`get_history`, `get_messages`, and `list_messages` fill in already-cached transcripts for voice messages instead of leaving the text empty, controlled by `TELEGRAM_TRANSCRIBE`:
88+
`get_history`, `get_messages`, `list_messages`, `search_messages`, `search_global`, and `get_message_context` fill in already-cached transcripts for voice messages instead of leaving the text empty, controlled by `TELEGRAM_TRANSCRIBE`:
8989

9090
- `off`: transcription is disabled at runtime. The `transcribe_voice` tool stays registered and returns `{"transcribed": false, "reason": "transcription_disabled"}` instead of transcribing, and listings never show transcripts. Use `TELEGRAM_EXPOSED_TOOLS` to hide the tool itself.
9191
- `on-demand` (default): listings show cached transcripts but never spend an API call fetching a new one.
92-
- `auto`: listings also prefetch missing transcripts, bounded per call by `TELEGRAM_TRANSCRIBE_MAX_VOICES`/`TELEGRAM_TRANSCRIBE_MAX_SECONDS` (Groq isn't free, so this prefetch is budgeted rather than unbounded).
92+
- `auto`: `get_history`, `get_messages`, `list_messages`, and `search_messages` also prefetch missing transcripts, bounded per call by `TELEGRAM_TRANSCRIBE_MAX_VOICES`/`TELEGRAM_TRANSCRIBE_MAX_SECONDS` (Groq isn't free, so this prefetch is budgeted rather than unbounded).
9393

9494
The cache lives in `TELEGRAM_TRANSCRIPT_CACHE_DIR` (default `data/transcripts`), written as a 700 directory / 600 file since it holds personal-chat text in plaintext — see [Docker](#docker) for why this needs its own volume mount in a container.
9595
- **Profile and privacy:** get your own account info, update profile fields, set or delete profile photos, inspect privacy settings, get user info/photos/status, and manage bot commands.

‎telegram_mcp/tools/messages.py‎

Lines changed: 24 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,21 @@ def message_to_dict(msg, chat_id: Optional[int] = None) -> dict:
198198
if media_label:
199199
d["media"] = media_label
200200

201+
page = getattr(msg, "web_preview", None)
202+
if page is not None and getattr(page, "url", None):
203+
d["web_preview"] = {
204+
k: sanitize_user_content(v) if k != "url" else v
205+
for k in ("url", "site_name", "title", "description")
206+
if (v := getattr(page, k, None))
207+
}
208+
209+
poll = getattr(msg, "poll", None)
210+
if poll is not None and getattr(poll, "poll", None) is not None:
211+
d["poll"] = {
212+
"question": sanitize_user_content(poll.poll.question.text),
213+
"answers": [sanitize_user_content(a.text.text) for a in poll.poll.answers],
214+
}
215+
201216
if not text:
202217
voice_info = transcription.voice_attachment_info(msg, chat_id)
203218
if voice_info is not None:
@@ -1132,49 +1147,7 @@ async def list_messages(
11321147
numeric_chat_id = get_marked_id(entity)
11331148
await transcription.prefetch_transcripts(cl, entity, numeric_chat_id, messages)
11341149

1135-
records = []
1136-
for msg in messages:
1137-
record = {
1138-
"id": msg.id,
1139-
"sender": get_sender_info(msg),
1140-
"date": msg.date,
1141-
"text": sanitize_user_content(msg.message),
1142-
**get_custom_emoji_metadata(msg),
1143-
}
1144-
# Upstream bug: this hand-built record never called get_media_label,
1145-
# so a voice/photo/etc. with no caption was indistinguishable from
1146-
# an actually-empty message. message_to_dict (used by get_history)
1147-
# already gets this right.
1148-
media_label = get_media_label(msg)
1149-
if media_label:
1150-
record["media"] = media_label
1151-
1152-
if not getattr(msg, "message", None):
1153-
voice_info = transcription.voice_attachment_info(msg, numeric_chat_id)
1154-
if voice_info is not None:
1155-
if voice_info["duration"] is not None:
1156-
record["duration"] = voice_info["duration"]
1157-
if voice_info["transcript_status"] == "ready":
1158-
record["transcript"] = voice_info["transcript"]
1159-
record["transcript_source"] = voice_info["transcript_source"]
1160-
record["transcript_note"] = "Machine transcript, not a verbatim quote."
1161-
elif voice_info["transcript_status"] == "pending":
1162-
record["transcript_status"] = "pending"
1163-
1164-
grouped_id = getattr(msg, "grouped_id", None)
1165-
if grouped_id is not None:
1166-
record["grouped_id"] = grouped_id
1167-
reply_to_id = getattr(msg.reply_to, "reply_to_msg_id", None) if msg.reply_to else None
1168-
if reply_to_id:
1169-
record["reply_to"] = reply_to_id
1170-
reply_quote = get_reply_quote(msg)
1171-
if reply_quote:
1172-
record["reply_quote"] = reply_quote
1173-
engagement = get_engagement_dict(msg)
1174-
if engagement:
1175-
record["engagement"] = engagement
1176-
records.append(record)
1177-
1150+
records = [message_to_dict(msg, numeric_chat_id) for msg in messages]
11781151
return format_tool_result(records)
11791152
except Exception as e:
11801153
return log_and_format_error("list_messages", e, chat_id=chat_id)
@@ -1364,52 +1337,17 @@ async def get_message_context(
13641337
# Combine messages in chronological order
13651338
all_messages = list(messages_before) + list(central_message) + list(messages_after)
13661339
all_messages.sort(key=lambda m: m.id)
1340+
numeric_chat_id = get_marked_id(chat)
13671341
records = []
13681342
for msg in all_messages:
1369-
sender_name = get_sender_name(msg)
1370-
record = {
1371-
"id": msg.id,
1372-
"sender": sender_name,
1373-
"date": msg.date,
1374-
"is_target": msg.id == message_id,
1375-
"text": sanitize_user_content(msg.message),
1376-
**get_custom_emoji_metadata(msg),
1377-
}
1378-
if getattr(msg, "sender_id", None):
1379-
record["sender_id"] = msg.sender_id
1380-
_username = get_sender_username(msg)
1381-
if _username:
1382-
record["username"] = _username
1383-
grouped_id = getattr(msg, "grouped_id", None)
1384-
if grouped_id is not None:
1385-
record["grouped_id"] = grouped_id
1386-
link_urls = _link_urls(msg)
1387-
if link_urls:
1388-
record["link_urls"] = link_urls
1343+
record = message_to_dict(msg, numeric_chat_id)
1344+
record["is_target"] = msg.id == message_id
13891345

1390-
# Check if this message is a reply and get the replied message
1391-
reply_quote = get_reply_quote(msg)
1392-
if reply_quote:
1393-
record["reply_quote"] = reply_quote
13941346
if msg.reply_to and msg.reply_to.reply_to_msg_id:
1395-
record["reply_to"] = msg.reply_to.reply_to_msg_id
13961347
try:
13971348
replied_msg = await cl.get_messages(chat, ids=msg.reply_to.reply_to_msg_id)
13981349
if replied_msg:
1399-
replied_record = {
1400-
"sender": get_sender_name(replied_msg),
1401-
"text": sanitize_user_content(replied_msg.message),
1402-
**get_custom_emoji_metadata(replied_msg),
1403-
}
1404-
if getattr(replied_msg, "sender_id", None):
1405-
replied_record["sender_id"] = replied_msg.sender_id
1406-
_r_username = get_sender_username(replied_msg)
1407-
if _r_username:
1408-
replied_record["username"] = _r_username
1409-
reply_link_urls = _link_urls(replied_msg)
1410-
if reply_link_urls:
1411-
replied_record["link_urls"] = reply_link_urls
1412-
record["replied_message"] = replied_record
1350+
record["replied_message"] = message_to_dict(replied_msg, numeric_chat_id)
14131351
except Exception:
14141352
record["replied_message"] = None
14151353

@@ -2023,21 +1961,9 @@ async def search_messages(
20231961
entity = await resolve_entity(chat_id, cl)
20241962
messages = await cl.get_messages(entity, limit=limit, search=query)
20251963

2026-
records = []
2027-
for msg in messages:
2028-
record = {
2029-
"id": msg.id,
2030-
"sender": get_sender_info(msg),
2031-
"date": msg.date,
2032-
"text": sanitize_user_content(msg.message),
2033-
**get_custom_emoji_metadata(msg),
2034-
}
2035-
if msg.reply_to and msg.reply_to.reply_to_msg_id:
2036-
record["reply_to"] = msg.reply_to.reply_to_msg_id
2037-
reply_quote = get_reply_quote(msg)
2038-
if reply_quote:
2039-
record["reply_quote"] = reply_quote
2040-
records.append(record)
1964+
numeric_chat_id = get_marked_id(entity)
1965+
await transcription.prefetch_transcripts(cl, entity, numeric_chat_id, messages)
1966+
records = [message_to_dict(msg, numeric_chat_id) for msg in messages]
20411967
return format_tool_result(records)
20421968
except Exception as e:
20431969
return log_and_format_error(
@@ -2085,11 +2011,7 @@ async def search_global(
20852011
{
20862012
"chat_name": sanitize_name(chat_name),
20872013
"chat_id": msg.chat_id,
2088-
"id": msg.id,
2089-
"sender": get_sender_info(msg),
2090-
"date": msg.date,
2091-
"text": sanitize_user_content(msg.message),
2092-
**get_custom_emoji_metadata(msg),
2014+
**message_to_dict(msg, msg.chat_id),
20932015
}
20942016
)
20952017

‎tests/test_full_message_records.py‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Every JSON reader returns the same complete record as get_history.
2+
3+
list_messages, search_messages, search_global and get_message_context used to
4+
hand-build a text-only record, so a channel post read through them lost the
5+
link hidden behind its text, its forward origin and its view count.
6+
"""
7+
8+
import json
9+
from datetime import datetime, timezone
10+
from types import SimpleNamespace
11+
from unittest.mock import AsyncMock
12+
13+
import pytest
14+
from telethon.tl import types
15+
16+
from telegram_mcp import transcription
17+
from telegram_mcp.tools import messages
18+
19+
DATE = datetime(2026, 9, 16, tzinfo=timezone.utc)
20+
HIDDEN_URL = "https://example.com/model"
21+
CHANNEL_ID = 1234567890
22+
MARKED_CHANNEL_ID = -1001234567890
23+
24+
25+
def _post(**kwargs):
26+
msg = types.Message(
27+
id=1,
28+
peer_id=types.PeerChannel(CHANNEL_ID),
29+
date=DATE,
30+
message="Model is here",
31+
entities=[types.MessageEntityTextUrl(9, 4, HIDDEN_URL)],
32+
fwd_from=types.MessageFwdHeader(
33+
date=DATE, from_id=types.PeerChannel(987654321), channel_post=17
34+
),
35+
views=1500,
36+
forwards=12,
37+
**kwargs,
38+
)
39+
msg._chat = SimpleNamespace(id=CHANNEL_ID, title="Example channel")
40+
return msg
41+
42+
43+
def _voice():
44+
doc = types.Document(
45+
id=1,
46+
access_hash=0,
47+
file_reference=b"",
48+
date=DATE,
49+
mime_type="audio/ogg",
50+
size=1,
51+
dc_id=2,
52+
attributes=[types.DocumentAttributeAudio(duration=13, voice=True)],
53+
)
54+
msg = types.Message(
55+
id=1,
56+
peer_id=types.PeerChannel(CHANNEL_ID),
57+
date=DATE,
58+
message="",
59+
media=types.MessageMediaDocument(document=doc),
60+
)
61+
msg._chat = SimpleNamespace(id=CHANNEL_ID, title="Example channel")
62+
return msg
63+
64+
65+
@pytest.fixture
66+
def client(monkeypatch):
67+
cl = AsyncMock()
68+
monkeypatch.setattr(messages, "get_client", lambda account=None: cl)
69+
monkeypatch.setattr(
70+
messages,
71+
"resolve_entity",
72+
AsyncMock(
73+
return_value=types.Channel(
74+
id=CHANNEL_ID, title="Example channel", photo=types.ChatPhotoEmpty(), date=DATE
75+
)
76+
),
77+
)
78+
monkeypatch.setattr(messages, "ensure_connected", AsyncMock())
79+
monkeypatch.setattr(messages.transcription, "prefetch_transcripts", AsyncMock())
80+
return cl
81+
82+
83+
@pytest.mark.asyncio
84+
@pytest.mark.parametrize(
85+
"tool, arguments",
86+
[
87+
(messages.get_history, {"chat_id": MARKED_CHANNEL_ID}),
88+
(messages.list_messages, {"chat_id": MARKED_CHANNEL_ID}),
89+
(messages.search_messages, {"chat_id": MARKED_CHANNEL_ID, "query": "Model"}),
90+
(messages.search_global, {"query": "Model"}),
91+
(messages.get_message_context, {"chat_id": MARKED_CHANNEL_ID, "message_id": 1}),
92+
],
93+
)
94+
async def test_json_readers_return_the_complete_record(client, tool, arguments):
95+
msg = _post()
96+
client.get_messages.return_value = [msg]
97+
if tool is messages.get_message_context:
98+
client.get_messages.side_effect = [[], msg, []]
99+
100+
record = json.loads(await tool(**arguments, account="test"))["results"][0]
101+
102+
assert record["text"] == "Model is here"
103+
assert record["link_urls"] == [HIDDEN_URL]
104+
assert record["forwarded"]["channel_post"] == 17
105+
assert record["engagement"]["views"] == 1500
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_search_global_keeps_chat_attribution(client):
110+
client.get_messages.return_value = [_post()]
111+
112+
record = json.loads(await messages.search_global("Model", account="test"))["results"][0]
113+
114+
assert record["chat_name"] == "Example channel"
115+
assert record["chat_id"] == MARKED_CHANNEL_ID
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_context_keeps_target_marker(client):
120+
msg = _post()
121+
client.get_messages.side_effect = [[], msg, []]
122+
123+
result = json.loads(await messages.get_message_context(MARKED_CHANNEL_ID, 1, account="test"))
124+
125+
assert result["results"][0]["is_target"] is True
126+
127+
128+
@pytest.mark.asyncio
129+
@pytest.mark.parametrize("tool", ["search_global", "get_message_context"])
130+
async def test_cached_voice_transcript_is_part_of_the_record(client, transcript_cache_dir, tool):
131+
transcription.save_transcript(MARKED_CHANNEL_ID, 1, "groq", "Hello there", duration=13)
132+
voice = _voice()
133+
if tool == "search_global":
134+
client.get_messages.return_value = [voice]
135+
result = await messages.search_global("Hello", account="test")
136+
else:
137+
client.get_messages.side_effect = [[], voice, []]
138+
result = await messages.get_message_context(MARKED_CHANNEL_ID, 1, account="test")
139+
140+
record = json.loads(result)["results"][0]
141+
142+
assert record["media"] == "voice"
143+
assert record["transcript"] == "Hello there"
144+
145+
146+
def test_link_preview_is_part_of_the_record():
147+
page = types.WebPage(
148+
id=1,
149+
url=HIDDEN_URL,
150+
display_url="example.com/model",
151+
hash=0,
152+
site_name="Example",
153+
title="Example model",
154+
)
155+
msg = _post(media=types.MessageMediaWebPage(webpage=page))
156+
157+
assert messages.message_to_dict(msg)["web_preview"] == {
158+
"url": HIDDEN_URL,
159+
"site_name": "Example",
160+
"title": "Example model",
161+
}
162+
163+
164+
def test_poll_question_and_answers_are_part_of_the_record():
165+
poll = types.Poll(
166+
id=1,
167+
question=types.TextWithEntities(text="Which model?", entities=[]),
168+
answers=[
169+
types.PollAnswer(text=types.TextWithEntities(text="Qwen", entities=[]), option=b"0"),
170+
types.PollAnswer(text=types.TextWithEntities(text="Gemma", entities=[]), option=b"1"),
171+
],
172+
hash=0,
173+
)
174+
msg = _post(media=types.MessageMediaPoll(poll=poll, results=types.PollResults()))
175+
176+
assert messages.message_to_dict(msg)["poll"] == {
177+
"question": "Which model?",
178+
"answers": ["Qwen", "Gemma"],
179+
}

0 commit comments

Comments
 (0)