Telegram rich message replies losing reply context
NousResearch/hermes-agent · cluster 46568 · gateway
Data as of
Nothing open on GitHub. Fix merged in #49996. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #49996. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #49996. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple bug reports and fix attempts addressing a specific issue where replies to rich/formatted Telegram messages arrive with empty reply_to_text. The cluster explores several related root causes and proposed fixes around how reply context is extracted and stored for sendRichMessage replies.
Open issues0
Open PRs0
Fixes on record
-
#47409 fix(telegram): resolve replies to rich (sendRichMessage) messages
-
#49996 fix(telegram): recover reply text from native rich echo (#49534)
files:
plugins/platforms/telegram/adapter.py,scripts/release.py,tests/gateway/test_telegram_rich_messages.pyShow the diff
-
plugins/platforms/telegram/adapter.py@@ -1334,6 +1334,15 @@ async def _try_edit_rich( error=str(exc), retryable=(is_connect_timeout or not is_timeout), ) + # Telegram won't echo rich content for messages that predate the bot's + # first rich send, so mirror the fresh-send index here too: a streamed + # final finalized via editMessageText is otherwise never recorded, and + # replies to it would have no native echo to recover from. + try: + from gateway import rich_sent_store + rich_sent_store.record(str(chat_id), str(message_id), content) + except Exception: + pass return SendResult(success=True, message_id=message_id) def _should_attempt_rich_draft(self, content: str) -> bool: @@ -6646,6 +6655,77 @@ def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: 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 +6852,11 @@ def _build_message_event( 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( -
scripts/release.py@@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "izumi0uu", # PR #49544 salvage (native rich reply echo; #49534) "[redacted:email]": "w31rdm4ch1nZ", "[redacted:email]": "x7peeps", "[redacted:email]": "ahmadashfq", -
tests/gateway/test_telegram_rich_messages.py@@ -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,83 @@ 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" + + [redacted:email] +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" + + [redacted:email] +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" + + [redacted:email] +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" + + [redacted:email] +async def test_try_edit_rich_records_streamed_final_for_reply_recovery(monkeypatch, tmp_path): + """A streamed final finalized via editMessageText must be indexed too. + + The native rich echo covers most replies, but messages that predate the + bot's first rich send have no echo — so editMessageText must mirror the + fresh-send index the same way _try_send_rich does. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from gateway import rich_sent_store + + adapter = _make_adapter() + result = await adapter._try_edit_rich("12345", "5724", "Готово. Основной бот живой.") + assert result is not None and result.success + assert rich_sent_store.lookup("12345", "5724") == "Готово. Основной бот живой."
-
3 PRs closed without merging
From the close comments: done elsewhere 1 · duplicate 1 · merged via another change 1
- #46569 fix(telegram): restore reply context for replies to rich messages · done elsewhere
-
#49544 fix(telegram): recover reply text from native rich echo (#49534) · merged via another change →
29e5e12,#49996 -
#49548 fix(telegram): extract reply_to_text from api_kwargs rich_message before store fallback · duplicate →
#49534,#49996,#49544
Closed issues · 2
- Closed#46568[Bug]: Telegram rich cron announcements lose reply context because reply_to_message.text is empty for sendRichMessage replies
- Closed#49534Replies to rich/formatted Telegram messages reach the agent with empty reply_to_text (rich_sent_store premise is wrong + mappingproxy + edit-path gap)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab