feat(telegram): two-phase guest reply — immediate stub + progressive streaming - #51886
feat(telegram): two-phase guest reply — immediate stub + progressive streaming#51886elphamale wants to merge 1 commit into
Conversation
ae0ac63 to
8bca05f
Compare
24aa5d0 to
e9ace50
Compare
Status update — branch cleaned, feature essentially completeThe PR branch has been rebased onto a fresh commit directly on top of current Guest mode delivery — feature completeWith this PR merged (and #49801 as the only prerequisite), Telegram guest-mode delivery is functionally complete and on par with what "claude tag" style bots provide:
Security implemented so far
Open questions / suggestions welcomeThe above covers the basics, but guest mode is a higher-exposure surface than regular DMs (bot hasn't joined the group, can't moderate it). Some areas that likely need attention:
Happy to discuss priorities or implementation approaches for any of the above. |
…cade Root cause: when sendMessageDraft fails (typing action expired, unsupported client, transient error), the stream consumer fell back to editMessageText for every remaining token — 200+ rapid edits on a long response exhaust Telegram's per-chat quota, causing a 280s RetryAfter and 4+ minutes of silence. Fix: any sendMessageDraft failure that is not a long flood-control wait now returns success=True, message_id=None (frame silently dropped) instead of a failure result. The consumer stays in draft mode throughout and never switches to the edit path. The final response is delivered by the base gateway send() at finalize — one call, no quota-burning intermediate edits. This mirrors the pattern in PR NousResearch#51886 (guest chats): returning success=True for structurally-unsupported frames keeps the consumer on the draft path. Cases that now return success=True (suppress): - ok=False from the Bot API (typing action expired, client too old, etc.) - Any non-MarkdownV2 exception (network hiccup, DRAFT_ID_INVALID, etc.) - Short flood-control (≤5s) sleep+retry that still fails after sleep Only long flood-control waits (>5s) still return retryable=True so the consumer can log them without counting against _draft_failures. _MAX_DRAFT_FAILURES is kept as a safety net but is now effectively unreachable via the adapter (no failures propagate as success=False). Also revert the earlier _try_strip_cursor delete-fallback: a partial message with a stuck cursor is better UX than deleting it and leaving the user with nothing for the flood-control wait duration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement Bot API 10.0 guest bot support with a polished reply flow: Phase 1 — immediate stub: on receiving a guest_message update, fire an answerGuestQuery call immediately with a "⏳ Thinking..." article so the user sees feedback before the LLM starts. The inline_message_id returned is stored for Phase 2. Phase 2 — typewriter delivery: all streaming send() calls are buffered rather than forwarded to the stream consumer as edits. on_processing_ complete reveals the completed response progressively via editMessageText (~8 frames, 0.4 s each) then delivers the final MarkdownV2-formatted text — creating a smooth typewriter appearance with no raw-markdown flicker. Additional fixes: - Block slash commands in guest context before the stub fires; reply directly with "📋 Slash commands aren't supported..." to prevent the double-stub artifact (orphaned ⏳ +⚠️ Sorry from command handlers firing their own answerGuestQuery). - Prevent duplicate chunks in stream consumer when send() split content across multiple 4096-char chunks on the legacy path. - Add thinking_verbs.py with a curated progress-verb list for the stub. - Full e2e test coverage for the guest reply flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
e9ace50 to
14c991c
Compare
|
Closing in favor of a clean rebuild off current main, sourced from the same production-tested guest-mode code: #56476 (two-phase reply foundation) and #56477 (deliver_ media flow, stacked on #56476). This branch had drifted too far from main to cleanly rebase, and the reconstruction accidentally carried some content that shouldn't ship (a personalized thinking_verbs.py data file) — the new PRs are clean of that. Docs in #56297 have been updated to point at the new PRs. |
Guest mode's guest_message/answerGuestQuery flow (two-phase stub reply, deliver-token media button) ships in NousResearch#51886 and NousResearch#52639 but was never documented — the existing guest_mode section only covers the pre-Bot-API-10.0 @mention bypass. Adds the missing behavior, config (TELEGRAM_HOME_CHANNEL), and limitations (10-minute token TTL, no session stickiness) to the user-guide so operators know what to expect and how to enable file delivery. Requires NousResearch#51886 and NousResearch#52639 to be merged first — this documents behavior that doesn't exist on main yet.
…cade Root cause: when sendMessageDraft fails (typing action expired, unsupported client, transient error), the stream consumer fell back to editMessageText for every remaining token — 200+ rapid edits on a long response exhaust Telegram's per-chat quota, causing a 280s RetryAfter and 4+ minutes of silence. Fix: any sendMessageDraft failure that is not a long flood-control wait now returns success=True, message_id=None (frame silently dropped) instead of a failure result. The consumer stays in draft mode throughout and never switches to the edit path. The final response is delivered by the base gateway send() at finalize — one call, no quota-burning intermediate edits. This mirrors the pattern in PR NousResearch#51886 (guest chats): returning success=True for structurally-unsupported frames keeps the consumer on the draft path. Cases that now return success=True (suppress): - ok=False from the Bot API (typing action expired, client too old, etc.) - Any non-MarkdownV2 exception (network hiccup, DRAFT_ID_INVALID, etc.) - Short flood-control (≤5s) sleep+retry that still fails after sleep Only long flood-control waits (>5s) still return retryable=True so the consumer can log them without counting against _draft_failures. _MAX_DRAFT_FAILURES is kept as a safety net but is now effectively unreachable via the adapter (no failures propagate as success=False). Also revert the earlier _try_strip_cursor delete-fallback: a partial message with a stuck cursor is better UX than deleting it and leaving the user with nothing for the flood-control wait duration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous commit removed the exec-based thinking_verbs.py loader as dead code, since the file didn't exist anywhere in this PR or on main — but that was the wrong fix. The file existed in this same feature's earlier iteration (NousResearch#51886, "feat(telegram): add bundled thinking_verbs.py with minimal curated list") and was simply dropped when this branch was rebuilt fresh off main. The loader itself is intentional: it lets operators customize the stub's progress-verb list (translate it, curate it to match SOUL.md persona, swap in emojis) via ~/.hermes/local-patches/plugins/platforms/telegram/thinking_verbs.py without needing a code change, matching this codebase's established local-patches override pattern. Restored the file from NousResearch#51886 (Thinking/Pondering/Cooking/Toolcalling/ Yarning) and the original try/except exec loader. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
Summary
Handles Bot API 10.0
guest_messageupdates with a two-phase delivery loop:guest_message, immediately fireanswerGuestQuerywith a random thinking-verb stub (e.g. "⏳ Brewing..."). The call returns aninline_message_ididentifying the placed bubble.send()returnsinline_message_idto the stream consumer, which then drives progressiveeditMessageText(inline_message_id=…)edits for each chunk. All existing adaptive flood-control and think-block filtering apply transparently — no new rate-limit paths._imiisNone),send()buffers text andon_processing_completedelivers viaanswerGuestQuery(original approach, still-liveguest_query_id). A second fallback covers the stream-consumer flood path: non-empty_bufferedtriggers a finaleditMessageTextedit.Previously, guest-mode users saw nothing until the full LLM response was ready (10–30 s). This change gives immediate feedback and then updates the bubble in-place — no second bubble, no blank wait.
Streaming-finalize hardening
Beyond the core two-phase loop, four media-independent robustness fixes to the guest text path:
_strip_mdv2— unclosed bold. A stream cut mid-token (e.g.- **2) leaves the balanced**…**regex unmatched, so raw markdown surfaced in the bubble. A trailing pass removes any leftover**.send()—chat_idnormalization. The event source can passchat_idasint, which silently missed everystr-keyed guest dict and dropped the reply onto the wrong delivery path. Normalized tostrbefore lookups.send()—MEDIA:residual strip. Intermediate streaming chunks (MEDIA:,MEDIA:/path) slip past the stream consumer's extension-anchored cleanup and would buffer as visible text.edit_message()— buffer kept current. Each streaming edit updates_guest_reply_buffer, soon_processing_completecan re-render through_strip_mdv2and recover a finalize the stream consumer left truncated.As a consequence,
on_processing_completenow always performs a final, idempotent re-render. When the stream consumer already finalized with identical text, Telegram returnsnot modified; that case is downgraded fromwarningtodebugrather than logged as a failure.Design note — why the stub stays immediate, not lazy
A natural follow-up question is whether the stub should fire lazily (on the first
send()/send_typing) instead of immediately at handler entry. It is deliberately not lazy in this PR:False/None/strsentinel plus multiple fire points) once native media delivery needs theanswerGuestQueryslot held open so a media tool can claim it. There is no such consumer here.send_typinghook the lazy stub fires at essentially the same pre-LLM moment as the immediate one.The lazy-stub refactor — and the gateway-rejection authz fallback it enables (an empty buffer with a placed stub should resolve to a terse refusal rather than a lingering "⏳") — is therefore scoped to the follow-up guest-media PR that stacks on this one.
thinking_verbs.py— design noteThe stub verb is drawn from
plugins/platforms/telegram/thinking_verbs.py, a deliberately separate file. The intent is that users can customize the list without touchingadapter.pyat all: swap in verbs from a different language, replace them with emojis, or maintain a larger set tuned to theirSOUL.mdpersona.The bundled list ships five verbs (
Thinking,Pondering,Cooking,Toolcalling,Yarning) as a neutral starting point. Users can drop a replacement file into~/.hermes/local-patches/plugins/platforms/telegram/thinking_verbs.pyand it will override the bundled list after every gateway restart.On verb provenance: During development I considered seeding the bundled list from the Claude Code spinner verb set. That list was not used — its provenance (extraction from a binary; no clear redistribution licence) creates ambiguity that is not worth importing into hermes-agent. The five bundled verbs are original.
Prerequisites
guest_messagetoallowed_updates+ reconnect polling + base buffer/flush logic inadapter.py. The two-phase stub builds directly on top of that.New state added to
__init__Test plan
- **2) renders clean after finalizetests/gateway/test_telegram_guest_reply.py(11 tests — streaming path, buffer fallback, state cleanup, and the four streaming-finalize fixes above)🤖 Generated with Claude Code