fix(telegram): clip mid-stream overflow instead of splitting (#48648)

This commit is contained in:
Tranquil-Flow 2026-06-21 22:10:36 +02:00 committed by Teknium
parent 47fccc0735
commit 73a20a6ad6
2 changed files with 99 additions and 14 deletions

View file

@ -2975,11 +2975,18 @@ class TelegramAdapter(BasePlatformAdapter):
return rich_result
# Pre-flight: if content already exceeds the limit, split-and-deliver
# without round-tripping a doomed edit.
# without round-tripping a doomed edit. During streaming
# (finalize=False) we truncate instead of splitting — splitting creates
# continuation messages whose IDs become the new edit target, and on
# the next token chunk the full accumulated text is re-edited into the
# continuation, triggering another split → infinite duplication loop
# (#48648). The full content is delivered when finalize=True.
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
return await self._edit_overflow_split(
chat_id, message_id, content, finalize=finalize, metadata=metadata,
)
if finalize:
return await self._edit_overflow_split(
chat_id, message_id, content, finalize=finalize, metadata=metadata,
)
content = self._truncate_stream_overflow_preview(content)
try:
if not finalize:
@ -3028,9 +3035,18 @@ class TelegramAdapter(BasePlatformAdapter):
"[%s] edit_message overflow (%d UTF-16 > %d), splitting",
self.name, utf16_len(content), self.MAX_MESSAGE_LENGTH,
)
return await self._edit_overflow_split(
chat_id, message_id, content, finalize=finalize, metadata=metadata,
if finalize:
return await self._edit_overflow_split(
chat_id, message_id, content, finalize=finalize, metadata=metadata,
)
# Mid-stream: truncate and retry instead of splitting (#48648).
truncated = self._truncate_stream_overflow_preview(content)
await self._bot.edit_message_text(
chat_id=int(chat_id),
message_id=int(message_id),
text=truncated,
)
return SendResult(success=True, message_id=message_id)
# Flood control / RetryAfter — short waits are retried inline,
# long waits return a failure immediately so streaming can fall back
# to a normal final send instead of leaving a truncated partial.
@ -3093,6 +3109,21 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=False, error=str(e))
def _truncate_stream_overflow_preview(self, content: str) -> str:
"""Return a one-message preview for oversized streaming edits.
Streaming edits must keep targeting the original message. Splitting a
mid-stream preview creates continuation messages and moves the active
message id, so the next accumulated-token edit repeats the overflow
cycle (#48648). Final edits still use ``_edit_overflow_split`` to
deliver the complete response.
"""
return self.truncate_message(
content,
self.MAX_MESSAGE_LENGTH,
len_fn=utf16_len,
)[0]
async def _edit_overflow_split(
self,
chat_id: str,

View file

@ -908,12 +908,12 @@ class TestEditMessageStreamingSafety:
@pytest.mark.asyncio
async def test_message_too_long_splits_into_continuations_not_silent_truncation(self):
"""When edit_message_text exceeds Telegram's 4096 UTF-16 limit, the
adapter must split the content across the existing message + new
continuation messages so the user gets the full reply. Previously
the adapter best-effort truncated the content with '' and returned
success=True, dropping everything past the truncation boundary
(#19537)."""
"""When edit_message_text exceeds Telegram's 4096 UTF-16 limit on
finalize, the adapter must split the content across the existing
message + new continuation messages so the user gets the full reply.
Previously the adapter best-effort truncated the content with '' and
returned success=True, dropping everything past the truncation
boundary (#19537)."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
adapter._bot = MagicMock()
adapter._bot.edit_message_text = AsyncMock()
@ -926,7 +926,7 @@ class TestEditMessageStreamingSafety:
# 6000-char content well over the 4096 UTF-16 limit.
oversized = "x" * 6000
result = await adapter.edit_message("123", "456", oversized, finalize=False)
result = await adapter.edit_message("123", "456", oversized, finalize=True)
# Adapter reports success with continuations populated.
assert result.success is True
@ -961,7 +961,7 @@ class TestEditMessageStreamingSafety:
"-100123",
"456",
"x" * 6000,
finalize=False,
finalize=True,
metadata={"thread_id": "17585"},
)
@ -970,6 +970,60 @@ class TestEditMessageStreamingSafety:
assert all(kwargs.get("message_thread_id") == 17585 for kwargs in sent_kwargs)
assert sent_kwargs[0]["reply_to_message_id"] == 456
@pytest.mark.asyncio
async def test_mid_stream_overflow_truncates_instead_of_splitting(self):
"""During streaming (finalize=False), oversized content must be
truncated to fit one message rather than split into continuations.
Splitting mid-stream creates new message IDs that the stream consumer
then edits with the full accumulated text, causing an infinite
duplication loop (#48648)."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
adapter._bot = MagicMock()
adapter._bot.edit_message_text = AsyncMock()
_next_id = [1000]
async def _fake_send(**kwargs):
_next_id[0] += 1
return SimpleNamespace(message_id=_next_id[0])
adapter._bot.send_message = AsyncMock(side_effect=_fake_send)
oversized = "x" * 6000
result = await adapter.edit_message("123", "456", oversized, finalize=False)
# Must NOT create continuation messages during streaming.
assert result.success is True
assert adapter._bot.send_message.await_count == 0, (
"mid-stream overflow must not send continuation messages"
)
# message_id stays the original — no shift to a new ID.
assert result.message_id == "456"
# The edit must contain truncated content (≤ MAX_MESSAGE_LENGTH).
edited_text = adapter._bot.edit_message_text.call_args.kwargs["text"]
assert len(edited_text) <= adapter.MAX_MESSAGE_LENGTH
@pytest.mark.asyncio
async def test_mid_stream_reactive_overflow_retries_truncated_edit(self):
"""If Telegram rejects a streaming edit as too long, retry with a
one-message preview instead of splitting into continuations."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
adapter._bot = MagicMock()
adapter._bot.edit_message_text = AsyncMock(
side_effect=[Exception("Bad Request: message is too long"), None]
)
adapter._bot.send_message = AsyncMock()
content = "x" * adapter.MAX_MESSAGE_LENGTH
result = await adapter.edit_message("123", "456", content, finalize=False)
assert result.success is True
assert result.message_id == "456"
adapter._bot.send_message.assert_not_called()
assert adapter._bot.edit_message_text.await_count == 2
retry_text = adapter._bot.edit_message_text.await_args_list[1].kwargs["text"]
assert len(retry_text) <= adapter.MAX_MESSAGE_LENGTH
# =========================================================================
# Telegram guest mention gating
# =========================================================================