Skip to content

Commit 10c0319

Browse files
committed
feat(messages): return the full message record from every JSON reader
get_history builds its records with message_to_dict, but list_messages, search_messages, search_global and get_message_context each assembled a smaller record by hand. Reading the same post through them dropped the URLs behind text links, the forward origin, views and forwards, edit and pin state, buttons and service actions, and search results also lost the media label and voice transcripts. All five now use message_to_dict, passing the chat id so cached voice transcripts show up everywhere. search_messages prefetches transcripts in auto mode like list_messages does. message_to_dict also gains the link preview (url, site name, title, description) and poll question and answers, which no reader returned before. The sender field in list_messages, search_messages and search_global is now the plain display name, as in get_history; the username and id it used to embed are the separate username and sender_id fields.
1 parent bdf58bb commit 10c0319

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)