fix(telegram): split-and-deliver oversized edits instead of silent truncation (salvage of #19537)#23576
Merged
Conversation
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
1 |
First entries
gateway/platforms/telegram.py:1735: [unresolved-attribute] unresolved-attribute: Attribute `edit_message_text` is not defined on `None` in union `Unknown | None`
✅ Fixed issues: none
Unchanged: 4270 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
…uncation
When edit_message_text exceeded Telegram's 4096 UTF-16 codepoint limit,
the adapter caught the BadRequest, best-effort truncated the content
with '…', and returned SendResult(success=True). The stream consumer
believed the full edit was delivered and never recovered, silently
dropping everything past the truncation boundary on long replies.
Returning failure isn't safe either — the consumer's existing fallback
path can race against the next streaming tick, producing duplicate
sends or gaps. Instead, the adapter now SPLITS the oversized payload
across the existing message + new continuation messages, so the user
always gets the full reply in correct order.
How it works:
1. Pre-flight: if utf16_len(content) already exceeds MAX_MESSAGE_LENGTH,
call the new _edit_overflow_split helper directly — saves a doomed
round-trip + a Telegram error.
2. Reactive: if Telegram still returns 'message_too_long' after the
pre-flight (e.g. parse_mode formatting inflated the payload past
the limit via MarkdownV2 escapes), the same helper handles it.
3. _edit_overflow_split:
- Splits via truncate_message(len_fn=utf16_len) — same chunking the
non-streaming send() path uses; chunks get '(1/N)' suffixes.
- Edits the original message_id with chunk 1 (with parse_mode +
plain-fallback when finalize=True, mirroring the main edit path).
- Sends each remaining chunk via self._bot.send_message threaded as
a reply to the previous chunk so the user sees them as a
contiguous block. MarkdownV2-with-plain-fallback per chunk on
finalize.
- Returns SendResult(success=True, message_id=<last_chunk_id>,
continuation_message_ids=(<chunk2_id>, <chunk3_id>, ...)) so the
stream consumer can keep editing the most recent visible message
and the gateway has full visibility into every message id.
SendResult contract extension:
Added optional continuation_message_ids: tuple = () field. When
empty (the common case), behavior is unchanged. When populated, the
caller knows the adapter delivered across multiple platform messages.
Stream consumer integration:
GatewayStreamConsumer._send_or_edit advances _message_id to the
last-continuation id when it sees continuation_message_ids on a
successful edit result, resets _last_sent_text (the new visible
message holds only the final chunk's text), and fires
on_new_message so tool-progress bubbles linearize below the new
continuation rather than the original. Mirrors the openclaw #32535
inter-tool-leak guard.
Composes with what just landed:
- PR #23455 (UTF-16 length-aware splitting in stream consumer)
prevents most overflows upstream by measuring text in UTF-16
codeunits before deciding to split. This PR is the safety net at
the adapter boundary.
- PR #23512 (native draft streaming, default for DM Telegram) routes
DM streaming through send_draft, which has its own contract
unaffected by this change. So this fix narrows in scope to the
edit-based path: groups, supergroups, forum topics, every
non-Telegram platform, and the per-response fallback after a
draft failure.
Salvage notes:
- Cherry-picked from PR #19537 by @kjames2001. Original PR returned
failure on overflow; this evolves to split-and-deliver so users
never lose content and the consumer state stays consistent.
- Dropped an unrelated model-picker hunk (line 2114-2117) that
silently killed the 'X more available — type /model <name>
directly' hint by hardcoding total=len(models). Not in scope.
- Restored the timeout-aware retryable=not is_timeout signal in
send()'s fallthrough catch block.
Closes #19537.
Two new tests: - tests/gateway/test_telegram_format.py test_message_too_long_splits_into_continuations_not_silent_truncation: asserts edit_message returns success=True with continuation_message_ids populated and message_id pointing at the last continuation when content exceeds MAX_MESSAGE_LENGTH (#19537). Replaces the original fail-on-overflow assertion with the split-and-deliver contract. - tests/gateway/test_stream_consumer.py TestEditOverflowSplitAndDeliver.test_consumer_advances_message_id_on_split_and_deliver: asserts the consumer side updates _message_id to the latest continuation, clears _last_sent_text, and fires on_new_message when the adapter reports a split-and-deliver result.
acb8ed9 to
d5130b8
Compare
3 tasks
5 tasks
1 task
Merged
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Telegram's edit-based streaming path could silently drop everything past the 4096-character limit on long replies. The adapter caught the
BadRequest: message_too_longerror, best-effort truncated the content with…, and returnedSendResult(success=True)— so the stream consumer believed the full edit was delivered and never recovered.Instead of returning failure (which races against the next streaming tick and can produce duplicates), the adapter now splits and delivers: edit the existing message with chunk 1, send the rest as continuation messages threaded as replies. User always gets the full reply in correct order; consumer state stays consistent.
How the fix works
Stream consumer then advances
_message_id = B, resets_last_sent_text, and fireson_new_messageso subsequent tool-progress bubbles linearize below the new continuation. Subsequent edits target B, the most recent visible message.Three layers (overflow handling on the edit path)
gateway/stream_consumer.py(#23455)gateway/platforms/telegram.py(this PR)gateway/platforms/telegram.py(#23512)edit_messageentirely; this layer only matters for groups, topics, and the per-response fallback after a draft failure.Changes
gateway/platforms/base.py:SendResult.continuation_message_ids: tuple = (). Empty by default; populated by adapters that split-and-deliver. Backwards-compatible.gateway/platforms/telegram.py:edit_message(): pre-flight onutf16_lenoverflow; reactive on Telegram'smessage_too_longerror after parse_mode formatting; both route through_edit_overflow_split._edit_overflow_split(): new helper. Edits originalmessage_idwith chunk 1 (with parse_mode + plain-fallback whenfinalize=True), sends remaining chunks viasend_messagethreaded as replies, returnsSendResult(success=True, message_id=<last>, continuation_message_ids=(...)).gateway/stream_consumer.py:_send_or_editadvances_message_idto the last continuation, resets_last_sent_text, and fireson_new_messagewhencontinuation_message_idsis populated.getattr(result, "continuation_message_ids", ())keeps backwards compat withSimpleNamespacemocks in existing tests.tests/gateway/test_telegram_format.py: regression test asserts split-and-deliver contract.tests/gateway/test_stream_consumer.py:TestEditOverflowSplitAndDeliverasserts the consumer-side_message_idadvance +on_new_messagefiring.scripts/release.py: AUTHOR_MAP entries for kjames2001.Improvements during salvage
success=Falseso the consumer's fallback path could re-send. That contract races against the next streaming tick and can produce duplicates or gaps. Split-and-deliver guarantees every byte of the user's reply lands in correct order with no consumer-side recovery dance.gateway/platforms/telegram.py:2114-2117that silently killed the "X more available" hint by hardcodingtotal = len(models). Not mentioned in the PR title or body.send()'s fallthrough catch block.Validation
tests/gateway/test_telegram_format.py+test_stream_consumer*.py: 204/204 pass.Closes #19537.
Authored by @kjames2001; design evolved from fail-on-overflow to split-and-deliver during salvage.