Skip to content

fix(channels/telegram): wire ApprovalGate end-to-end for Telegram turns (#3098 sub-issue 2)#3232

Merged
senamakel merged 1 commit into
tinyhumansai:mainfrom
CodeGhost21:fix/3098-telegram-approval-surface
Jun 2, 2026
Merged

fix(channels/telegram): wire ApprovalGate end-to-end for Telegram turns (#3098 sub-issue 2)#3232
senamakel merged 1 commit into
tinyhumansai:mainfrom
CodeGhost21:fix/3098-telegram-approval-surface

Conversation

@CodeGhost21
Copy link
Copy Markdown
Contributor

@CodeGhost21 CodeGhost21 commented Jun 2, 2026

Summary

  • Telegram channel turns now scope an ApprovalChatContext so the ApprovalGate actually fires for Prompt-class tool calls instead of silently allowing them. The user gets an in-chat approval prompt; replying yes / no resumes the parked tool call via ApprovalGate::decide.
  • New TelegramApprovalSurfaceSubscriber bridges DomainEvent::ApprovalRequested events whose client_id="telegram" into Telegram messages, looking up the right reply target from inbound message history.
  • Discord, Slack, iMessage, Mattermost are intentionally untouched — they stay in the legacy "no chat context → silently allow" state until each gets its own surface PR. Surfacing approvals there without a subscriber would TTL-deny every parked call (worse than the bypass).

Problem

Sub-issue 2 of #3098 says "cannot commit files via Telegram." On investigation the actual gap is much more serious than the reported symptom:

  • channels/runtime/dispatch.rs invokes the agent turn (request_native_global(agent.run_turn, ...)) without wrapping it in an APPROVAL_CHAT_CONTEXT.scope(...).
  • The ApprovalGate explicitly handles "no chat context" as "background / triage / cron turn — pre-authorized, allow straight through" (approval/gate.rs:219-231).
  • Result: every Prompt-class tool call (file_write, edit_file, shell cd && git commit, curl, npm_exec, …) initiated over Telegram executes without any approval prompt, regardless of autonomy tier. level=supervised is silently identical to level=full on Telegram — voiding the supervised approval model.

The reporter's stated symptom ("can't commit files") is most plausibly explained by this gap interacting with level=readonly (policy-layer block fires before the gate even runs and produces a cryptic error), or by level=full actually committing files silently but the bot reply not surfacing the result clearly.

This same gap affects Discord, Slack, iMessage, and Mattermost — but this PR is scoped to Telegram (the reported channel).

Solution

Mirror the existing web channel pattern for Telegram:

1. Scope the agent turn (channels/runtime/dispatch.rs)

Add channel_has_approval_surface(channel: &str) -> bool that returns true only for channels with a registered approval surface (Telegram today). In the dispatch loop, wrap the agent turn invocation in APPROVAL_CHAT_CONTEXT.scope(ApprovalChatContext { thread_id: history_key, client_id: msg.channel }, agent_call) when the gate returns true. Other channels execute the agent turn unscoped, preserving the legacy bypass until they get their own surface.

2. Intercept yes/no replies for parked approvals

Add try_route_approval_reply(msg) -> bool that — for channels with a surface — checks ApprovalGate::pending_for_thread(&history_key) and routes a yes/no reply to ApprovalGate::decide(...). Mirrors channels/providers/web.rs:493-525. Non-yes/no replies fall through to the normal dispatch (the user is redirecting; existing turn cancellation kicks in).

3. New TelegramApprovalSurfaceSubscriber

channels/providers/telegram/approval_surface.rs. Subscribes to both channel and approval domains:

  • ChannelMessageReceived on telegram → records (thread_id, reply_target, thread_ts) in an in-memory index. Same thread_id shape as conversation_history_key (pinned by telegram_history_key_matches_format_in_dispatch_helper test — drift would silently miss every parked approval).
  • ApprovalRequested with client_id="telegram" → looks up the index, renders a 🔐 Approval needed / Tool: X / Action: Y / Reply yes to approve or no to deny. message, sends it via the registered TelegramChannel.

Registered in channels/runtime/startup.rs only when Telegram is enabled in config.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy — 8 unit tests on the subscriber + 3 inline gating tests on channel_has_approval_surface. Failure paths covered: missing context, non-telegram client_id, missing client_id, non-telegram channel inbound, unknown channel.
  • Diff coverage ≥ 80% — every new function and branch is exercised. send_approval_prompt happy/missing-context paths, record_reply_context from inbound events, channel_has_approval_surface for both telegram and rejected channels, format_approval_prompt body shape, history-key format parity.
  • Coverage matrix updated — N/A: behavior fix on the existing "Telegram channel" capability; no new feature row.
  • All affected feature IDs from the matrix are listed in the PR description under ## RelatedN/A: no matrix row added or removed.
  • No new external network dependencies introduced (mock backend used per Testing Strategy) — tests use a RecordingChannel mock; no Telegram Bot API calls.
  • Manual smoke checklist updated if this touches release-cut surfaces (docs/RELEASE-MANUAL-SMOKE.md) — N/A: behavioral fix on an existing surface; no new release-cut item.
  • Linked issue closed via Closes #NNN in the ## Related section — see below (Telegram interface ignores local model selection + can't commit files or run skills via Telegram on macOS #3098 has 4 sub-issues; this PR closes sub-issue 2 only, issue stays open).

Impact

  • Telegram users on level=supervised: previously silently bypassed; now actually get an approval prompt in the chat for each Prompt-class tool call. This is a security improvement — the supervised model now works as documented for Telegram.
  • Telegram users on level=full: identical behavior for the Allow paths; Network/Install/Destructive tools (already Prompt-class under full) now correctly prompt instead of silently running.
  • Telegram users on level=readonly: unchanged. Policy-layer block still fires before the gate.
  • Desktop / web users: zero change. Web channel sets its own ApprovalChatContext and uses its own subscriber.
  • Discord / Slack / iMessage / Mattermost: zero change. The dispatch loop's channel_has_approval_surface returns false for them — they keep the legacy bypass until each gets its own surface PR (intentional; see approval_surface_gating_tests::other_channels_do_not_yet_have_an_approval_surface).
  • Performance: marginal. One extra tokio::task_local! scope per Telegram turn (negligible) and one extra HashMap lookup per inbound Telegram message (subscriber's index update). No new I/O on the hot path.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: fix/3098-telegram-approval-surface
  • Commit SHA: a49b1e3842f108f913b8ca0bec52dee07d4e3282

Validation Run

  • pnpm --filter openhuman-app format:checkN/A: Rust-only change.
  • pnpm typecheckN/A: no TS/TSX touched.
  • Focused tests: cargo test --lib -p openhuman channels:: approval::1163 passed, 0 failed. Includes the 8 new subscriber tests + 3 new dispatch gating tests + all pre-existing channels/approval coverage.
  • Rust fmt/check (if changed): cargo fmt --check clean; cargo check --lib clean (pre-existing warnings only).
  • Tauri fmt/check (if changed): N/A: app/src-tauri not touched.

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: Telegram channel turns now go through the ApprovalGate instead of silently bypassing it. level=supervised tool calls park and surface as a Telegram message asking yes/no; the user's reply resumes or denies the parked call.
  • User-visible effect: Telegram users on supervised now see approval prompts in the chat (previously: silent execution). Telegram users on full see prompts for Network / Install / Destructive tools (previously: silent execution). Telegram users on readonly see no change.

Parity Contract

  • Legacy behavior preserved: Discord, Slack, iMessage, Mattermost, IRC, and any other non-Telegram non-web channel keep their existing "no chat context → silently allow" behavior. The channel_has_approval_surface gate is a per-channel allowlist; only telegram evaluates true. Pinned by approval_surface_gating_tests::other_channels_do_not_yet_have_an_approval_surface. The web channel is unaffected — it sets its own ApprovalChatContext in channels/providers/web.rs:472-477 and has its own surface subscriber.
  • Guard/fallback/dispatch parity checks: The ApprovalGate's "no chat context → allow" branch (approval/gate.rs:219-231) is intentionally NOT changed — background / triage / cron turns continue to flow through unchanged. The gate's TTL deny remains the safety net. The Telegram intercept (yes/no reply routing) only fires when pending_for_thread returns Some, so non-approval messages dispatch normally.

Duplicate / Superseded PR Handling

Summary by CodeRabbit

  • New Features

    • Telegram approval requests now appear as prompts in conversation threads, enabling users to approve or reject pending actions via simple yes/no replies without leaving the chat.
  • Tests

    • Added comprehensive test coverage for Telegram approval routing and message formatting.

…ns (tinyhumansai#3098)

Sub-issue 2 of tinyhumansai#3098 is more serious than the "Telegram is read-only"
framing suggests. The actual gap: every non-web channel (Telegram,
Discord, Slack, iMessage, Mattermost) silently bypasses the
`ApprovalGate`. The dispatch loop in `channels/runtime/dispatch.rs`
invokes the agent turn without setting an `ApprovalChatContext`, so the
gate's "no chat context → allow straight through" branch
(`approval/gate.rs:219-231`) fires for every `Prompt`-class tool call.
A user on `level=supervised` gets the same unprompted behavior as
`level=full` over Telegram — which voids the entire supervised
approval model and is the most likely reason the reporter saw "files
can't be committed via Telegram" (the operation either ran silently or
produced a result the bot never surfaced clearly).

This PR closes the gap for Telegram only:

- New `TelegramApprovalSurfaceSubscriber` (`channels/providers/telegram/
  approval_surface.rs`) listens on the `channel` + `approval` event
  domains. Inbound `ChannelMessageReceived` events on `telegram` populate
  a small `thread_id → (reply_target, thread_ts)` index. Outbound
  `ApprovalRequested` events with `client_id="telegram"` are rendered as
  a "🔐 Approval needed / reply yes or no" message sent through the
  registered `TelegramChannel`.

- `dispatch.rs` scopes the agent turn invocation in an
  `APPROVAL_CHAT_CONTEXT.scope(...)` for channels that have a registered
  approval surface — currently Telegram only via
  `channel_has_approval_surface("telegram")`. With the context set, the
  gate parks `Prompt`-class tool calls instead of allowing them.

- `dispatch.rs` also intercepts inbound `yes`/`no` chat replies BEFORE
  the normal turn dispatch via `try_route_approval_reply`. When a
  parked approval exists for the same `conversation_history_key`, the
  reply is routed to `ApprovalGate::decide` (resuming the parked tool
  call) instead of starting a fresh turn. Mirrors the web channel
  intercept at `channels/providers/web.rs:493-525`.

- `startup.rs` registers `TelegramApprovalSurfaceSubscriber` when the
  Telegram channel is enabled, alongside the existing
  `TelegramRemoteSubscriber`.

Discord / Slack / iMessage / Mattermost intentionally STAY in the
legacy "no chat context → silently allow" state. Setting the context
without a surface subscriber would make every parked approval TTL-deny
with no UI for the user to answer — strictly worse than the bypass.
Each will get its own per-channel surface PR.

Tests added:
- 8 unit tests on the subscriber (`approval_surface_tests.rs`) covering
  inbound context capture, channel scoping (reject non-telegram and
  non-web events), client_id scoping, missing-context safety, and the
  prompt body format.
- 3 inline gating tests on `channel_has_approval_surface` in
  `dispatch.rs` pin the matrix: telegram in, others out.

All 1163 channels + approval tests pass. cargo fmt clean.

Closes sub-issue 2 of tinyhumansai#3098 for the Telegram path. Three follow-ups
remain (Discord / Slack / iMessage approval surfaces), each tracked
separately.
@CodeGhost21 CodeGhost21 requested a review from a team June 2, 2026 20:21
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 2, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a Telegram approval surface: a subscriber that records inbound Telegram message context, routes parked tool-call approvals into Telegram chat threads as yes/no prompts, integrates approval-reply detection into the channel dispatch loop, and wires the subscriber at startup.

Changes

Telegram Approval Surface

Layer / File(s) Summary
Telegram approval surface subscriber implementation
src/openhuman/channels/providers/telegram/approval_surface.rs, src/openhuman/channels/providers/telegram/approval_surface_tests.rs
TelegramApprovalSurfaceSubscriber captures reply context (target and thread ID) from inbound Telegram messages and routes ApprovalRequested events (filtered by client_id == "telegram") into formatted approval prompts sent to the corresponding Telegram thread. Tests verify reply-context recording, approval routing by client ID, and formatting contracts for history keys and prompt messages.
Dispatch approval reply interception and context wrapping
src/openhuman/channels/runtime/dispatch.rs
The message dispatch loop now detects yes/no replies targeting parked approvals and routes them through ApprovalGate::decide before running agent turns. Agent execution is refactored to wrap the LLM call in ApprovalChatContext (for approval-surface channels like Telegram) to ensure tool calls are intercepted by the approval gate rather than bypassing it. Includes tests for the approval-surface gating predicate.
Public API and startup registration
src/openhuman/channels/providers/telegram/mod.rs, src/openhuman/channels/runtime/startup.rs
The approval_surface module is declared and its subscriber and client-id constant are re-exported from the Telegram provider. The subscriber is conditionally registered on the event bus at startup when a Telegram channel is present.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

rust-core, working

Suggested reviewers

  • graycyrus

Poem

📱 A Telegram thread awaits, yes and no,
Parked approvals now routed where replies can flow,
Context recorded in threads, messages sent with care,
Dispatch intercepts answers before agent turns there!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: wiring ApprovalGate end-to-end for Telegram turns with approval surface functionality.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team. labels Jun 2, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/openhuman/channels/providers/telegram/approval_surface.rs (1)

162-166: 💤 Low value

Consider redacting reply_target in logs to avoid PII leakage.

The reply_target field may contain user identifiers (e.g., Telegram chat IDs or usernames) that could be considered PII. As per coding guidelines, logs should not contain full PII.

🔒 Proposed redaction approach
         tracing::info!(
             "{LOG_PREFIX} surfacing approval prompt request_id={request_id} tool={tool_name} \
-             thread_id={thread_id} reply_target={}",
-            reply_ctx.reply_target
+             thread_id={thread_id} reply_target=[REDACTED]"
         );

Based on learnings, never log secrets or full PII — redact sensitive information in logs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/channels/providers/telegram/approval_surface.rs` around lines
162 - 166, The log currently prints reply_ctx.reply_target (via tracing::info
with LOG_PREFIX, request_id, tool_name, thread_id), which may leak PII; change
the call to log a redacted or derived value instead (e.g., mask most characters,
emit only a hashed or last-N chars, or a boolean like "present") by replacing
reply_ctx.reply_target with a sanitized string produced by a helper (e.g.,
redact_pii or mask_reply_target) and use that sanitized value in the
tracing::info invocation so only non-identifying info is logged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/openhuman/channels/providers/telegram/approval_surface.rs`:
- Around line 162-166: The log currently prints reply_ctx.reply_target (via
tracing::info with LOG_PREFIX, request_id, tool_name, thread_id), which may leak
PII; change the call to log a redacted or derived value instead (e.g., mask most
characters, emit only a hashed or last-N chars, or a boolean like "present") by
replacing reply_ctx.reply_target with a sanitized string produced by a helper
(e.g., redact_pii or mask_reply_target) and use that sanitized value in the
tracing::info invocation so only non-identifying info is logged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 653453ca-88fa-473b-9575-7e300a72ec96

📥 Commits

Reviewing files that changed from the base of the PR and between b156392 and a49b1e3.

📒 Files selected for processing (5)
  • src/openhuman/channels/providers/telegram/approval_surface.rs
  • src/openhuman/channels/providers/telegram/approval_surface_tests.rs
  • src/openhuman/channels/providers/telegram/mod.rs
  • src/openhuman/channels/runtime/dispatch.rs
  • src/openhuman/channels/runtime/startup.rs

@senamakel senamakel merged commit 991b285 into tinyhumansai:main Jun 2, 2026
24 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants