feat(telegram): telegram bot host with connection recovery

This commit is contained in:
EugeneTes
2026-09-25 14:42:12 +00:00
parent 2321906719
commit 308e1fbf8e
26 changed files with 2283 additions and 2 deletions

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"
}
}