Compare commits

...

3 Commits

Author SHA1 Message Date
EugeneTes
308e1fbf8e feat(telegram): telegram bot host with connection recovery 2026-09-25 14:42:12 +00:00
EugeneTes
2321906719 feat(claude-code): pinned claude-opus-5-5 model 2026-09-25 14:42:12 +00:00
EugeneTes
8a97a9890f docs: telegram host design 2026-09-25 14:29:32 +00:00
30 changed files with 2379 additions and 5 deletions

View File

@@ -2,7 +2,7 @@
A headless, extensible agent harness for .NET, modelled on [earendil-works/pi](https://github.com/earendil-works/pi).
It keeps the parts of pi that make it *extensible* and *provider-agnostic* and leaves out the terminal UI,
so you can put any front end on top (a Telegram bot is the intended first one).
so you can put any front end on top. A Telegram bot ships as the first one.
It also ships a C# port of [chem/pi-claude-code-provider](https://github.com/chem/pi-claude-code-provider):
an extension that drives the locally installed `claude` CLI on a Claude subscription, so the core can use
@@ -17,6 +17,7 @@ Claude Code as a model provider without an API key.
| Anthropic Messages API provider | `extensions/Volcanic.AgenticCore.Extensions.Anthropic` | `ANTHROPIC_API_KEY` |
| OpenAI-compatible provider | `extensions/Volcanic.AgenticCore.Extensions.OpenAI` | OpenAI, OpenRouter, Groq, Ollama, llama.cpp, vLLM |
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
| Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list |
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API |
@@ -34,6 +35,9 @@ ANTHROPIC_API_KEY=sk-ant-... dotnet run --project samples/Volcanic.AgenticCore.S
# with a Claude subscription through the claude CLI
dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --model claude-code/sonnet "List the files here"
# the Telegram bot (see docs/telegram-bot.md)
TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
Hosting it yourself:
@@ -62,6 +66,7 @@ await session.PromptAsync("Explain this repository");
- [Architecture](docs/architecture.md): projects, vertical slices, the agent loop and the event model.
- [Writing extensions](docs/extensions.md): tools, commands, lifecycle hooks, providers, message injection, discovery.
- [Providers and models](docs/providers.md): model keys, authentication, the built-in provider extensions.
- [Telegram bot](docs/telegram-bot.md): setup, commands, security, connection recovery.
- [Building a UI (Telegram guide)](docs/ui-integration.md): how to put a chat front end on the core.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor command.
- Design spec and plan: `docs/superpowers/specs/`, `docs/superpowers/plans/`.

View File

@@ -5,6 +5,9 @@
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
</Folder>
<Folder Name="/hosts/">
<Project Path="hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/Volcanic.AgenticCore.Sample.Headless/Volcanic.AgenticCore.Sample.Headless.csproj" />
</Folder>
@@ -14,6 +17,7 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -20,7 +20,7 @@ Register it and pick a model:
```csharp
o.AddExtension<ClaudeCodeProviderExtension>();
o.DefaultModel = "claude-code/sonnet"; // or opus, haiku, fable
o.DefaultModel = "claude-code/sonnet"; // or opus, haiku, fable, or the pinned claude-opus-5-5
```
Run `/claude-code-doctor` in a session to check the installation, auth, required CLI flags and the bridge

View File

@@ -0,0 +1,91 @@
# 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, 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`.
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.
Placeholder message on assistant `MessageStart`; text deltas edit it at most every 1.5 s; final edit on `MessageEnd`;
text over 4096 chars continues in new messages; typing action every 4 s while running; tool lines
`▶ bash: <args>` → `✓ bash` / `✗ bash: <excerpt>`; 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 prompt streams into an edited message; long text is split; `/stop` aborts a running prompt.
- `TelegramUi` select round-trip through a callback query.
- Formatter escaping and splitting.

75
docs/telegram-bot.md Normal file
View File

@@ -0,0 +1,75 @@
# Telegram bot
`hosts/Volcanic.AgenticCore.Telegram` lets you chat with the agent from Telegram. It uses long polling (no public
URL, no webhook), gives every private chat its own agent session and workspace, and runs the model through the
Claude Code provider (`claude-code/claude-opus-5-5`) on your Claude subscription.
## Setup
1. Create a bot with [@BotFather](https://t.me/BotFather) (`/newbot`) and copy the token.
2. Find your numeric Telegram user id, for example by messaging [@userinfobot](https://t.me/userinfobot).
3. Install Claude Code on the machine that runs the bot and log in (`claude`, then `/login`). Check with
`claude --version`; see [claude-code-provider.md](claude-code-provider.md).
4. Run:
```bash
TELEGRAM_BOT_TOKEN=123456:ABC... \
TELEGRAM_ALLOWED_USER_IDS=111111111 \
dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
Open the bot in Telegram and say hello.
## Configuration
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment):
| Setting | Default | Notes |
|---|---|---|
| `TELEGRAM_BOT_TOKEN` / `Telegram:BotToken` | — | Required |
| `TELEGRAM_ALLOWED_USER_IDS` / `Telegram:AllowedUserIds` | — | Required, comma separated. The bot refuses to start without it |
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>` |
| `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves |
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of a streaming reply |
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
its most recent conversation.
## Using it
Write normally. The reply streams into one message (split into several past Telegram's 4096 character limit);
each tool call shows as a line, `▶ bash: ls -la` becoming `✓ bash` or `✗ bash` with an excerpt of the error.
A message sent while the agent works steers the current run. Photos (with or without a caption) are passed to the model.
| Command | |
|---|---|
| `/new` | Start a new conversation |
| `/stop` | Stop the current run |
| `/status` | Model, thinking level, session, context usage, workspace |
| `/sessions`, `/resume <id>` | List and resume earlier conversations (an id prefix is enough) |
| `/compact` | Summarise the conversation to free context |
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
| `/help` | Short help |
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` work.
When an extension asks something, the question appears with buttons (or as a reply prompt for free text).
## Security
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids
are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a
container, and treat the workspace root as writable by anyone on the allow-list.
## Connection loss
Polling uses Telegram.Bot's built-in loop with a hardened error handler:
- a failed `getUpdates` waits 1 s, 2 s, 4 s … up to 60 s (±20 % jitter) before the next attempt, instead of retrying
in a tight loop; the first successful poll resets the delay and logs `Telegram connection restored`;
- the update offset only moves past updates that were handed on, so reconnecting neither loses nor repeats messages;
- if the loop itself ever ends while the host runs, it is restarted from the last offset;
- a `401` (revoked or wrong token) is the one error it does not retry: the host logs it and exits with code 1.
Replies that fail to send (Telegram unreachable mid-run) are logged; the render loop keeps going and later edits
catch the message up.

View File

@@ -7,7 +7,8 @@ The core has no user interface. A UI is a process that
3. subscribes to `AgentSessionEvent`s and renders them,
4. implements `IUserInterface` so extensions can ask the user questions.
This guide uses Telegram as the worked example. Everything transfers to Slack, Discord, a web socket
This guide uses Telegram as the worked example; the finished bot lives in `hosts/Volcanic.AgenticCore.Telegram`
(see [telegram-bot.md](telegram-bot.md)) and is the reference for everything below. Everything transfers to Slack, Discord, a web socket
front end or a desktop app; only the rendering code changes.
## 1. One session per chat

View File

@@ -30,8 +30,10 @@ public static class ClaudeCodeModels
// Haiku has no effort control; Claude Code still owns its thinking default.
public static readonly Model Haiku = Create("haiku", "Claude Code Haiku", 200_000, 32_000, reasoning: false);
public static readonly Model Fable = Create("fable", "Claude Code Fable", 1_000_000, 64_000, reasoning: true);
// A full model id pins the version; the aliases above follow whatever Claude Code maps them to.
public static readonly Model Opus55 = Create("claude-opus-5-5", "Claude Code Opus 5.5", 1_000_000, 128_000, reasoning: true);
public static IReadOnlyList<Model> All { get; } = [Sonnet, Opus, Haiku, Fable];
public static IReadOnlyList<Model> All { get; } = [Sonnet, Opus, Haiku, Fable, Opus55];
/// <summary>The <c>--effort</c> value for a request, or null to omit the flag.</summary>
public static string? EffortFor(Model model, ThinkingLevel? level)

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Volcanic.AgenticCore.Telegram.Chats;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>
/// Best-effort start-up calls: log who the bot is and publish the command menu. Retried in the background until the
/// first success, so a host started without network still comes up.
/// </summary>
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, ILogger<BotSetupService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var delay = options.ReconnectInitialDelay;
while (!stoppingToken.IsCancellationRequested)
{
try
{
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(ChatCommands.Menu, cancellationToken: stoppingToken).ConfigureAwait(false);
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
return;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Bot setup failed; retrying in {Delay}", delay);
}
try
{
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, options.ReconnectMaxDelay.Ticks));
}
}
}

View File

@@ -0,0 +1,171 @@
using System.Globalization;
using System.Text;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
public sealed class ChatCommands(TelegramSender sender)
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "new", Description = "Start a new conversation" },
new() { Command = "stop", Description = "Stop the current run" },
new() { Command = "status", Description = "Model, session and context usage" },
new() { Command = "sessions", Description = "Recent conversations" },
new() { Command = "resume", Description = "Resume a conversation: /resume <id>" },
new() { Command = "compact", Description = "Summarise the conversation to free context" },
new() { Command = "thinking", Description = "Show or set the thinking level" },
new() { Command = "help", Description = "What this bot does" },
];
/// <summary>Split <c>/name@bot args</c>. Null when the text is not a command.</summary>
public static (string Name, string Args)? Parse(string text)
{
if (!text.StartsWith('/') || text.Length < 2) return null;
var space = text.IndexOfAny([' ', '\n']);
var head = space < 0 ? text[1..] : text[1..space];
var args = space < 0 ? "" : text[(space + 1)..].Trim();
var at = head.IndexOf('@');
if (at >= 0) head = head[..at];
return (head.ToLowerInvariant(), args);
}
/// <summary>Run a bot command. False when <paramref name="text"/> is not one of ours.</summary>
public async Task<bool> TryHandleAsync(ChatContext chat, string text, CancellationToken cancellationToken)
{
if (Parse(text) is not { } command) return false;
var session = chat.Session;
switch (command.Name)
{
case "start":
case "help":
await ReplyAsync(chat, Help(chat), cancellationToken).ConfigureAwait(false);
return true;
case "stop":
if (!session.IsStreaming && !session.IsCompacting)
{
await ReplyAsync(chat, "Nothing is running.", cancellationToken).ConfigureAwait(false);
return true;
}
await session.AbortAsync().ConfigureAwait(false);
return true; // the renderer reports "Stopped."
case "new":
if (session.IsStreaming) await session.AbortAsync().ConfigureAwait(false);
var created = await session.NewSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
await ReplyAsync(chat, created.Cancelled ? "An extension cancelled the new session." : "🆕 New conversation.", cancellationToken).ConfigureAwait(false);
return true;
case "status":
await ReplyAsync(chat, Status(chat), cancellationToken).ConfigureAwait(false);
return true;
case "sessions":
await ReplyAsync(chat, await SessionsAsync(chat, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "resume":
await ReplyAsync(chat, await ResumeAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "compact":
if (session.IsStreaming)
{
await ReplyAsync(chat, "Wait until the current run finishes, or /stop it.", cancellationToken).ConfigureAwait(false);
return true;
}
_ = Task.Run(async () =>
{
try
{
await session.CompactAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
chat.Report($"Compaction failed: {ex.Message}");
}
}, CancellationToken.None);
return true; // the renderer shows compaction progress
case "thinking":
await ReplyAsync(chat, await ThinkingAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
default:
return false;
}
}
private Task ReplyAsync(ChatContext chat, string text, CancellationToken cancellationToken) =>
sender.SendHtmlAsync(chat.ChatId, TelegramFormatter.ToHtml(text), text, cancellationToken: cancellationToken);
private static string Help(ChatContext chat) =>
$"""
I am a coding agent running **{chat.Session.Model?.Name ?? "an unconfigured model"}**. Write to me as you would to a colleague; I can read and change files and run shell commands in my workspace `{chat.Session.Cwd}`.
While I am working, a new message steers the current run.
/new – start a new conversation
/stop – stop the current run
/status – model, session and context usage
/sessions, /resume <id> – earlier conversations
/compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level
""";
private static string Status(ChatContext chat)
{
var session = chat.Session;
var sb = new StringBuilder();
sb.AppendLine(CultureInfo.InvariantCulture, $"**Model:** {session.Model?.Key ?? "none"}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Thinking:** {session.ThinkingLevel.ToString().ToLowerInvariant()}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Session:** `{session.SessionId}`{(session.SessionName is { } name ? $" ({name})" : "")}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Messages:** {session.Messages.Count}");
if (session.GetContextUsage() is { } usage)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
return sb.ToString();
}
private static async Task<string> SessionsAsync(ChatContext chat, CancellationToken cancellationToken)
{
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
if (sessions.Count == 0) return "No conversations yet.";
var sb = new StringBuilder("**Recent conversations** (newest first):\n");
foreach (var s in sessions.Take(10))
{
var title = s.Name ?? s.FirstUserText ?? "(empty)";
if (title.Length > 60) title = title[..60] + "…";
var current = s.SessionId == chat.Session.SessionId ? " ← current" : "";
sb.Append(CultureInfo.InvariantCulture, $"\n`/resume {s.SessionId}`{current}\n{s.Modified:yyyy-MM-dd HH:mm} · {s.MessageCount} messages · {title}\n");
}
return sb.ToString();
}
private static async Task<string> ResumeAsync(ChatContext chat, string id, CancellationToken cancellationToken)
{
if (id.Length == 0) return "Usage: /resume <id> (see /sessions).";
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
var matches = sessions.Where(s => s.SessionId.StartsWith(id, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count != 1) return matches.Count == 0 ? $"No conversation `{id}`." : $"`{id}` matches several conversations; use more characters.";
if (chat.Session.IsStreaming) await chat.Session.AbortAsync().ConfigureAwait(false);
var result = await chat.Session.SwitchSessionAsync(matches[0].SessionId, cancellationToken).ConfigureAwait(false);
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
}
private static async Task<string> ThinkingAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
var session = chat.Session;
var available = string.Join(", ", session.AvailableThinkingLevels.Select(l => l.ToString().ToLowerInvariant()));
if (arg.Length == 0) return $"Thinking: **{session.ThinkingLevel.ToString().ToLowerInvariant()}**. Available: {available}.";
if (!Enum.TryParse<ThinkingLevel>(arg, ignoreCase: true, out var level) || !session.AvailableThinkingLevels.Contains(level))
return $"Unknown level `{arg}`. Available: {available}.";
await session.SetThinkingLevelAsync(level, cancellationToken).ConfigureAwait(false);
return $"Thinking: **{session.ThinkingLevel.ToString().ToLowerInvariant()}**.";
}
}

View File

@@ -0,0 +1,135 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Everything that belongs to one Telegram chat: its agent session, renderer and dialog surface.</summary>
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ILogger logger)
: IAsyncDisposable
{
public long ChatId => chatId;
public AgentSession Session => session;
public TelegramUi Ui => ui;
public ChatRenderer Renderer => renderer;
/// <summary>The most recent background prompt; completes when that run has settled.</summary>
public Task LastPrompt { get; private set; } = Task.CompletedTask;
/// <summary>
/// Send user input without waiting for the run (it can take minutes, and the polling loop must keep receiving so
/// <c>/stop</c> gets through). While a run is active the text steers it.
/// </summary>
public Task PromptInBackground(string text, IReadOnlyList<ImageContent>? images = null) => LastPrompt = Task.Run(async () =>
{
try
{
await session.PromptAsync(text, images, StreamingBehavior.Steer).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Prompt in chat {ChatId} failed", chatId);
Report($"The request failed: {ex.Message}");
}
});
/// <summary>Show an error in the chat, in order with the rendered events.</summary>
public void Report(string message) => renderer.Post(new AgentSessionEvent.Notification(message, NotifyKind.Error));
public async ValueTask DisposeAsync()
{
subscription.Dispose();
await session.DisposeAsync().ConfigureAwait(false);
await renderer.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// One <see cref="ChatContext"/> per chat, created on first use. Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>,
/// stores its sessions under scope <c>telegram-&lt;chatId&gt;</c> and resumes the most recent one after a restart.
/// </summary>
public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender sender, TelegramOptions options, ILoggerFactory loggerFactory)
: IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
public static string ScopeFor(long chatId) => $"telegram-{chatId}";
public string WorkspaceFor(long chatId) => Path.Combine(options.WorkspaceRoot, chatId.ToString(System.Globalization.CultureInfo.InvariantCulture));
/// <summary>The chat's context if it was already created, without creating it.</summary>
public ChatContext? Find(long chatId) =>
_chats.TryGetValue(chatId, out var lazy) && lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully ? lazy.Value.Result : null;
public async Task<ChatContext> GetAsync(long chatId, CancellationToken cancellationToken = default)
{
var lazy = _chats.GetOrAdd(chatId, id => new Lazy<Task<ChatContext>>(() => CreateAsync(id)));
try
{
return await lazy.Value.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception) when (lazy.Value.IsFaulted)
{
_chats.TryRemove(new KeyValuePair<long, Lazy<Task<ChatContext>>>(chatId, lazy)); // let the next message try again
throw;
}
}
private async Task<ChatContext> CreateAsync(long chatId)
{
var cwd = WorkspaceFor(chatId);
Directory.CreateDirectory(cwd);
var scope = ScopeFor(chatId);
var last = (await factory.ListSessionsAsync(cwd, scope).ConfigureAwait(false)).FirstOrDefault();
var logger = loggerFactory.CreateLogger<ChatContext>();
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
AgentSession session;
try
{
session = await factory.CreateAsync(new AgentSessionOptions
{
Cwd = cwd,
Scope = scope,
SessionId = last?.SessionId,
Model = options.Model,
Ui = ui,
}).ConfigureAwait(false);
}
catch
{
await renderer.DisposeAsync().ConfigureAwait(false);
throw;
}
var subscription = session.Subscribe(renderer.Post);
_logger.LogInformation("Chat {ChatId}: {Action} session {SessionId} in {Cwd}", chatId, last is null ? "new" : "resumed", session.SessionId, cwd);
foreach (var error in session.ExtensionErrors) _logger.LogWarning("Chat {ChatId}: extension error {Source}: {Error}", chatId, error.Source, error.Error);
return new ChatContext(chatId, session, ui, renderer, subscription, logger);
}
public async ValueTask DisposeAsync()
{
foreach (var lazy in _chats.Values)
{
if (!lazy.IsValueCreated) continue;
try
{
await (await lazy.Value.ConfigureAwait(false)).DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Disposing a chat failed");
}
}
_chats.Clear();
}
}

View File

@@ -0,0 +1,92 @@
using System.Collections.Concurrent;
using Telegram.Bot.Types.ReplyMarkups;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// <see cref="IUserInterface"/> for one chat: choices become inline keyboards (callback data <c>ui:&lt;id&gt;:&lt;index&gt;</c>),
/// free-text questions a ForceReply prompt answered by the chat's next plain message.
/// </summary>
public sealed class TelegramUi(long chatId, TelegramSender sender, ChatRenderer renderer) : IUserInterface
{
public const string CallbackPrefix = "ui:";
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(10);
private readonly ConcurrentDictionary<string, TaskCompletionSource<string?>> _choices = new();
private TaskCompletionSource<string?>? _input;
/// <summary>True while an <see cref="InputAsync"/> question waits for its answer.</summary>
public bool IsWaitingForInput => Volatile.Read(ref _input) is { Task.IsCompleted: false };
public async Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken)
{
if (options.Count == 0) return null;
var id = Guid.NewGuid().ToString("N")[..8];
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
_choices[id] = tcs;
try
{
var keyboard = new InlineKeyboardMarkup(options.Select((option, i) =>
new[] { InlineKeyboardButton.WithCallbackData(option, $"{CallbackPrefix}{id}:{i}") }));
var messageId = await sender.SendTextAsync(chatId, title, keyboard, cancellationToken).ConfigureAwait(false);
if (messageId is null) return null;
var index = await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false);
var choice = int.TryParse(index, out var i) && i >= 0 && i < options.Count ? options[i] : null;
var outcome = choice is null ? "— no answer" : $"→ {choice}";
await sender.EditHtmlAsync(chatId, messageId.Value, TelegramFormatter.Escape($"{title}\n{outcome}"), $"{title}\n{outcome}", CancellationToken.None)
.ConfigureAwait(false);
return choice;
}
finally
{
_choices.TryRemove(id, out _);
}
}
public async Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) =>
await SelectAsync($"{title}\n{message}", ["Yes", "No"], requestOptions, cancellationToken).ConfigureAwait(false) == "Yes";
public async Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
Interlocked.Exchange(ref _input, tcs)?.TrySetResult(null); // one question at a time: a newer one cancels the older
try
{
var markup = new ForceReplyMarkup { InputFieldPlaceholder = placeholder };
if (await sender.SendTextAsync(chatId, title, markup, cancellationToken).ConfigureAwait(false) is null) return null;
return await WaitAsync(tcs, requestOptions, cancellationToken).ConfigureAwait(false);
}
finally
{
Interlocked.CompareExchange(ref _input, null, tcs);
}
}
public void Notify(string message, NotifyKind kind = NotifyKind.Info) =>
renderer.Post(new Abstractions.Events.AgentSessionEvent.Notification(message, kind));
/// <summary>Status lines are not shown in Telegram; tool and retry progress is rendered from session events.</summary>
public void SetStatus(string key, string? text) { }
/// <summary>Resolve a pending choice from callback data. False when it is not ours or no longer pending.</summary>
public bool TryResolveCallback(string data)
{
if (!data.StartsWith(CallbackPrefix, StringComparison.Ordinal)) return false;
var parts = data[CallbackPrefix.Length..].Split(':');
return parts.Length == 2 && _choices.TryGetValue(parts[0], out var tcs) && tcs.TrySetResult(parts[1]);
}
/// <summary>Answer a pending <see cref="InputAsync"/> question. False when none is pending.</summary>
public bool TryResolveInput(string text) => Interlocked.Exchange(ref _input, null) is { } tcs && tcs.TrySetResult(text);
private static async Task<string?> WaitAsync(TaskCompletionSource<string?> tcs, UiRequestOptions? requestOptions, CancellationToken cancellationToken)
{
using var timeout = new CancellationTokenSource(requestOptions?.Timeout ?? DefaultTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken);
await using var registration = linked.Token.Register(() => tcs.TrySetResult(null)).ConfigureAwait(false);
return await tcs.Task.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// Routes an update to its chat: only allow-listed users in private chats get through; button presses and answers go to
/// the chat's pending dialog, bot commands run directly, everything else becomes a prompt that runs in the background.
/// </summary>
public sealed class UpdateRouter(
TelegramOptions options,
ChatRegistry chats,
ChatCommands commands,
TelegramSender sender,
ILogger<UpdateRouter> logger) : IUpdateRouter
{
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
{
switch (update)
{
case { CallbackQuery: { } query }:
await OnCallbackAsync(query, cancellationToken).ConfigureAwait(false);
break;
case { Message: { } message }:
await OnMessageAsync(message, cancellationToken).ConfigureAwait(false);
break;
}
}
private async Task OnCallbackAsync(CallbackQuery query, CancellationToken cancellationToken)
{
if (!options.IsAllowed(query.From.Id))
{
logger.LogWarning("Ignored a button press from user {UserId} (not allowed)", query.From.Id);
return;
}
var resolved = query.Message is { } message && query.Data is { } data
&& chats.Find(message.Chat.Id) is { } chat && chat.Ui.TryResolveCallback(data);
await sender.AnswerCallbackAsync(query.Id, resolved ? null : "This question has expired.", cancellationToken).ConfigureAwait(false);
}
private async Task OnMessageAsync(Message message, CancellationToken cancellationToken)
{
if (!options.IsAllowed(message.From?.Id))
{
logger.LogWarning("Ignored a message from user {UserId} ({Username}) in chat {ChatId}: not in the allow-list",
message.From?.Id, message.From?.Username, message.Chat.Id);
return;
}
if (message.Chat.Type != ChatType.Private)
{
logger.LogInformation("Ignored a message in non-private chat {ChatId}", message.Chat.Id);
return;
}
var text = message.Text ?? message.Caption ?? "";
if (text.Length == 0 && message.Photo is not { Length: > 0 })
{
await sender.SendTextAsync(message.Chat.Id, "I can read text and photos.", cancellationToken: cancellationToken).ConfigureAwait(false);
return;
}
var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false);
var isCommand = ChatCommands.Parse(text) is not null;
if (!isCommand && chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
if (isCommand && await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return;
var images = await DownloadPhotoAsync(chat, message, cancellationToken).ConfigureAwait(false);
if (text.Length == 0 && images is null) return; // photo download failed and already reported
_ = chat.PromptInBackground(text, images);
}
private async Task<IReadOnlyList<ImageContent>?> DownloadPhotoAsync(ChatContext chat, Message message, CancellationToken cancellationToken)
{
if (message.Photo is not { Length: > 0 } sizes) return null;
try
{
var largest = sizes.MaxBy(p => p.FileSize ?? (long)p.Width * p.Height)!;
using var stream = new MemoryStream();
await sender.Bot.GetInfoAndDownloadFile(largest.FileId, stream, cancellationToken).ConfigureAwait(false);
return [new ImageContent(Convert.ToBase64String(stream.ToArray()), "image/jpeg")];
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
chat.Report("I could not download that photo.");
return null;
}
}
}

View File

@@ -0,0 +1,153 @@
using System.Net;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Args;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Polling;
using Telegram.Bot.Requests;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace Volcanic.AgenticCore.Telegram.Polling;
/// <summary>Receives one update. Must return quickly: the polling loop waits for it before the next update.</summary>
public interface IUpdateRouter
{
Task RouteAsync(Update update, CancellationToken cancellationToken);
}
/// <summary>
/// Runs Telegram.Bot's built-in long-polling loop and makes it survive connection loss.
/// <para>
/// The built-in loop polls again right after a failed <c>getUpdates</c> (a busy loop while offline) and ends for good
/// if the error handler throws. So the error handler here never throws and waits out a backoff delay, which is what
/// holds the loop back; a successful <c>getUpdates</c> response resets the backoff. <c>401</c> is the only fatal error.
/// If the loop still ends while the host runs, it is restarted from the last offset, so no update is lost or repeated.
/// </para>
/// </summary>
public sealed class PollingService(
ITelegramBotClient bot,
IUpdateRouter router,
ReconnectPolicy policy,
IHostApplicationLifetime lifetime,
ILogger<PollingService> logger) : BackgroundService, IUpdateHandler
{
internal static readonly UpdateType[] AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery];
private int _offset;
/// <summary>The next update id to request; everything below it has been handed to the router.</summary>
public int Offset => Volatile.Read(ref _offset);
/// <summary>Set when polling stopped the application because retrying cannot help (a rejected token).</summary>
public Exception? FatalError { get; private set; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
bot.OnApiResponseReceived += OnApiResponseReceived;
try
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
logger.LogInformation("Polling Telegram for updates");
await bot.ReceiveAsync(this, new ReceiverOptions { Offset = Offset, AllowedUpdates = AllowedUpdates }, stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (FatalPollingException ex)
{
logger.LogCritical(ex.InnerException, "{Reason}. Stopping.", ex.Message);
FatalError = ex;
lifetime.StopApplication();
return;
}
catch (Exception ex)
{
logger.LogError(ex, "The Telegram polling loop failed; restarting it");
}
if (stoppingToken.IsCancellationRequested) return;
if (!await DelayAsync(policy.NextDelay(), stoppingToken).ConfigureAwait(false)) return;
}
}
finally
{
bot.OnApiResponseReceived -= OnApiResponseReceived;
}
}
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
{
Volatile.Write(ref _offset, update.Id + 1);
MarkConnected();
try
{
await router.RouteAsync(update, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Handling update {UpdateId} failed", update.Id);
}
}
public async Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
{
if (source != HandleErrorSource.PollingError)
{
logger.LogError(exception, "Telegram update handling failed ({Source})", source);
return;
}
if (exception is ApiRequestException { ErrorCode: (int)HttpStatusCode.Unauthorized })
throw new FatalPollingException("Telegram rejected the bot token (401 Unauthorized)", exception);
var delay = policy.NextDelay();
var failures = policy.Failures;
if (exception is ApiRequestException { ErrorCode: (int)HttpStatusCode.Conflict })
logger.LogWarning("Telegram reports another poller or a webhook for this bot (409): {Message}. Retrying in {Delay:0.#} s",
exception.Message, delay.TotalSeconds);
else if (failures == 1)
// A single failure is usually the long-poll timeout racing the HTTP timeout; only repeated ones matter.
logger.LogDebug(exception, "Polling Telegram failed; retrying in {Delay:0.#} s", delay.TotalSeconds);
else
logger.LogWarning("Telegram unreachable (attempt {Attempt}): {Message}. Retrying in {Delay:0.#} s",
failures, exception.Message, delay.TotalSeconds);
await DelayAsync(delay, cancellationToken).ConfigureAwait(false);
}
private ValueTask OnApiResponseReceived(ITelegramBotClient botClient, ApiResponseEventArgs args, CancellationToken cancellationToken)
{
if (args.ApiRequestEventArgs.Request is GetUpdatesRequest && args.ResponseMessage.IsSuccessStatusCode) MarkConnected();
return ValueTask.CompletedTask;
}
private void MarkConnected()
{
var failures = policy.Reset();
if (failures > 1) logger.LogInformation("Telegram connection restored after {Failures} failed attempts", failures);
}
/// <returns>False when the host is stopping.</returns>
private static async Task<bool> DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
return false;
}
}
}
/// <summary>A polling error retrying cannot fix.</summary>
public sealed class FatalPollingException(string message, Exception inner) : Exception(message, inner);

