forked from admin/VolcanicAgenticCore
While on, the admin's updates are routed as the target user's (allow-list, chat, workspace, session, settings, jobs) and the target chat's output goes to the admin's chat. The target's own updates are dropped with a notice that an admin is working with their account. In memory; /impersonate 0 stops.
97 lines
6.2 KiB
Markdown
97 lines
6.2 KiB
Markdown
# 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 `AdminUserId` (`ADMIN_USER_ID`) may `/impersonate <user_id>` (`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`.
|
|
- `AdminUserId` (also `ADMIN_USER_ID`), optional, positive: the user who may `/impersonate`.
|
|
|
|
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.
|