Skip to content

feat(telegram): two-phase guest reply — immediate stub + progressive streaming - #51886

Closed
elphamale wants to merge 1 commit into
NousResearch:mainfrom
elphamale:feat/guest-two-phase-reply
Closed

feat(telegram): two-phase guest reply — immediate stub + progressive streaming#51886
elphamale wants to merge 1 commit into
NousResearch:mainfrom
elphamale:feat/guest-two-phase-reply

Conversation

@elphamale

@elphamale elphamale commented Jun 24, 2026

Copy link
Copy Markdown

Summary

Handles Bot API 10.0 guest_message updates with a two-phase delivery loop:

  • Phase 1: On receiving a guest_message, immediately fire answerGuestQuery with a random thinking-verb stub (e.g. "⏳ Brewing..."). The call returns an inline_message_id identifying the placed bubble.
  • Phase 2 — streaming: send() returns inline_message_id to the stream consumer, which then drives progressive editMessageText(inline_message_id=…) edits for each chunk. All existing adaptive flood-control and think-block filtering apply transparently — no new rate-limit paths.
  • Phase 2 — fallback: If the stub call fails (_imi is None), send() buffers text and on_processing_complete delivers via answerGuestQuery (original approach, still-live guest_query_id). A second fallback covers the stream-consumer flood path: non-empty _buffered triggers a final editMessageText edit.

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_id normalization. The event source can pass chat_id as int, which silently missed every str-keyed guest dict and dropped the reply onto the wrong delivery path. Normalized to str before 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, so on_processing_complete can re-render through _strip_mdv2 and recover a finalize the stream consumer left truncated.

As a consequence, on_processing_complete now always performs a final, idempotent re-render. When the stream consumer already finalized with identical text, Telegram returns not modified; that case is downgraded from warning to debug rather 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:

  • Deferring the stub only earns its added state (a three-way False/None/str sentinel plus multiple fire points) once native media delivery needs the answerGuestQuery slot held open so a media tool can claim it. There is no such consumer here.
  • For text-only replies there is no behavioral payoff: with a send_typing hook the lazy stub fires at essentially the same pre-LLM moment as the immediate one.
  • Keeping it immediate makes this PR self-justifying and matches its title.

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 note

The 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 touching adapter.py at all: swap in verbs from a different language, replace them with emojis, or maintain a larger set tuned to their SOUL.md persona.

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.py and 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

New state added to __init__

self._guest_inline_message_ids: Dict[str, Optional[str]] = {}

Test plan

  • Mention the bot in a foreign chat (guest mode eligible)
  • Verify a random thinking-verb stub appears immediately (e.g. "⏳ Brewing...")
  • Verify each streaming chunk progressively edits the bubble in-place
  • Verify the final answer replaces the stub completely on completion
  • Verify a reply cut mid-token (- **2) renders clean after finalize
  • Automated coverage: tests/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

@elphamale
elphamale marked this pull request as draft June 24, 2026 13:08
@alt-glitch alt-glitch added type/feature New feature or request platform/telegram Telegram bot adapter comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jun 24, 2026
@elphamale
elphamale force-pushed the feat/guest-two-phase-reply branch from ae0ac63 to 8bca05f Compare June 24, 2026 15:39
@elphamale elphamale changed the title feat(telegram): two-phase guest reply — immediate stub + editMessageText feat(telegram): two-phase guest reply — immediate stub + progressive streaming Jun 24, 2026
@elphamale
elphamale marked this pull request as ready for review June 25, 2026 17:34
@elphamale
elphamale force-pushed the feat/guest-two-phase-reply branch 2 times, most recently from 24aa5d0 to e9ace50 Compare June 27, 2026 09:19
@elphamale

elphamale commented Jun 27, 2026

Copy link
Copy Markdown
Author

Status update — branch cleaned, feature essentially complete

The PR branch has been rebased onto a fresh commit directly on top of current main (no shared-history issue, no stale merge commits from dependency PRs). It is now MERGEABLE against main.


