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

0open issues
0open PRs
7items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #47409 fix(telegram): resolve replies to rich (sendRichMessage) messages

    merged to mainSuggested link · files and diff not shown

  • #49996 fix(telegram): recover reply text from native rich echo (#49534)

    merged to main

    files: plugins/platforms/telegram/adapter.py, scripts/release.py, tests/gateway/test_telegram_rich_messages.py

    Show 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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#47409, #49996
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.