Skip to content

feat(telegram): implement Bot API 10.0 guest mode (@mention from non-member chats) - #43049

Closed
elphamale wants to merge 5 commits into
NousResearch:mainfrom
elphamale:feat/telegram-guest-mode
Closed

feat(telegram): implement Bot API 10.0 guest mode (@mention from non-member chats)#43049
elphamale wants to merge 5 commits into
NousResearch:mainfrom
elphamale:feat/telegram-guest-mode

Conversation

@elphamale

@elphamale elphamale commented Jun 9, 2026

Copy link
Copy Markdown

Relates to #21587.

Summary

Telegram Bot API 10.0 (May 2026) introduced Guest Bots — bots that can receive @mention updates from groups they haven't joined and reply via answerGuestQuery. PTB 22.6 targets Bot API 9.5 and has no native support for the new guest_message update type or answerGuestQuery method. This PR adds full guest mode support as a backward-compatible layer on top of the existing TelegramAdapter.

The feature is opt-in via guest_mode: true in the telegram: config section (mirrors the BotFather setting).

What changed

  • Update ingestion — register a TypeHandler(Update, _handle_guest_message_update) in handler group 1 to intercept guest_message updates, which PTB forwards via update.api_kwargs since they're outside its typed schema. Add "guest_message" to allowed_updates in all three connection paths (polling, webhook, reconnect polling).

  • Routing_handle_guest_message_update parses the raw payload via Message.de_json(), stores the guest_query_id in _pending_guest_queries[chat_id], marks the chat in _guest_only_chats, and routes the message through the existing text-processing pipeline unchanged.

  • Send suppression — for the duration of processing, send_draft() and send_or_update_status() return silent success for guest chats (the bot is not a member so there is no message to edit or animate). send() buffers content rather than calling sendMessage, keeping only the most recent write so earlier thinking/tool-progress text is overwritten by the final answer.

  • Flush on completionon_processing_complete() calls answerGuestQuery with an InlineQueryResultArticle-shaped payload containing the buffered final answer, then cleans up all per-chat guest state. Deferring to completion ensures the user sees the full, coherent response. The Telegram API documents no timeout for guest_query_id (unlike the 10-minute window on answerInlineQuery), so this is safe even for long-running tool-use responses.

Compatibility

  • No changes to any existing code path — all new logic is gated on guest_mode config or the presence of a guest_query_id.
  • Falls back gracefully if TypeHandler import fails (warning logged, feature disabled for that session).
  • Works alongside the existing TELEGRAM_ALLOWED_USERS / TELEGRAM_GROUP_ALLOWED_CHATS authorization gates.

Test plan

  • 12 unit tests in tests/gateway/test_telegram_guest_mode.py — all passing
  • Enable Guest Mode in BotFather (/setguestmode)
  • Set guest_mode: true in telegram: config section
  • @mention the bot from a group it has not joined — confirm it replies with the final answer
  • @mention with a prompt that triggers tool use (multi-step, >10s) — confirm the reply still arrives after processing completes
  • @mention with a prompt that produces status messages — confirm no thinking/progress text leaks into the reply
  • Normal messages in a chat the bot has joined — confirm no regression

🤖 Generated with Claude Code

@kyssta-exe

Copy link
Copy Markdown
Contributor

Thanks for the PR! The guest mode implementation looks well-thought-out, especially the buffering approach and the cleanup in . A few observations:

Missing automated tests — The test plan is entirely manual, but this feature modifies several core gateway paths (, , , ) and parses raw API payloads via . Even a few unit tests would add confidence:

  • A test that correctly extracts from and routes the message through .
  • A test that buffers content for guest chats instead of calling the API.
  • A test that flushes via and cleans up all three state dicts (, , ).
  • A test for the denial path (line ~143) — it cleans up but not , which could accumulate stale entries over time if the bot receives guest messages it decides not to process.

The stale entry on the denial path (line 143) is minor since rejection should be rare, but it's worth noting for completeness.

Overall the approach is solid — deferring to processing completion ensures the user sees the full answer. Nice work!

@kyssta-exe

Copy link
Copy Markdown
Contributor

Thanks for the PR! The guest mode implementation looks well-thought-out, especially the buffering approach and the cleanup in on_processing_complete. A few observations:

