Skip to content

feat(telegram): guest-mode native media delivery + lazy two-phase stub - #52639

Closed
elphamale wants to merge 14 commits into
NousResearch:mainfrom
elphamale:feat/guest-native-media
Closed

feat(telegram): guest-mode native media delivery + lazy two-phase stub#52639
elphamale wants to merge 14 commits into
NousResearch:mainfrom
elphamale:feat/guest-native-media

Conversation

@elphamale

@elphamale elphamale commented Jun 25, 2026

Copy link
Copy Markdown

Stacked on #51886

This builds on #51886 (two-phase guest reply) — its branch contains that machinery, so this can only merge after #51886 lands on main. Until then the diff renders cumulatively (≈ #51886 + this PR); GitHub trims it to the guest-media changes alone once #51886 merges. It is complete and reviewable as-is.

#47716 (Docker path translation) is an optional enhancement, not a dependency. _guest_media_send calls translate_docker_local_paths only when that shared helper is present (it ships with #47716) and otherwise uses the path as-is — correct for non-Docker and host-path media, and for MEDIA:-tagged files, which the gateway delivery loop already host-translates. The in-method translation only sharpens direct send_voice/send_document calls in Docker setups.

Summary

Adds native media delivery for guest @mentions (Bot API 10.0, non-member chats) and converts the two-phase thinking stub from immediate to lazy, so the one-shot answerGuestQuery slot can be claimed by media instead of being consumed by a text stub.

Stub timing: immediate → lazy

_guest_inline_message_ids now uses a three-state sentinel:

value meaning
False slot open, stub not fired
None stub fired, API returned no inline_message_id
str stub fired, real inline_message_id

The stub fires lazily — on the first non-media tool-progress call, or the first text stream (send_typing fires it early for non-media queries via _GUEST_MEDIA_QUERY_RE). If the first output is media, the slot stays open for native delivery. on_processing_complete gains matching sentinel handling plus a slot-open authorization fallback: an empty buffer with the slot still open (gateway rejected the sender) resolves to a terse refusal instead of a lingering "⏳".

Media delivery

  • _guest_media_send stages a file to TELEGRAM_HOME_CHANNEL to mint a file_id, reserves the first item for native answerGuestQuery (type=photo/audio/video/document), and notes later items for DM pickup. A (path, type) file_id cache avoids re-staging on follow-up turns.
  • send_voice / send_image_file / send_document / send_video / send_image route to _guest_media_send in guest chats instead of returning an error.
  • SSRF: send_image hoists the is_safe_url check above the guest staging branch, so guest URL-photos get the same validation as normal sends.
  • Docker container paths are translated via the shared BasePlatformAdapter.translate_docker_* helper when it's present (fix(gateway): translate Docker container paths to host before media delivery #47716); otherwise the path is used as-is (see the note at the top).

Note on churn

This rewrites on_processing_complete and the stub flow that #51886 establishes. That's intentional layering: #51886 ships the text-only immediate stub (self-contained, no media); deferring it is precisely the change that lets media claim the slot, so it lands here with the feature that needs it.

Tests

