Repository navigation
fix(channels): split Telegram messages by UTF-16 code units, not code points - #6067
Conversation
| display = display[: TELEGRAM_MAX_MESSAGE_LENGTH - 1] + "…" | ||
| if _utf16_length(display) > TELEGRAM_MAX_MESSAGE_LENGTH: | ||
| # Leave room for the ellipsis in the same unit the limit is measured in. | ||
| display = self._split_message(display, limit=TELEGRAM_MAX_MESSAGE_LENGTH - 1)[0] + "…" |
There was a problem hiding this comment.
[P2] Bound work when truncating cumulative stream previews. For every oversized streaming update, this calls _split_message on the entire cumulative reply and discards every chunk but the first. Together with the new full-text _utf16_length check, a preview that stopped changing after 4096 units still scans and materializes the complete, growing answer on each update, before the existing throttle/unchanged-text checks. The manager can publish an update every 60 new characters, so long replies incur quadratic total work and transient allocations on the async channel path. Stop counting after the first over-limit unit and construct only the first bounded chunk for the preview.
There was a problem hiding this comment.
Pushed b1e18b6. _send_stream_update now clips through a new _first_utf16_chunk(text, limit), which returns at the first character that does not fit and never walks past the limit, and the ellipsis is appended to that bounded prefix: no full-text measurement and no full-text split per update. _utf16_length is gone, the per-character rule lives in one _utf16_width helper shared by the splitter and the clip.
Output is unchanged: for 4095/4096/4097 BMP characters, 3000 emoji, an alternating emoji/ASCII reply and a 200k-character reply the clipped string is identical to what the previous code produced. Width evaluations on the 200k case drop from two full passes (~400k) to 8,193 (two scans bounded by the limit).
test_stream_update_clips_without_measuring_the_whole_reply counts _utf16_width calls through a wrapper and asserts the total stays within 2 * (limit + 1), so the bound is enforced by a test rather than only described.
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed at head d594d41: the core fix is correct. The UTF-16 accounting in _split_message is right (each char is atomic, so a non-BMP char can never straddle a boundary; empty input still yields [""]; pure-BMP text slices at exactly 4096 as before, which the preserved test_channels.py BMP assertions and the new regression test pin down). The stream-preview math checks out too: a <=4095-unit first chunk plus the 1-unit U+2026 ellipsis stays within 4096 units. The keyword-only limit default keeps the signature backward compatible for the three existing call sites, and the plain-text path sets no parse_mode, so there is no markdown-entity-spanning concern; the Rich Message 32768 code-point gate is reasonably left out of scope since a rich failure falls back to this now-correct plain path. Coverage is good, including the emoji-heavy end-to-end send() path. Two new minor findings inline (test-oracle independence, grapheme-boundary documentation); the per-update truncation cost is already tracked in the existing thread.
|
|
||
|
|
||
| def _utf16_length(text: str) -> int: | ||
| return sum(2 if ord(char) > 0xFFFF else 1 for char in text) |
There was a problem hiding this comment.
Every length assertion in this file is checked with a test-local _utf16_length that is a character-for-character copy of the production formula in telegram.py, so if the shared rule were wrong (e.g. a bad 0xFFFF threshold), all five tests would still pass. Consider asserting against an independent oracle instead — len(chunk.encode("utf-16-le")) // 2, which the PR description itself uses in its repro snippet — at least for the two all(_utf16_length(chunk) <= ...) budget assertions. Related nit: the production helper _utf16_length is never imported here, so it is only exercised indirectly through the send paths.
There was a problem hiding this comment.
Fixed in b1e18b6. The duplicated helper is gone: every budget assertion now measures with utf16_units(text) = len(text.encode("utf-16-le")) // 2, the codec oracle the PR description uses in its repro.
Both production helpers are also exercised directly (from app.channels import telegram, the module-attribute style the other channel tests use). test_utf16_width_agrees_with_the_codec pins a BMP letter, an accented Latin char, a CJK char, U+200D, an emoji and a reserved astral code point against the codec, so a wrong threshold in the shared rule now fails on its own instead of being ratified by a copy of itself. _first_utf16_chunk gets its own fits/does-not-fit cases, including the astral character that straddles the boundary.
| units = 0 | ||
| for char in text: | ||
| width = 2 if ord(char) > 0xFFFF else 1 | ||
| if units + width > limit: |
There was a problem hiding this comment.
Nit, not a blocker: the split boundary can still land inside a user-perceived character — between a base character and a combining mark, mid ZWJ emoji sequence (a family flag-style sequence of three astral code points costs 6 units), or between the two regional indicators that form a flag emoji. Each chunk stays within the UTF-16 budget so Telegram accepts it, and this is strictly no worse than the old code-point slicing, but a one-line docstring note (or a follow-up that prefers breaking before a ZWJ run or an odd trailing regional indicator) would set expectations for rendering artifacts at chunk seams. Fine to defer.
There was a problem hiding this comment.
Took the docstring option in b1e18b6: _split_message now says a split can fall inside a combining mark, a ZWJ emoji sequence or the second regional indicator of a flag, and that every chunk stays within budget, so a seam is a rendering artifact rather than a rejected send.
I left the break-before-ZWJ / odd-trailing-regional-indicator rule out of this PR: it moves chunk boundaries for text that already sends successfully, which reads like a separate change with its own tests.
Every cumulative stream update measured the full answer and then split the full answer to keep only its first chunk, so a long reply cost two complete passes per update on the async channel path. _first_utf16_chunk stops at the first character that does not fit, and the preview is built from that bounded prefix. The tests now measure chunk length with the UTF-16 codec instead of a copy of the production formula, cover the width helper and the bounded clip directly, and fail if the preview stops being bounded by the limit.
Why
Trigger: both outbound size checks in the Telegram adapter count Python code points (
len(text), andtext[:4096]-style slicing), but Telegram measures its 4096-character message limit in UTF-16 code units, where every non-BMP character — most emoji, CJK Extension B — costs 2.Telegram's own definitions use that unit:
textEntityin TDLib documentsoffset_/length_as "Offset/Length of the entity, in UTF-16 code units" (https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1text_entity.html), and python-telegram-bot documentsMessageEntity.offsetthe same way. The "characters" ofMessageLimit.MAX_TEXT_LENGTH = 4096are those units.Pain: a reply that is under 4096 code points but over 4096 UTF-16 units is handed to
sendMessageas one oversized chunk. The API rejects it with a deterministic400 Message is too long, the shared channel retry policy repeats that non-retryable error three times with backoff, and_send_with_retrythen re-raises — out ofsend(), so the chunks after the failing one are never sent either. The chat is left with the frozen streaming preview and no final answer. Emoji-heavy agent replies (the common case for the IM users of this project) hit this at only ~2049 emoji.Reproducible on
main(ed09efdb):The same mismatch affects the streamed preview:
display[: 4095] + "…"is a length check plus a truncation in code points, so the preview can be up to 8191 units and the firstsend_messageof a stream fails too.What changed
…now takes one of the 4096 units instead of adding a 4097th."a" * 4096 + "b" * 100still splits into["a" * 4096, "b" * 100], and empty text still yields[""].Deliberately out of scope: the Rich Message gate (
_can_send_rich,TELEGRAM_MAX_RICH_MESSAGE_LENGTH) also counts code points, but a failed rich send already falls back to the plain-text path above, so it degrades instead of losing the reply.Surface area
backend/app(outbound Telegram delivery inbackend/app/channels/telegram.py)Screenshots / Recording
N/A — no frontend change.
Bug fix verification
backend/tests/test_telegram_message_length.pymainand green on this branch? yesmain(ed09efdb):4 failed, 1 passed5 passedtest_split_message_still_slices_bmp_text_at_the_limit, which is the guard that BMP behaviour is unchanged.Validation
Run locally from
backend/on auv sync --lockedenvironment:PYTHONPATH=. uv run pytest tests/test_telegram_message_length.py→ 5 passed on the branch; 4 failed / 1 passed with the fix revertedPYTHONPATH=. uv run pytest tests/test_channels.py -k "telegram or Telegram"→ 67 passed (existing streaming/splitting tests unchanged, includingtest_stream_update_truncates_long_textandtest_final_message_splits_long_text)PYTHONPATH=. uv run pytest tests/test_telegram_channel_connections.py→ 5 passeduv run ruff check backend/app/channels/telegram.py backend/tests/test_telegram_message_length.pyanduv run ruff format --checkon the same two files → cleanAI assistance
Tool(s) used: Qoder CLI agent, running the
/contributorworkflow.How you used it: the agent read the adapter's size checks, drafted the fix and the regression tests, and ran them locally against
mainand against the branch; the diff is the agent's, the verification numbers above are from its local runs.