docs(spec): telegram remote-control phase 2 — inline approvals (#1805)#2502
docs(spec): telegram remote-control phase 2 — inline approvals (#1805)#2502CodeGhost21 wants to merge 1 commit into
Conversation
…tinyhumansai#1805) Design doc for phase 2 of tinyhumansai#1805. Scope: route ApprovalGate prompts to all bound Telegram chats with inline approve / approve-always / deny buttons, edit messages cross-chat on decision with actor + surface attribution, and add /pending for on-demand recovery. Approvals only — Q&A and other remote-control slices (live ticks, /abort, /task, /files, /models, /worktree) are explicit non-goals, each its own future spec.
📝 WalkthroughWalkthroughThis PR introduces a comprehensive design specification for Telegram inline approvals (phase 2 of the remote-control initiative). The spec details multi-chat approval broadcasting, callback-driven decision handling with identity attribution, a ChangesTelegram Inline Approvals Specification
Estimated code review effort🎯 2 (Simple) | ⏱️ ~20 minutes Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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.
Inline comments:
In `@docs/superpowers/specs/2026-05-23-telegram-inline-approvals-design.md`:
- Line 478: The table row contains unescaped pipe characters in the snippet
`appr:<o|a|d>:` which breaks the Markdown table parse; update the table cell to
escape the pipes (e.g. `appr:<o\|a\|d>:`) or wrap the entire fragment in a code
span that preserves pipes, and ensure the same escaped form or explanatory note
is used where `approval_keyboard` is described so the table stays valid and the
construction-time validation remark still matches the token format.
- Around line 25-77: The fenced architecture diagram block (the block showing
ApprovalGate, DomainEvent::ApprovalRequested, TelegramApprovalSubscriber,
channel_recv.rs callback_query handler, etc.) is missing a language tag which
triggers MD040; update the opening fence from ``` to ```text so the diagram is
fenced as a text block and no other changes are needed. Ensure the closing fence
remains ``` and leave the inner diagram (ApprovalGate, Telegram Bot API,
TelegramApprovalSubscriber.handle, pending_map.remove) unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9f49d7b8-1355-492c-8588-413c25de5f09
📒 Files selected for processing (1)
docs/superpowers/specs/2026-05-23-telegram-inline-approvals-design.md
| ``` | ||
| ┌──────────────────┐ | ||
| │ ApprovalGate │ intercepts external-effect tool calls | ||
| │ (existing) │ parks future on oneshot | ||
| └────────┬─────────┘ | ||
| │ publishes | ||
| ▼ | ||
| ┌───────────────────────────────┐ | ||
| │ DomainEvent::ApprovalRequested│ | ||
| └─────────────┬─────────────────┘ | ||
| │ event bus | ||
| ┌─────────────────────┼─────────────────────┐ | ||
| ▼ ▼ ▼ | ||
| ┌────────────────┐ ┌──────────────────────┐ ┌────────────────┐ | ||
| │ Desktop UI │ │ TelegramApproval- │ │ (future │ | ||
| │ (existing) │ │ Subscriber (new) │ │ providers) │ | ||
| └───────┬────────┘ └──────────┬───────────┘ └────────────────┘ | ||
| │ │ | ||
| │ │ for every bound chat: | ||
| │ │ sendMessage + inline_keyboard | ||
| │ │ record (chat_id, msg_id) in pending_map | ||
| │ ▼ | ||
| │ ┌─────────────────┐ | ||
| │ │ Telegram Bot │ | ||
| │ │ API │ | ||
| │ └────────┬────────┘ | ||
| │ │ user taps button | ||
| │ ▼ | ||
| │ ┌─────────────────────────┐ | ||
| │ │ channel_recv.rs: │ | ||
| │ │ callback_query handler │ ← allowlist re-check | ||
| │ │ (new branch) │ | ||
| │ └────────────┬────────────┘ | ||
| │ │ approval_decide( | ||
| │ │ request_id, decision, | ||
| │ │ actor=@user, surface="telegram") | ||
| ▼ ▼ | ||
| approval_decide RPC ───────┐ | ||
| │ publishes | ||
| ▼ | ||
| ┌──────────────────────────────────┐ | ||
| │ DomainEvent::ApprovalDecided │ | ||
| │ (extended w/ decided_by_actor + │ | ||
| │ decided_by_surface) │ | ||
| └────────────────┬─────────────────┘ | ||
| │ | ||
| ▼ | ||
| TelegramApprovalSubscriber.handle() | ||
| for each (chat_id, msg_id) in pending_map: | ||
| editMessageText("✓ decided by @who via X") | ||
| [remove inline_keyboard] | ||
| pending_map.remove(request_id) | ||
| ``` |
There was a problem hiding this comment.
Add a language tag to the architecture fenced block.
Line 25 opens a fenced code block without a language, which triggers MD040 and reduces renderer/tooling consistency.
Proposed fix
-```
+```text
┌──────────────────┐
│ ApprovalGate │ intercepts external-effect tool calls
...
pending_map.remove(request_id)
-```
+```📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` | |
| ┌──────────────────┐ | |
| │ ApprovalGate │ intercepts external-effect tool calls | |
| │ (existing) │ parks future on oneshot | |
| └────────┬─────────┘ | |
| │ publishes | |
| ▼ | |
| ┌───────────────────────────────┐ | |
| │ DomainEvent::ApprovalRequested│ | |
| └─────────────┬─────────────────┘ | |
| │ event bus | |
| ┌─────────────────────┼─────────────────────┐ | |
| ▼ ▼ ▼ | |
| ┌────────────────┐ ┌──────────────────────┐ ┌────────────────┐ | |
| │ Desktop UI │ │ TelegramApproval- │ │ (future │ | |
| │ (existing) │ │ Subscriber (new) │ │ providers) │ | |
| └───────┬────────┘ └──────────┬───────────┘ └────────────────┘ | |
| │ │ | |
| │ │ for every bound chat: | |
| │ │ sendMessage + inline_keyboard | |
| │ │ record (chat_id, msg_id) in pending_map | |
| │ ▼ | |
| │ ┌─────────────────┐ | |
| │ │ Telegram Bot │ | |
| │ │ API │ | |
| │ └────────┬────────┘ | |
| │ │ user taps button | |
| │ ▼ | |
| │ ┌─────────────────────────┐ | |
| │ │ channel_recv.rs: │ | |
| │ │ callback_query handler │ ← allowlist re-check | |
| │ │ (new branch) │ | |
| │ └────────────┬────────────┘ | |
| │ │ approval_decide( | |
| │ │ request_id, decision, | |
| │ │ actor=@user, surface="telegram") | |
| ▼ ▼ | |
| approval_decide RPC ───────┐ | |
| │ publishes | |
| ▼ | |
| ┌──────────────────────────────────┐ | |
| │ DomainEvent::ApprovalDecided │ | |
| │ (extended w/ decided_by_actor + │ | |
| │ decided_by_surface) │ | |
| └────────────────┬─────────────────┘ | |
| │ | |
| ▼ | |
| TelegramApprovalSubscriber.handle() | |
| for each (chat_id, msg_id) in pending_map: | |
| editMessageText("✓ decided by @who via X") | |
| [remove inline_keyboard] | |
| pending_map.remove(request_id) | |
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 25-25: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 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 `@docs/superpowers/specs/2026-05-23-telegram-inline-approvals-design.md` around
lines 25 - 77, The fenced architecture diagram block (the block showing
ApprovalGate, DomainEvent::ApprovalRequested, TelegramApprovalSubscriber,
channel_recv.rs callback_query handler, etc.) is missing a language tag which
triggers MD040; update the opening fence from ``` to ```text so the diagram is
fenced as a text block and no other changes are needed. Ensure the closing fence
remains ``` and leave the inner diagram (ApprovalGate, Telegram Bot API,
TelegramApprovalSubscriber.handle, pending_map.remove) unchanged.
| | Existing desktop frontend calls `approval_decide` without the new params | Server defaults `surface = Some("desktop")` when both are absent. Frontend update is additive. | | ||
| | `Arc<dyn Channel>` → `Arc<TelegramChannel>` downcast may need a small trait change | Plan covers a `try_downcast` helper option first; falls back to `as_any` only if needed. | | ||
| | Real Telegram users tap stale buttons after core restart | Acceptable — "already decided or expired" toast + best-effort edit covers it. | | ||
| | `callback_data` length cap (64 bytes) is exceeded by long request_ids | Request ids are uuids (36 bytes); `appr:<o|a|d>:` adds 7. Total 43. Comfortable headroom. Validate at construction time in `approval_keyboard` to fail loud if this ever changes. | |
There was a problem hiding this comment.
Escape pipe characters inside the table cell to preserve column structure.
Line 478 includes unescaped | inside a table row (appr:<o|a|d>:), which breaks the 2-column table parse (MD056).
Proposed fix
-| `callback_data` length cap (64 bytes) is exceeded by long request_ids | Request ids are uuids (36 bytes); `appr:<o|a|d>:` adds 7. Total 43. Comfortable headroom. Validate at construction time in `approval_keyboard` to fail loud if this ever changes. |
+| `callback_data` length cap (64 bytes) is exceeded by long request_ids | Request ids are uuids (36 bytes); `appr:<o\|a\|d>:` adds 7. Total 43. Comfortable headroom. Validate at construction time in `approval_keyboard` to fail loud if this ever changes. |📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | `callback_data` length cap (64 bytes) is exceeded by long request_ids | Request ids are uuids (36 bytes); `appr:<o|a|d>:` adds 7. Total 43. Comfortable headroom. Validate at construction time in `approval_keyboard` to fail loud if this ever changes. | | |
| | `callback_data` length cap (64 bytes) is exceeded by long request_ids | Request ids are uuids (36 bytes); `appr:<o\|a\|d>:` adds 7. Total 43. Comfortable headroom. Validate at construction time in `approval_keyboard` to fail loud if this ever changes. | |
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 478-478: Table column count
Expected: 2; Actual: 4; Too many cells, extra data will be missing
(MD056, table-column-count)
🤖 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 `@docs/superpowers/specs/2026-05-23-telegram-inline-approvals-design.md` at
line 478, The table row contains unescaped pipe characters in the snippet
`appr:<o|a|d>:` which breaks the Markdown table parse; update the table cell to
escape the pipes (e.g. `appr:<o\|a\|d>:`) or wrap the entire fragment in a code
span that preserves pipes, and ensure the same escaped form or explanatory note
is used where `approval_keyboard` is described so the table stays valid and the
construction-time validation remark still matches the token format.
Summary
ApprovalGateprompts to all bound Telegram chats with inline approve / approve-always / deny buttons./pendingslash command for on-demand recovery when the auto-broadcast was missed (e.g. across a core restart).ApprovalDecidedevent +approval_decideRPC +approval_auditrow with optionaldecided_by_actor/decided_by_surface.Problem
Phase 1 (#2249) shipped
/status,/sessions,/new,/help— useful for visibility, but operators on Telegram still can't act on permission requests. Today theApprovalGateparks tool calls and surfaces them only to the desktop UI. The fully-stated goal of #1805 covers many more slices (live ticks,/abort,/task,/files,/models,/worktree); each is its own spec/plan/PR cycle. This spec narrowly addresses the inline approvals slice — the most-requested operator-visible gap.Solution
TelegramApprovalSubscriberlistens on theapprovaldomain of the event bus:ApprovalRequested→ broadcasts a prompt + 3-button inline keyboard to every chat that has a session binding; records(chat_id, message_id)in an in-memorypending_map.ApprovalDecided(from any surface) → edits every recorded message to a final "✓ decided by @who via X" state and removes the keyboard.A new
callback_querybranch inchannel_recv.rsre-checks the allowlist on every tap, callsapproval_decidewithactor=@username, surface=\"telegram\", and toasts the result viaanswerCallbackQuery. Stale taps (already-decided / expired) self-correct to a toast + best-effort message edit.Key design choices (full rationale in the spec):
pub(crate)methods onTelegramChannel(send_with_inline_keyboard,edit_message_text,answer_callback_query); genericChanneltrait untouched.ApprovalDecidedevent + RPC + audit row extended with optionaldecided_by_actor/decided_by_surface. Server defaultssurface=\"desktop\"when absent for back-compat with stale frontend builds./pendingis chat-local (not a broadcast) and dedupes per chat againstpending_map.Explicit non-goals for this slice: agent Q&A elicitation, active expiry sweeping, real Telegram E2E automation, and every other #1805 slice. Each gets its own spec.
Submission Checklist
## Related— N/A: no code surfaces touched yet.#NNNin the## Relatedsection (this PR documents phase 2 of Bring OpenHuman’s Telegram channel to OpenCode-level remote-control parity #1805; the implementation PR will close it).Impact
surface=\"desktop\"). Existing desktop frontend continues to work unchanged until the wrapper is updated alongside the implementation PR.callback_query, reuses existingapproval/redact.rsscrubbing, never logs message bodies / args / bot token.[telegram-approval]log prefix throughout.approval_audit— additive, no data loss.Related
/status,/sessions,/new,/help)/abort,/task,/files,/models,/worktree) tracked separately as future phase specs.AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
feat/1805-telegram-inline-approvals-spec4d18cddd70a09da09a33d41306edb0fac2abef7bValidation Run
pnpm --filter openhuman-app format:check— N/A (no app code changed)pnpm typecheck— N/A (no TS changed)Validation Blocked
command:N/Aerror:N/Aimpact:N/ABehavior Changes
Parity Contract
approval_decidecallers).Duplicate / Superseded PR Handling
Summary by CodeRabbit