tests/gateway/test_telegram_guest_reply.py — 19 cases. New coverage: media staging / pending-queue / file_id cache reuse / missing-home-channel error, native answerGuestQuery delivery, SSRF-blocked-before-staging, lazy stub firing, and the media-query regex. The two-phase suite is reconciled for the lazy sentinel (the obsolete "noop" case becomes the slot-open authz-refusal test). Guest-media tests stub translate_docker_local_paths (the #47716 method) to identity.

Test plan

  • Unit suite (19) green
  • @mention in a non-member group asking for an image → native photo in the group
  • Ask for audio → native audio with the LLM text as caption
  • Text-only @mention → stub then streamed answer (unchanged)
  • Unauthorized sender → "⛔ Not authorized" rather than a lingering stub

🤖 Generated with Claude Code

elphamale and others added 13 commits June 17, 2026 11:10
…validation

When the terminal backend is Docker, agent commands run inside a container
where /workspace is a bind-mount of a host directory.  send_message runs on
the HOST where /workspace doesn't exist, so validate_media_delivery_path
silently drops MEDIA:/workspace/... files — the text caption is sent but the
video/image never is.

Add _translate_docker_workspace_paths() which looks up the active
DockerEnvironment._workspace_dir from terminal_tool._active_environments and
rewrites /workspace/... paths to their host equivalents before path validation
runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…+ /tmp fallback

The previous fix only handled /workspace/ paths.  Agent downloads often land
in /tmp/ or /root/.hermes/cache/ inside the container, which are not bind-
mounted to /workspace but may be covered by other mounts (e.g. audio_cache).

New approach:
  1. Run `docker inspect` to get the full mount table for the active container.
  2. Walk mounts longest-first to translate any container path to its host
     equivalent (covers /root, /root/.hermes/cache/audio, /mnt/hermes_home, etc.)
  3. For paths in unmounted dirs like /tmp/, fall back to `docker cp` into a
     temp file on the host so the file can still be delivered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
PTB 22.6's Update.ALL_TYPES does not include guest_message (Bot API
10.0 field not yet in the typed layer). Telegram only delivers an
update type if it is listed in allowed_updates — without this,
getUpdates never returns guest_message payloads and guest mode is
silently dead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The _reconnect_polling path had a different indentation for its
allowed_updates call so the previous replace_all missed it. This
ensures guest_message is requested even after a network-error reconnect.

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

In draft-streaming mode (Telegram DMs), _message_id stays None throughout
streaming.  At got_done the stream consumer reaches the "First message" path
and calls adapter.send() with a full-length response.  When that response
exceeds 4096 chars the legacy split path sends M1 "(1/2)" + M2 "(2/2)" but
returned only message_ids[0], so _last_edit_overflowed was never set.

Because REQUIRES_EDIT_FINALIZE=True for Telegram and _last_edit_overflowed
was False, the got_done block unconditionally fired a second _send_or_edit
that tried to edit M1 — triggering _edit_overflow_split and producing a
third "(2/2)" chunk.  Multiple tool-call iterations compounded the issue into
4-5 visible copies of the same answer.

Fix:
- adapter.send() now populates continuation_message_ids from any extra chunks
  the legacy 4096-split path delivers (symmetric with _edit_overflow_split).
- The stream consumer's "First message" success path detects a non-empty
  continuation_message_ids and sets _last_edit_overflowed=True, causing the
  got_done condition to recognise full delivery and skip the redundant second
  finalize edit.

Root cause introduced by 4ed293b (feat: native draft streaming, 2026-05-10).
The companion bf1f409 wired continuation_message_ids for the edit overflow
path but left the send() return without it, creating the gap closed here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Handles Bot API 10.0 guest_message updates via a two-phase delivery:
- Phase 1: answerGuestQuery fires immediately with a random thinking-verb
  stub (e.g. "⏳ Brewing..."), returning inline_message_id.
- Phase 2: stream consumer plumbs inline_message_id into its edit path —
  each progressive chunk becomes editMessageText(inline_message_id=...).
  All existing adaptive flood-control and think-block filtering apply
  transparently.

Fallback chain: stub failure → buffer mode → answerGuestQuery on
completion; flood-fallback → buffered text → final editMessageText.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ships 5 default verbs used in the answerGuestQuery stub. Kept deliberately
small so users can customize without touching adapter.py — translate to
another language, add emojis, or maintain a larger personal list matched
to their SOUL.md persona by dropping a replacement into local-patches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Six tests covering the three delivery paths:
- Streaming: send() returns imi, edit_message() routes to editMessageText,
  on_processing_complete is a no-op when buffer is empty.
- Buffer fallback: send() accumulates text, on_processing_complete flushes
  via answerGuestQuery when stub failed (_imi is None).
