5.7 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 |
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 fromTELEGRAM_BOT_TOKEN), required.AllowedUserIds, required, non-empty (Telegram__AllowedUserIds__0=123, orTELEGRAM_ALLOWED_USER_IDS=1,2).WorkspaceRoot, default~/.volcanic/telegram-workspaces.Model, defaultclaude-code/claude-opus-5-5.
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):
- After a
getUpdatesfailure the loop callsHandleErrorAsync(PollingError)and then polls again immediately: without a delay it spins while the network is down. - If
HandleErrorAsyncthrows anything butOperationCanceledException, the exception leavesReceiveAsyncand polling is over. - The long-poll timeout equals
HttpClient.Timeout, so an idle poll occasionally times out client side ("Bot API Request timed out"). Harmless. - The offset advances before an update's handler runs and handlers run one at a time.
429withretry_after <= 60is retried inside the client.
Design (PollingService : BackgroundService, BotUpdateHandler : IUpdateHandler, ReconnectPolicy):
HandleErrorAsync(PollingError)never throws. It asksReconnectPolicyfor 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".
OnApiResponseReceivedfor a successfulgetUpdatesresets the policy and, if it was failing, logs "Telegram connection restored after n failed attempts".PollingServicewrapsReceiveAsyncin 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.HandleUpdateAsyncreturns fast: prompts run in background tasks, so the loop keeps receiving (/stoparrives 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: oneAgentSessionper chat, lazy; resumes the most recent session in scopetelegram-<chatId>.ChatRenderer: subscribes once per session, events into a channel, one render task per chat. Each assistant message is sent once, complete, onMessageEnd(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/commandsgo toPromptAsync(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;
/stopaborts a running prompt. - A recorded Claude Code event stream replayed with its timing leaves only the answers in the chat.
TelegramUiselect round-trip through a callback query.- Formatter escaping and splitting.