mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
fix(telegram): upgrade streaming preview to rich in place at stream end
The rich branch in send_delta(stream_end=True) was unreachable: it was guarded by 'not buf.message_id' after an earlier return had already ensured buf.message_id is set, so sendRichMessage never fired with streaming enabled and the final message always went through the legacy HTML editMessageText path. Bot API 10.1 added a rich_message parameter to editMessageText, which upgrades an existing message to rich in place. Use it at stream end via do_api_request so the streaming preview keeps its identity — no delete-and-resend, so none of the flickering or dropped line breaks that made rich-at-stream-end fail in #4470. Capability errors (server older than 10.1) trip the existing _rich_send_disabled latch and fall back to the legacy HTML path, which is unchanged. Fixes #5516
This commit is contained in:
@@ -897,6 +897,47 @@ class TelegramChannel(BaseChannel):
|
|||||||
self.logger.debug("sendRichMessage failed: {}", exc)
|
self.logger.debug("sendRichMessage failed: {}", exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _try_edit_rich(self, chat_id: int, message_id: int, content: str) -> bool:
|
||||||
|
"""Upgrade an existing message to rich in place via editMessageText (Bot API 10.1).
|
||||||
|
|
||||||
|
Editing in place keeps the message identity, so the streaming preview is
|
||||||
|
upgraded without the delete-and-resend pattern that caused flickering and
|
||||||
|
dropped line breaks (issue #4470). Returns True on success; on capability
|
||||||
|
errors (server older than Bot API 10.1) the rich latch is tripped so the
|
||||||
|
legacy HTML path is used from then on.
|
||||||
|
"""
|
||||||
|
if not self._app:
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"rich_message": {
|
||||||
|
"markdown": content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self._call_with_retry(
|
||||||
|
self._app.bot.do_api_request,
|
||||||
|
"editMessageText",
|
||||||
|
api_kwargs=payload,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except BadRequest as exc:
|
||||||
|
if self._is_rich_capability_error(exc):
|
||||||
|
self.logger.debug("editMessageText rich_message not available, disabling")
|
||||||
|
self._rich_send_disabled = True
|
||||||
|
else:
|
||||||
|
self.logger.debug("editMessageText rich_message rejected: {}", exc)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
err_str = str(exc).lower()
|
||||||
|
if "timed out" in err_str or isinstance(exc, TimedOut):
|
||||||
|
self.logger.debug("editMessageText rich_message timeout, falling back to legacy path")
|
||||||
|
return False
|
||||||
|
self.logger.debug("editMessageText rich_message failed: {}", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through Telegram."""
|
"""Send a message through Telegram."""
|
||||||
app = await self._wait_for_app()
|
app = await self._wait_for_app()
|
||||||
@@ -1136,26 +1177,16 @@ class TelegramChannel(BaseChannel):
|
|||||||
thread_kwargs["message_thread_id"] = message_thread_id
|
thread_kwargs["message_thread_id"] = message_thread_id
|
||||||
raw_text = buf.text
|
raw_text = buf.text
|
||||||
|
|
||||||
# Try sendRichMessage for final output (Bot API 10.1).
|
# Try upgrading the streaming preview to rich in place (Bot API 10.1:
|
||||||
# Skip when a streaming preview already exists to avoid the
|
# editMessageText gained a rich_message parameter). Editing in place
|
||||||
# delete-and-resend pattern that causes flickering and drops
|
# keeps the message identity, so there is no delete-and-resend and
|
||||||
# line breaks (issue #4470).
|
# none of the flickering / dropped line breaks from issue #4470.
|
||||||
if not buf.message_id and self.config.rich_messages and not getattr(self, "_rich_send_disabled", False):
|
# The previous branch here was unreachable: it was guarded by
|
||||||
reply_params = None
|
# ``not buf.message_id`` after an early return had already ensured
|
||||||
if reply_to_message_id := meta.get("message_id"):
|
# ``buf.message_id`` is set (issue #5516).
|
||||||
reply_params = {"message_id": int(reply_to_message_id), "allow_sending_without_reply": True}
|
if self.config.rich_messages and not getattr(self, "_rich_send_disabled", False):
|
||||||
rich_ok = await self._try_send_rich(
|
rich_ok = await self._try_edit_rich(int_chat_id, buf.message_id, raw_text)
|
||||||
int_chat_id, raw_text, reply_params, thread_kwargs, None,
|
|
||||||
)
|
|
||||||
if rich_ok:
|
if rich_ok:
|
||||||
# Delete the streaming preview message
|
|
||||||
try:
|
|
||||||
await self._call_with_retry(
|
|
||||||
app.bot.delete_message,
|
|
||||||
chat_id=int_chat_id, message_id=buf.message_id,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass # Preview stays if delete fails
|
|
||||||
self._stream_bufs.pop(chat_id, None)
|
self._stream_bufs.pop(chat_id, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -2735,3 +2735,76 @@ def test_markdown_to_html_code_block_same_line_no_newline() -> None:
|
|||||||
|
|
||||||
stripped = _strip_md_block(text)
|
stripped = _strip_md_block(text)
|
||||||
assert stripped == "Use <tag> here"
|
assert stripped == "Use <tag> here"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_stream_end_upgrades_preview_to_rich_in_place() -> None:
|
||||||
|
"""Rich messages finally work with streaming: the preview is upgraded via
|
||||||
|
editMessageText rich_message (in place), not delete-and-resend (issue #5516)."""
|
||||||
|
from telegram.error import BadRequest
|
||||||
|
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
_install_ready_app(channel)
|
||||||
|
channel._app.bot.do_api_request = AsyncMock()
|
||||||
|
channel._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("should not be reached"))
|
||||||
|
channel._stream_bufs["123"] = _StreamBuf(text="**hello**", message_id=7, last_edit=0.0)
|
||||||
|
|
||||||
|
await channel.send_delta("123", "", stream_end=True)
|
||||||
|
|
||||||
|
# editMessageText with rich_message payload, in place (same message_id)
|
||||||
|
channel._app.bot.do_api_request.assert_awaited_once()
|
||||||
|
args, kwargs = channel._app.bot.do_api_request.await_args
|
||||||
|
assert args[0] == "editMessageText"
|
||||||
|
assert kwargs["api_kwargs"]["chat_id"] == 123
|
||||||
|
assert kwargs["api_kwargs"]["message_id"] == 7
|
||||||
|
assert kwargs["api_kwargs"]["rich_message"] == {"markdown": "**hello**"}
|
||||||
|
# No delete-and-resend, no legacy HTML edit
|
||||||
|
channel._app.bot.edit_message_text.assert_not_awaited()
|
||||||
|
assert "123" not in channel._stream_bufs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_stream_end_rich_capability_error_latches_and_falls_back() -> None:
|
||||||
|
"""On a pre-10.1 Bot API server the rich edit fails, the latch trips, and the
|
||||||
|
legacy HTML edit handles the final output."""
|
||||||
|
from telegram.error import BadRequest
|
||||||
|
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
_install_ready_app(channel)
|
||||||
|
channel._app.bot.do_api_request = AsyncMock(side_effect=BadRequest("Method not found"))
|
||||||
|
channel._app.bot.edit_message_text = AsyncMock()
|
||||||
|
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
||||||
|
|
||||||
|
await channel.send_delta("123", "", stream_end=True)
|
||||||
|
|
||||||
|
channel._app.bot.do_api_request.assert_awaited_once()
|
||||||
|
# Latch tripped: subsequent sends skip the rich path entirely
|
||||||
|
assert channel._rich_send_disabled is True
|
||||||
|
# Legacy HTML edit handled the final message
|
||||||
|
channel._app.bot.edit_message_text.assert_awaited_once()
|
||||||
|
assert "123" not in channel._stream_bufs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_stream_end_rich_disabled_uses_legacy_html() -> None:
|
||||||
|
"""rich_messages=False (the default) keeps the legacy HTML path untouched."""
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
_install_ready_app(channel)
|
||||||
|
channel._app.bot.do_api_request = AsyncMock()
|
||||||
|
channel._app.bot.edit_message_text = AsyncMock()
|
||||||
|
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
||||||
|
|
||||||
|
await channel.send_delta("123", "", stream_end=True)
|
||||||
|
|
||||||
|
channel._app.bot.do_api_request.assert_not_called()
|
||||||
|
channel._app.bot.edit_message_text.assert_awaited_once()
|
||||||
|
assert "123" not in channel._stream_bufs
|
||||||
|
|||||||
Reference in New Issue
Block a user