NousResearch/hermes-agent

Telegram rich message rendering fixes and consistency

Cluster 45911. Data as of

Nothing open on GitHub. Fix merged in #46890 on 16 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #46890. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple fixes addressing inconsistent rich message rendering in Telegram, including streamed previews, markdown handling, and duplicate message issues. Relates to a reported bug about inconsistent Telegram message display.

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.

  • #46206 fix(telegram): avoid rich final duplicate previews

    merged to main

    files: cli-config.yaml.example, gateway/config.py, gateway/platforms/telegram.py, gateway/stream_consumer.py, hermes_cli/config.py, tests/gateway/test_config.py, tests/gateway/test_stream_consumer.py, tests/gateway/test_stream_consumer_fresh_final.py, tests/gateway/test_telegram_rich_messages.py, website/docs/user-guide/configuration.md, website/docs/user-guide/messaging/telegram.md, website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md, website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md

  • #46890 fix(telegram): edit streamed previews in place as rich (Bot API 10.1)

    merged to main

    files: cli-config.yaml.example, gateway/platforms/telegram.py, hermes_cli/config.py, tests/gateway/test_config.py, tests/gateway/test_telegram_rich_messages.py, website/docs/user-guide/messaging/telegram.md, website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md

    Show the diff
    • cli-config.yaml.example
      @@ -724,7 +724,7 @@ platform_toolsets:
       #     # allowed_chats: ["-1001234567890"]
       #     extra:
       #       disable_link_previews: false  # Set true to suppress Telegram URL previews in bot messages
      -#       rich_messages: false          # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
      +#       rich_messages: false          # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
       #
       # Discord-specific settings (config.yaml top-level, not under platforms:):
       #
    • gateway/platforms/telegram.py
      @@ -419,11 +419,13 @@ def __init__(self, config: PlatformConfig):
               self._mention_patterns = self._compile_mention_patterns()
               self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
               self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
      -        # Bot API 10.1 Rich Messages: when explicitly enabled, send final
      -        # replies via sendRichMessage with the raw agent markdown so
      -        # tables/task lists/etc. render natively. Disabled by default because
      -        # several Telegram clients accept but render rich messages poorly.
      -        self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
      +        # Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
      +        # path degrades (tables → bullet lists, task lists, <details>, block
      +        # math) via sendRichMessage / editMessageText's rich_message param using
      +        # the raw agent markdown. Enabled by default; users can opt out for
      +        # clients that accept but render rich messages poorly via
      +        # platforms.telegram.extra.rich_messages: false.
      +        self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
               # Latched off after a capability failure on sendRichMessage /
               # sendRichMessageDraft (e.g. older python-telegram-bot without the
               # endpoint) so later sends skip the doomed rich attempt entirely.
      @@ -979,31 +981,68 @@ def _has_telegram_desktop_details_math_crash_shape(self, content: str) -> bool:
                       return True
               return False
       
      -    def _should_attempt_rich(
      -        self, content: str, metadata: Optional[Dict[str, Any]] = None
      -    ) -> bool:
      +    def _needs_rich_rendering(self, content: str) -> bool:
      +        """Return True for markdown constructs that the legacy path degrades.
      +
      +        Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram
      +        clients render a consistent font weight/spacing. The rich endpoint is
      +        reserved for constructs where raw markdown materially improves output:
      +        pipe tables (MarkdownV2 has no table syntax and rewrites them into
      +        bullet lists), GFM task lists, collapsible ``<details>`` blocks, and
      +        block math.  Adapted from #45995 (@YonganZhang).
      +        """
      +        if not content:
      +            return False
      +        if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
      +            return True
      +        if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
      +            return True
      +        if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
      +            return True
      +        if "$$" in content:
      +            return True
      +        return False
      +
      +    def _rich_eligible(self, content: str) -> bool:
      +        """Capability/content eligibility for rich, ignoring ``expect_edits``.
      +
      +        Shared core of :meth:`_should_attempt_rich` minus the per-call
      +        ``expect_edits`` metadata gate.  The rich EDIT-finalize path
      +        (:meth:`_try_edit_rich`) needs this: a streamed preview is sent with
      +        ``expect_edits=True`` to stay on the editable path mid-stream, but the
      +        FINAL edit should still upgrade to rich when the content warrants it.
      +        """
               return bool(
      -            getattr(self, "_rich_messages_enabled", False)
      +            getattr(self, "_rich_messages_enabled", True)
                   and not getattr(self, "_rich_send_disabled", False)
      -            and not (metadata or {}).get("expect_edits")
                   and content
                   and content.strip()
      +            and self._needs_rich_rendering(content)
                   and not self._has_telegram_desktop_details_math_crash_shape(content)
                   and self._content_fits_rich_limits(content)
                   and self._bot_supports_rich()
               )
       
      +    def _should_attempt_rich(
      +        self, content: str, metadata: Optional[Dict[str, Any]] = None
      +    ) -> bool:
      +        return bool(
      +            not (metadata or {}).get("expect_edits")
      +            and self._rich_eligible(content)
      +        )
      +
           def prefers_fresh_final_streaming(
               self, content: str, metadata: Optional[Dict[str, Any]] = None
           ) -> bool:
               """Whether to replace a streamed preview with a fresh rich final.
       
      -        Keep this disabled for Telegram. The fresh-final path briefly shows two
      -        copies of the final answer, then deletes the streaming preview after the
      -        rich send succeeds. That is especially visible on clients that support
      -        rich messages well, and it looks like duplicate delivery at the end of
      -        every streamed turn. Until Telegram rich edits are wired directly, final
      -        streamed replies should edit the existing preview in place.
      +        Disabled for Telegram. The fresh-final path briefly shows two copies of
      +        the final answer, then deletes the streaming preview after the rich send
      +        succeeds — it looks like duplicate delivery at the end of every streamed
      +        turn (the reason #46206 reverted it).  Rich finalize is instead handled
      +        by editing the existing preview in place via Bot API 10.1's
      +        ``editMessageText`` ``rich_message`` parameter (see
      +        :meth:`_try_edit_rich`), so no fresh re-send / delete is needed.
               """
               return False
       
      @@ -1019,7 +1058,7 @@ def streaming_overflow_limit(self) -> Optional[int]:
               streams split exactly as before.
               """
               if (
      -            getattr(self, "_rich_messages_enabled", False)
      +            getattr(self, "_rich_messages_enabled", True)
                   and not getattr(self, "_rich_send_disabled", False)
                   and self._bot_supports_rich()
               ):
      @@ -1207,9 +1246,74 @@ async def _try_send_rich(
                   message_id=str(message_id) if message_id is not None else None,
               )
       
      +    async def _try_edit_rich(
      +        self,
      +        chat_id: str,
      +        message_id: str,
      +        content: str,
      +    ) -> Optional[SendResult]:
      +        """Edit an existing message in place as a rich message (Bot API 10.1).
      +
      +        Uses ``editMessageText`` with the ``rich_message`` parameter so a
      +        streamed preview can finalize as rich (tables/task lists/details/math)
      +        WITHOUT a fresh send + delete — no duplicate preview.  Mirrors
      +        :meth:`_try_send_rich`'s error contract:
      +
      +        - success → ``SendResult(success=True, message_id=...)``
      +        - permanent / capability error → ``None`` (caller falls back to the
      +          legacy MarkdownV2 edit; capability errors latch rich off)
      +        - transient / unknown → ``SendResult(success=False)`` with retry
      +          semantics (the message may already be edited; do NOT legacy-resend)
      +        """
      +        payload: Dict[str, Any] = {
      +            "chat_id": int(chat_id),
      +            "message_id": int(message_id),
      +            "rich_message": self._rich_message_payload(content),
      +        }
      +        if getattr(self, "_disable_link_previews", False):
      +            payload["link_preview_options"] = {"is_disabled": True}
      +        try:
      +            # Raw Bot API result; do not request return_type=Message (PTB does
      +            # not fully model the 10.1 response shape yet — a post-edit parse
      +            # error must not be mistaken for a failed edit).
      +            await self._bot.do_api_request("editMessageText", api_kwargs=payload)
      +        except Exception as exc:
      +            if self._is_rich_fallback_error(exc):
      +                if self._is_rich_capability_error(exc):
      +                    self._rich_send_disabled = True
      +                # "Message is not modified" — content identical to the current
      +                # rich message; treat as a successful no-op so the caller does
      +                # not fall through to a redundant legacy edit.
      +                if "not modified" in str(exc).lower():
      +                    return SendResult(success=True, message_id=message_id)
      +                logger.debug(
      +                    "[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
      +                    self.name, exc,
      +                )
      +                return None
      +            if "not modified" in str(exc).lower():
      +                return SendResult(success=True, message_id=message_id)
      +            err_str = str(exc).lower()
      +            try:
      +                from telegram.error import TimedOut as _TimedOut
      +            except (ImportError, AttributeError):
      +                _TimedOut = None
      +            is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
      +            is_connect_timeout = self._looks_like_connect_timeout(exc)
      +            logger.warning(
      +                "[%s] rich editMessageText transient failure (no legacy resend): %s",
      +                self.name, exc,
      +            )
      +            return SendResult(
      +                success=False,
      +                error=str(exc),
      +                retryable=(is_connect_timeout or not is_timeout),
      +            )
      +        return SendResult(success=True, message_id=message_id)
      +
           def _should_attempt_rich_draft(self, content: str) -> bool:
               return bool(
      -            getattr(self, "_rich_messages_enabled", False)
      +            getattr(self, "_rich_messages_enabled", True)
                   and not getattr(self, "_rich_send_disabled", False)
                   and not getattr(self, "_rich_draft_disabled", False)
                   and content
      @@ -2555,6 +2659,21 @@ async def edit_message(
               if not self._bot:
                   return SendResult(success=False, error="Not connected")
       
      +        # Rich finalize (Bot API 10.1): when the completed content has
      +        # constructs the legacy MarkdownV2 edit degrades (tables → bullet
      +        # lists, task lists, <details>, block math) and rich is available,
      +        # edit the preview IN PLACE via editMessageText's rich_message param.
      +        # No fresh send + delete → no duplicate preview (the problem #46206
      +        # reverted the fresh-final path for).  Attempted before the 4,096
      +        # overflow pre-flight because the rich text cap is 32,768 — a rich
      +        # table that exceeds the MarkdownV2 limit must not be split into legacy
      +        # chunks.  Falls back to the legacy edit path (overflow split included)
      +        # on capability/permanent rejection.
      +        if finalize and self._rich_eligible(content):
      +            rich_result = await self._try_edit_rich(chat_id, message_id, content)
      +            if rich_result is not None:
      +                return rich_result
      +
               # Pre-flight: if content already exceeds the limit, split-and-deliver
               # without round-tripping a doomed edit.
               if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
    • hermes_cli/config.py
      @@ -1991,7 +1991,7 @@ def _ensure_hermes_home_managed(home: Path):
               "channel_prompts": {},         # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
               "allowed_chats": "",           # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
               "extra": {
      -            "rich_messages": False,     # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
      +            "rich_messages": True,      # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
               },
           },
       
    • tests/gateway/test_config.py
      @@ -832,7 +832,7 @@ def test_loads_telegram_rich_messages_from_gateway_platform_extra(self, tmp_path
       
               assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False
       
      -    def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch):
      +    def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monkeypatch):
               hermes_home = tmp_path / ".hermes"
               hermes_home.mkdir()
       
      @@ -842,7 +842,7 @@ def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, mon
       
               config = load_config()
       
      -        assert config["telegram"]["extra"]["rich_messages"] is False
      +        assert config["telegram"]["extra"]["rich_messages"] is True
       
           def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch):
               hermes_home = tmp_path / ".hermes"
    • tests/gateway/test_telegram_rich_messages.py
      @@ -61,6 +61,8 @@ def _make_adapter(extra=None):
           bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
           bot.send_chat_action = AsyncMock()  # keeps the post-send typing re-trigger quiet
           bot.send_message_draft = AsyncMock(return_value=True)  # legacy draft fallback
      +    bot.edit_message_text = AsyncMock(return_value=MagicMock(message_id=1))  # legacy edit path
      +    bot.delete_message = AsyncMock(return_value=True)
           adapter._bot = bot
           return adapter
       
      @@ -184,7 +186,10 @@ async def test_rich_messages_opt_out_accepts_string_false():
       
       
       @pytest.mark.asyncio
      -async def test_rich_messages_default_is_disabled():
      +async def test_rich_messages_default_is_enabled():
      +    """Rich messages are on by default (Bot API 10.1); rich-eligible content
      +    (tables/task lists/details/math) goes through sendRichMessage without the
      +    user having to opt in."""
           config = PlatformConfig(enabled=True, token="[redacted:secret-assignment]")
           adapter = TelegramAdapter(config)
           bot = MagicMock()
      @@ -195,6 +200,42 @@ async def test_rich_messages_default_is_disabled():
       
           result = await adapter.send("12345", RICH_CONTENT)
       
      +    assert result.success is True
      +    bot = adapter._bot
      +    assert bot is not None
      +    bot.do_api_request.assert_awaited_once()
      +    bot.send_message.assert_not_called()
      +
      +
      [redacted:email]
      +async def test_rich_messages_can_be_opted_out():
      +    """Setting platforms.telegram.extra.rich_messages: false keeps every reply
      +    on the legacy MarkdownV2 path even for rich-eligible content."""
      +    config = PlatformConfig(
      +        enabled=True, token="[redacted:secret-assignment]", extra={"rich_messages": False}
      +    )
      +    adapter = TelegramAdapter(config)
      +    bot = MagicMock()
      +    bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=123))
      +    bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
      +    bot.send_chat_action = AsyncMock()
      +    adapter._bot = bot
      +
      +    result = await adapter.send("12345", RICH_CONTENT)
      +
      +    assert result.success is True
      +    bot.do_api_request.assert_not_called()
      +    bot.send_message.assert_awaited()
      +
      +
      [redacted:email]
      +async def test_plain_markdown_stays_on_legacy_path():
      +    """Ordinary replies (no table/task-list/details/math) stay on the legacy
      +    MarkdownV2 path for consistent client rendering, even with rich enabled."""
      +    adapter = _make_adapter()
      +
      +    result = await adapter.send("12345", "Hello **there**\n\nA normal reply.")
      +
           assert result.success is True
           bot = adapter._bot
           assert bot is not None
      @@ -240,7 +281,9 @@ async def test_oversized_content_skips_rich_and_chunks():
       async def test_rich_limit_is_characters_not_bytes():
           """Telegram's rich limit is UTF-8 characters, not encoded bytes."""
           adapter = _make_adapter()
      -    cjk = "测" * 20000  # 20k chars, 60k UTF-8 bytes
      +    # Rich-eligible (table) so the content takes the rich path; the CJK body
      +    # is 20k chars / 60k UTF-8 bytes — over the byte count, under the char cap.
      +    cjk = "| a | b |\n|---|---|\n" + "测" * 20000  # 20k chars, ~60k UTF-8 bytes
           assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
           assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
       
      @@ -324,7 +367,9 @@ async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):
       async def test_rich_payload_preserves_link_preview_disable():
           adapter = _make_adapter(extra={"disable_link_previews": True})
       
      -    result = await adapter.send("12345", "See https://example.com")
      +    result = await adapter.send(
      +        "12345", "| Link | Note |\n|---|---|\n| See https://example.com | x |"
      +    )
       
           assert result.success is True
           api_kwargs = _rich_api_kwargs(adapter)
      @@ -575,3 +620,139 @@ async def test_rich_draft_opt_out_uses_legacy():
           assert bot is not None
           bot.do_api_request.assert_not_called()
           bot.send_message_draft.assert_awaited_once()
      +
      +
      +# ----------------------------------------------------------------------------
      +# Rich finalize via editMessageText (Bot API 10.1 rich_message edit param).
      +# Streamed previews finalize by editing the existing message IN PLACE as rich,
      +# so tables/task lists survive without a fresh send + delete (no duplicate).
      +# ----------------------------------------------------------------------------
      +
      +
      +def _rich_edit_kwargs(adapter):
      +    """Return the api_kwargs dict from the single editMessageText rich call."""
      +    call = adapter._bot.do_api_request.call_args
      +    assert call.args[0] == "editMessageText"
      +    return call.kwargs["api_kwargs"]
      +
      +
      [redacted:email]
      +async def test_finalize_edit_uses_rich_for_table_content():
      +    """Finalizing a streamed preview whose content is a table edits the
      +    existing message IN PLACE via editMessageText's rich_message param —
      +    no fresh send, no delete, no duplicate."""
      +    adapter = _make_adapter()
      +
      +    result = await adapter.edit_message(
      +        "12345", "555", RICH_CONTENT, finalize=True,
      +    )
      +
      +    assert result.success is True
      +    assert result.message_id == "555"  # same message, edited in place
      +    api_kwargs = _rich_edit_kwargs(adapter)
      +    assert api_kwargs["message_id"] == 555
      +    # RAW markdown is passed through so table pipes survive.
      +    assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT
      +    # No fresh send / delete — the whole point of the in-place rich edit.
      +    adapter._bot.edit_message_text.assert_not_called()
      +    adapter._bot.delete_message.assert_not_called()
      +
      +
      [redacted:email]
      +async def test_finalize_edit_plain_content_stays_legacy():
      +    """Finalizing plain content (no table/task-list/details/math) uses the
      +    legacy MarkdownV2 edit_message_text path, not the rich edit endpoint."""
      +    adapter = _make_adapter()
      +
      +    result = await adapter.edit_message(
      +        "12345", "555", "Just a normal answer, no rich constructs.", finalize=True,
      +    )
      +
      +    assert result.success is True
      +    adapter._bot.do_api_request.assert_not_called()
      +    adapter._bot.edit_message_text.assert_awaited()
      +
      +
      [redacted:email]
      +async def test_finalize_edit_rich_capability_error_falls_back_to_legacy():
      +    """A capability error on the rich edit latches rich off and falls back to
      +    the legacy MarkdownV2 edit so the user still gets the final answer."""
      +    adapter = _make_adapter()
      +    adapter._bot.do_api_request = AsyncMock(side_effect=PTB_ENDPOINT_NOT_FOUND)
      +
      +    result = await adapter.edit_message(
      +        "12345", "555", RICH_CONTENT, finalize=True,
      +    )
      +
      +    assert result.success is True
      +    assert adapter._rich_send_disabled is True
      +    adapter._bot.edit_message_text.assert_awaited()
      +
      +
      [redacted:email]
      +async def test_finalize_edit_rich_not_modified_is_success_noop():
      +    """'Message is not modified' on a rich edit is a no-op success — must NOT
      +    fall through to a redundant legacy edit."""
      +    adapter = _make_adapter()
      +    adapter._bot.do_api_request = AsyncMock(
      +        side_effect=BadRequest("Message is not modified")
      +    )
      +
      +    result = await adapter.edit_message(
      +        "12345", "555", RICH_CONTENT, finalize=True,
      +    )
      +
      +    assert result.success is True
      +    adapter._bot.edit_message_text.assert_not_called()
      +
      +
      [redacted:email]
      +async def test_non_finalize_edit_never_uses_rich():
      +    """Intermediate (non-finalize) stream edits stay on the plain edit path;
      +    rich is only applied on the final edit."""
      +    adapter = _make_adapter()
      +
      +    result = await adapter.edit_message(
      +        "12345", "555", RICH_CONTENT, finalize=False,
      +    )
      +
      +    assert result.success is True
      +    adapter._bot.do_api_request.assert_not_called()
      +    adapter._bot.edit_message_text.assert_awaited()
      +
      +
      [redacted:email]
      +async def test_finalize_edit_opt_out_uses_legacy():
      +    """With rich_messages: false, even a table finalizes via the legacy
      +    MarkdownV2 edit path."""
      +    adapter = _make_adapter(extra={"rich_messages": False})
      +
      +    result = await adapter.edit_message(
      +        "12345", "555", RICH_CONTENT, finalize=True,
      +    )
      +
      +    assert result.success is True
      +    adapter._bot.do_api_request.assert_not_called()
      +    adapter._bot.edit_message_text.assert_awaited()
      +
      +
      [redacted:email]
      +async def test_finalize_edit_rich_over_markdownv2_limit_not_split():
      +    """A rich table that exceeds the 4,096 MarkdownV2 limit but fits the 32,768
      +    rich cap is edited in place as one rich message, NOT split into legacy
      +    chunks."""
      +    adapter = _make_adapter()
      +    big_table = "| a | b |\n|---|---|\n" + "\n".join(
      +        f"| {'x' * 50} | {'y' * 50} |" for _ in range(40)
      +    )
      +    assert len(big_table) > TelegramAdapter.MAX_MESSAGE_LENGTH
      +    assert len(big_table) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
      +
      +    result = await adapter.edit_message(
      +        "12345", "555", big_table, finalize=True,
      +    )
      +
      +    assert result.success is True
      +    api_kwargs = _rich_edit_kwargs(adapter)
      +    assert api_kwargs["rich_message"]["markdown"] == big_table
      +    adapter._bot.edit_message_text.assert_not_called()
    • website/docs/user-guide/messaging/telegram.md
      @@ -900,23 +900,23 @@ gateway:
       
       ## Rendering: Rich Messages, Tables and Link Previews
       
      -**Rich Messages (Bot API 10.1).** When opted in, final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `<details>`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message.
      +**Rich Messages (Bot API 10.1).** Final replies that contain constructs the legacy MarkdownV2 path degrades — tables, task lists, collapsible `<details>`, and block math — are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so they render natively with no client-side flattening. During streaming, the final answer is delivered by **editing the existing preview in place** via `editMessageText`'s `rich_message` parameter — no second message, no delete, so there is no duplicate-delivery flicker at the end of a turn. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. Ordinary replies (plain prose, bold/italic, simple lists) stay on the MarkdownV2 path for consistent font weight and spacing across clients.
       
      -The rich path is skipped automatically when content exceeds the 32,768-byte rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message).
      +The rich path is skipped automatically when content exceeds the 32,768-character rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message).
       
       **MarkdownV2 fallback.** When the rich path is unavailable for a message, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized:
       
       - **Small tables** are flattened into **row-group bullets** — each row becomes a readable bulleted list under the column headings. Good for 2–4 columns and short cells.
       - **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses.
       
      -Rich messages are disabled by default because some Telegram clients accept the Bot API payload but render it poorly. To opt in for clients that handle rich messages well:
      +Rich messages are **enabled by default**. Some Telegram clients accept the Bot API payload but render it poorly; to opt out and force every reply onto the legacy MarkdownV2 path:
       
       ```yaml
       gateway:
         platforms:
           telegram:
             extra:
      -        rich_messages: true
      +        rich_messages: false
       ```
       
       This setting is for client-rendering compatibility; Hermes already falls back automatically when Telegram rejects the rich API call. If you only want the legacy "always code-block" table behavior while keeping rich messages enabled, disable table normalization by setting `telegram.pretty_tables: false` in `config.yaml` (default: `true`).
    • website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md
      @@ -877,23 +877,23 @@ gateway:
       
       ## 渲染:富消息、表格和链接预览
       
      -**富消息(Bot API 10.1)。** 选择启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。
      +**富消息(Bot API 10.1)。** 最终回复中那些会被旧版 MarkdownV2 路径降级的结构——表格、任务列表、可折叠的 `<details>` 以及块级数学公式——会通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,从而原生渲染、无需客户端展平。在流式传输过程中,最终答案通过 `editMessageText` 的 `rich_message` 参数**就地编辑现有预览**来交付——不发第二条消息、不删除,因此一轮结束时不会出现重复投递的闪烁。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。普通回复(纯文本、粗体/斜体、简单列表)仍走 MarkdownV2 路径,以在各客户端保持一致的字重和间距。
       
      -当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。
      +当内容超过 32,768 字符的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。
       
       **MarkdownV2 回退。** 当某条消息无法使用富消息路径时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化:
       
       - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。
       - **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。
       
      -富消息默认关闭,因为一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差。若你的客户端能良好处理富消息,可以选择启用:
      +富消息**默认启用**。一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差;若要关闭并强制所有回复走旧版 MarkdownV2 路径:
       
       ```yaml
       gateway:
         platforms:
           telegram:
             extra:
      -        rich_messages: true
      +        rich_messages: false
       ```
       
       这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时,Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #45995 fix(telegram): reserve rich messages for rich markdown · superseded by another PR → a6364bf, #46890, #46055, #46206
Closed issues · 1
  • Closed#45911[Bug]: The rendering of messages in Telegram is off, and not all are consistent.
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: 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

Merged PRs
#46206, #46890
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
16 Jun 2026

For your agent

The same facts, typed.