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