Skip to content

Commit 7623e6b

Browse files
authored
Merge pull request chigwell#210 from Rangezi/fix/read-rich-message-text
fix: read rich (block-format) messages instead of reporting them empty
2 parents fbc4adc + 4119f20 commit 7623e6b

3 files changed

Lines changed: 259 additions & 1 deletion

File tree

‎telegram_mcp/runtime.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from difflib import SequenceMatcher
1313
from datetime import datetime, timedelta, timezone
1414
from enum import Enum
15-
from typing import List, Dict, Optional, Union, Any
15+
from typing import List, Dict, Optional, Union, Any, get_args
1616
from pathlib import Path
1717
from urllib.parse import unquote, urlparse
1818

@@ -968,6 +968,83 @@ def make_rich_input(parse_mode: str, text: str):
968968
return types.InputRichMessageMarkdown(markdown=text)
969969

970970

971+
# Reading a rich message is the other direction, and it needs its own walk: a
972+
# channel posting in this format leaves msg.message empty and carries every word
973+
# as Instant-View page blocks, so a reader that only looks at msg.message
974+
# reports the whole post as empty.
975+
_RICH_TEXT_TYPES = tuple(get_args(types.TypeRichText))
976+
977+
# RichText is a recursive tree: a node either holds a plain string, wraps
978+
# another node, or concatenates a list of them. Dispatching on the field rather
979+
# than on the class keeps a node type Telegram adds later flattening instead of
980+
# vanishing.
981+
_RICH_TEXT_FIELDS = ("texts", "text", "alt", "source")
982+
983+
# Where a page block, list item, table row or caption keeps its words. Same walk
984+
# covers the blocks nested inside details, collages and embedded posts.
985+
_PAGE_TEXT_FIELDS = (
986+
"title",
987+
"subtitle",
988+
"author",
989+
"text",
990+
"caption",
991+
"credit",
992+
"items",
993+
"blocks",
994+
"rows",
995+
"articles",
996+
)
997+
998+
999+
def rich_text_to_str(node) -> str:
1000+
"""Flatten one RichText node into plain text.
1001+
1002+
TextCustomEmoji contributes its alt character - dropping it would silently
1003+
eat the emoji a channel used as a bullet or a heading marker.
1004+
"""
1005+
if node is None:
1006+
return ""
1007+
if isinstance(node, str):
1008+
return node
1009+
if isinstance(node, (list, tuple)):
1010+
return "".join(rich_text_to_str(item) for item in node)
1011+
for field in _RICH_TEXT_FIELDS:
1012+
value = getattr(node, field, None)
1013+
if value is not None:
1014+
return rich_text_to_str(value)
1015+
return "" # TextEmpty, TextImage and anything else carrying no text
1016+
1017+
1018+
def _page_lines(node) -> List[str]:
1019+
"""Text lines carried by a page block, list item, table row or caption."""
1020+
if node is None:
1021+
return []
1022+
if isinstance(node, (list, tuple)):
1023+
return [line for item in node for line in _page_lines(item)]
1024+
if isinstance(node, _RICH_TEXT_TYPES):
1025+
text = rich_text_to_str(node).strip()
1026+
return [text] if text else []
1027+
cells = getattr(node, "cells", None)
1028+
if cells is not None: # a table row reads as one line, not one line per cell
1029+
row = " | ".join(line for cell in cells for line in _page_lines(cell))
1030+
return [row] if row else []
1031+
return [line for f in _PAGE_TEXT_FIELDS for line in _page_lines(getattr(node, f, None))]
1032+
1033+
1034+
def rich_message_text(msg) -> str:
1035+
"""Plain text of a rich (block-format) message, "" when there is none.
1036+
1037+
Each block becomes a paragraph and the lines within one block stay together,
1038+
so a list reads as a list instead of one run-on line. An unknown block type
1039+
yields nothing rather than breaking the whole message.
1040+
"""
1041+
blocks = getattr(getattr(msg, "rich_message", None), "blocks", None)
1042+
if not blocks:
1043+
return ""
1044+
paragraphs = ("\n".join(_page_lines(block)) for block in blocks)
1045+
return "\n\n".join(p for p in paragraphs if p)
1046+
1047+
9711048
def premium_required_result(action: str) -> str:
9721049
"""Structured refusal so the agent can degrade gracefully instead of sending garbage."""
9731050
return json.dumps(

‎telegram_mcp/tools/messages.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,18 @@ def message_to_dict(msg, chat_id: Optional[int] = None) -> dict:
141141
d["out"] = True
142142

143143
text = sanitize_user_content(msg.message) if getattr(msg, "message", None) else ""
144+
rich = False
145+
if not text:
146+
# Block-format posts leave .message empty and keep the words in
147+
# .rich_message; without this the whole post reads back as "[empty]".
148+
rich_text = rich_message_text(msg)
149+
if rich_text:
150+
text = sanitize_user_content(rich_text)
151+
rich = True
144152
if text:
145153
d["text"] = text
154+
if rich:
155+
d["rich"] = True # text rebuilt from page blocks, not a verbatim .message
146156

147157
media_label = get_media_label(msg)
148158
if media_label:
@@ -327,6 +337,11 @@ def format_message_line(msg, chat_id: Optional[int] = None) -> str:
327337
parts.append(engagement_info)
328338

329339
raw = sanitize_user_content(msg.message) if getattr(msg, "message", None) else ""
340+
if not raw:
341+
rich_text = rich_message_text(msg)
342+
if rich_text:
343+
raw = sanitize_user_content(rich_text)
344+
parts.append("rich")
330345
if raw:
331346
safe_text = raw.replace("\n", "\\n")
332347
else:

‎tests/test_rich_messages.py‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,169 @@ async def test_edit_rich_both_premium_cases():
117117
result = json.loads(await messages._edit_rich(no, "peer", 7, "new", "rich"))
118118
assert result["reason"] == "telegram_premium_required"
119119
assert no.requests == []
120+
121+
122+
# --- reading rich messages -------------------------------------------------
123+
# A channel posting in the block format leaves msg.message empty and puts every
124+
# word into msg.rich_message, so such a post used to read back as "[empty]".
125+
126+
types = runtime.types
127+
128+
129+
def _msg(**overrides):
130+
base = dict(
131+
id=224,
132+
sender=None,
133+
sender_id=42,
134+
date="2026-09-03",
135+
message="",
136+
reply_to=None,
137+
rich_message=None,
138+
)
139+
base.update(overrides)
140+
return SimpleNamespace(**base)
141+
142+
143+
def _plain(text):
144+
return types.TextPlain(text=text)
145+
146+
147+
def _rich(*blocks):
148+
return types.RichMessage(blocks=list(blocks), photos=[], documents=[])
149+
150+
151+
def _cell(text):
152+
return types.PageTableCell(
153+
header=False,
154+
align_center=False,
155+
align_right=False,
156+
valign_middle=False,
157+
valign_bottom=False,
158+
text=_plain(text),
159+
colspan=None,
160+
rowspan=None,
161+
)
162+
163+
164+
def _post_like_agents_lab_224():
165+
"""The shape a real block-format post arrives in: a heading carrying a
166+
custom emoji, an uncaptioned photo, then paragraphs."""
167+
return _rich(
168+
types.PageBlockHeading1(
169+
text=types.TextConcat(
170+
texts=[
171+
_plain("Личная CRM "),
172+
types.TextCustomEmoji(document_id=5927026418616636353, alt="🧠"),
173+
]
174+
)
175+
),
176+
types.PageBlockPhoto(
177+
photo_id=5197653938998550234,
178+
caption=types.PageCaption(text=types.TextEmpty(), credit=types.TextEmpty()),
179+
spoiler=False,
180+
url=None,
181+
webpage_id=None,
182+
),
183+
types.PageBlockParagraph(text=_plain("Первый абзац.")),
184+
types.PageBlockParagraph(text=_plain("Второй абзац.")),
185+
)
186+
187+
188+
def test_rich_text_flattens_every_nesting_shape():
189+
node = types.TextConcat(
190+
texts=[
191+
_plain("plain "),
192+
types.TextBold(text=_plain("bold ")),
193+
types.TextUrl(text=_plain("link"), url="https://example.com", webpage_id=0),
194+
types.TextCustomEmoji(document_id=1, alt="🧠"),
195+
types.TextEmpty(),
196+
]
197+
)
198+
199+
assert runtime.rich_text_to_str(node) == "plain bold link🧠"
200+
201+
202+
def test_rich_text_ignores_nodes_that_carry_no_text():
203+
assert runtime.rich_text_to_str(types.TextEmpty()) == ""
204+
assert runtime.rich_text_to_str(types.TextImage(document_id=1, w=10, h=10)) == ""
205+
assert runtime.rich_text_to_str(None) == ""
206+
207+
208+
def test_rich_message_text_walks_blocks():
209+
text = runtime.rich_message_text(_msg(rich_message=_post_like_agents_lab_224()))
210+
211+
# One paragraph per block; the empty photo caption contributes nothing.
212+
assert text == "Личная CRM 🧠\n\nПервый абзац.\n\nВторой абзац."
213+
214+
215+
def test_rich_message_text_reaches_captions_lists_tables_and_details():
216+
rich = _rich(
217+
types.PageBlockVideo(
218+
video_id=1,
219+
caption=types.PageCaption(text=_plain("под видео"), credit=types.TextEmpty()),
220+
autoplay=False,
221+
loop=False,
222+
spoiler=False,
223+
),
224+
types.PageBlockList(
225+
items=[
226+
types.PageListItemText(text=_plain("первый"), checkbox=None, checked=None),
227+
types.PageListItemBlocks(
228+
blocks=[types.PageBlockParagraph(text=_plain("второй"))],
229+
checkbox=None,
230+
checked=None,
231+
),
232+
]
233+
),
234+
types.PageBlockTable(
235+
title=types.TextEmpty(),
236+
rows=[types.PageTableRow(cells=[_cell("A"), _cell("B")])],
237+
bordered=False,
238+
striped=False,
239+
),
240+
types.PageBlockDetails(
241+
blocks=[types.PageBlockParagraph(text=_plain("скрытое"))],
242+
title=_plain("подробнее"),
243+
open=False,
244+
),
245+
)
246+
247+
assert runtime.rich_message_text(_msg(rich_message=rich)) == (
248+
"под видео\n\nпервый\nвторой\n\nA | B\n\nподробнее\nскрытое"
249+
)
250+
251+
252+
def test_rich_message_text_skips_unknown_blocks_instead_of_failing():
253+
rich = _rich(
254+
SimpleNamespace(), # a block type Telegram adds after this telethon build
255+
types.PageBlockParagraph(text=_plain("уцелевший абзац")),
256+
)
257+
258+
assert runtime.rich_message_text(_msg(rich_message=rich)) == "уцелевший абзац"
259+
260+
261+
def test_rich_message_text_is_empty_without_a_rich_message():
262+
assert runtime.rich_message_text(_msg()) == ""
263+
assert runtime.rich_message_text(SimpleNamespace()) == ""
264+
265+
266+
def test_message_to_dict_falls_back_to_rich_message():
267+
d = messages.message_to_dict(_msg(rich_message=_post_like_agents_lab_224()))
268+
269+
assert d["text"].startswith("Личная CRM 🧠")
270+
assert d["rich"] is True
271+
272+
273+
def test_format_message_line_falls_back_to_rich_message():
274+
line = messages.format_message_line(_msg(rich_message=_post_like_agents_lab_224()))
275+
276+
assert "Message: [empty]" not in line
277+
assert "Личная CRM 🧠\\n\\nПервый абзац." in line
278+
assert "rich" in line
279+
280+
281+
def test_plain_message_text_still_wins_over_rich_message():
282+
msg = _msg(message="обычный текст", rich_message=_post_like_agents_lab_224())
283+
284+
assert messages.message_to_dict(msg)["text"] == "обычный текст"
285+
assert "обычный текст" in messages.format_message_line(msg)

0 commit comments

Comments
 (0)