Guest mode delivery — feature complete

With 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:

  • Immediate feedback stubanswerGuestQuery fires as soon as the mention arrives, showing ⏳ Thinking… so the user never stares at a blank chat.
  • Buffer-only streaming — no intermediate raw-markdown edits during LLM generation; the full response is buffered first.
  • Typewriter animation — after buffering, editMessageText progressively reveals the response (~8 frames, 0.4 s each) before delivering the final MarkdownV2-formatted text. Looks polished, no ** flicker.
  • Slash command guard/commands in guest context are caught before the LLM runs and answered directly; prevents the double-stub artifact.
  • Media delivery — native photo/audio/video/document delivery via answerGuestQuery with LLM text as caption (covered by feat(telegram): guest-mode native media delivery + lazy two-phase stub #52639).
  • Multi-segment tool responses — preamble text from tool calls is dropped; only the final answer reaches the user.

Security implemented so far

  1. Slash command blocking — commands are rejected at the handler boundary before any LLM or PTB command-dispatcher path is entered. Prevents unintended tool execution via guest context.
  2. _should_process_message gate — existing authorization checks (user ID validation, allowlist/blocklist) run before the stub is fired, so unauthorized senders never consume an answerGuestQuery slot.
  3. Tool-progress stripsend() drops non-stream-consumer calls (tool progress blocks, 💻 terminal blocks) so internal execution details never leak into the guest reply.
  4. MEDIA: tag sanitization — any raw MEDIA: path residuals are stripped from the buffer before delivery, preventing workspace path disclosure.
  5. Guest-only chat isolation_guest_only_chats set prevents guest chat IDs from accidentally routing through the regular sendMessage path (which Telegram would reject and which could expose bot errors to regular chats).

Open questions / suggestions welcome

The 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:

  • Security of authorized interactions — testing so far has been limited to a single known user; the "not authorized" path for unknown users works, but there is currently no protection against malicious or dangerous queries coming from an authorized user or from an authorized chat where third-party members can craft the mention text. One approach under consideration is assigning a separate, curtailed bot profile to guest-mode interactions (reduced tool access, stricter system prompt, no sensitive-context injection) so that even a fully authorized guest query operates within tighter constraints than a private DM session. This needs more design thought — in particular how the profile interacts with the existing persona/profile system and what the right capability boundary is.
  • Per-chat rate limiting — a single group could fire many mentions rapidly; there's currently no cooldown or token-bucket guard at the guest handler level.
  • Query length cap — very long queries (e.g. pasted documents) increase LLM cost and latency with no user-visible limit. A character cap with a friendly error reply would help.
  • Per-user cooldown — distinct from per-chat; a single user in a large group could spam mentions.
  • Group/domain allowlisting — optionally restrict which chat_ids (or chat types: groups vs supergroups vs channels) are permitted to trigger guest replies.
  • Reply content guardrails — guest replies go to chats the bot doesn't moderate; there's no second-pass filter on the outgoing text (beyond what the LLM itself provides).
  • Audit trail — guest interactions are currently logged at logger.info level which is below the default verbosity threshold. Explicit warning-level audit events for every guest query (who asked, from which chat, response length) would help with abuse detection.
  • Stub timeout / cancellation — if the LLM takes too long and the answerGuestQuery stub expires (Telegram TTL), the inline message becomes un-editable but there's no graceful fallback or user notification.

Happy to discuss priorities or implementation approaches for any of the above.

elphamale pushed a commit to elphamale/hermes-agent that referenced this pull request Jun 28, 2026
…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>
@elphamale

Copy link
Copy Markdown
Author

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.

elphamale pushed a commit to elphamale/hermes-agent that referenced this pull request Jul 16, 2026
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.
elphamale pushed a commit to elphamale/hermes-agent that referenced this pull request Aug 13, 2026
…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>
elphamale pushed a commit to elphamale/hermes-agent that referenced this pull request Aug 13, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tools Tool registry, model_tools, toolsets P3 Low — cosmetic, nice to have platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants