From 23b366a1014df72a7605f392a0d0c8b00937127d Mon Sep 17 00:00:00 2001 From: "Grapette.L" <90967079+Lesereingrape@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:31:40 +0800 Subject: [PATCH] fix(telegram): measure the rich-message cap in UTF-16 code units (#6174) `_can_send_rich` checked `len(text)`, a code-point count, against the 32768 rich-message cap, while Telegram measures that cap in UTF-16 code units like every other limit it enforces. An emoji-heavy reply that fits by code points is still over the cap by code units, so the rich send was attempted, rejected by the API, and the reply degraded to chunked plain text. The check now reuses `_first_utf16_chunk`, the bounded scan the plain-text path already uses, so eligibility costs the limit rather than the whole reply. --- backend/app/channels/telegram.py | 8 +++++- backend/tests/test_telegram_message_length.py | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py index 6b6b52d9ce..04e1171913 100644 --- a/backend/app/channels/telegram.py +++ b/backend/app/channels/telegram.py @@ -376,7 +376,13 @@ class TelegramChannel(Channel): # actually contains a rich construct. Structured command/error replies # are plain text with none, so they stay plain and their newlines and # tokens are not collapsed into one line. - return bool(self.config.get("rich_messages")) and 0 < len(text) <= TELEGRAM_MAX_RICH_MESSAGE_LENGTH and _has_rich_constructs(text) + if not self.config.get("rich_messages"): + return False + # Telegram measures this cap in UTF-16 code units like its other limits, while + # `len()` counts code points, so reuse the bounded scan the plain-text path + # uses: an emoji-heavy reply that fits by code points is still rejected. + _, over_limit = _first_utf16_chunk(text, TELEGRAM_MAX_RICH_MESSAGE_LENGTH) + return not over_limit and _has_rich_constructs(text) async def _edit_rich_message(self, chat_id: int, message_id: int, text: str) -> bool: """Replace a streamed preview with a persistent Telegram Rich Message.""" diff --git a/backend/tests/test_telegram_message_length.py b/backend/tests/test_telegram_message_length.py index b1baa86d6a..b007454b44 100644 --- a/backend/tests/test_telegram_message_length.py +++ b/backend/tests/test_telegram_message_length.py @@ -140,3 +140,28 @@ def test_stream_update_clips_without_measuring_the_whole_reply(monkeypatch): assert display.endswith("…") # Bounded by the limit twice (measure against it, then clip one unit shorter), not by 200,000 characters. assert measured <= 2 * (TELEGRAM_MAX_MESSAGE_LENGTH + 1) + + +def _rich_channel(): + ch, bot = _channel_with_bot() + ch.config["rich_messages"] = True + return ch, bot + + +def test_rich_send_is_refused_when_the_reply_crosses_the_utf16_rich_limit(): + """The rich-message cap is measured in code units too, so an emoji-heavy reply cannot pass a code-point check.""" + ch, _ = _rich_channel() + # Half the limit in emoji plus a bold construct: under the cap by code points, over it by code units. + text = EMOJI * (telegram.TELEGRAM_MAX_RICH_MESSAGE_LENGTH // 2) + "**x**" + + assert len(text) < telegram.TELEGRAM_MAX_RICH_MESSAGE_LENGTH + assert utf16_units(text) > telegram.TELEGRAM_MAX_RICH_MESSAGE_LENGTH + assert ch._can_send_rich(text) is False + + +def test_rich_send_is_still_offered_inside_the_utf16_rich_limit(): + ch, _ = _rich_channel() + text = EMOJI * 10 + "**x**" + + assert utf16_units(text) == 25 + assert ch._can_send_rich(text) is True