Skip to content

Telegram Bot, Commands & Pairing

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Module map

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.

Telegram API client (api.ts)

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 params is 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's error_code (or the HTTP status) into errorCode. Documented examples: 401 bad token, 409 another poller, 400 bad 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.

Pairing model (pairing.ts)

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.

Command surface (commands.ts)

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:

  1. denied → the NOT_PAIRED message telling the user to add the chat id in Settings → Telegram → allowed chats.
  2. pair-me with anything other than /start → "say /start to pair this chat with termsprawl".
  3. pair-me with /start → persist the chat and reply with a paired confirmation plus the help text.
  4. 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 to null ("no terminal matching …").
  • truncate — caps every reply at MAX_REPLY_CHARS = 4096 (Telegram's hard limit), appending a …(+N chars) note.
  • sanitizePane — strips ANSI CSI/OSC/charset escapes and other control characters (keeping \n and \t), trims trailing whitespace per line, and collapses runs of blank lines, so /peek reads as tidy text instead of raw terminal noise.
  • Formatting — formatProjects, formatTerminals, and formatHelp are 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.

Bot runtime (bot.ts)

createTelegramBot(deps) builds a TelegramClient, a streamers map keyed by chat id, and the local state running, abort (AbortController), and offset.

AppAdapter wiring

The adapter is where commands meet the live app:

  • status — version from deps.version(), project count from workspaceStore.snapshot().index.projects, live terminal count from ptyManager.liveSessionIds().
  • listProjects — maps each ProjectMeta to a BotProject, using remoteLabel(p.remote) for remote projects, (closed) suffixes, and per-project session counts.
  • listTerminals — walks ptyManager.liveSessionIds() and attributes each session to a project by scanning sessionIdsForProject.
  • writeTerminal — guards with ptyManager.has(id), then ptyManager.write(id, ${text}\r) (Enter included); returns false for unknown ids.
  • captureTerminal — delegates to ptyManager.capturePane(id).

Update handling

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, poll, stop

  • start() — no-op if already running; creates the AbortController and sets running. It then calls getMe under a 12-second guard timer: because fetch has no default timeout, a proxy/DNS stall would otherwise leave the bot "starting" forever, so the timer aborts and logs getMe timed out — bot not started. On non-ok (or the abort path) it resets running/abort and returns. On success it calls deleteWebhook (so long-polling works) and starts pollLoop() without awaiting it.
  • pollLoop() — while (running && abort) calls getUpdates(offset, 20, signal). For successful batches it advances offset to update_id + 1 (only when update_id >= offset) and awaits handleUpdate for 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() — flips running, aborts the controller, clears it, and stops every active streamer. isRunning() exposes the flag.

/attach streaming

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 → send auto-detached from <id> (5 min) and stop;
  • if the captured pane is null/empty and ptyManager.has(terminalId) is false → send terminal <id> ended and stop;
  • if the pane differs from lastText → update lastText and 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
Loading

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
Loading

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.

Boundary conditions

  • 4096-char replies — every user-facing payload passes through truncate, including streamed panes.
  • Output hygiene — /peek and the initial /attach reply run sanitizePane; the recurring stream tick sends the raw captured pane through truncate only, 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 — writeTerminal returns false when the PTY is gone; streaming detects a vanished PTY and announces terminal <id> ended.
  • Text-only updates — updates without message.text are dropped, and replies are filtered for whitespace-only fragments so no blank bubble is sent.
  • Empty-stream edge — a Streamer starts with lastText = '', so the first interval tick sends the pane again if it is non-empty.
  • Stop idempotence — start() and stop() 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) or settings.telegram.token, and is never committed to the repo. Start/stop is driven by AppSettings.telegram (enabled + token + allowlist) through syncTelegramBot() in main/index.ts.

Extension points

  • New Telegram API method — add a typed method on TelegramClient in api.ts; it inherits injectable fetch and normalized error handling.
  • New command — add a case to the handleCommand switch, extend AppAdapter if it needs new app capabilities, implement it in bot.ts's adapter, and add the line to formatHelp().
  • New reply side effect — add an optional marker field to CommandResult (like attach/detach) and handle it in handleUpdate, keeping the pure/impure split intact.
  • Different transport or persistence — TelegramBotDeps injects allowedChatIds, saveAllowedChatIds, workspaceStore, ptyManager, version, log, and fetchImpl, so tests and alternate hosts can substitute all of them.
  • Pairing policy — pairingDecision and addPairedChat are 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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally