-
Notifications
You must be signed in to change notification settings - Fork 0
Telegram Bot, Commands & Pairing
termsprawl exposes a phone-friendly control surface over Telegram: the desktop app long-polls the Telegram Bot API, maps incoming chat messages onto workspace and terminal operations, and streams a live terminal pane back to the chat on demand. The feature is deliberately split into pure core logic (src/core/telegram/*, electron-free and network-free) and a main-process runtime (src/main/telegram/bot.ts) that owns the polling loop, pairing persistence, and the bridge to live app state.
| File | Runs in | Responsibility |
|---|---|---|
src/core/telegram/api.ts |
any | Zero-dependency typed wrapper over the Telegram Bot API: one telegramRequest helper plus a TelegramClient with getMe, deleteWebhook, getUpdates, sendMessage, sendChatAction. Fetch is injectable. |
src/core/telegram/pairing.ts |
any | Pure allowlist logic: decide whether a chat is allowed, needs pairing, or is denied, and append a newly paired chat id. |
src/core/telegram/commands.ts |
any | Pure command surface: parse /command args, format replies, sanitize terminal output, and dispatch through an AppAdapter so it is unit-testable without a bot, PTY, or network. |
src/main/telegram/bot.ts |
Electron main | Runtime: long-poll loop, AppAdapter implementation over WorkspaceStore + PtyManager, pairing persistence, and /attach output streaming. |
The core files carry no Electron or network dependencies; bot.ts itself is electron-free too, with every dependency injected (TelegramBotDeps), which is what makes the fake-fetch unit tests possible.
telegramRequest<T>(token, method, params, fetchImpl, signal) is the single call primitive. It:
- builds
https://api.telegram.org/bot<token>/<method>; - sends a GET when
paramsis empty, otherwise a POST with a JSON body; -
never throws on HTTP/API errors — it normalizes the response to
{ ok, result } | { ok: false, error, errorCode }, mapping Telegram'serror_code(or the HTTP status) intoerrorCode. Documented examples:401bad token,409another poller,400bad request.
TelegramClient is a thin typed facade: getMe, deleteWebhook (required so long-polling getUpdates does not collide with a webhook and return 409), getUpdates(offset, timeout = 20), sendMessage, and sendChatAction. The class deliberately does not own a loop — "callers own the loop," which is the bot runtime.
pairingDecision(chatId, allowedChatIds) returns one of three outcomes:
| Allowlist state | Chat in list | Decision |
|---|---|---|
| empty | — |
pair-me (the first contact becomes the owner) |
| non-empty | yes | allowed |
| non-empty | no | denied |
The security posture is secure by default: nothing works until a chat is paired. An empty allowlist is the only onboarding window, and it closes the moment any chat is added — after that, unlisted chats can never self-pair. addPairedChat appends a chat id, deduped, and returns a new array.
Persistence happens in the runtime, not the core: handleCommand receives a pairChat(chatId) callback, and bot.ts implements it as saveAllowedChatIds(addPairedChat(deps.allowedChatIds(), id)) followed by a log line. The allowlist is read fresh on every update (deps.allowedChatIds()), so a pairing made by the bot is immediately visible to the settings panel and vice versa.
parseCommand accepts only text starting with /, lowercases the command head, and splits arguments on whitespace. handleCommand(ctx, adapter) applies the pairing gate before the command switch:
-
denied→ theNOT_PAIREDmessage telling the user to add the chat id in Settings → Telegram → allowed chats. -
pair-mewith anything other than/start→ "say /start to pair this chat with termsprawl". -
pair-mewith/start→ persist the chat and reply with a paired confirmation plus the help text. -
allowed→ dispatch the command.
| Command | Behavior | Adapter touchpoints |
|---|---|---|
/start, /help
|
Print the command list. | — |
/projects |
Numbered list of projects with location (remoteLabel for remotes, cwd, or "no folder"), closed marker, and live terminal count. |
listProjects() |
/terminals |
Bulleted list of live terminal ids with their project name ("unowned" when not attributed). | listTerminals() |
/send <id> <text> |
Writes text + \r into the terminal; reports "sent to <id>" or "terminal <id> is gone". |
resolveTerminalId, writeTerminal
|
/peek <id> |
One-shot sanitized, truncated snapshot of recent pane output. |
resolveTerminalId, captureTerminal
|
/attach <id> |
Starts streaming; the initial reply includes the current pane. |
resolveTerminalId, captureTerminal, attach marker |
/detach |
Stops streaming for this chat. |
detach marker |
/status |
Version plus project and live-terminal counts. | status() |
Notable pure helpers:
-
resolveTerminalId— exact match first, then a unique prefix match, so phone-typed short ids work but ambiguity resolves tonull("no terminal matching …"). -
truncate— caps every reply atMAX_REPLY_CHARS = 4096(Telegram's hard limit), appending a…(+N chars)note. -
sanitizePane— strips ANSI CSI/OSC/charset escapes and other control characters (keeping\nand\t), trims trailing whitespace per line, and collapses runs of blank lines, so/peekreads as tidy text instead of raw terminal noise. -
Formatting —
formatProjects,formatTerminals, andformatHelpare separate, testable functions.
handleCommand is pure: it returns CommandResult (replies, optional attach, optional detach) and performs no I/O. The caller decides how to deliver those markers.
createTelegramBot(deps) builds a TelegramClient, a streamers map keyed by chat id, and the local state running, abort (AbortController), and offset.
The adapter is where commands meet the live app:
-
status— version fromdeps.version(), project count fromworkspaceStore.snapshot().index.projects, live terminal count fromptyManager.liveSessionIds(). -
listProjects— maps eachProjectMetato aBotProject, usingremoteLabel(p.remote)for remote projects,(closed)suffixes, and per-project session counts. -
listTerminals— walksptyManager.liveSessionIds()and attributes each session to a project by scanningsessionIdsForProject. -
writeTerminal— guards withptyManager.has(id), thenptyManager.write(id,${text}\r)(Enter included); returnsfalsefor unknown ids. -
captureTerminal— delegates toptyManager.capturePane(id).
handleUpdate(update) ignores updates with no message, no text, or empty text. Otherwise it calls handleCommand with the fresh allowlist and the pairing callback, joins the non-empty reply fragments with \n so a chat never gets a blank or split bubble, sends exactly one message per command when there is content, and finally acts on result.attach (startStream) or result.detach (stopStream).
-
start()— no-op if already running; creates theAbortControllerand setsrunning. It then callsgetMeunder a 12-second guard timer: becausefetchhas no default timeout, a proxy/DNS stall would otherwise leave the bot "starting" forever, so the timer aborts and logsgetMe timed out — bot not started. On non-ok(or the abort path) it resetsrunning/abortand returns. On success it callsdeleteWebhook(so long-polling works) and startspollLoop()without awaiting it. -
pollLoop()—while (running && abort)callsgetUpdates(offset, 20, signal). For successful batches it advancesoffsettoupdate_id + 1(only whenupdate_id >= offset) and awaitshandleUpdatefor each update before continuing. On failure:-
401→ log "bad token", stop permanently. -
409→ log "another poller is running", stop permanently. - anything else → log and retry after
NETWORK_BACKOFF_MS = 3000.
-
-
stop()— flipsrunning, aborts the controller, clears it, and stops every active streamer.isRunning()exposes the flag.
startStream(chatId, terminalId) first stops any existing stream for that chat (one stream per chat, switching replaces), then installs a setInterval at ATTACH_INTERVAL_MS = 2000 holding a Streamer { terminalId, lastText, startedAt, timer }. Each tick:
- after
ATTACH_MAX_MS = 5 * 60 * 1000→ sendauto-detached from <id> (5 min)and stop; - if the captured pane is null/empty and
ptyManager.has(terminalId)is false → sendterminal <id> endedand stop; - if the pane differs from
lastText→ updatelastTextand send the truncated pane (diff-style streaming, no duplicate bubbles).
stopStream clears the interval and removes the map entry.
sequenceDiagram
autonumber
participant TG as Telegram Bot API
participant Bot as createTelegramBot (main/telegram/bot.ts)
participant Cmd as handleCommand (core/telegram/commands.ts)
participant App as AppAdapter → workspaceStore / ptyManager
participant Set as Settings allowlist
Bot->>TG: getUpdates(offset, timeout=20)
TG-->>Bot: TelegramUpdate[]
Bot->>Bot: offset = update_id + 1
Bot->>Cmd: handleCommand({chatId, text, allowedChatIds}, adapter)
Cmd->>Cmd: pairingDecision(chatId, allowedChatIds)
alt denied (non-empty allowlist, chat not listed)
Cmd-->>Bot: [NOT_PAIRED]
else pair-me and name != "start"
Cmd-->>Bot: [say /start to pair]
else pair-me and /start
Cmd->>Set: pairChat(chatId) → addPairedChat + saveAllowedChatIds
Cmd-->>Bot: [paired … help]
else allowed
Cmd->>App: listProjects / listTerminals / write / capture
App-->>Cmd: data
Cmd-->>Bot: replies (+ attach / detach markers)
end
Bot->>TG: sendMessage(chatId, joined non-empty replies)
opt result.attach
Bot->>Bot: startStream(chatId, terminalId)
end
Key nodes: the pairing decision happens inside the pure command layer, but persistence is pushed back out through the pairChat callback; the adapter boundary is the only place live app state is touched; and attach/detach are data markers, so streaming is started by the runtime, never by the command layer.
stateDiagram-v2
[*] --> Stopped
Stopped --> Starting: start()
Starting --> Stopped: getMe failed / 12s timeout
Starting --> Polling: getMe ok + deleteWebhook
Polling --> Polling: getUpdates ok → handleUpdate per update
Polling --> Backoff: transient error → 3s sleep
Backoff --> Polling: retry
Polling --> Stopped: stop() | 401 bad token | 409 conflict
Key nodes: Starting is where the bot can silently wedge without the 12-second guard; 401 and 409 are terminal states rather than retries, because retrying a bad token or a webhook conflict would only loop; only transient network failures enter Backoff.
-
4096-char replies — every user-facing payload passes through
truncate, including streamed panes. -
Output hygiene —
/peekand the initial/attachreply runsanitizePane; the recurring stream tick sends the raw captured pane throughtruncateonly, so streamed frames can still contain terminal control sequences. - Terminal identity — exact-or-unique-prefix resolution; ambiguous or missing prefixes produce a friendly error instead of guessing.
-
Missing terminal —
writeTerminalreturnsfalsewhen the PTY is gone; streaming detects a vanished PTY and announcesterminal <id> ended. -
Text-only updates — updates without
message.textare dropped, andrepliesare filtered for whitespace-only fragments so no blank bubble is sent. -
Empty-stream edge — a
Streamerstarts withlastText = '', so the first interval tick sends the pane again if it is non-empty. -
Stop idempotence —
start()andstop()both short-circuit when already in the target state;stop()tears down all stream timers. -
Token handling — the token comes from
TERMSPRAWL_TELEGRAM_TOKEN(wins) orsettings.telegram.token, and is never committed to the repo. Start/stop is driven byAppSettings.telegram(enabled + token + allowlist) throughsyncTelegramBot()inmain/index.ts.
-
New Telegram API method — add a typed method on
TelegramClientinapi.ts; it inherits injectable fetch and normalized error handling. -
New command — add a case to the
handleCommandswitch, extendAppAdapterif it needs new app capabilities, implement it inbot.ts's adapter, and add the line toformatHelp(). -
New reply side effect — add an optional marker field to
CommandResult(likeattach/detach) and handle it inhandleUpdate, keeping the pure/impure split intact. -
Different transport or persistence —
TelegramBotDepsinjectsallowedChatIds,saveAllowedChatIds,workspaceStore,ptyManager,version,log, andfetchImpl, so tests and alternate hosts can substitute all of them. -
Pairing policy —
pairingDecisionandaddPairedChatare pure, so richer admission rules (expiry, quotas) can be layered without touching the runtime.
Sources: src/core/telegram/api.ts, src/core/telegram/pairing.ts, src/core/telegram/commands.ts, src/core/telegram/commands.ts, src/main/telegram/bot.ts, src/main/telegram/bot.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance