Files
VolcanicAgenticCore/docs/superpowers/specs/2026-09-25-telegram-host-design.md
EugeneTes d51438e665 feat(telegram): several admins via ADMIN_USER_IDS, each impersonating independently
ADMIN_USER_IDS (Telegram:AdminUserIds) takes a comma separated list; the older
ADMIN_USER_ID / Telegram:AdminUserId still works, also takes a list and is merged.
Empty entries and duplicates are ignored, non-numeric entries fail validation.
Impersonation is now per admin: one admin per target, an impersonating admin
can't be impersonated, and every admin's chat menu lists /impersonate.
2026-10-15 14:02:59 +00:00

6.5 KiB

Telegram host — design

Date: 2026-09-25. Status: approved in chat.

Goal

Chat with the agent through a Telegram bot. A new host project in this repo, run with dotnet run, long polling (no public URL). Implements the contract described in docs/ui-integration.md.

Decisions

Topic Decision
Location hosts/Volcanic.AgenticCore.Telegram (console app, Generic Host) + tests/Volcanic.AgenticCore.Telegram.Tests
Library Telegram.Bot 22.x
Polling The library's built-in loop (ReceiveAsync with an IUpdateHandler), hardened for connection loss (below)
Provider ClaudeCodeProviderExtension only
Model claude-code/claude-opus-5-5, a pinned entry added to ClaudeCodeModels (the CLI accepts full ids in --model)
Tools All built-in tools; each chat works in its own directory <WorkspaceRoot>/<chatId>
Access Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently
Chats Private chats only
Impersonation Optional admins AdminUserIds (ADMIN_USER_IDS, comma separated; the older AdminUserId/ADMIN_USER_ID is merged in), each may /impersonate <user_id> independently (one admin per target, an impersonating admin can't be a target) (0 stops, in memory). The admin's updates are then routed as the target's (allow-list, chat, workspace, session, settings, jobs) and the target chat's output (replies, questions, files, scheduled deliveries) goes to the admin's chat. The target's own updates are dropped with a "an admin is working with your account" notice

Out of scope: webhooks, Docker/Coolify (see 2026-09-26-docker-deployment-design.md), voice and documents, group chats, several bot tokens, a custom polling loop.

Configuration

appsettings.json section Telegram, overridable by environment variables (Telegram__...):

  • BotToken (also read from TELEGRAM_BOT_TOKEN), required.
  • AllowedUserIds, required, non-empty (Telegram__AllowedUserIds__0=123, or TELEGRAM_ALLOWED_USER_IDS=1,2).
  • WorkspaceRoot, default ~/.volcanic/telegram-workspaces.
  • Model, default claude-code/claude-opus-5-5.
  • AdminUserIds (also ADMIN_USER_IDS, comma separated), optional, positive: the users who may /impersonate, all with the same rights. The older AdminUserId (ADMIN_USER_ID, which also takes a list) is merged in; non-numeric entries fail validation.

At start the host logs whether the claude CLI was found; a missing CLI is a warning (the doctor command explains).

Polling and connection recovery

Findings from the Telegram.Bot 22.x source (TelegramBotClientExtensions.Polling.cs):

  1. After a getUpdates failure the loop calls HandleErrorAsync(PollingError) and then polls again immediately: without a delay it spins while the network is down.
  2. If HandleErrorAsync throws anything but OperationCanceledException, the exception leaves ReceiveAsync and polling is over.
  3. The long-poll timeout equals HttpClient.Timeout, so an idle poll occasionally times out client side ("Bot API Request timed out"). Harmless.
  4. The offset advances before an update's handler runs and handlers run one at a time.
  5. 429 with retry_after <= 60 is retried inside the client.

Design (PollingService : BackgroundService, BotUpdateHandler : IUpdateHandler, ReconnectPolicy):

  • HandleErrorAsync(PollingError) never throws. It asks ReconnectPolicy for the next delay and awaits it, which is what holds the built-in loop back: 1 s, 2 s, 4 s … capped at 60 s, ±20 % jitter.
  • 401 Unauthorized (token revoked or wrong) is fatal: log and stop the application.
  • 409 Conflict (another instance polling) is logged as such and backed off like any other error.
  • The first failure is logged at debug level (covers the idle timeout race), later ones as warnings with "Telegram unreachable (attempt n), retrying in x s".
  • OnApiResponseReceived for a successful getUpdates resets the policy and, if it was failing, logs "Telegram connection restored after n failed attempts".
  • PollingService wraps ReceiveAsync in an outer loop: if it ever returns or throws while the host is still running, it is restarted after the policy delay with the last offset kept, so no update is lost or repeated.
  • HandleUpdateAsync returns fast: prompts run in background tasks, so the loop keeps receiving (/stop arrives while a run is active).

Components

  • TelegramOptions + validation.
  • BotUpdateHandler: allow-list, then route: callback query → TelegramUi; reply to a pending input → TelegramUi; bot command → ChatCommands; text/photo → prompt in the background. Errors are reported to the chat.
  • ChatSessions: one AgentSession per chat, lazy; resumes the most recent session in scope telegram-<chatId>.
  • ChatRenderer: subscribes once per session, events into a channel, one render task per chat. Each assistant message is sent once, complete, on MessageEnd (no streaming); text over 4096 chars continues in new messages; typing action every 4 s while running; one status message per run, "⚙️ Working..." with the last 5 tool calls (▶ running, ✓ done, ✗ failed), kept as the last message in the chat (moved below anything sent under it on its next tool update, edited at most every 1.5 s) and deleted when the run settles; retry, compaction, errors and notifications as short lines. Telegram send/edit failures are logged and never stop the render task.
  • TelegramFormatter: Markdown → Telegram HTML (code blocks, inline code, bold, italics, links; everything else escaped); a send that Telegram rejects as bad markup is retried as plain text. Splitting at 4096.
  • TelegramUi : IUserInterface: inline keyboards for select/confirm, ForceReply for input, timeouts.
  • ChatCommands: /start, /help, /new, /stop, /status, /sessions, /resume <id>, /compact, /thinking <level>. Other /commands go to PromptAsync (extension commands).

Testing

xunit, a fake ITelegramBotClient that records requests and scripts getUpdates responses, FauxProvider as model.

  • Polling survives thrown network errors and keeps delivering every update exactly once in order; delays grow and reset.
  • A 401 stops the application.
  • A throwing handler does not stop polling.
  • Unknown users are ignored.
  • A reply is sent once, unedited; long text is split; /stop aborts a running prompt.
  • A recorded Claude Code event stream replayed with its timing leaves only the answers in the chat.
  • TelegramUi select round-trip through a callback query.
  • Formatter escaping and splitting.