| name | telegram |
| description | Multi-bot Telegram bridge — routes messages between Telegram and Claude tmux sessions |
| version | 1.7.0 |
| tags | ["communication","bridge","telegram","bot"] |
| requires | {"pip":["telethon"],"bins":["tmux","claude"],"config":[],"aipass":["api","prax","hooks","cli"]} |
| switch | {"systemd_user":["telegram-bot@api","telegram-bot@base","telegram-bot@devpulse","telegram-bot@prax_monitor","telegram-bot@scheduler"]} |
| has_handler | true |
Telegram Bridge
Multi-bot personal-assistant bridge: long-polling listener routes user Telegram messages into Claude tmux sessions. The base reply flow is Claude's Stop hook writing a pending file, which the bot picks up and sends back to Telegram; bots opting into streaming (below) also live-edit a "Processing..." placeholder while the reply is still being generated. A control-center bot exposes /start, /kill, /lock, /suspend to wake, kill, and lock or sleep terminal sessions by branch, and a separate hook mirrors terminal-typed messages into the same TG chat so the conversation reads as one continuous thread regardless of which door you type in.
Architecture
- BaseBot — polling loop, tmux injection, heartbeat, lock management, control verbs, streaming, offline backoff
- BranchPlugin — per-branch overrides (message prefix, response prefix, session startup)
- ResponseRouter — CWD-safe pending-file routing for multi-bot
- TelegramStandards — shared /start, /help, /new, /status command handlers
- BotFactory — bot create/delete lifecycle (8-step)
- BotRegistry — fcntl-locked JSON registry CRUD
- BotOperations — start/stop/status ops
- BotFatherClient — optional Telethon BotFather automation
- Config — bot configuration via @api secrets store
- FileHandler — download, classify, and prompt file uploads
- LogStreamer — daemon thread tailing logs to Telegram
- Notifier — standalone push notification sender
- TmuxManager — tmux session helpers (not the control verbs — see below)
- UserMessageRelay — UserPromptSubmit hook that mirrors terminal-typed messages into the branch's TG chat
Usage
drone @skills run telegram start <bot_id>
drone @skills run telegram stop <bot_id>
drone @skills run telegram status [bot_id]
drone @skills run telegram create <bot_id> --token <token>
drone @skills run telegram delete <bot_id>
drone @skills run telegram notify "message"
Control verbs (DPLAN-0270 P1)
One bot doubles as a control center: _is_control_bot() is true when branch_name is None (a bare base bot) or "aipass" (the deployed control-center config — same bot_id="base" process, no separate bot). Only that bot handles these commands; branch bots fall through to the normal command set.
/start [branch] — wake a terminal agent (default branch: aipass). Spawns a detached tmux session aipass-<branch> in the branch's registered path and launches claude -c || claude. No-ops with "already running" if the session exists (one session per branch).
/kill [branch] — kill the aipass-<branch> tmux session outright. No graceful-stop nuance in v1 (Patrick's ruling).
/status — on the control bot, appends a live listing of all aipass-* sessions (branch, PID, alive/dead) below the normal status text.
Session names use the CONTROL_SESSION_PREFIX = "aipass-" prefix and are managed with direct subprocess calls to tmux inside base_bot.py — not tmux_manager.py's kill_session/list_sessions/has_tmux, which remain ported-but-unwired (see table below).
The command menu is re-registered via BotFather's setMyCommands on every startup (_set_command_menu(), called from run()), so the control bot's /start entry is overridden with control-verb text instead of the generic welcome copy.
/lock
Deployment state, machine config, saga and ops runbook: docs/suspend_lock_deployment.md. This section documents the mechanism; that doc documents what is actually switched on.
Control-bot-only. Password-locks and darkens the screen while every agent keeps running behind the password wall. No root, no sudoers grant, no polkit rule, nothing sleeps, so none of /suspend's wake/grace/reachability machinery applies. Per Patrick's ruling #217 this is the daily-driver verb: the machine stays awake 24/7 and /suspend is retired from routine use.
The bot runs as a systemd --user service, outside the graphical session scope — it has no XDG_SESSION_ID, so a bare loginctl lock-session has no ambient session to resolve and can refuse. _resolve_graphical_session() therefore walks loginctl list-sessions and picks the session whose Type is wayland or x11, State=active, and User equals the bot's own uid (never another user's desktop), then locks it by id. If that path fails or loginctl is missing, it falls back to the GNOME ScreenSaver Lock method on the session bus via gdbus. Only if both fail does it report the failure — a screen that never locked is never acked as locked.
Live-verified twice on 2026-08-02, both resolving session 3 (Type=wayland, State=active, uid 1000): first from a stripped environment with XDG_SESSION_ID/XDG_SESSION_TYPE unset to reproduce the service context (LockedHint no → yes), then by Patrick's own tap in the control chat through the live telegram-bot@base.service after its restart onto v1.5.1. The gdbus fallback is covered by mocked tests only — it has never needed to fire on this machine.
/suspend (DPLAN-0270 P5)
Control-bot-only, and gated by suspend_enabled in the bot config (default true) — an ops kill-switch that grounds the verb without a code edit. /suspend [duration]:
- No argument — heartbeat mode. Arms
rtcwake, suspends, and on each wake opens a grace window; if no human turns up, it re-arms and re-suspends on its own (absorbs spurious wakes without staying up).
8h / 45m — single-wake mode, wakes once after the given duration.
Cadence is adaptive, and all three knobs are bot-config overridable: suspend_active_heartbeat_minutes (default 3) while the conversation is live, suspend_heartbeat_minutes (default 25) once it goes quiet, and suspend_active_window_minutes (default 30) for how recent an inbound counts as live. Liveness is read from the shared presence stamp, so chatting with @devpulse tightens the control bot's beats. This deliberately recreates the Jul 30 - Aug 1 behaviour, where spurious ACPI wakes accidentally duty-cycled the machine in 7-44s beats and chat-behind-suspend felt near-live; once aipass-wake-sources masking made suspend actually stick, that accident stopped and the long beat trapped the conversation.
Resume detection has two triggers: a wall-clock jump in the poll loop larger than RESUME_WALLCLOCK_JUMP_SECONDS (45s — clears both the 30s poll timeout and the 60s network-backoff cap, so neither produces a false positive), and reaching the armed alarm time (_suspend_alarm_at), which catches a nap too short for the gap check to see. An optional systemd system-sleep hook (aipass-resume-signal) writing a resume-stamp file is kept as a third, secondary signal, since it is not proven to fire reliably on the deployed hardware.
Wake cause is decided by comparing the wake against the armed alarm time, not by guessing from gap size: waking more than SUSPEND_EARLY_WAKE_MARGIN_SECONDS (60s) early means a human woke the machine, so the whole cycle is cancelled and the RTC alarm disarmed. At or near the alarm, it's our own RTC and the grace window opens.
The grace window (SUSPEND_GRACE_WINDOW_SECONDS, 180s) is measured from the first successful Telegram poll after resume, not from resume detection — DNS/network needs 45-60s to come back, and the whole reply chain (poll → inject → model turn → send) has to fit inside the window or the machine re-suspends mid-conversation. Re-arming is also held while any bot has an undelivered pending (_turn_in_flight()), so a reply in flight is never cut off.
Human presence crosses processes. Every bot process stamps ~/.aipass/telegram_bots/last_inbound.json on any allowed-user inbound message; the control bot's grace check reads it. The control bot cannot see another bot's traffic in-process, so without this, chatting with @devpulse did not register as "human present" and the machine re-suspended under Patrick's hands (incident 2026-08-02). Any inbound message on any bot now cancels the cycle, not just a control verb on the control bot.
Root-privileged pieces live as reviewable repo files in tools/suspend/, installed by tools/suspend/install_suspend_grants.sh (never applied directly to /etc by an agent):
aipass-suspend-sudoers — passwordless rtcwake for the bot user
60-aipass-suspend.rules — polkit rule for systemctl suspend
aipass-resume-signal — optional system-sleep resume-stamp hook
aipass-wake-sources.sh + aipass-wake-sources.service — opt-in only, via --with-wake-sources. Boot-time oneshot that re-masks a spurious ACPI GPE wake source and disables USB wakeup on affected devices (both reset every reboot). Default is not installed, and reinstalling the grants never brings it back: masking those wakes made suspend real and trapped the conversation (ruling 2026-08-02, compass #216). Compass #217 later superseded the reasoning — the machine now stays awake 24/7 and /lock replaces /suspend entirely — but the opt-in default stands, and the unit is disabled on this machine.
Honest status: /suspend is retired from daily use and grounded as of 2026-08-02 (Patrick's ruling, compass #217) — do not live-test it without him. The v1.5.0 rework worked as designed in a live soak, and Patrick still hit the wall: fixed suspend is still suspend, and real sleep is real disconnect. So the machine now stays awake 24/7 and /lock is the daily driver. The verb stays shipped and tested as a battery-saver, with suspend_enabled as the parking brake; it has still never passed a hands-off overnight soak (DPLAN-0270 test-matrix step T4). Full deployment picture: docs/suspend_lock_deployment.md.
Slash passthrough and the /context relay
An unregistered /xyz message must never reach tmux raw — the TUI's slash menu fuzzy-autocompletes unknown commands into unrelated registered ones. _guard_slash_injection() prefixes a space to anything not on the exact-match allowlist, so the TUI treats it as plain text. Two allowlists feed that one guard:
- Side-effect commands (
DEFAULT_PASSTHROUGH_COMMANDS — clear, compact, prep, memo; config key passthrough_commands) — injected as-is, fire and forget, normal pending-file flow.
- Informational commands (
DEFAULT_INFORMATIONAL_COMMANDS — context; config key informational_commands) — injected as-is, then their stdout is relayed back to the chat.
Why informational commands need their own completion path. A CC local command produces no assistant turn — the caveat wrapper suppresses a reply — so the Stop hook never fires. Writing a pending file for one would leave it undelivered until PENDING_STUCK_TIMEOUT_SECONDS gave up: a fresh flavour of the S179 stuck-pending bug. So _handle_informational_command() writes no pending file and starts no heartbeat. It captures a transcript line-count baseline, injects, and hands off to _relay_slash_stdout() on a daemon thread, which polls every SLASH_STDOUT_POLL_INTERVAL (1s) up to SLASH_STDOUT_TIMEOUT_SECONDS (90s) and then either relays the panel or edits the placeholder to an honest "no output appeared" message. Either way it terminates.
Two transcript shapes, both handled. CC writes a local command's stdout as type=system, subtype=local_command with the payload at the top-level content key (older builds used message.content — both are read), wrapped in <local-command-stdout>…</local-command-stdout>. Current CC emits /context twice: the ANSI-art TUI panel, immediately followed by an isMeta user entry carrying the same content as clean markdown. The twin is preferred; the search for it is bounded to TWIN_LOOKAHEAD_ENTRIES (3) so a later command's meta entry can't be mistaken for this one's output. If only the ANSI panel is present the relay spends exactly one extra poll waiting for the twin, then relays the escape-stripped panel rather than losing the output.
Scope guard — no surprise echo. The relay only ever runs for a command the bot itself injected, enforced twice over: the watcher starts only from handle_message (a TG-inbound message), and the scan is bounded to transcript lines written after the injection baseline. A /context run at the desk or via remote control cannot reach the phone.
Formatting. Output is markdown tables, which Telegram does not render at all, so _format_stdout_for_telegram() strips ANSI, escapes &/</>, and wraps in <pre> — sent with parse_mode="HTML" via the optional send_message(..., parse_mode=...) argument. Everything else still sends as plain text with no parse mode. Chunking wraps each chunk separately, so a <pre> is never split across two messages.
/cost is deliberately not allowlisted. It has never been run on this machine, so its transcript shape is unverified — the allowlist takes only commands whose output shape has actually been observed. Add it via the informational_commands config key once someone has confirmed it behaves like /context.
Streaming replies (DPLAN-0229)
Opt-in per bot via the stream config key (stream: true, default false). When enabled, instead of waiting silently for the Stop hook, _streaming_loop tails the active Claude transcript every STREAM_INTERVAL (2s) and live-edits the "Processing..." placeholder message with the growing response via editMessageText. _stream_edit handles Telegram's edit-specific quirks: a 429 backs off for the given retry_after seconds, and a "message is not modified" 400 is treated as success (no-op edit). The pending file written for the Stop hook still carries a "streaming": True flag either way — streaming is a live preview layered on top of the same finalize-on-Stop-hook flow, not a replacement for it.
user_message_relay — terminal-to-TG mirror
user_message_relay.py is a UserPromptSubmit hook: when you type in a terminal (or any non-TG door) instead of Telegram, it posts that message into the branch's TG chat so the chat reads as the full conversation. It skips: subagent prompts, system/dispatch noise, TG-origin messages (marked with "via Telegram:"), and consecutive duplicate prompts (md5-hashed).
Dual registration is required — an enabled: true flag alone does nothing. The hook needs BOTH:
- An enabled handler entry in
.aipass/hooks.json (project config)
- A matching provider bridge entry in
~/.claude/settings.json
drone @hooks verify cross-checks the two and reports handlers that are enabled in one but missing from the other — run it after any hook registration change.
Bot lookup uses two directories with different naming conventions, searched separately:
MIRROR_DIR (~/.aipass/telegram_bots/) — bot_factory.py shadow configs, named {bot_id}.json
PENDING_DIR (~/.aipass/telegram_pending/) — transcript-relay stream state, named bot-{bot_id}.json
Offline handling — backoff, 409, 429
The main poll loop (run()) tracks two independent backoffs:
- Non-network errors — plain
retry_delay, 5s doubling to a 60s cap.
- Network errors (
_NetworkPollError, raised for connection failures, HTTP 5xx, and HTTP 409 — a second poller holding the long-poll) — exponential backoff from NETWORK_BACKOFF_INIT (1s) to NETWORK_BACKOFF_CAP (60s), with a summary log line every NETWORK_LOG_INTERVAL while still offline and a "reachable again" log on recovery.
HTTP 429 (rate limit) on poll_updates is handled inline — sleep for the retry_after Telegram returns, then return an empty update batch rather than raising.
Secrets
Bot tokens and config accessed via the in-process aipass.api.apps.modules.secrets.get_secret API.
State files (offset, lock, registry) stay with the skill in .local/.
Ported-but-unwired (DPLAN-0220)
This bridge is a partial port of the ~9k-line "Dev-Pass" telegram system. Several
functions are ported but not yet wired — they have no caller today and will be
connected as DPLAN-0220 completes. They are not dead code (do not delete them; see
S249), so seedgo's unused_function check is bypassed for them in
.seedgo/bypass.json. As each one is wired up, remove its bypass entry.
| File | Function(s) | Awaiting |
|---|
base_bot.py | on_response | response hook (Wave-2 design call) |
base_bot.py | _read_transcript_tail | DPLAN-0226 OUT relay — built and tested, wiring pending end-to-end integration |
branch_plugin.py | on_response | per-branch response hook (Wave-2 design call) |
response_router.py | find_pending_bot, clean_expired_pending | response_router import-vs-delete decision |
bot_registry.py | get_bot_by_work_dir | CWD→bot match for the response router |
bot_operations.py | get_all_bots | multi-bot listing |
config.py | get_allowed_user_ids, validate_config | config accessor/validator wiring |
file_handler.py | download_telegram_file, cleanup_file | file up/download feature |
tmux_manager.py | _send_rename, has_tmux, kill_session, list_sessions, get_session_pane | interactive tmux session management — control verbs use their own direct subprocess calls instead |
chunk_text (long-message splitting, now used by scheduler_bot.py) and _extract_assistant_text (DPLAN-0226 OUT relay, now used by base_bot.py) are wired as of this pass and have been removed from this table and from .seedgo/bypass.json.