View File

@@ -0,0 +1,27 @@
namespace Volcanic.AgenticCore.Telegram.Polling;
/// <summary>
/// Exponential backoff with jitter for consecutive polling failures. Thread-safe: failures are counted by the polling
/// loop, the reset comes from the HTTP response callback.
/// </summary>
public sealed class ReconnectPolicy(TimeSpan initialDelay, TimeSpan maxDelay, double jitter = 0.2, Random? random = null)
{
private readonly Random _random = random ?? Random.Shared;
private int _failures;
/// <summary>Consecutive failures since the last success.</summary>
public int Failures => Volatile.Read(ref _failures);
/// <summary>Record a failure and return how long to wait before the next attempt.</summary>
public TimeSpan NextDelay()
{
var failures = Interlocked.Increment(ref _failures);
var exponent = Math.Min(failures - 1, 30);
var ms = Math.Min(maxDelay.TotalMilliseconds, initialDelay.TotalMilliseconds * Math.Pow(2, exponent));
var factor = 1 + ((_random.NextDouble() * 2) - 1) * jitter;
return TimeSpan.FromMilliseconds(Math.Max(0, ms * factor));
}
/// <summary>Record a success. Returns the number of failures it ends (0 when the connection was healthy).</summary>
public int Reset() => Interlocked.Exchange(ref _failures, 0);
}

View File

@@ -0,0 +1,55 @@
// Telegram front end for the agent: long polling, one agent session per private chat, Claude Code as the model.
//
// TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
var builder = Host.CreateApplicationBuilder(args);
var options = builder.Configuration.GetSection(TelegramOptions.SectionName).Get<TelegramOptions>() ?? new TelegramOptions();
options.ApplyEnvironment(Environment.GetEnvironmentVariable);
var errors = options.Validate();
if (errors.Count > 0)
{
foreach (var error in errors) Console.Error.WriteLine(error);
return 2;
}
builder.Services.AddSingleton(options);
builder.Services.AddAgenticCore(o =>
{
o.DefaultModel = options.Model;
o.AddExtension<ClaudeCodeProviderExtension>();
});
builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(options.BotToken));
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
builder.Services.AddSingleton<TelegramSender>();
builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton<ChatCommands>();
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
builder.Services.AddSingleton<PollingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
builder.Services.AddHostedService<BotSetupService>();
using var host = builder.Build();
var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Volcanic.AgenticCore.Telegram");
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else
logger.LogWarning("Claude Code (claude) was not found on PATH; requests will fail until it is installed and logged in. Send /claude-code-doctor to the bot for details.");
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);
var polling = host.Services.GetRequiredService<PollingService>();
await host.RunAsync(); // disposes the host
return polling.FatalError is null ? 0 : 1;

View File

@@ -0,0 +1,309 @@
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using static Volcanic.AgenticCore.Abstractions.Events.AgentSessionEvent;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>
/// Renders one chat's session events. Session handlers run on the agent loop, so events are queued into a channel and
/// rendered by a single task: streamed text edits one message (split into several past the size limit) at most every
/// <see cref="TelegramOptions.EditInterval"/>, tool calls become status lines, and "typing…" repeats while the agent runs.
/// </summary>
public sealed class ChatRenderer : IAsyncDisposable
{
private const int ToolExcerptLength = 300;
private readonly long _chatId;
private readonly TelegramSender _sender;
private readonly TelegramOptions _options;
private readonly ILogger _logger;
private readonly TimeProvider _time;
private readonly Channel<AgentSessionEvent> _events = Channel.CreateUnbounded<AgentSessionEvent>(new UnboundedChannelOptions { SingleReader = true });
private readonly CancellationTokenSource _stop = new();
private readonly Task _loop;
// Streaming reply state; touched only by the render loop.
private readonly List<(int MessageId, string Sent)> _replyMessages = [];
private readonly Dictionary<string, (int MessageId, string Args)> _toolMessages = [];
private string _replyText = "";
private bool _replyDirty;
private DateTimeOffset _lastFlush = DateTimeOffset.MinValue;
private CancellationTokenSource? _typing;
private int _pending;
public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null)
{
_chatId = chatId;
_sender = sender;
_options = options;
_logger = logger;
_time = time ?? TimeProvider.System;
_loop = Task.Run(RunAsync);
}
/// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary>
public void Post(AgentSessionEvent e)
{
Interlocked.Increment(ref _pending);
if (!_events.Writer.TryWrite(e)) Interlocked.Decrement(ref _pending);
}
/// <summary>Completes once every queued event is rendered. For tests.</summary>
internal async Task DrainAsync()
{
while (Volatile.Read(ref _pending) > 0 || _replyDirty) await Task.Delay(10).ConfigureAwait(false);
}
private async Task RunAsync()
{
var reader = _events.Reader;
var ct = _stop.Token;
try
{
while (true)
{
if (_replyDirty)
{
var wait = _lastFlush + _options.EditInterval - _time.GetUtcNow();
if (wait <= TimeSpan.Zero)
{
await FlushReplyAsync(ct).ConfigureAwait(false);
continue;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(wait);
try
{
if (!await reader.WaitToReadAsync(timeout.Token).ConfigureAwait(false)) break;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
continue; // edit interval elapsed: flush on the next iteration
}
}
else if (!await reader.WaitToReadAsync(ct).ConfigureAwait(false))
{
break;
}
while (reader.TryRead(out var e))
{
try
{
await RenderAsync(e, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Rendering {Event} for chat {ChatId} failed", e.GetType().Name, _chatId);
}
finally
{
Interlocked.Decrement(ref _pending);
}
}
}
if (_replyDirty) await FlushReplyAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
finally
{
StopTyping();
}
}
private async Task RenderAsync(AgentSessionEvent e, CancellationToken ct)
{
switch (e)
{
case AgentStart:
StartTyping();
break;
case AgentSettled:
StopTyping();
break;
case MessageStart { Message: AssistantMessage }:
ResetReply();
break;
case MessageUpdate { AssistantMessageEvent: AssistantMessageEvent.TextDelta } update:
_replyText = update.Message.Text;
_replyDirty = true;
break;
case MessageEnd { Message: AssistantMessage message }:
_replyText = message.Text;
_replyDirty = true;
await FlushReplyAsync(ct).ConfigureAwait(false);
if (message.StopReason == StopReason.Error)
await SendLineAsync($"❌ {message.ErrorMessage ?? "The model request failed."}", ct).ConfigureAwait(false);
else if (message.StopReason == StopReason.Aborted)
await SendLineAsync("⏹ Stopped.", ct).ConfigureAwait(false);
ResetReply();
break;
case ToolExecutionStart start:
// Keep the reply above the tool line in the chat.
if (_replyDirty) await FlushReplyAsync(ct).ConfigureAwait(false);
var args = FormatArgs(start.Args);
if (await SendHtmlLineAsync($"▶ <b>{TelegramFormatter.Escape(start.ToolName)}</b>{args}", ct).ConfigureAwait(false) is { } id)
_toolMessages[start.ToolCallId] = (id, args);
break;
case ToolExecutionEnd end:
var started = _toolMessages.Remove(end.ToolCallId, out var tool);
var line = end.IsError
? $"✗ <b>{TelegramFormatter.Escape(end.ToolName)}</b>{tool.Args}{FormatExcerpt(end.Result)}"
: $"✓ <b>{TelegramFormatter.Escape(end.ToolName)}</b>{tool.Args}";
if (started)
await _sender.EditHtmlAsync(_chatId, tool.MessageId, line, StripTags(line), ct).ConfigureAwait(false);
else
await SendHtmlLineAsync(line, ct).ConfigureAwait(false);
break;
case AutoRetryStart retry:
await SendLineAsync($"⏳ {retry.ErrorMessage} — retrying in {retry.Delay.TotalSeconds:0} s ({retry.Attempt}/{retry.MaxAttempts})", ct).ConfigureAwait(false);
break;
case AutoRetryEnd { Success: false } retryEnd:
await SendLineAsync($"❌ Giving up after {retryEnd.Attempt} attempts: {retryEnd.FinalError}", ct).ConfigureAwait(false);
break;
case CompactionStart:
await SendLineAsync("🗜 Compacting the conversation…", ct).ConfigureAwait(false);
break;
case CompactionEnd { ErrorMessage: { } error }:
await SendLineAsync($"❌ Compaction failed: {error}", ct).ConfigureAwait(false);
break;
case CompactionEnd { Result: { } result }:
await SendLineAsync($"🗜 Compacted: ~{result.TokensBefore:N0} → ~{result.EstimatedTokensAfter:N0} tokens.", ct).ConfigureAwait(false);
break;
case Notification notification:
await SendLineAsync($"{Icon(notification.Kind)} {notification.Message}", ct).ConfigureAwait(false);
break;
case ExtensionError error:
_logger.LogWarning("Extension {Extension} failed on {Event}: {Error}", error.ExtensionName, error.Event, error.Error);
await SendLineAsync($"⚠️ {error.ExtensionName}: {error.Error}", ct).ConfigureAwait(false);
break;
}
}
private async Task FlushReplyAsync(CancellationToken ct)
{
_replyDirty = false;
_lastFlush = _time.GetUtcNow();
if (string.IsNullOrWhiteSpace(_replyText)) return;
var chunks = TelegramFormatter.Split(_replyText);
for (var i = 0; i < chunks.Count; i++)
{
var chunk = chunks[i];
var html = TelegramFormatter.ToHtml(chunk);
if (i < _replyMessages.Count)
{
if (_replyMessages[i].Sent == chunk) continue;
if (await _sender.EditHtmlAsync(_chatId, _replyMessages[i].MessageId, html, chunk, ct).ConfigureAwait(false))
_replyMessages[i] = (_replyMessages[i].MessageId, chunk);
}
else if (await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false) is { } id)
{
_replyMessages.Add((id, chunk));
}
else
{
break; // try the rest on the next flush
}
}
}
private void ResetReply()
{
_replyMessages.Clear();
_replyText = "";
_replyDirty = false;
_lastFlush = DateTimeOffset.MinValue;
}
private Task<int?> SendLineAsync(string text, CancellationToken ct) =>
_sender.SendHtmlAsync(_chatId, TelegramFormatter.Escape(text), text, cancellationToken: ct);
private Task<int?> SendHtmlLineAsync(string html, CancellationToken ct) =>
_sender.SendHtmlAsync(_chatId, html, StripTags(html), cancellationToken: ct);
private void StartTyping()
{
if (_typing is not null) return;
var cts = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token);
_typing = cts;
_ = Task.Run(async () =>
{
try
{
while (!cts.IsCancellationRequested)
{
await _sender.SendTypingAsync(_chatId, cts.Token).ConfigureAwait(false);
await Task.Delay(_options.TypingInterval, cts.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
});
}
private void StopTyping()
{
var typing = Interlocked.Exchange(ref _typing, null);
typing?.Cancel();
typing?.Dispose();
}
/// <summary>": <code>ls -la</code>" for bash, the most telling argument otherwise.</summary>
internal static string FormatArgs(JsonElement args)
{
if (args.ValueKind != JsonValueKind.Object) return "";
string? summary = null;
foreach (var key in new[] { "command", "path", "pattern", "file_path" })
{
if (args.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
{
summary = value.GetString();
break;
}
}
summary ??= args.EnumerateObject().Any() ? args.GetRawText() : null;
if (string.IsNullOrWhiteSpace(summary)) return "";
return $": <code>{TelegramFormatter.Escape(Shorten(summary, 200))}</code>";
}
private static string FormatExcerpt(ToolResult result)
{
var text = string.Concat(result.Content.OfType<TextContent>().Select(c => c.Text)).Trim();
return text.Length == 0 ? "" : $"\n<pre>{TelegramFormatter.Escape(Shorten(text, ToolExcerptLength))}</pre>";
}
private static string Shorten(string text, int max) => text.Length <= max ? text : text[..max] + "…";
private static string StripTags(string html) =>
System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", ""));
private static string Icon(NotifyKind kind) => kind switch { NotifyKind.Warning => "⚠️", NotifyKind.Error => "❌", _ => "ℹ️" };
public async ValueTask DisposeAsync()
{
_events.Writer.TryComplete();
try
{
await _loop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
catch (TimeoutException)
{
}
await _stop.CancelAsync().ConfigureAwait(false);
StopTyping();
_stop.Dispose();
}
}

View File

@@ -0,0 +1,187 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>
/// Turns the model's Markdown into Telegram HTML and splits long text into message-sized chunks.
/// The output is always well formed: everything is escaped and tags are only emitted in matched pairs, so partial
/// (still streaming) Markdown renders too.
/// </summary>
public static partial class TelegramFormatter
{
/// <summary>Telegram's limit for one message, in UTF-16 code units after entity parsing.</summary>
public const int MessageLimit = 4096;
/// <summary>Raw text per chunk; leaves room for the HTML tags and escapes the conversion adds.</summary>
public const int ChunkLimit = 3500;
private const string Fence = "```";
/// <summary>Escape the three characters Telegram HTML requires; everything else (emoji included) stays as is.</summary>
public static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>Convert Markdown to Telegram HTML.</summary>
public static string ToHtml(string markdown)
{
var html = new StringBuilder();
var lines = markdown.Replace("\r\n", "\n").Split('\n');
var inCode = false;
string? language = null;
var code = new StringBuilder();
void CloseCode()
{
var body = Escape(code.ToString().TrimEnd('\n'));
html.Append(language is { Length: > 0 }
? $"<pre><code class=\"language-{Escape(language)}\">{body}</code></pre>"
: $"<pre>{body}</pre>");
code.Clear();
}
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (line.TrimStart().StartsWith(Fence, StringComparison.Ordinal))
{
if (inCode)
{
CloseCode();
inCode = false;
}
else
{
inCode = true;
language = line.TrimStart()[Fence.Length..].Trim();
}
if (i < lines.Length - 1 && !inCode) html.Append('\n');
continue;
}
if (inCode)
{
code.Append(line).Append('\n');
continue;
}
html.Append(FormatLine(line));
if (i < lines.Length - 1) html.Append('\n');
}
if (inCode) CloseCode(); // unterminated fence while streaming: show what we have as code
return html.ToString();
}
private static string FormatLine(string line)
{
if (HeadingRegex().Match(line) is { Success: true } heading) return $"<b>{FormatInline(heading.Groups[1].Value)}</b>";
if (BulletRegex().Match(line) is { Success: true } bullet) return $"{bullet.Groups[1].Value}• {FormatInline(bullet.Groups[2].Value)}";
return FormatInline(line);
}
/// <summary>Inline code, bold, italics, strikethrough and links. Code spans are cut out first so their content stays literal.</summary>
private static string FormatInline(string text)
{
var result = new StringBuilder();
var last = 0;
foreach (Match m in InlineCodeRegex().Matches(text))
{
result.Append(FormatEmphasis(text[last..m.Index]));
result.Append("<code>").Append(Escape(m.Groups[1].Value)).Append("</code>");
last = m.Index + m.Length;
}
result.Append(FormatEmphasis(text[last..]));
return result.ToString();
}
private static string FormatEmphasis(string text)
{
var s = Escape(text);
s = LinkRegex().Replace(s, m => $"<a href=\"{m.Groups[2].Value}\">{m.Groups[1].Value}</a>");
s = BoldRegex().Replace(s, "<b>$2</b>");
s = StrikeRegex().Replace(s, "<s>$1</s>");
s = ItalicRegex().Replace(s, "$1<i>$3</i>");
return s;
}
/// <summary>
/// Split raw Markdown into chunks of at most <paramref name="limit"/> characters, preferring paragraph, line and
/// word boundaries. A code fence cut by a split is closed at the end of one chunk and reopened in the next.
/// </summary>
public static IReadOnlyList<string> Split(string text, int limit = ChunkLimit)
{
if (text.Length <= limit) return [text];
var chunks = new List<string>();
var rest = text;
string? reopen = null;
while (rest.Length > 0)
{
var prefix = reopen is null ? "" : reopen + "\n";
var budget = limit - prefix.Length - (Fence.Length + 1); // room to close a fence
if (prefix.Length + rest.Length <= limit)
{
chunks.Add(prefix + rest);
break;
}
var (cut, skip) = FindCut(rest, budget);
var piece = rest[..cut];
rest = rest[(cut + skip)..].TrimStart('\n');
var openFence = OpenFence(prefix + piece);
var chunk = prefix + piece.TrimEnd('\n');
if (openFence is not null) chunk += "\n" + Fence;
chunks.Add(chunk);
reopen = openFence;
}
return chunks;
}
/// <returns>Where the chunk ends and how many separator characters to drop after it.</returns>
private static (int Cut, int Skip) FindCut(string text, int budget)
{
budget = Math.Max(1, Math.Min(budget, text.Length));
var window = text[..budget];
foreach (var separator in new[] { "\n\n", "\n", " " })
{
var at = window.LastIndexOf(separator, StringComparison.Ordinal);
if (at > budget / 2) return (at, separator.Length);
}
// Do not split a surrogate pair.
return (char.IsHighSurrogate(text[budget - 1]) ? budget - 1 : budget, 0);
}
/// <summary>The opening fence line (with language) when <paramref name="text"/> ends inside a code block.</summary>
private static string? OpenFence(string text)
{
string? open = null;
foreach (var line in text.Split('\n'))
{
var trimmed = line.TrimStart();
if (!trimmed.StartsWith(Fence, StringComparison.Ordinal)) continue;
open = open is null ? trimmed : null;
}
return open;
}
[GeneratedRegex(@"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")]
private static partial Regex HeadingRegex();
[GeneratedRegex(@"^(\s*)[-*+]\s+(.*)$")]
private static partial Regex BulletRegex();
[GeneratedRegex(@"`([^`\n]+)`")]
private static partial Regex InlineCodeRegex();
[GeneratedRegex(@"\[([^\]\n]+)\]\((https?://[^\s)""]+)\)")]
private static partial Regex LinkRegex();
[GeneratedRegex(@"(\*\*|__)(?=\S)(.+?)(?<=\S)\1")]
private static partial Regex BoldRegex();
[GeneratedRegex(@"~~(?=\S)(.+?)(?<=\S)~~")]
private static partial Regex StrikeRegex();
[GeneratedRegex(@"(^|[^\w*])([*_])(?=\S)([^*_\n]+?)(?<=\S)\2(?![\w*])")]
private static partial Regex ItalicRegex();
}

View File

@@ -0,0 +1,126 @@
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>
/// Sends and edits chat messages without ever throwing (except on cancellation): a failed call is logged and reported
/// as null/false. HTML Telegram rejects is resent as plain text; "message is not modified" counts as success.
/// </summary>
public sealed class TelegramSender(ITelegramBotClient bot, ILogger<TelegramSender> logger)
{
public ITelegramBotClient Bot => bot;
/// <summary>Send HTML; returns the message id, or null when sending failed.</summary>
public async Task<int?> SendHtmlAsync(long chatId, string html, string plain, ReplyMarkup? replyMarkup = null, CancellationToken cancellationToken = default)
{
try
{
var message = await bot.SendMessage(chatId, html, parseMode: ParseMode.Html, replyMarkup: replyMarkup,
linkPreviewOptions: NoPreview, cancellationToken: cancellationToken).ConfigureAwait(false);
return message.Id;
}
catch (ApiRequestException ex) when (IsBadMarkup(ex))
{
return await SendTextAsync(chatId, plain, replyMarkup, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Sending a message to chat {ChatId} failed", chatId);
return null;
}
}
/// <summary>Send plain text; returns the message id, or null when sending failed.</summary>
public async Task<int?> SendTextAsync(long chatId, string text, ReplyMarkup? replyMarkup = null, CancellationToken cancellationToken = default)
{
try
{
var message = await bot.SendMessage(chatId, Truncate(text), replyMarkup: replyMarkup,
linkPreviewOptions: NoPreview, cancellationToken: cancellationToken).ConfigureAwait(false);
return message.Id;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Sending a message to chat {ChatId} failed", chatId);
return null;
}
}
/// <summary>Replace a message's text with HTML (plain fallback). Returns false when the edit failed.</summary>
public async Task<bool> EditHtmlAsync(long chatId, int messageId, string html, string plain, CancellationToken cancellationToken = default)
{
try
{
await bot.EditMessageText(chatId, messageId, html, parseMode: ParseMode.Html,
linkPreviewOptions: NoPreview, cancellationToken: cancellationToken).ConfigureAwait(false);
return true;
}
catch (ApiRequestException ex) when (IsNotModified(ex))
{
return true;
}
catch (ApiRequestException ex) when (IsBadMarkup(ex))
{
try
{
await bot.EditMessageText(chatId, messageId, Truncate(plain), linkPreviewOptions: NoPreview,
cancellationToken: cancellationToken).ConfigureAwait(false);
return true;
}
catch (ApiRequestException inner) when (IsNotModified(inner))
{
return true;
}
catch (Exception inner) when (inner is not OperationCanceledException)
{
logger.LogWarning(inner, "Editing message {MessageId} in chat {ChatId} failed", messageId, chatId);
return false;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Editing message {MessageId} in chat {ChatId} failed", messageId, chatId);
return false;
}
}
public async Task SendTypingAsync(long chatId, CancellationToken cancellationToken = default)
{
try
{
await bot.SendChatAction(chatId, ChatAction.Typing, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogDebug(ex, "Sending the typing action to chat {ChatId} failed", chatId);
}
}
public async Task AnswerCallbackAsync(string callbackQueryId, string? text = null, CancellationToken cancellationToken = default)
{
try
{
await bot.AnswerCallbackQuery(callbackQueryId, text, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogDebug(ex, "Answering callback query {Id} failed", callbackQueryId);
}
}
private static readonly LinkPreviewOptions NoPreview = new() { IsDisabled = true };
private static string Truncate(string text) =>
text.Length <= TelegramFormatter.MessageLimit ? text : text[..(TelegramFormatter.MessageLimit - 1)] + "…";
private static bool IsBadMarkup(ApiRequestException ex) =>
ex.ErrorCode == 400 && ex.Message.Contains("parse entities", StringComparison.OrdinalIgnoreCase);
private static bool IsNotModified(ApiRequestException ex) =>
ex.ErrorCode == 400 && ex.Message.Contains("message is not modified", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,62 @@
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>Telegram</c> configuration section.</summary>
public sealed class TelegramOptions
{
public const string SectionName = "Telegram";
public const string DefaultModel = "claude-code/claude-opus-5-5";
/// <summary>Bot token from @BotFather. Also read from <c>TELEGRAM_BOT_TOKEN</c>.</summary>
public string BotToken { get; set; } = "";
/// <summary>Telegram user ids allowed to talk to the bot. Required: the agent runs shell commands.</summary>
public List<long> AllowedUserIds { get; set; } = [];
/// <summary>Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>.</summary>
public string WorkspaceRoot { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "telegram-workspaces");
/// <summary><c>provider/model-id</c> every chat uses.</summary>
public string Model { get; set; } = DefaultModel;
/// <summary>Minimum time between two edits of a streaming reply (Telegram rate-limits edits).</summary>
public TimeSpan EditInterval { get; set; } = TimeSpan.FromSeconds(1.5);
/// <summary>How often the "typing…" action is repeated while the agent runs (Telegram shows it for 5 s).</summary>
public TimeSpan TypingInterval { get; set; } = TimeSpan.FromSeconds(4);
/// <summary>First delay after a failed poll; doubles per consecutive failure up to <see cref="ReconnectMaxDelay"/>.</summary>
public TimeSpan ReconnectInitialDelay { get; set; } = TimeSpan.FromSeconds(1);
public TimeSpan ReconnectMaxDelay { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Reads the environment-variable shortcuts the README documents, when the section leaves them empty.</summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
if (string.IsNullOrWhiteSpace(BotToken)) BotToken = getEnv("TELEGRAM_BOT_TOKEN") ?? "";
if (AllowedUserIds.Count == 0 && getEnv("TELEGRAM_ALLOWED_USER_IDS") is { Length: > 0 } ids)
{
foreach (var part in ids.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries))
if (long.TryParse(part, out var id)) AllowedUserIds.Add(id);
}
}
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(BotToken))
errors.Add("Telegram bot token is missing: set TELEGRAM_BOT_TOKEN or Telegram:BotToken.");
if (AllowedUserIds.Count == 0)
errors.Add("No allowed users: set TELEGRAM_ALLOWED_USER_IDS (comma separated) or Telegram:AllowedUserIds. " +
"The bot runs shell commands, so it refuses to start open to everyone.");
if (string.IsNullOrWhiteSpace(WorkspaceRoot)) errors.Add("Telegram:WorkspaceRoot is empty.");
if (string.IsNullOrWhiteSpace(Model) || !Model.Contains('/')) errors.Add("Telegram:Model must be 'provider/model-id'.");
if (EditInterval <= TimeSpan.Zero || TypingInterval <= TimeSpan.Zero) errors.Add("Telegram intervals must be positive.");
if (ReconnectInitialDelay <= TimeSpan.Zero || ReconnectMaxDelay < ReconnectInitialDelay)
errors.Add("Telegram reconnect delays must be positive with max >= initial.");
return errors;
}
public bool IsAllowed(long? userId) => userId is { } id && AllowedUserIds.Contains(id);
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<UserSecretsId>volcanic-agentic-core-telegram</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<InternalsVisibleTo Include="Volcanic.AgenticCore.Telegram.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Warning",
"System.Net.Http": "Warning"
}
},
"Telegram": {
"BotToken": "",
"AllowedUserIds": [],
"Model": "claude-code/claude-opus-5-5"
}
}

View File

@@ -105,7 +105,7 @@ public class ProviderExtensionTests
Assert.Equal("claude-code-headless", provider.Api);
Assert.Equal("claude-code://local", provider.BaseUrl);
Assert.False(provider.RequiresApiKey);
Assert.Equal(["sonnet", "opus", "haiku", "fable"], provider.Models.Select(m => m.Id).ToArray());
Assert.Equal(["sonnet", "opus", "haiku", "fable", "claude-opus-5-5"], provider.Models.Select(m => m.Id).ToArray());
Assert.All(provider.Models, m => Assert.Equal(ModelCost.Zero, m.Cost));
Assert.Equal(1_000_000, provider.Models[0].ContextWindow);
Assert.Equal(64_000, provider.Models[0].MaxTokens);

View File

@@ -0,0 +1,220 @@
using Telegram.Bot.Requests;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ChatTests
{
[Fact]
public async Task A_prompt_streams_into_one_reply_message()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("Hello **there**, how can I help?"));
await host.SendAsync("hi");
await host.SettleAsync();
var reply = Assert.Single(host.Bot.Sent);
Assert.Equal(ParseMode.Html, reply.ParseMode);
Assert.Equal("Hello <b>there</b>, how can I help?", host.Bot.CurrentTexts.Values.Single());
Assert.Contains(host.Bot.Requests, r => r is SendChatActionRequest);
Assert.Equal("hi", host.Faux.Requests.Single().Messages.Last().Text);
}
[Fact]
public async Task Messages_from_users_outside_the_allow_list_are_ignored()
{
await using var host = new ChatHost();
await host.SendAsync("let me in", userId: Updates.Stranger);
Assert.Empty(host.Bot.Requests);
Assert.Null(host.Chats.Find(Updates.Stranger));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Group_chats_are_ignored()
{
await using var host = new ChatHost();
await host.Router.RouteAsync(Updates.Text("hi all", chatId: -100, chatType: ChatType.Group), CancellationToken.None);
Assert.Empty(host.Bot.Requests);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_long_reply_is_split_across_messages()
{
await using var host = new ChatHost();
var words = string.Join(" ", Enumerable.Range(0, 2000).Select(i => $"word{i}"));
host.Faux.Enqueue(FauxTurn.Say(words));
await host.SendAsync("talk a lot");
await host.SettleAsync();
var texts = host.Texts.ToList();
Assert.True(texts.Count >= 4, $"expected several messages, got {texts.Count}");
Assert.All(texts, t => Assert.True(t.Length <= 4096));
Assert.Equal(words, string.Join(" ", texts));
}
[Fact]
public async Task Tool_calls_show_a_status_line_that_is_completed()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Tool("ls", new { path = "." }), FauxTurn.Say("Nothing here."));
await host.SendAsync("what is here?");
await host.SettleAsync();
var texts = host.Texts.ToList();
Assert.Contains("✓ <b>ls</b>: <code>.</code>", texts);
Assert.Equal("Nothing here.", texts.Last());
Assert.Contains(host.Bot.Sent, s => s.Text.StartsWith("▶ <b>ls</b>", StringComparison.Ordinal));
}
[Fact]
public async Task Stop_aborts_a_running_prompt()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("too late").WithDelay(TimeSpan.FromSeconds(30)));
await host.SendAsync("take your time");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Session.IsStreaming, "the run to start");
await host.SendAsync("/stop");
await host.SettleAsync();
Assert.Contains("⏹ Stopped.", host.Texts);
Assert.DoesNotContain(host.Texts, t => t.Contains("too late", StringComparison.Ordinal));
}
[Fact]
public async Task A_message_while_running_steers_instead_of_failing()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Tool("ls", new { }).WithDelay(TimeSpan.FromMilliseconds(300)), FauxTurn.Say("Adjusted."));
await host.SendAsync("list files");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Session.IsStreaming, "the run to start");
await host.SendAsync("actually, also say hi");
await host.SettleAsync();
Assert.DoesNotContain(host.Texts, t => t.Contains("failed", StringComparison.OrdinalIgnoreCase));
Assert.Contains(host.Faux.Requests.Last().Messages, m => m.Text == "actually, also say hi");
}
[Fact]
public async Task New_starts_a_fresh_session()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("one"));
await host.SendAsync("first");
await host.SettleAsync();
var chat = await host.ChatAsync();
var firstId = chat.Session.SessionId;
await host.SendAsync("/new");
Assert.NotEqual(firstId, chat.Session.SessionId);
Assert.Empty(chat.Session.Messages);
Assert.Contains("🆕 New conversation.", host.Texts);
}
[Fact]
public async Task Status_reports_the_model()
{
await using var host = new ChatHost();
await host.SendAsync("/status@fake_bot");
Assert.Contains(host.Texts, t => t.Contains(FauxProvider.ModelKey, StringComparison.Ordinal));
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Unknown_commands_go_to_the_session()
{
await using var host = new ChatHost();
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.SendAsync("/whatever please");
await host.SettleAsync();
Assert.Equal("/whatever please", host.Faux.Requests.Single().Messages.Last().Text);
}
[Fact]
public async Task Select_is_answered_with_an_inline_keyboard_button()
{
await using var host = new ChatHost();
await host.SendAsync("/status"); // creates the chat
var chat = await host.ChatAsync();
var answer = chat.Ui.SelectAsync("Pick one", ["red", "green"], null, CancellationToken.None);
await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard");
var keyboard = (InlineKeyboardMarkup)host.Bot.Sent.Last(s => s.ReplyMarkup is InlineKeyboardMarkup).ReplyMarkup!;
var green = keyboard.InlineKeyboard.SelectMany(row => row).Single(b => b.Text == "green");
await host.Router.RouteAsync(Updates.Callback(green.CallbackData!, Updates.AllowedUser), CancellationToken.None);
Assert.Equal("green", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Contains(host.Bot.Requests, r => r is AnswerCallbackQueryRequest);
Assert.Contains("Pick one\n→ green", host.Texts);
}
[Fact]
public async Task Input_is_answered_by_the_next_plain_message()
{
await using var host = new ChatHost();
await host.SendAsync("/status");
var chat = await host.ChatAsync();
var answer = chat.Ui.InputAsync("Your name?", "name", null, CancellationToken.None);
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the question");
await host.SendAsync("Ada");
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Empty(host.Faux.Requests); // the answer is not a prompt
}
[Fact]
public async Task Buttons_from_strangers_are_ignored()
{
await using var host = new ChatHost();
await host.SendAsync("/status");
var chat = await host.ChatAsync();
var answer = chat.Ui.SelectAsync("Pick", ["a"], new(TimeSpan.FromMilliseconds(300)), CancellationToken.None);
await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.ReplyMarkup is InlineKeyboardMarkup), "the keyboard");
var data = ((InlineKeyboardMarkup)host.Bot.Sent.Last().ReplyMarkup!).InlineKeyboard.First().First().CallbackData!;
await host.Router.RouteAsync(Updates.Callback(data, Updates.AllowedUser, userId: Updates.Stranger), CancellationToken.None);
Assert.Null(await answer.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task Telegram_send_failures_do_not_break_rendering()
{
await using var host = new ChatHost();
var failures = 0;
host.Bot.OnSend = r =>
{
if (r is SendChatActionRequest && Interlocked.Increment(ref failures) < 100) throw FakeBotClient.NetworkDown();
};
host.Faux.Enqueue(FauxTurn.Say("still here"));
await host.SendAsync("hello?");
await host.SettleAsync();
Assert.Contains("still here", host.Texts);
}
}

View File

@@ -0,0 +1,87 @@
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class FormattingAndOptionsTests
{
[Theory]
[InlineData("a < b & c > d", "a &lt; b &amp; c &gt; d")]
[InlineData("**bold** and *it* and _it_", "<b>bold</b> and <i>it</i> and <i>it</i>")]
[InlineData("use `x < 1` here", "use <code>x &lt; 1</code> here")]
[InlineData("`**not bold**`", "<code>**not bold**</code>")]
[InlineData("snake_case_name stays", "snake_case_name stays")]
[InlineData("# Title", "<b>Title</b>")]
[InlineData("- item", "• item")]
[InlineData("[docs](https://example.com/a?b=1&c=2)", "<a href=\"https://example.com/a?b=1&amp;c=2\">docs</a>")]
[InlineData("~~gone~~", "<s>gone</s>")]
[InlineData("🆕 Привет \"quoted\" it's", "🆕 Привет \"quoted\" it's")]
public void Markdown_becomes_telegram_html(string markdown, string html) =>
Assert.Equal(html, TelegramFormatter.ToHtml(markdown));
[Fact]
public void Code_blocks_keep_their_content_literal()
{
var html = TelegramFormatter.ToHtml("Run:\n```bash\necho \"<hi>\" **x**\n```\nDone.");
Assert.Equal("Run:\n<pre><code class=\"language-bash\">echo \"&lt;hi&gt;\" **x**</code></pre>\nDone.", html);
}
[Fact]
public void An_unterminated_code_block_while_streaming_is_still_well_formed()
{
Assert.Equal("<pre>partial &lt;</pre>", TelegramFormatter.ToHtml("```\npartial <"));
}
[Fact]
public void Split_prefers_paragraphs_and_reopens_cut_code_blocks()
{
var code = string.Join("\n", Enumerable.Range(0, 40).Select(i => $"line {i}"));
var text = "intro\n\n```python\n" + code + "\n```\nafter";
var chunks = TelegramFormatter.Split(text, limit: 120);
Assert.True(chunks.Count > 2);
Assert.All(chunks, c => Assert.True(c.Length <= 120, $"{c.Length}: {c}"));
Assert.All(chunks, c => Assert.Equal(0, c.Split('\n').Count(l => l.StartsWith("```", StringComparison.Ordinal)) % 2));
Assert.StartsWith("```python\n", chunks[1]);
Assert.Equal(text.Replace("```python\n", "").Replace("```\n", "").Replace("\n", ""),
string.Concat(chunks.Select(c => string.Join("", c.Split('\n').Where(l => !l.StartsWith("```", StringComparison.Ordinal))))));
}
[Fact]
public void Short_text_is_one_chunk() => Assert.Equal(["hi"], TelegramFormatter.Split("hi"));
[Theory]
[InlineData("/stop", "stop", "")]
[InlineData("/resume@my_bot abc", "resume", "abc")]
[InlineData("/Thinking high ", "thinking", "high")]
public void Commands_parse(string text, string name, string args) => Assert.Equal((name, args), ChatCommands.Parse(text));
[Theory]
[InlineData("hello")]
[InlineData("/")]
public void Non_commands_do_not_parse(string text) => Assert.Null(ChatCommands.Parse(text));
[Fact]
public void An_empty_allow_list_is_rejected()
{
var errors = new TelegramOptions { BotToken = "t" }.Validate();
Assert.Contains(errors, e => e.Contains("allowed users", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Environment_shortcuts_fill_the_token_and_users()
{
var env = new Dictionary<string, string> { ["TELEGRAM_BOT_TOKEN"] = "123:abc", ["TELEGRAM_ALLOWED_USER_IDS"] = "1, 2;3" };
var options = new TelegramOptions();
options.ApplyEnvironment(k => env.GetValueOrDefault(k));
Assert.Equal("123:abc", options.BotToken);
Assert.Equal([1L, 2L, 3L], options.AllowedUserIds);
Assert.Empty(options.Validate());
Assert.Equal("claude-code/claude-opus-5-5", options.Model);
Assert.True(options.IsAllowed(2));
Assert.False(options.IsAllowed(4));
Assert.False(options.IsAllowed(null));
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,139 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class PollingServiceTests
{
private sealed class RecordingRouter(Func<Update, Task>? onRoute = null) : IUpdateRouter
{
private readonly List<int> _ids = [];
public IReadOnlyList<int> Ids { get { lock (_ids) return _ids.ToList(); } }
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
{
lock (_ids) _ids.Add(update.Id);
if (onRoute is not null) await onRoute(update);
}
}
private static (PollingService Service, ReconnectPolicy Policy, FakeLifetime Lifetime) Create(
FakeBotClient bot, IUpdateRouter router, int initialMs = 5, int maxMs = 20)
{
var policy = new ReconnectPolicy(TimeSpan.FromMilliseconds(initialMs), TimeSpan.FromMilliseconds(maxMs), jitter: 0);
var lifetime = new FakeLifetime();
return (new PollingService(bot, router, policy, lifetime, NullLogger<PollingService>.Instance), policy, lifetime);
}
[Fact]
public async Task Recovers_from_connection_loss_and_delivers_every_update_once_in_order()
{
var bot = new FakeBotClient()
.EnqueueFailure(FakeBotClient.NetworkDown())
.EnqueueFailure(FakeBotClient.NetworkDown())
.EnqueueUpdates(Updates.WithId(1), Updates.WithId(2))
.EnqueueFailure(FakeBotClient.NetworkDown())
.EnqueueFailure(FakeBotClient.NetworkDown())
.EnqueueFailure(FakeBotClient.NetworkDown())
.EnqueueUpdates(Updates.WithId(3));
var router = new RecordingRouter();
var (service, policy, lifetime) = Create(bot, router);
await service.StartAsync(CancellationToken.None);
await Wait.UntilAsync(() => router.Ids.Count == 3, "three updates");
await Wait.UntilAsync(() => bot.PollOffsets.Count == 8, "the poll after the last update");
await service.StopAsync(CancellationToken.None);
Assert.Equal([1, 2, 3], router.Ids);
// After a failure the same offset is asked again; after updates the offset moves past them.
Assert.Equal([0, 0, 0, 3, 3, 3, 3, 4], bot.PollOffsets);
Assert.Equal(0, policy.Failures);
Assert.False(lifetime.StopRequested);
}
[Fact]
public async Task Backs_off_between_failed_polls_instead_of_spinning()
{
var bot = new FakeBotClient();
for (var i = 0; i < 100; i++) bot.EnqueueFailure(FakeBotClient.NetworkDown());
var (service, policy, _) = Create(bot, new RecordingRouter(), initialMs: 40, maxMs: 80);
var clock = Stopwatch.StartNew();
await service.StartAsync(CancellationToken.None);
await Wait.UntilAsync(() => bot.PollOffsets.Count >= 4, "four polls");
var elapsed = clock.Elapsed;
await service.StopAsync(CancellationToken.None);
// Delays 40 + 80 + 80 ms between the four polls (no jitter).
Assert.True(elapsed >= TimeSpan.FromMilliseconds(190), $"four polls took only {elapsed.TotalMilliseconds} ms");
Assert.True(policy.Failures >= 3);
}
[Fact]
public async Task Failures_after_a_success_start_again_from_the_initial_delay()
{
var policy = new ReconnectPolicy(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(8), jitter: 0);
Assert.Equal([1, 2, 4, 8, 8], Enumerable.Range(0, 5).Select(_ => policy.NextDelay().TotalSeconds));
Assert.Equal(5, policy.Reset());
Assert.Equal(1, policy.NextDelay().TotalSeconds);
}
[Fact]
public void Jitter_stays_within_bounds()
{
var policy = new ReconnectPolicy(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10), jitter: 0.2);
for (var i = 0; i < 200; i++)
{
var delay = policy.NextDelay().TotalSeconds;
Assert.InRange(delay, 8, 12);
}
}
[Fact]
public async Task A_rejected_token_stops_the_application()
{
var bot = new FakeBotClient().EnqueueFailure(FakeBotClient.Unauthorized());
var (service, _, lifetime) = Create(bot, new RecordingRouter());
await service.StartAsync(CancellationToken.None);
await Wait.UntilAsync(() => lifetime.StopRequested, "StopApplication");
await service.StopAsync(CancellationToken.None);
Assert.Single(bot.PollOffsets);
Assert.NotNull(service.FatalError);
}
[Fact]
public async Task A_failing_handler_does_not_stop_polling()
{
var bot = new FakeBotClient()
.EnqueueUpdates(Updates.WithId(10))
.EnqueueUpdates(Updates.WithId(11));
var router = new RecordingRouter(u => u.Id == 10 ? throw new InvalidOperationException("boom") : Task.CompletedTask);
var (service, _, _) = Create(bot, router);
await service.StartAsync(CancellationToken.None);
await Wait.UntilAsync(() => router.Ids.Count == 2, "both updates");
await service.StopAsync(CancellationToken.None);
Assert.Equal([10, 11], router.Ids);
Assert.Equal(12, service.Offset);
}
[Fact]
public async Task Stops_promptly_while_waiting_out_a_long_backoff()
{
var bot = new FakeBotClient().EnqueueFailure(FakeBotClient.NetworkDown()).EnqueueFailure(FakeBotClient.NetworkDown());
var (service, _, _) = Create(bot, new RecordingRouter(), initialMs: 60_000, maxMs: 60_000);
await service.StartAsync(CancellationToken.None);
await Wait.UntilAsync(() => bot.PollOffsets.Count == 1, "the first poll");
var clock = Stopwatch.StartNew();
await service.StopAsync(CancellationToken.None);
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(5));
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
/// <summary>The bot's chat side wired to a <see cref="FakeBotClient"/> and the <see cref="FauxProvider"/>.</summary>
public sealed class ChatHost : IAsyncDisposable
{
public FakeBotClient Bot { get; } = new();
public FauxProvider Faux { get; } = new();
public TelegramOptions Options { get; }
public ChatRegistry Chats { get; }
public UpdateRouter Router { get; }
private readonly ServiceProvider _services;
private readonly string _root;
public ChatHost()
{
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
Options = new TelegramOptions
{
BotToken = "test",
AllowedUserIds = [Updates.AllowedUser],
WorkspaceRoot = Path.Combine(_root, "workspaces"),
Model = FauxProvider.ModelKey,
EditInterval = TimeSpan.FromMilliseconds(20),
TypingInterval = TimeSpan.FromMilliseconds(50),
};
var store = new InMemorySessionStore();
var services = new ServiceCollection();
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(_root, "agent");
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
o.SessionStoreFactory = _ => store;
});
_services = services.BuildServiceProvider();
ILoggerFactory loggers = NullLoggerFactory.Instance;
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, NullLogger<UpdateRouter>.Instance);
}
public Task SendAsync(string text, long userId = Updates.AllowedUser) => Router.RouteAsync(Updates.Text(text, userId), CancellationToken.None);
public async Task<ChatContext> ChatAsync() => await Chats.GetAsync(Updates.AllowedUser);
/// <summary>Wait until the chat's run has settled and everything it produced is rendered.</summary>
public async Task SettleAsync()
{
var chat = await ChatAsync();
await chat.LastPrompt.WaitAsync(TimeSpan.FromSeconds(10));
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to settle");
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
}
public IEnumerable<string> Texts => Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value);
public async ValueTask DisposeAsync()
{
await Chats.DisposeAsync();
await _services.DisposeAsync();
try { Directory.Delete(_root, true); } catch (IOException) { }
}
}

View File

@@ -0,0 +1,107 @@
using System.Collections.Concurrent;
using System.Net;
using Telegram.Bot;
using Telegram.Bot.Args;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Requests.Abstractions;
using Telegram.Bot.Types;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
/// <summary>
/// In-memory <see cref="ITelegramBotClient"/>: records every request, answers sends with fake messages and plays a
/// script of <c>getUpdates</c> results. When the script is exhausted a poll blocks until cancelled, like an idle long poll.
/// </summary>
public sealed class FakeBotClient : ITelegramBotClient
{
private readonly ConcurrentQueue<Func<Update[]>> _polls = new();
private readonly List<IRequest> _requests = [];
private int _nextMessageId = 100;
public bool LocalBotServer => false;
public long BotId => 42;
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(50);
public IExceptionParser ExceptionsParser { get; set; } = new DefaultExceptionParser();
public event AsyncEventHandler<ApiRequestEventArgs>? OnMakingApiRequest;
public event AsyncEventHandler<ApiResponseEventArgs>? OnApiResponseReceived;
/// <summary>Called for every non-poll request before it is answered; throw to simulate a failure.</summary>
public Action<IRequest>? OnSend { get; set; }
public IReadOnlyList<IRequest> Requests { get { lock (_requests) return _requests.ToList(); } }
/// <summary>The offset of every <c>getUpdates</c> call (the library reuses one request object, so it is captured per call).</summary>
public IReadOnlyList<int?> PollOffsets { get { lock (_pollOffsets) return _pollOffsets.ToList(); } }
private readonly List<int?> _pollOffsets = [];
public IReadOnlyList<SendMessageRequest> Sent => Requests.OfType<SendMessageRequest>().ToList();
public IReadOnlyList<EditMessageTextRequest> Edits => Requests.OfType<EditMessageTextRequest>().ToList();
/// <summary>The text each sent message shows now (after edits), by message id.</summary>
public IReadOnlyDictionary<int, string> CurrentTexts => new Dictionary<int, string>(_texts);
private readonly ConcurrentDictionary<int, string> _texts = new();
public FakeBotClient EnqueueUpdates(params Update[] updates)
{
_polls.Enqueue(() => updates);
return this;
}
public FakeBotClient EnqueueFailure(Exception exception)
{
_polls.Enqueue(() => throw exception);
return this;
}
public static RequestException NetworkDown() =>
new("Bot API Service Failure: HttpRequestException: Name or service not known", new HttpRequestException("Name or service not known"));
public static ApiRequestException Unauthorized() => new("Unauthorized", (int)HttpStatusCode.Unauthorized);
public async Task<TResponse> SendRequest<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
{
lock (_requests) _requests.Add(request);
if (OnMakingApiRequest is not null) await OnMakingApiRequest(this, new ApiRequestEventArgs(request), cancellationToken);
if (request is GetUpdatesRequest getUpdates)
{
lock (_pollOffsets) _pollOffsets.Add(getUpdates.Offset);
if (!_polls.TryDequeue(out var poll))
{
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken);
throw new OperationCanceledException(cancellationToken);
}
var updates = poll(); // may throw: a failed poll
if (OnApiResponseReceived is not null)
{
using var response = new HttpResponseMessage(HttpStatusCode.OK);
await OnApiResponseReceived(this, new ApiResponseEventArgs(response, new ApiRequestEventArgs(request)), cancellationToken);
}
return (TResponse)(object)updates;
}
OnSend?.Invoke(request);
object result;
switch (request)
{
case SendMessageRequest send:
var id = Interlocked.Increment(ref _nextMessageId);
_texts[id] = send.Text;
result = new Message { Id = id, Chat = new Chat { Id = send.ChatId.Identifier ?? 0 }, Text = send.Text };
break;
case EditMessageTextRequest edit:
_texts[edit.MessageId] = edit.Text!;
result = new Message { Id = edit.MessageId, Text = edit.Text };
break;
default:
result = request is GetMeRequest ? new User { Id = BotId, IsBot = true, FirstName = "Fake", Username = "fake_bot" } : true;
break;
}
return typeof(TResponse) == typeof(bool) ? (TResponse)(object)true : (TResponse)result;
}
public Task<bool> TestApi(CancellationToken cancellationToken = default) => Task.FromResult(true);
public Task DownloadFile(string filePath, Stream destination, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DownloadFile(TGFile file, Stream destination, CancellationToken cancellationToken = default) => Task.CompletedTask;
}

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Hosting;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
public static class Updates
{
public const long AllowedUser = 1001;
public const long Stranger = 666;
private static int _nextUpdateId;
private static int _nextMessageId = 1;
public static Update Text(string text, long userId = AllowedUser, long? chatId = null, ChatType chatType = ChatType.Private) => new()
{
Id = Interlocked.Increment(ref _nextUpdateId),
Message = new Message
{
Id = Interlocked.Increment(ref _nextMessageId),
From = new User { Id = userId, FirstName = "Test" },
Chat = new Chat { Id = chatId ?? userId, Type = chatType },
Text = text,
Date = DateTime.UtcNow,
},
};
public static Update WithId(int id) => new() { Id = id, Message = Text("#" + id).Message };
public static Update Callback(string data, long chatId, long userId = AllowedUser) => new()
{
Id = Interlocked.Increment(ref _nextUpdateId),
CallbackQuery = new CallbackQuery
{
Id = Guid.NewGuid().ToString("N"),
From = new User { Id = userId, FirstName = "Test" },
Message = new Message { Id = 1, Chat = new Chat { Id = chatId, Type = ChatType.Private }, Date = DateTime.UtcNow },
Data = data,
ChatInstance = "ci",
},
};
}
public static class Wait
{
public static async Task UntilAsync(Func<bool> condition, string what, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException($"Timed out waiting for {what}");
await Task.Delay(10);
}
}
}
public sealed class FakeLifetime : IHostApplicationLifetime
{
private readonly CancellationTokenSource _stopping = new();
public CancellationToken ApplicationStarted => CancellationToken.None;
public CancellationToken ApplicationStopping => _stopping.Token;
public CancellationToken ApplicationStopped => CancellationToken.None;
public bool StopRequested => _stopping.IsCancellationRequested;
public void StopApplication() => _stopping.Cancel();
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj" />
</ItemGroup>
</Project>