Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2975,11 +2975,18 @@ async def edit_message(
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:
Expand Down Expand Up @@ -3028,9 +3035,18 @@ async def edit_message(
"[%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.
Expand Down Expand Up @@ -3093,6 +3109,21 @@ async def edit_message(
)
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,
Expand Down
70 changes: 62 additions & 8 deletions tests/gateway/test_telegram_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,12 +908,12 @@ async def test_final_edit_uses_markdownv2_with_plain_fallback(self):

@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()
Expand All @@ -926,7 +926,7 @@ async def _fake_send(**kwargs):

# 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
Expand Down Expand Up @@ -961,7 +961,7 @@ async def _fake_send(**kwargs):
"-100123",
"456",
"x" * 6000,
finalize=False,
finalize=True,
metadata={"thread_id": "17585"},
)

Expand All @@ -970,6 +970,60 @@ async def _fake_send(**kwargs):
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
# =========================================================================
Expand Down
Loading