{"repo":"NousResearch/hermes-agent","public_id":"46568","display_label":"Telegram rich message replies losing reply context","summary":"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.","subsystem":"gateway","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":49996},"resolution":{"state":"partially_resolved","by":"pr-49996"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T06:42:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":49534,"pr_number":49996,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":46568,"title":"[Bug]: Telegram rich cron announcements lose reply context because reply_to_message.text is empty for sendRichMessage replies","url":null,"state":"closed"},{"kind":"issue","number":49534,"title":"Replies to rich/formatted Telegram messages reach the agent with empty reply_to_text (rich_sent_store premise is wrong + mappingproxy + edit-path gap)","url":null,"state":"closed"},{"kind":"pr","number":46569,"title":"fix(telegram): restore reply context for replies to rich messages","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":47409,"title":"fix(telegram): resolve replies to rich (sendRichMessage) messages","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":49544,"title":"fix(telegram): recover reply text from native rich echo (#49534)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"29e5e127c","number":0},{"kind":"pr","value":"#49996","number":49996}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49548,"title":"fix(telegram): extract reply_to_text from api_kwargs rich_message before store fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49534","number":49534},{"kind":"pr","value":"#49996","number":49996},{"kind":"pr","value":"#49544","number":49544},{"kind":"pr","value":"#49544","number":49544}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":49996,"title":"fix(telegram): recover reply text from native rich echo (#49534)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","scripts/release.py","tests/gateway/test_telegram_rich_messages.py"],"fix_hunks":[{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -1334,6 +1334,15 @@ async def _try_edit_rich(\n                 error=str(exc),\n                 retryable=(is_connect_timeout or not is_timeout),\n             )\n+        # Telegram won't echo rich content for messages that predate the bot's\n+        # first rich send, so mirror the fresh-send index here too: a streamed\n+        # final finalized via editMessageText is otherwise never recorded, and\n+        # replies to it would have no native echo to recover from.\n+        try:\n+            from gateway import rich_sent_store\n+            rich_sent_store.record(str(chat_id), str(message_id), content)\n+        except Exception:\n+            pass\n         return SendResult(success=True, message_id=message_id)\n \n     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:\n                 self.name, cache_key, thread_id,\n             )\n \n+    @classmethod\n+    def _flatten_rich_inline_text(cls, value: Any) -> str:\n+        \"\"\"Best-effort plaintext flattener for Bot API rich-message inline nodes.\"\"\"\n+        if value is None:\n+            return \"\"\n+        if isinstance(value, str):\n+            return value\n+        if isinstance(value, list):\n+            return \"\".join(cls._flatten_rich_inline_text(item) for item in value)\n+        if isinstance(value, dict):\n+            text = value.get(\"text\")\n+            if text is not None:\n+                return cls._flatten_rich_inline_text(text)\n+            children = value.get(\"children\")\n+            if children is not None:\n+                return cls._flatten_rich_inline_text(children)\n+        return \"\"\n+\n+    @classmethod\n+    def _flatten_rich_blocks(cls, blocks: Any) -> str:\n+        \"\"\"Best-effort plaintext flattener for Bot API rich-message blocks.\"\"\"\n+        if not isinstance(blocks, list):\n+            return \"\"\n+\n+        lines: List[str] = []\n+        for block in blocks:\n+            if not isinstance(block, dict):\n+                continue\n+\n+            block_type = block.get(\"type\")\n+            if block_type == \"list\":\n+                for item in block.get(\"items\", []):\n+                    if not isinstance(item, dict):\n+                        continue\n+                    item_text = cls._flatten_rich_blocks(item.get(\"blocks\"))\n+                    if not item_text:\n+                        continue\n+                    label = item.get(\"label\")\n+                    item_lines = item_text.splitlines()\n+                    if not item_lines:\n+                        continue\n+                    first_line = item_lines[0]\n+                    if label:\n+                        first_line = f\"{label} {first_line}\".strip()\n+                    lines.append(first_line)\n+                    lines.extend(item_lines[1:])\n+                continue\n+\n+            text = cls._flatten_rich_inline_text(block.get(\"text\"))\n+            if text:\n+                lines.extend(text.splitlines())\n+\n+        return \"\\n\".join(line.rstrip() for line in lines if line)\n+\n+    @classmethod\n+    def _extract_rich_reply_text(cls, reply_to_message: Any) -> Optional[str]:\n+        \"\"\"Return plaintext echoed by Telegram's rich_message reply payload.\"\"\"\n+        try:\n+            api_kwargs = getattr(reply_to_message, \"api_kwargs\", None)\n+            getter = getattr(api_kwargs, \"get\", None)\n+            if not callable(getter):\n+                return None\n+            rich_message = getter(\"rich_message\")\n+            rich_getter = getattr(rich_message, \"get\", None)\n+            if not callable(rich_getter):\n+                return None\n+            text = cls._flatten_rich_blocks(rich_getter(\"blocks\")).strip()\n+            return text or None\n+        except Exception:\n+            return None\n+\n     def _build_message_event(\n         self,\n         message: Message,","@@ -6772,11 +6852,11 @@ def _build_message_event(\n                     or None\n                 )\n                 if not reply_to_text:\n-                    # Rich messages (sendRichMessage — the launchd briefings and\n-                    # the gateway's own rich finals) are NOT echoed with their\n-                    # content in reply_to_message; Telegram sends no text,\n-                    # caption, or api_kwargs for them. Recover the text we sent\n-                    # from our local send-time index, keyed by message id.\n+                    # Prefer Telegram's native rich-message echo when present;\n+                    # keep the local send-time index only as a fallback for\n+                    # older/unrecoverable reply payloads.\n+                    reply_to_text = self._extract_rich_reply_text(message.reply_to_message)\n+                if not reply_to_text:\n                     try:\n                         from gateway import rich_sent_store\n                         reply_to_text = rich_sent_store.lookup("]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"izumi0uu\",  # PR #49544 salvage (native rich reply echo; #49534)\n     \"[redacted:email]\": \"w31rdm4ch1nZ\",\n     \"[redacted:email]\": \"x7peeps\",\n     \"[redacted:email]\": \"ahmadashfq\","]},{"file":"tests/gateway/test_telegram_rich_messages.py","hunks":["@@ -791,6 +791,39 @@ def _reply_message(reply_to_id, *, reply_text=None, reply_caption=None, quote_te\n     )\n \n \n+def _reply_message_with_rich_blocks(\n+    reply_to_id,\n+    *,\n+    blocks,\n+    quote_text=None,\n+    api_kwargs_factory=dict,\n+):\n+    \"\"\"Build a reply whose echoed content lives only in api_kwargs.rich_message.\"\"\"\n+    replied = SimpleNamespace(\n+        message_id=int(reply_to_id),\n+        text=None,\n+        caption=None,\n+        api_kwargs=api_kwargs_factory({\"rich_message\": {\"blocks\": blocks}}),\n+    )\n+    quote = SimpleNamespace(text=quote_text) if quote_text is not None else None\n+    return SimpleNamespace(\n+        message_id=999,\n+        chat=SimpleNamespace(id=12345, type=\"private\", title=None, full_name=\"U\"),\n+        from_user=SimpleNamespace(\n+            id=42, username=\"u\", first_name=\"U\", last_name=None,\n+            full_name=\"U\", is_bot=False,\n+        ),\n+        text=\"what did this mean?\",\n+        caption=None,\n+        reply_to_message=replied,\n+        quote=quote,\n+        message_thread_id=None,\n+        is_topic_message=False,\n+        entities=[],\n+        date=None,\n+    )\n+\n+\n @pytest.mark.asyncio\n async def test_rich_reply_records_and_recovers_text(monkeypatch, tmp_path):\n     \"\"\"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):\n         _reply_message(\"678\", reply_caption=\"echoed caption\"), MessageType.TEXT,\n     )\n     assert event.reply_to_text == \"echoed caption\"\n+\n+\n[redacted:email]\n+async def test_rich_reply_native_blocks_fill_reply_text_without_index(monkeypatch, tmp_path):\n+    \"\"\"Echoed rich_message blocks should recover reply text natively.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    from gateway.platforms.base import MessageType\n+\n+    adapter = _make_adapter()\n+    event = adapter._build_message_event(\n+        _reply_message_with_rich_blocks(\n+            \"678\",\n+            blocks=[\n+                {\"type\": \"paragraph\", \"text\": [\"Hello \", {\"type\": \"bold\", \"text\": \"world\"}]},\n+                {\"type\": \"pre\", \"text\": \"Line 2\"},\n+            ],\n+        ),\n+        MessageType.TEXT,\n+    )\n+    assert event.reply_to_text == \"Hello world\\nLine 2\"\n+\n+\n[redacted:email]\n+async def test_rich_reply_native_blocks_win_over_index(monkeypatch, tmp_path):\n+    \"\"\"Native rich echo should beat the local send-time index fallback.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    from gateway.platforms.base import MessageType\n+    from gateway import rich_sent_store\n+\n+    rich_sent_store.record(\"12345\", \"678\", \"recorded body\")\n+    adapter = _make_adapter()\n+    event = adapter._build_message_event(\n+        _reply_message_with_rich_blocks(\n+            \"678\",\n+            blocks=[{\"type\": \"paragraph\", \"text\": [\"Echoed \", {\"type\": \"italic\", \"text\": \"body\"}]}],\n+        ),\n+        MessageType.TEXT,\n+    )\n+    assert event.reply_to_text == \"Echoed body\"\n+\n+\n[redacted:email]\n+async def test_rich_reply_native_blocks_support_mappingproxy_like_api_kwargs(monkeypatch, tmp_path):\n+    \"\"\"Duck-type api_kwargs via .get() so mappingproxy-like objects also work.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    from gateway.platforms.base import MessageType\n+\n+    class MappingProxyLike(dict):\n+        pass\n+\n+    adapter = _make_adapter()\n+    event = adapter._build_message_event(\n+        _reply_message_with_rich_blocks(\n+            \"678\",\n+            blocks=[\n+                {\"type\": \"heading\", \"text\": \"Status\", \"size\": 2},\n+                {\"type\": \"list\", \"items\": [{\"label\": \"-\", \"blocks\": [{\"type\": \"paragraph\", \"text\": [\"done\"]}]}]},\n+            ],\n+            api_kwargs_factory=MappingProxyLike,\n+        ),\n+        MessageType.TEXT,\n+    )\n+    assert event.reply_to_text == \"Status\\n- done\"\n+\n+\n[redacted:email]\n+async def test_try_edit_rich_records_streamed_final_for_reply_recovery(monkeypatch, tmp_path):\n+    \"\"\"A streamed final finalized via editMessageText must be indexed too.\n+\n+    The native rich echo covers most replies, but messages that predate the\n+    bot's first rich send have no echo — so editMessageText must mirror the\n+    fresh-send index the same way _try_send_rich does.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    from gateway import rich_sent_store\n+\n+    adapter = _make_adapter()\n+    result = await adapter._try_edit_rich(\"12345\", \"5724\", \"Готово. Основной бот живой.\")\n+    assert result is not None and result.success\n+    assert rich_sent_store.lookup(\"12345\", \"5724\") == \"Готово. Основной бот живой.\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}