fix(telegram): convert markdown to HTML before splitting to avoid message length overflow

Cherry-picked from #3316 (himax12). When streaming completes in send_delta(),
the code was splitting raw markdown text by 4000, then converting to HTML.
The markdown-to-HTML conversion adds 10-33% characters, which could push
the result over Telegram's 4096 character limit.

The fix converts markdown to HTML first, then splits by 4096 (actual Telegram
limit), ensuring the edited message always fits.

Fixes #3315
This commit is contained in:
himax12
2026-04-20 16:58:46 +08:00
committed by Xubin Ren
parent 297b852f6e
commit fd8f08cc83
2 changed files with 69 additions and 14 deletions
+20 -9
View File
@@ -561,14 +561,22 @@ class TelegramChannel(BaseChannel):
await self._remove_reaction(chat_id, int(reply_to_message_id))
except ValueError:
pass
chunks = split_message(buf.text, TELEGRAM_MAX_MESSAGE_LEN)
primary_text = chunks[0] if chunks else buf.text
thread_kwargs = {}
if message_thread_id := meta.get("message_thread_id"):
thread_kwargs["message_thread_id"] = message_thread_id
html = _markdown_to_telegram_html(buf.text)
if len(html) <= 4096:
primary_html = html
extra_html_chunks = []
else:
html_chunks = split_message(html, 4096)
primary_html = html_chunks[0]
extra_html_chunks = html_chunks[1:]
try:
html = _markdown_to_telegram_html(primary_text)
await self._call_with_retry(
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
text=html, parse_mode="HTML",
text=primary_html, parse_mode="HTML",
)
except BadRequest as e:
# Only fall back to plain text on actual HTML parse/format errors.
@@ -583,7 +591,7 @@ class TelegramChannel(BaseChannel):
await self._call_with_retry(
self._app.bot.edit_message_text,
chat_id=int_chat_id, message_id=buf.message_id,
text=primary_text,
text=primary_html,
)
except Exception as e2:
if self._is_not_modified_error(e2):
@@ -591,10 +599,13 @@ class TelegramChannel(BaseChannel):
else:
logger.warning("Final stream edit failed: {}", e2)
raise # Let ChannelManager handle retry
# If final content exceeds Telegram limit, keep the first chunk in
# the edited stream message and send the rest as follow-up messages.
for extra_chunk in chunks[1:]:
await self._send_text(int_chat_id, extra_chunk)
for extra_html_chunk in extra_html_chunks:
await self._call_with_retry(
self._app.bot.send_message,
chat_id=int_chat_id, text=extra_html_chunk,
parse_mode="HTML",
**thread_kwargs,
)
self._stream_bufs.pop(chat_id, None)
return
+49 -5
View File
@@ -467,8 +467,45 @@ async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
@pytest.mark.asyncio
async def test_send_delta_stream_end_splits_oversized_reply() -> None:
"""Final streamed reply exceeding Telegram limit is split into chunks."""
from nanobot.channels.telegram import TELEGRAM_MAX_MESSAGE_LEN
"""Final streamed reply exceeding Telegram limit is split into chunks.
The fix converts markdown to HTML first, then splits by 4096 (actual Telegram
limit), ensuring the edited message always fits within Telegram's constraint.
Previously, the code split by 4000 (TELEGRAM_MAX_MESSAGE_LEN) before HTML
conversion, which could still overflow when HTML tags were added.
"""
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
oversized = "x" * (4000 + 500)
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
assert len(edit_text) <= 4096, f"edit_text length {len(edit_text)} exceeds Telegram's 4096 limit"
channel._app.bot.send_message.assert_called_once()
send_text = channel._app.bot.send_message.call_args.kwargs.get("text", "")
assert len(send_text) <= 4096
assert "123" not in channel._stream_bufs
async def test_send_delta_stream_end_html_expansion_does_not_overflow() -> None:
"""Markdown that expands when converted to HTML is still split correctly.
This is the actual bug from issue #3315: markdown like **bold** expands to
<b>bold</b>, adding ~33% characters. A 3600-char message with heavy markdown
could become 4800+ chars after HTML conversion, exceeding 4096 limit.
The fix converts to HTML first, THEN splits by 4096.
"""
from nanobot.channels.telegram import _markdown_to_telegram_html
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
@@ -478,14 +515,21 @@ async def test_send_delta_stream_end_splits_oversized_reply() -> None:
channel._app.bot.edit_message_text = AsyncMock()
channel._app.bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=99))
oversized = "x" * (TELEGRAM_MAX_MESSAGE_LEN + 500)
channel._stream_bufs["123"] = _StreamBuf(text=oversized, message_id=7, last_edit=0.0)
markdown_text = "**bold** " * 400 # 3600 chars raw, expands ~33% to 4800 HTML
raw_len = len(markdown_text)
html_len = len(_markdown_to_telegram_html(markdown_text))
assert html_len > 4096, f"Test precondition failed: HTML should exceed 4096 (was {html_len})"
channel._stream_bufs["123"] = _StreamBuf(text=markdown_text, message_id=7, last_edit=0.0)
await channel.send_delta("123", "", {"_stream_end": True})
channel._app.bot.edit_message_text.assert_called_once()
edit_text = channel._app.bot.edit_message_text.call_args.kwargs.get("text", "")
assert len(edit_text) <= TELEGRAM_MAX_MESSAGE_LEN
assert len(edit_text) <= 4096, (
f"HTML text length {len(edit_text)} exceeds Telegram's 4096 limit. "
f"Raw was {raw_len}, HTML was {html_len}."
)
channel._app.bot.send_message.assert_called_once()
assert "123" not in channel._stream_bufs