# NousResearch/hermes-agent · Telegram rich message replies losing reply context

## Open now

- 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.

## TL;DR

- 7 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #49996. Verify it addresses your case.
- **Recommended:** read PR #49996 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-49996`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #46568: \[Bug\]: Telegram rich cron announcements lose reply context because reply_to_message.text is empty for sendRichMessage replies
- #49534: Replies to rich/formatted Telegram messages reach the agent with empty reply_to_text (rich_sent_store premise is wrong + mappingproxy + edit-path gap)

## Prior & competing PRs

- **#46569: fix(telegram): restore reply context for replies to rich messages**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#47409: fix(telegram): resolve replies to rich (sendRichMessage) messages**. merged to main · fix tier `suggested`
  - maintainer direction: category `merged`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#49996: fix(telegram): recover reply text from native rich echo (#49534)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/platforms/telegram/adapter.py`, `scripts/release.py`, `tests/gateway/test_telegram_rich_messages.py`
  - fix hunks:
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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."""
    ```

    ```diff
    @@ -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") == "Готово. Основной бот живой."
    ```
- **#49544: fix(telegram): recover reply text from native rich echo (#49534)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `29e5e127c`, `#49996`
- **#49548: fix(telegram): extract reply_to_text from api_kwargs rich_message before store fallback**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#49534`, `#49996`, `#49544`, `#49544`

## Fix status

- Merged to main in PR #49996 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-21`.
- Subsystem: `gateway`