- State cleanup: on_processing_complete clears all per-query guest state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four media-independent robustness fixes to the Bot API 10.0 guest text
reply path:

- _strip_mdv2: strip leftover unclosed `**` markers. A stream cut
  mid-token (e.g. "- **2") leaves the balanced-pair regex unmatched, so
  the raw markdown surfaced in the delivered message.
- send(): normalize chat_id to str before the guest dict lookups. The
  event source can pass chat_id as int, which silently missed every
  str-keyed guest dict and dropped the reply onto the wrong path.
- send(): strip MEDIA: residuals from buffered chunks. Intermediate
  streaming chunks ("MEDIA:", "MEDIA:/path") slip past the stream
  consumer's extension-anchored cleanup and would buffer as visible text.
- edit_message(): keep _guest_reply_buffer current on each streaming
  edit 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", which is downgraded from
warning to debug rather than logged as a failure.

Adds 5 unit tests covering each fix plus the end-to-end truncation
re-render.

Design note: the immediate stub fired at handler entry is intentionally
retained here. Deferring it (lazy fire) only earns its added state once
native media delivery needs the answerGuestQuery slot held open, so that
refactor — and its known gateway-rejection authz fallback — is scoped to
the follow-up guest-media PR that builds on this one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the text two-phase reply: defers the thinking stub so the
answerGuestQuery slot stays open for native media, and adds media delivery
for guest @mentions from non-member chats.

Stub timing — immediate to lazy:
  _guest_inline_message_ids now uses a three-state sentinel
  (False = slot open / None = fired, no imi / str = real imi). The stub
  fires lazily on the first non-media tool-progress call or first text
  stream (send_typing fires it early for non-media queries), keeping the
  slot free when the first output is media. on_processing_complete gains
  matching sentinel handling plus a slot-open authorization fallback
  (empty buffer -> terse refusal rather than a lingering stub).

Media delivery:
  - _guest_media_send stages a file to TELEGRAM_HOME_CHANNEL to mint a
    file_id, then reserves the first item for native answerGuestQuery
    (type=photo/audio/video/document); later items are noted for DM
    pickup. A (path, type) file_id cache avoids re-staging on follow-ups.
  - send_voice / send_image_file / send_document / send_video / send_image
    route to _guest_media_send in guest chats instead of returning an error.
  - send_image hoists the is_safe_url SSRF check above the guest staging
    branch so guest URL-photos get the same validation as normal sends.
  - Docker container paths are translated via the shared
    BasePlatformAdapter.translate_docker_* helpers.

Tests: extends tests/gateway/test_telegram_guest_reply.py to 19 cases —
media staging/pending/cache/no-channel, native delivery, SSRF-before-
staging, lazy stub firing, and the media-query regex. The two-phase suite
is reconciled for the lazy sentinel.

Prerequisites: two-phase guest reply PR (machinery) and the Docker
path-translation PR (BasePlatformAdapter.translate_docker_*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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 25, 2026
_guest_media_send now calls translate_docker_local_paths only when the
shared helper is present (it ships separately), falling back to the path
as-is otherwise. MEDIA:-tagged files are already host-translated by the
gateway delivery loop, so the in-method translation only affected direct
send_voice/send_document calls in Docker setups. This removes the hard
dependency on the Docker-path-translation work — guest media works for
non-Docker and host-path media on its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@elphamale

Copy link
Copy Markdown
Author

Closing in favor of a clean rebuild off current main: the media-delivery portion of this PR is now #56477, stacked on #56476 (which replaces #51886). Same underlying production-tested code, rebuilt cleanly against current main and without the personalized thinking_verbs.py content that had leaked into this branch. Docs in #56297 have been updated to point at the new PRs.

@elphamale elphamale closed this Jul 1, 2026
@elphamale
elphamale deleted the feat/guest-native-media branch July 1, 2026 17:22
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.
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