This file documents the Telegram bot subsystem. Claude Code auto-loads it when a file under `devplacepy/services/telegram/` is read or edited.

## Telegram bot (`services/telegram/`)

Puts Devii on Telegram. `TelegramService(BaseService)` (`services/telegram/service.py`, `default_enabled=False`, registered in `main.py`) supervises ONE long-poller subprocess (`python -m devplacepy.services.telegram.worker`). Because `BaseService` runs only on the service-lock owner, there is always exactly one poller - mandatory, since Telegram returns **409 Conflict** on concurrent `getUpdates` per token (a load-bearing invariant). The bot token is a **secret `ConfigField`** passed to the subprocess via the `TELEGRAM_BOT_TOKEN` env var, never baked or stored outside `site_settings`.

### Worker (`worker.py`, `backend.py`)

A pure Telegram I/O gateway: long-polls `getUpdates`, handles every Telegram API call, rate-limits, honours `429 retry_after`, treats `409` as transient backoff, and self-exits on stdin EOF (parent death). It imports only stdlib + `devplacepy.stealth` + `format.py` - **no FastAPI app / database import** (verify with `python -c "import devplacepy.services.telegram.worker"`). It speaks NDJSON over stdin/stdout: inbound `{type:"message", chat_id, from_id, text, images:[data-uri]}` on stdout, outbound `{cmd:"send"|"edit"|"chat_action", req_id, ...}` on stdin, plus `{type:"result", req_id, message_id}` and `{type:"log"}` frames. Private chats only. A sent photo is downloaded and inlined as a base64 `data:` URI (capped by `TELEGRAM_IMAGE_MAX_BYTES`); HTTP goes through the stealth client. `backend.py` has a `TelegramBackend` ABC + `HttpTelegramBackend` (real) + `FakeTelegramBackend` (tests).

### Bridge (`bridge.py`)

Runs in the parent (co-located with the Devii hub on the lock owner) and drives Devii **in-process** - NOT via the WebSocket, no WebSocket-from-subprocess. For each inbound message it resolves the chat to a pairing (`store.py`), then for a paired user opens a `hub.get_or_create("user", uid, ..., channel="telegram")` session, attaches a per-turn `TelegramConnection` (an object exposing `async send_json` - the same sink shape `session.attach` expects, reusing the `routers/devii.py` quota gate and the shared ledger), runs the same quota gate as `routers/devii.py` (`devii.daily_limit_for` / `spent_24h`, shared ledger), and calls `session.spawn_turn(content, audit_text=...)`. Turns serialize per chat via a lock and are bounded by a global semaphore. The connection sends a `Devii is thinking...` placeholder, refreshes a `typing` chat action every ~4s, throttle-edits the placeholder with tool progress, and on the `reply` frame edits it into the final answer (markdown -> Telegram HTML via `format.py`, split at 4096 with a plain-text fallback; overflow becomes extra messages) - so **a turn updates ONE Telegram message** (placeholder -> typing -> progress edits -> final answer) instead of spamming. Browser/avatar frames are resolved immediately as unsupported (no web client on Telegram). `_emit` frames never reach an open web terminal because the thread is its own channel.

### `channel="telegram"` isolation

A dedicated conversation thread, isolated from the web `main` thread, but `hub.get_or_create` is patched so this channel still uses the persistent `db` (shared lessons/behavior/tasks/quota with `main`) - `hub.get_or_create` treats `telegram` like `main` for `owned_db`, so lessons/behavior/tasks/quota are shared; only `devii_conversations` is keyed per channel. The scheduler only starts on `main`, so the Telegram session never double-runs it; scheduled-task pushes to Telegram go through the `telegram_send` tool from the user's `main` session.

### Pairing (`store.py`, `routers/profile/telegram.py`)

`POST /profile/{username}/telegram` (owner-or-admin, mirrors `ai_correction`) issues a single-use 4 digit code (sha256-hashed, unique among active codes, reissue invalidates prior, TTL `telegram_code_ttl_minutes`) into `telegram_pairings`, or unpairs. Sending the code to the bot binds `telegram_links` (chat_id+from_id -> user_uid); wrong attempts are throttled per chat in memory. Tables are ensured in `init_db`. Profile shows a Telegram card (`static/js/TelegramPairing.js`).

### `telegram_send` Devii tool

`services/devii/telegram/`, `handler="telegram"`, `requires_auth=True`, audited `telegram.send`. Looks up the paired chat, requires the service running on the lock owner, and delivers a markdown message. Use it for notifications from a turn or a scheduled task.

### Image vision (turn extension)

`session.spawn_turn`/`_run_turn` and `agent.respond` accept `str | list` for image vision; the bridge builds an OpenAI `[{type:text},{type:image_url,image_url:{url:<data-uri>}}]` content list, which the gateway `VisionAugmenter` describes when the route has a vision model. After the turn the structured message is redacted to a short `[image]` placeholder so `devii_conversations` never stores base64.

### Exact per-user cost (vision included)

The turn authenticates the gateway with the paired user's own api_key, so `resolve_owner` books both the chat call and the vision sub-call to `gateway_usage_ledger` under that user (authoritative). For Devii's own per-user ledger and 24h cap to also be exact, the gateway folds the vision sub-call cost into the chat response `X-Gateway-Cost-USD` (and exposes `X-Gateway-Vision-Cost-USD`), and `services/devii/llm.py` records that native cost via `cost.record_cost` instead of estimating from tokens (`CostTracker.has_native` makes `cost_usd()["total"]` the gateway's real cost). This applies to every gateway consumer, not just Telegram; with no gateway header it falls back to token-based pricing.

### Live logs and stats

Worker output streams to the pubsub topic `admin.services.telegram.logs` and the service detail page's live pane (`ServiceMonitor.appendLive`); it is never persisted. `collect_metrics` reports messages in/out, edits, errors, average response latency, uptime, and token presence.

### Audit and nginx

Audit events: `telegram.pair.request|success|failure`, `telegram.unpair`, `telegram.send` (category `telegram`). nginx note: the bot uses outbound long-polling, so no inbound WebSocket location is needed.
