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

This commit is contained in:
Teknium 2026-06-14 11:13:38 -07:00 committed by GitHub
parent 6c34088a17
commit a1f51feb72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 83 additions and 69 deletions

View file

@ -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: true # Set false to force legacy MarkdownV2 for clients that cannot render Bot API rich messages
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#

View file

@ -417,9 +417,9 @@ class StreamingConfig:
# if the original preview has been visible for at least this many
# seconds, so the platform's visible timestamp reflects completion
# time instead of the preview creation time. Currently applied to
# Telegram only (other platforms ignore the setting). Default 60s
# matches the OpenClaw rollout. Set to 0 to disable.
fresh_final_after_seconds: float = 60.0
# Telegram only (other platforms ignore the setting). Default 0 disables
# the fresh-message replacement path; set >0 to opt in.
fresh_final_after_seconds: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return {
@ -446,7 +446,7 @@ class StreamingConfig:
),
cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR),
fresh_final_after_seconds=_coerce_float(
data.get("fresh_final_after_seconds"), 60.0
data.get("fresh_final_after_seconds"), 0.0
),
)

View file

@ -419,11 +419,11 @@ class TelegramAdapter(BasePlatformAdapter):
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: send final replies via sendRichMessage
# with the raw agent markdown so tables/task lists/etc. render natively.
# Enabled by default; users can opt out for clients that accept but do
# not render rich messages via platforms.telegram.extra.rich_messages.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
# 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)
# 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.
@ -983,7 +983,7 @@ class TelegramAdapter(BasePlatformAdapter):
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", True)
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and not (metadata or {}).get("expect_edits")
and content
@ -996,23 +996,16 @@ class TelegramAdapter(BasePlatformAdapter):
def prefers_fresh_final_streaming(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""Finalize rich-eligible streamed replies with a fresh sendRichMessage
instead of Hermes' current MarkdownV2 edit path.
"""Whether to replace a streamed preview with a fresh rich final.
The final edit path has not yet been upgraded to Bot API 10.1's
``rich_message`` edit parameter, so finalizing through edit would lose
rich constructs such as tables/task lists. When the completed content
is rich-eligible, re-send it via ``sendRichMessage`` and delete the
preview (see ``gateway.stream_consumer._try_fresh_final``).
``metadata`` is intentionally ignored: the preview was sent with
``expect_edits=True`` (to stay on the editable path mid-stream), but the
FINAL answer is a brand-new message that should render rich. Gating
otherwise matches :meth:`_should_attempt_rich`: rich not latched off,
content present and within the rich character limit, and the bot exposes
an async ``do_api_request``.
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.
"""
return self._should_attempt_rich(content)
return False
def streaming_overflow_limit(self) -> Optional[int]:
"""Allow the stream consumer to accumulate up to the rich-message cap
@ -1026,7 +1019,7 @@ class TelegramAdapter(BasePlatformAdapter):
streams split exactly as before.
"""
if (
getattr(self, "_rich_messages_enabled", True)
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and self._bot_supports_rich()
):
@ -1216,7 +1209,7 @@ class TelegramAdapter(BasePlatformAdapter):
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", True)
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content

View file

@ -635,6 +635,15 @@ class GatewayStreamConsumer:
)
if self._final_response_sent:
self._final_content_delivered = True
elif self._fallback_final_send:
# The final edit attempt itself may be the one
# that exhausts flood-control strikes and
# promotes the consumer into fallback mode. Do
# not return to the gateway with a full-response
# fallback still pending; send only the unsent
# tail here so the normal gateway send path does
# not duplicate the visible prefix.
await self._send_fallback_final(self._accumulated)
elif not self._already_sent:
self._final_response_sent = await self._send_or_edit(self._accumulated)
if self._final_response_sent:

View file

@ -1990,7 +1990,7 @@ DEFAULT_CONFIG = {
"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": True, # Bot API 10.1 rich messages; set false to force legacy MarkdownV2
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
},
},
@ -2327,7 +2327,7 @@ DEFAULT_CONFIG = {
# delivered as a fresh message if the preview has been visible at
# least this many seconds, so the platform timestamp reflects
# completion time. Telegram only; other platforms ignore it.
"fresh_final_after_seconds": 60.0,
"fresh_final_after_seconds": 0.0,
},
# Session storage — controls automatic cleanup of ~/.hermes/state.db.

View file

@ -186,7 +186,7 @@ class TestStreamingConfig:
)
assert restored.edit_interval == 0.8
assert restored.buffer_threshold == 24
assert restored.fresh_final_after_seconds == 60.0
assert restored.fresh_final_after_seconds == 0.0
class TestGatewayConfigRoundtrip:
@ -832,7 +832,7 @@ class TestLoadGatewayConfig:
assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False
def test_load_config_default_includes_telegram_rich_messages(self, tmp_path, monkeypatch):
def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
@ -842,7 +842,7 @@ class TestLoadGatewayConfig:
config = load_config()
assert config["telegram"]["extra"]["rich_messages"] is True
assert config["telegram"]["extra"]["rich_messages"] is False
def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"

View file

@ -993,17 +993,16 @@ class TestFinalContentDeliveredGuard:
requiring a second finalize edit even when content is unchanged."""
adapter = MagicMock()
adapter.REQUIRES_EDIT_FINALIZE = True # Telegram adapter behavior
# First send (initial streaming message) succeeds
# Mid-stream finalize edit succeeds
# Final finalize edit FAILS (e.g. flood control on Telegram)
adapter.edit_message = AsyncMock(side_effect=[
SimpleNamespace(success=True), # mid-stream edit
SimpleNamespace(success=True), # finalize edit on line 548
SimpleNamespace(success=False), # final finalize on line 580 (FAILS)
# First send (initial streaming message) succeeds.
# Mid-stream edit succeeds.
# Final finalize edit fails, and the consumer's own fallback send also
# fails, so no path has confirmed the complete final response reached
# the user.
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=False))
adapter.send = AsyncMock(side_effect=[
SimpleNamespace(success=True, message_id="msg_1"),
SimpleNamespace(success=False, error="network down"),
])
adapter.send = AsyncMock(
return_value=SimpleNamespace(success=True, message_id="msg_1"),
)
adapter.MAX_MESSAGE_LENGTH = 4096
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
@ -1013,6 +1012,10 @@ class TestFinalContentDeliveredGuard:
consumer.on_delta("Part one of the response...\n")
task = asyncio.create_task(consumer.run())
await asyncio.sleep(0.05)
# Keep the second delta buffered until finish so the complete answer is
# not already visible before the final edit attempt fails.
consumer.cfg.buffer_threshold = 10_000
consumer._current_edit_interval = 10.0
consumer.on_delta("Part two, the complete final answer.\n")
await asyncio.sleep(0.05)

View file

@ -617,15 +617,15 @@ class TestStreamConsumerConfigFreshFinalField:
class TestStreamingConfigFreshFinalField:
"""The gateway-level StreamingConfig carries the setting."""
def test_default_enables_with_60s(self):
def test_default_is_disabled(self):
from gateway.config import StreamingConfig
cfg = StreamingConfig()
assert cfg.fresh_final_after_seconds == 60.0
assert cfg.fresh_final_after_seconds == 0.0
def test_from_dict_uses_default_when_missing(self):
from gateway.config import StreamingConfig
cfg = StreamingConfig.from_dict({"enabled": True})
assert cfg.fresh_final_after_seconds == 60.0
assert cfg.fresh_final_after_seconds == 0.0
def test_from_dict_respects_explicit_zero(self):
from gateway.config import StreamingConfig

View file

@ -48,7 +48,11 @@ PTB_INVALID_TOKEN_404 = InvalidToken(
def _make_adapter(extra=None):
"""Build a TelegramAdapter with a mock bot wired for the rich path."""
config = PlatformConfig(enabled=True, token="fake-token", extra=extra or {})
config = PlatformConfig(
enabled=True,
token="fake-token",
extra={"rich_messages": True, **(extra or {})},
)
adapter = TelegramAdapter(config)
bot = MagicMock()
# do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True,
@ -180,16 +184,22 @@ async def test_rich_messages_opt_out_accepts_string_false():
@pytest.mark.asyncio
async def test_rich_messages_default_is_enabled():
adapter = _make_adapter()
async def test_rich_messages_default_is_disabled():
config = PlatformConfig(enabled=True, token="fake-token")
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 = adapter._bot
assert bot is not None
bot.do_api_request.assert_awaited_once()
bot.send_message.assert_not_called()
bot.do_api_request.assert_not_called()
bot.send_message.assert_awaited()
@pytest.mark.asyncio
@ -519,14 +529,13 @@ async def test_rich_draft_oversized_uses_legacy():
# ----------------------------------------------------------------------
# prefers_fresh_final_streaming: the stream consumer asks the adapter whether
# to finalize a streamed reply by sending a fresh (rich) message + deleting the
# preview, instead of final-editing the preview through the non-rich edit path.
# Telegram opts in exactly when the content is rich-eligible.
# prefers_fresh_final_streaming: Telegram keeps streamed finals on the edit
# path, even when rich messages are enabled, so users do not briefly see two
# copies of the answer while the preview cleanup delete races the fresh send.
# ----------------------------------------------------------------------
def test_prefers_fresh_final_streaming_when_rich_enabled():
def test_prefers_fresh_final_streaming_stays_disabled_when_rich_enabled():
adapter = _make_adapter()
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is True
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is False
def test_prefers_fresh_final_streaming_honors_rich_opt_out():

View file

@ -1486,7 +1486,7 @@ streaming:
edit_interval: 0.3 # Seconds between message edits
buffer_threshold: 40 # Characters before forcing an edit flush
cursor: " ▉" # Cursor shown during streaming
fresh_final_after_seconds: 60 # Send fresh final (Telegram) when preview is this old; 0 = always edit in place
fresh_final_after_seconds: 0 # Opt in to fresh final (Telegram) when preview is this old
```
When enabled, the bot sends a message on the first token, then progressively edits it as more tokens arrive. Platforms that don't support message editing (Signal, Email, Home Assistant) are auto-detected on the first attempt — streaming is gracefully disabled for that session with no flood of messages.
@ -1495,7 +1495,7 @@ For separate natural mid-turn assistant updates without progressive token editin
**Overflow handling:** If the streamed text exceeds the platform's message length limit (~4096 chars), the current message is finalized and a new one starts automatically.
**Fresh final (Telegram):** Telegram's `editMessageText` preserves the original message timestamp, so a long-running streamed reply would keep the first-token timestamp even after completion. When `fresh_final_after_seconds > 0` (default `60`), the completed reply is delivered as a brand-new message (with the stale preview best-effort deleted) so Telegram's visible timestamp reflects completion time. Short previews still finalize in place. Set to `0` to always edit in place.
**Fresh final (Telegram):** Telegram's `editMessageText` preserves the original message timestamp, so a long-running streamed reply would keep the first-token timestamp even after completion. Set `fresh_final_after_seconds > 0` to opt in to delivering old previews as brand-new final messages with best-effort preview deletion. The default is `0`, which always finalizes streamed replies in place and avoids the brief duplicate-message/delete sequence on clients that show both operations.
:::note Per-platform streaming defaults
The master `streaming.enabled` switch is `false` by default — nothing streams until you flip it. Once enabled, streaming is decided **per platform**: Telegram ships with `display.platforms.telegram.streaming: true` (streams) and Discord with `display.platforms.discord.streaming: false` (does not). So after enabling streaming, Telegram streams out of the box and Discord stays on whole-message replies until you change its toggle. You can adjust these per-platform switches from the dashboard's **Channels** toggles or directly in `~/.hermes/config.yaml`.

View file

@ -900,7 +900,7 @@ gateway:
## Rendering: Rich Messages, Tables and Link Previews
**Rich Messages (Bot API 10.1).** 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).** 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.
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).
@ -909,17 +909,17 @@ The rich path is skipped automatically when content exceeds the 32,768-byte rich
- **Small tables** are flattened into **row-group bullets** — each row becomes a readable bulleted list under the column headings. Good for 24 columns and short cells.
- **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses.
There's nothing to configure for the API fallback — the adapter picks the right rendering per message. If a Telegram client accepts but cannot render rich messages (for example, a watch client that shows them as opaque media blocks), opt out and force the MarkdownV2 path:
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:
```yaml
gateway:
platforms:
telegram:
extra:
rich_messages: false
rich_messages: true
```
Rich messages are enabled by default (`rich_messages: true`). 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`).
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`).
**Link previews.** Telegram auto-generates link previews for URLs in bot messages. If you'd rather suppress those (long `/tools` output, agent reply that mentions ten links, etc.):

View file

@ -1302,7 +1302,7 @@ streaming:
edit_interval: 0.3 # 消息编辑之间的秒数
buffer_threshold: 40 # 强制编辑刷新前的字符数
cursor: " ▉" # 流式传输期间显示的光标
fresh_final_after_seconds: 60 # 预览超过此时间时发送新的最终消息Telegram0 = 始终就地编辑
fresh_final_after_seconds: 0 # 预览超过此时间时选择发送新的最终消息Telegram
```
启用后bot 在第一个 token 时发送消息,然后随着更多 token 到来渐进式编辑它。不支持消息编辑的平台Signal、Email、Home Assistant在第一次尝试时自动检测 —— 该会话的流式传输被优雅地禁用,不会产生大量消息。
@ -1311,7 +1311,7 @@ streaming:
**溢出处理:** 如果流式传输的文本超过平台的消息长度限制(约 4096 字符),当前消息被最终化,新消息自动开始。
**新的最终消息Telegram** Telegram 的 `editMessageText` 保留原始消息时间戳,因此长时间运行的流式回复即使在完成后也会保留第一个 token 的时间戳。`fresh_final_after_seconds > 0`(默认 `60`)时,完成的回复作为全新消息传递(尽力删除旧预览),以便 Telegram 的可见时间戳反映完成时间。短预览仍然就地最终化。设置为 `0` 以始终就地编辑
**新的最终消息Telegram** Telegram 的 `editMessageText` 保留原始消息时间戳,因此长时间运行的流式回复即使在完成后也会保留第一个 token 的时间戳。设置 `fresh_final_after_seconds > 0` 可选择将旧预览作为全新的最终消息传递,并尽力删除旧预览。默认值为 `0`,始终就地最终化流式回复,避免某些客户端短暂显示重复消息再删除其中一条
:::note
主开关 `streaming.enabled` 默认为 `false`——在你启用之前不会有任何流式传输。启用后,是否流式传输按**平台**决定Telegram 默认带有 `display.platforms.telegram.streaming: true`流式传输Discord 为 `display.platforms.discord.streaming: false`不流式传输。因此启用流式传输后Telegram 开箱即用地流式传输Discord 在你修改其开关之前仍使用整条消息回复。你可以在仪表盘的 **Channels** 开关中或直接在 `~/.hermes/config.yaml` 中调整这些按平台的开关。

View file

@ -877,7 +877,7 @@ gateway:
## 渲染:富消息、表格和链接预览
**富消息Bot API 10.1)。** 最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。
**富消息Bot API 10.1)。** 选择启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。
当内容超过 32,768 字节的富文本上限时富消息路径会自动跳过Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。
@ -886,17 +886,17 @@ gateway:
- **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。
- **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。
API 回退无需配置——适配器会为每条消息选择正确的渲染方式。如果某个 Telegram 客户端能接收但不能渲染富消息(例如手表客户端把它们显示为不透明媒体块),可以选择退出并强制使用 MarkdownV2 路径
富消息默认关闭,因为一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差。若你的客户端能良好处理富消息,可以选择启用
```yaml
gateway:
platforms:
telegram:
extra:
rich_messages: false
rich_messages: true
```
富消息默认启用(`rich_messages: true`)。这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。
这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。
**链接预览。** Telegram 会为机器人消息中的 URL 自动生成链接预览。如果你希望抑制这些预览(长 `/tools` 输出、提及十个链接的 Agent 回复等):