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.Extensions.GoogleCalendar; using Volcanic.AgenticCore.Extensions.Scheduler; using Volcanic.AgenticCore.Features.Agent; using Volcanic.AgenticCore.Features.Extensions; using Volcanic.AgenticCore.Hosting; using Volcanic.AgenticCore.Telegram.Inbound; using Volcanic.AgenticCore.Telegram.Rendering; namespace Volcanic.AgenticCore.Telegram.Chats; /// Everything that belongs to one Telegram chat: its agent session, renderer and dialog surface. public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ChatInbox inbox, ILogger logger, IChatChannel channel) : IAsyncDisposable { public long ChatId => chatId; public AgentSession Session => session; public TelegramUi Ui => ui; public ChatRenderer Renderer => renderer; /// Delivers files and texts into this chat in order with rendered output (the scheduler uses it too). public IChatChannel Channel => channel; /// Groups this chat's incoming messages into prompts (see ). public ChatInbox Inbox => inbox; /// The most recent background prompt; completes when that run has settled. public Task LastPrompt { get; private set; } = Task.CompletedTask; /// /// Send user input without waiting for the run (it can take minutes, and the polling loop must keep receiving so /// /stop gets through). While a run is active the text steers it. /// public Task PromptInBackground(string text, IReadOnlyList? 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}"); } }); /// Show an error in the chat, in order with the rendered events. public void Report(string message) => renderer.Post(new AgentSessionEvent.Notification(message, NotifyKind.Error)); public async ValueTask DisposeAsync() { await inbox.DisposeAsync().ConfigureAwait(false); subscription.Dispose(); await session.DisposeAsync().ConfigureAwait(false); await renderer.DisposeAsync().ConfigureAwait(false); } } /// /// One per chat, created on first use. Each chat works in <WorkspaceRoot>/<chatId>, /// stores its sessions under scope telegram-<chatId> and resumes the most recent one after a restart. /// public sealed class ChatRegistry( IAgentSessionFactory factory, TelegramSender sender, TelegramOptions options, ILoggerFactory loggerFactory, TimeProvider? time = null, GoogleCalendarOptions? calendar = null, ChatSettingsStore? settings = null, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null) : IAsyncDisposable { private readonly ConcurrentDictionary>> _chats = new(); private readonly ILogger _logger = loggerFactory.CreateLogger(); private readonly TimeProvider _time = time ?? TimeProvider.System; public static string ScopeFor(long chatId) => $"telegram-{chatId}"; public string WorkspaceFor(long chatId) => Path.Combine(options.WorkspaceRoot, chatId.ToString(System.Globalization.CultureInfo.InvariantCulture)); /// The chat's context if it was already created, without creating it. public ChatContext? Find(long chatId) => _chats.TryGetValue(chatId, out var lazy) && lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully ? lazy.Value.Result : null; public async Task GetAsync(long chatId, CancellationToken cancellationToken = default) { var lazy = _chats.GetOrAdd(chatId, id => new Lazy>(() => CreateAsync(id))); try { return await lazy.Value.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (Exception) when (lazy.Value.IsFaulted) { _chats.TryRemove(new KeyValuePair>>(chatId, lazy)); // let the next message try again throw; } } private async Task 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(); var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger()); var ui = new TelegramUi(chatId, sender, renderer); var channel = new TelegramChatChannel(chatId, sender, renderer); var extras = new List(); if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar)); if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones))); if (scheduler is not null) extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, Scheduling.TelegramJobDelivery.TargetOf(chatId)))); AgentSession session; try { session = await factory.CreateAsync(new AgentSessionOptions { Cwd = cwd, Scope = scope, SessionId = last?.SessionId, Model = options.Model, Ui = ui, Channel = channel, TimeZone = settings?.TimeZoneFor(chatId), ExtraExtensions = extras, }).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); ChatContext? context = null; var inbox = new ChatInbox(parts => FlushAsync(context!, parts), options.InputDebounce, _time, logger: loggerFactory.CreateLogger()); context = new ChatContext(chatId, session, ui, renderer, subscription, inbox, logger, channel); return context; // no lease can be taken before this returns, so the flush always sees the context } /// /// The zone for a chat's timestamp headers: the chat's setting (), which also /// feeds the session's zone. Without a store (bare test setups) the session's zone, else the machine's. Read at flush /// time, so a /timezone change applies to the next batch. Never creates a session. /// public TimeZoneInfo TimeZoneFor(long chatId) => settings?.TimeZoneFor(chatId) ?? Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local; /// A flushed batch becomes one prompt; while a run is active it steers it and each message gets a 👀 reaction. internal Task FlushAsync(ChatContext chat, IReadOnlyList parts) { var (text, images) = InboundBatch.Render(parts, TimeZoneFor(chat.ChatId)); // Tell the user their message joined the running request (best-effort, fire-and-forget). If the run ends between // this check and the steer, the message starts a new run and still shows 👀: harmless. if (chat.Session.IsStreaming) foreach (var part in parts) _ = sender.ReactAsync(chat.ChatId, part.MessageId, "👀"); _ = chat.PromptInBackground(text, images.Count > 0 ? images : null); return Task.CompletedTask; } 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(); } }