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.