Missing automated tests - The test plan is entirely manual, but this feature modifies several core gateway paths (send(), send_draft(), send_or_update_status(), on_processing_complete()) and parses raw API payloads via Message.de_json(). Even a few unit tests would add confidence:

  • A test that _handle_guest_message_update correctly extracts guest_query_id from update.api_kwargs and routes the message through _enqueue_text_event.
  • A test that send() buffers content for guest chats instead of calling the Telegram API.
  • A test that on_processing_complete flushes via answerGuestQuery and cleans up all three state dicts (_pending_guest_queries, _guest_only_chats, _guest_reply_buffer).
  • A test for the _should_process_message denial path (around line 143 of the handler) - it cleans up _pending_guest_queries but not _guest_only_chats, which could accumulate stale entries over time if the bot receives guest messages it decides not to process.

The stale _guest_only_chats entry on the denial path is minor since _should_process_message rejection should be rare, but it's worth noting for completeness.

Overall the approach is solid - deferring answerGuestQuery to processing completion ensures the user sees the full answer. Nice work!

@elphamale

Copy link
Copy Markdown
Author

Addressed all four review points:

Bug fix — on the denial path (_should_process_message returns False), _guest_only_chats was not being cleared alongside _pending_guest_queries. Fixed in the second commit — both dicts are now discarded on rejection so future sends to that chat aren't silently suppressed after the bot joins.

Tests — added tests/gateway/test_telegram_guest_mode.py with 12 unit tests (all passing):

  • guest_query_id extraction and state setup in _handle_guest_message_update
  • send() buffers content instead of calling sendMessage for guest chats
  • Last-write-wins behaviour (earlier thinking text overwritten by final answer)
  • send_or_update_status() suppressed for guest chats
  • on_processing_complete() flushes via answerGuestQuery and cleans up all three state dicts
  • Denial path: both _pending_guest_queries and _guest_only_chats cleared
  • answerGuestQuery API failure is caught and logged, state still cleaned up

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter labels Jun 9, 2026
@abner-augusto

Copy link
Copy Markdown

Reproduced locally against Hermes Telegram gateway.

Observed behavior:

  • Guest mode works in groups.
  • Guest mode does not work in private chats.

So the implementation now appears group-only in practice, despite the feature writeup implying broader private-chat support as well. If that distinction is intentional in Telegram Bot API 10, the PR should probably be narrowed; if not, this looks like an API/docs mismatch or an unsupported edge case worth tracking.

I also validated the local gateway path by wiring guest_message handling to answerGuestQuery and confirmed the group path works end-to-end.

@abner-augusto

Copy link
Copy Markdown

Correction to my earlier comment on the PR: for private chats, Hermes does not appear to render a visible reply in the intermediary 1:1 chat. Instead, it sends the response directly to the other user's inbox as a separate message, which is why it looked like Guest Mode failed from the original chat.

So the behavior split is:

  • groups: works as expected
  • private chats: reply is routed directly to the other user's DM/inbox, not the visible intermediary thread

This is worth capturing explicitly because it changes the bug from "Guest Mode absent in private chats" to "Guest Mode routes private-chat responses into the wrong conversational surface."

@elphamale

Copy link
Copy Markdown
Author

Thanks for testing and for the correction, @abner-augusto — that distinction is useful.

The Telegram docs say guest mode works in "any group or private chat" and that the bot can "Respond Directly with a message back to the chat where the interaction occurred." The private-chat routing you observed (reply landing in the bot's DM with the user, not the P2P thread) is Telegram's own behavior, not a bug in this implementation. Our code calls answerGuestQuery with the guest_query_id exactly as the API specifies — what Telegram does with that reply in a P2P chat context is outside our control. Bots aren't participants in private chats between two regular users, so Telegram surfaces the response in the next closest place: the bot's DM thread with the user who sent the @mention.

This is arguably reasonable UX — the user does get a reply — but the conversational surface mismatch is worth noting. I'll add a code comment documenting this behavior so future maintainers don't mistake it for a routing bug.

@abner-augusto

Copy link
Copy Markdown

Thanks — agreed on the private-chat surface distinction.

I updated my findings in #46196 to narrow the remaining issue. I no longer think the core problem is that the guest_query_id is missing or that Telegram is routing the private-chat response to the “wrong” place. The logs show Telegram guest_message accepted with a real guest_query_id, and the gateway source still has that ID by the time it starts processing.

The remaining bug appears to be delivery semantics: answerGuestQuery is a one-shot response path, but the gateway still allows the normal streaming/editing path for sources that have guest_query_id. That means the response can go through expect_edits / interim output behavior even though guest replies need to be consolidated and sent once.

So I think the fix belongs mostly in the gateway runner, not in Telegram payload parsing:

  • if source.guest_query_id is present, disable streaming
  • suppress interim/status output for that source
  • let the final consolidated response flush through answerGuestQuery

I tracked the narrowed repro/root cause here: #46196

I’m going to take a pass at a PR for that unless you already have it covered.

@elphamale

Copy link
Copy Markdown
Author

Thanks for the narrowed repro in #46196, @abner-augusto — the expect_edits / streaming-during-processing concern is a real one worth calling out.

That said, this PR already covers it, though from the platform layer rather than the runner. Here's how:

  • send() (lines ~1904–1906): if the chat has a pending guest_query_id or is in _guest_only_chats, the content is written to _guest_reply_buffer and the method returns immediately — no Telegram API call is made. Last-write-wins, so earlier thinking/status text gets overwritten by the final answer.
  • send_or_update_status() (line ~2170): silently suppressed for guest chats — no interim status message is ever sent.
  • send_draft() (line ~2557): also suppressed — the draft-animation path never fires for guest chats.
  • on_processing_complete(): the single answerGuestQuery call fires here, using whatever is in _guest_reply_buffer at that point (the complete final answer).

The net effect is the same as gating at the runner level: no interim/streaming output reaches Telegram during processing, and the response is sent exactly once when processing is finished.

If you end up going with a runner-level approach in a separate PR, the two implementations would be functionally equivalent — just at different layers. Either way, happy to coordinate so we don't duplicate effort.

@elphamale
elphamale force-pushed the feat/telegram-guest-mode branch from 588d88b to 1445bf2 Compare June 17, 2026 08:20
elphamale and others added 5 commits June 19, 2026 16:19
…member chats)

PTB 22.6 doesn't natively support the `guest_message` update type or
`answerGuestQuery` method introduced in Telegram Bot API 10.0.  This
patch adds full guest bot support as a backward-compatible layer on top
of the existing adapter.

Changes:
- Register a `TypeHandler(Update, ...)` in group=1 to intercept
  `guest_message` updates, which PTB passes through via
  `update.api_kwargs` since they're unknown to its typed layer.
- Add `"guest_message"` to `allowed_updates` in all three start-polling
  / webhook paths so Telegram actually delivers the updates.
- Parse the raw `guest_message` payload via `Message.de_json()` and
  route it through the existing text-processing pipeline.
- Store the `guest_query_id` per chat in `_pending_guest_queries` and
  mark the chat in `_guest_only_chats` for the duration of the request.
- In `send()`, `send_draft()`, and `send_or_update_status()`, silently
  buffer/suppress all outgoing content for guest chats rather than
  attempting `sendMessage` (which would fail — the bot is not a member).
  `send()` keeps only the most recent write (final answer overwrites
  any earlier thinking/tool-progress text).
- In `on_processing_complete()`, flush the buffered reply via
  `answerGuestQuery` using an `InlineQueryResultArticle`-shaped payload,
  then clean up all per-chat guest state.  Flushing at completion means
  the user receives the full, coherent final answer rather than a
  mid-processing fragment.  The Telegram API imposes no documented
  timeout on `guest_query_id` (unlike `answerInlineQuery`), so deferral
  until completion is safe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… tests

Fix a stale-state bug: when _should_process_message() rejects a guest
message, _pending_guest_queries was popped but _guest_only_chats was
not discarded.  Any subsequent send() to that chat (e.g. after the bot
joins the group later) would be silently suppressed forever.

Add 12 unit tests covering the four scenarios raised in PR review:
- guest_query_id extraction and state setup in _handle_guest_message_update
- send() buffering instead of sendMessage for guest chats
- on_processing_complete answerGuestQuery flush and full state cleanup
- denial path: both _pending_guest_queries and _guest_only_chats cleared

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In P2P private chats, Telegram cannot post a bot message into the
conversation, so it surfaces the answerGuestQuery reply in the bot's DM
thread with the mentioning user instead of the original chat.  This is
Telegram API behaviour; our call is identical for groups and private
chats.  Add a code comment so future maintainers don't mistake the
private-chat routing for a bug in this implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
send_voice / send_video / send_image_file / send_document: add the same
guest-chat guard already present on send() / send_draft() /
send_or_update_status(). Without it, the adapter attempts bot.send_voice()
on a chat the bot is not a member of, getting a Telegram API rejection.

Also fix edit_message: return early on the "__no_edit__" stream_consumer
sentinel instead of crashing int("__no_edit__").

Add _resolve_workspace_path() to translate /workspace/<file> Docker container
paths to their host equivalents before os.path.exists() is called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously send_audio/send_image/send_document/send_video returned
success=True (silent no-op) when the chat was a guest chat. The agent
saw "success", reported "sent silently", and the file never arrived in
the group. When the user complained the agent would explain the
limitation, but the initial false-success response was confusing.

Now these methods return success=False with an explicit error message
instructing the agent to send to the user's private DMs and notify
them in the group chat. The agent can then proactively communicate
the guest-mode media limitation before the user has to ask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@elphamale
elphamale force-pushed the feat/telegram-guest-mode branch from 83569bc to d3b8e51 Compare June 19, 2026 13:20
@elphamale

Copy link
Copy Markdown
Author

Force-pushed to clean up branch history — previous push accidentally included commits from other in-progress PRs (Spotify auth, Docker path translation). Same content and intent, clean diff now: only gateway/platforms/telegram.py and the test file.

elphamale pushed a commit to elphamale/hermes-agent that referenced this pull request Jun 19, 2026
…sor, fix accumulation

In guest-chat mode (Bot API 10.0 answerGuestQuery), all send() calls during
a turn are buffered and flushed as a single reply.  Three bugs caused the
delivered reply to contain garbled content:

1. Tool-use progress blocks (💻 terminal …) reached send() from
   send_progress_messages() and were stored in the guest reply buffer,
   polluting the final answer with intermediate streaming state.

   Fix: tag every adapter.send() / edit_message() call inside
   send_progress_messages() with metadata["tool_progress"] = True
   (run.py, _progress_metadata).  telegram.py send() drops these
   immediately for guest chats before touching the buffer.

2. The streaming cursor " ▉" was stored verbatim in the buffer from the
   first streaming frame, embedding it mid-word in the final reply
   (e.g. "Відмін ▉но").

   Fix: strip the trailing " ▉" / "▉" from content before buffering.

3. The stream consumer uses the __no_edit__ path for guest chats: it sends
   a short first frame, then _send_fallback_final delivers only the
   continuation (text after the first frame).  Simple overwrite caused the
   buffer to hold just the continuation, losing the opening words.

   Fix: replace only when the new content starts with what is already
   buffered (cumulative streaming update); otherwise append (continuation
   or overflow chunk).

Follow-up to NousResearch#43049 (guest mode / @mention support).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
elphamale pushed a commit to elphamale/hermes-agent that referenced this pull request Jun 20, 2026
Upstream commit 5600105 moved gateway/platforms/telegram.py →
plugins/platforms/telegram/adapter.py.  This applies the equivalent
of PRs NousResearch#43049 / NousResearch#49116 / NousResearch#49186 to the new path:

- NousResearch#43049 (guest mode): _pending_guest_queries / _guest_only_chats /
  _guest_reply_buffer state; send() buffer block; TypeHandler
  registration; _handle_guest_message_update(); on_processing_complete()
  answerGuestQuery flush; media-method guards for send_voice /
  send_image_file / send_document / send_video / send_image.

- NousResearch#49116 (sender_chat): _build_message_event() uses sender_chat.id /
  .title when from_user is None (channel-as-user posts in groups).

- NousResearch#49186 (buffer quality): tool-progress drops (expect_edits/notify
  flags), cursor-strip before buffering, startswith-accumulation so
  cumulative streaming frames replace rather than double-append.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@elphamale

Copy link
Copy Markdown
Author

Closing as superseded by the clean guest-mode rebuild: #56476 (two-phase reply, Bot API 10.0 text-only) and #56477 (deliver_<token> media flow).

This PR's foundational design — _pending_guest_queries, _guest_only_chats, _guest_reply_buffer, _handle_guest_message_update, answerGuestQuery flush on completion, guest-mode media-blocked handling, guest_message in allowed_updates — all exists in #56476/#56477, more developed (stub architecture, _is_guest_chat() helper, update-id dedup). Verified by diffing this PR's changes against the current rebuild.

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 P3 Low — cosmetic, nice to have platform/telegram Telegram bot adapter type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants