fix(telegram): recover reply text from native rich echo

Telegram DOES echo a rich message's content back in
reply_to_message.api_kwargs['rich_message']['blocks'] when a user
replies to it. Read that native field first in _build_message_event,
keeping the local send-time index only as a fallback. Duck-type
api_kwargs via .get() since it is a mappingproxy, not a dict.

Fixes #49534
This commit is contained in:
izumi0uu 2026-06-20 23:28:56 -07:00 committed by Teknium
parent fcdefb4181
commit 29e5e127c6
2 changed files with 172 additions and 5 deletions

View file

@ -6646,6 +6646,77 @@ class TelegramAdapter(BasePlatformAdapter):
self.name, cache_key, thread_id,
)
@classmethod
def _flatten_rich_inline_text(cls, value: Any) -> str:
"""Best-effort plaintext flattener for Bot API rich-message inline nodes."""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(cls._flatten_rich_inline_text(item) for item in value)
if isinstance(value, dict):
text = value.get("text")
if text is not None:
return cls._flatten_rich_inline_text(text)
children = value.get("children")
if children is not None:
return cls._flatten_rich_inline_text(children)
return ""
@classmethod
def _flatten_rich_blocks(cls, blocks: Any) -> str:
"""Best-effort plaintext flattener for Bot API rich-message blocks."""
if not isinstance(blocks, list):
return ""
lines: List[str] = []
for block in blocks:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type == "list":
for item in block.get("items", []):
if not isinstance(item, dict):
continue
item_text = cls._flatten_rich_blocks(item.get("blocks"))
if not item_text:
continue
label = item.get("label")
item_lines = item_text.splitlines()
if not item_lines:
continue
first_line = item_lines[0]
if label:
first_line = f"{label} {first_line}".strip()
lines.append(first_line)
lines.extend(item_lines[1:])
continue
text = cls._flatten_rich_inline_text(block.get("text"))
if text:
lines.extend(text.splitlines())
return "\n".join(line.rstrip() for line in lines if line)
@classmethod
def _extract_rich_reply_text(cls, reply_to_message: Any) -> Optional[str]:
"""Return plaintext echoed by Telegram's rich_message reply payload."""
try:
api_kwargs = getattr(reply_to_message, "api_kwargs", None)
getter = getattr(api_kwargs, "get", None)
if not callable(getter):
return None
rich_message = getter("rich_message")
rich_getter = getattr(rich_message, "get", None)
if not callable(rich_getter):
return None
text = cls._flatten_rich_blocks(rich_getter("blocks")).strip()
return text or None
except Exception:
return None
def _build_message_event(
self,
message: Message,
@ -6772,11 +6843,11 @@ class TelegramAdapter(BasePlatformAdapter):
or None
)
if not reply_to_text:
# Rich messages (sendRichMessage — the launchd briefings and
# the gateway's own rich finals) are NOT echoed with their
# content in reply_to_message; Telegram sends no text,
# caption, or api_kwargs for them. Recover the text we sent
# from our local send-time index, keyed by message id.
# Prefer Telegram's native rich-message echo when present;
# keep the local send-time index only as a fallback for
# older/unrecoverable reply payloads.
reply_to_text = self._extract_rich_reply_text(message.reply_to_message)
if not reply_to_text:
try:
from gateway import rich_sent_store
reply_to_text = rich_sent_store.lookup(

View file

@ -791,6 +791,39 @@ def _reply_message(reply_to_id, *, reply_text=None, reply_caption=None, quote_te
)
def _reply_message_with_rich_blocks(
reply_to_id,
*,
blocks,
quote_text=None,
api_kwargs_factory=dict,
):
"""Build a reply whose echoed content lives only in api_kwargs.rich_message."""
replied = SimpleNamespace(
message_id=int(reply_to_id),
text=None,
caption=None,
api_kwargs=api_kwargs_factory({"rich_message": {"blocks": blocks}}),
)
quote = SimpleNamespace(text=quote_text) if quote_text is not None else None
return SimpleNamespace(
message_id=999,
chat=SimpleNamespace(id=12345, type="private", title=None, full_name="U"),
from_user=SimpleNamespace(
id=42, username="u", first_name="U", last_name=None,
full_name="U", is_bot=False,
),
text="what did this mean?",
caption=None,
reply_to_message=replied,
quote=quote,
message_thread_id=None,
is_topic_message=False,
entities=[],
date=None,
)
@pytest.mark.asyncio
async def test_rich_reply_records_and_recovers_text(monkeypatch, tmp_path):
"""A reply to a rich-sent message resolves the original text via the index."""
@ -863,3 +896,66 @@ async def test_rich_reply_caption_wins_over_lookup(monkeypatch, tmp_path):
_reply_message("678", reply_caption="echoed caption"), MessageType.TEXT,
)
assert event.reply_to_text == "echoed caption"
@pytest.mark.asyncio
async def test_rich_reply_native_blocks_fill_reply_text_without_index(monkeypatch, tmp_path):
"""Echoed rich_message blocks should recover reply text natively."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from gateway.platforms.base import MessageType
adapter = _make_adapter()
event = adapter._build_message_event(
_reply_message_with_rich_blocks(
"678",
blocks=[
{"type": "paragraph", "text": ["Hello ", {"type": "bold", "text": "world"}]},
{"type": "pre", "text": "Line 2"},
],
),
MessageType.TEXT,
)
assert event.reply_to_text == "Hello world\nLine 2"
@pytest.mark.asyncio
async def test_rich_reply_native_blocks_win_over_index(monkeypatch, tmp_path):
"""Native rich echo should beat the local send-time index fallback."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from gateway.platforms.base import MessageType
from gateway import rich_sent_store
rich_sent_store.record("12345", "678", "recorded body")
adapter = _make_adapter()
event = adapter._build_message_event(
_reply_message_with_rich_blocks(
"678",
blocks=[{"type": "paragraph", "text": ["Echoed ", {"type": "italic", "text": "body"}]}],
),
MessageType.TEXT,
)
assert event.reply_to_text == "Echoed body"
@pytest.mark.asyncio
async def test_rich_reply_native_blocks_support_mappingproxy_like_api_kwargs(monkeypatch, tmp_path):
"""Duck-type api_kwargs via .get() so mappingproxy-like objects also work."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from gateway.platforms.base import MessageType
class MappingProxyLike(dict):
pass
adapter = _make_adapter()
event = adapter._build_message_event(
_reply_message_with_rich_blocks(
"678",
blocks=[
{"type": "heading", "text": "Status", "size": 2},
{"type": "list", "items": [{"label": "-", "blocks": [{"type": "paragraph", "text": ["done"]}]}]},
],
api_kwargs_factory=MappingProxyLike,
),
MessageType.TEXT,
)
assert event.reply_to_text == "Status\n- done"