using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Telegram.Bot.Types; using Volcanic.AgenticCore.Abstractions.Processes; using Volcanic.AgenticCore.Extensions.Scheduler; 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.Inbound; using Volcanic.AgenticCore.Telegram.Rendering; using Volcanic.AgenticCore.Telegram.Scheduling; using Volcanic.AgenticCore.Telegram.Groups; using Volcanic.AgenticCore.Telegram.Triggers; using Volcanic.AgenticCore.Testing; namespace Volcanic.AgenticCore.Telegram.Tests.Support; /// /// The bot's chat side wired to a and the ; /// adds services and adjusts the core options (extensions, tools) before the provider is built; /// either may capture the host, whose exist by the time a session runs. /// 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; private readonly bool _ownsRoot; /// Per-chat settings (<root>/agent/telegram/chat-settings.json); the default zone is the core option. public ChatSettingsStore Settings { get; } public ChatTimeZoneService Zones { get; } /// The host's root directory; a caller-supplied root survives disposal (e.g. for a restart). public string Root => _root; /// The scheduler when the host was built with scheduler: options; start it with . public JobScheduler? Scheduler { get; } public TelegramJobDelivery? Delivery { get; } /// as a scheduler target. public const string Target = "1001"; public Task StartSchedulerAsync() => Scheduler!.StartAsync(Delivery!, CancellationToken.None); public ChatHost(Action? configure = null, Action? core = null, TimeProvider? time = null, Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null, Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null, IScriptRunner? runner = null, TriggerOptions? triggers = null, IJevClient? jev = null) { _ownsRoot = root is null; _root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N")); var agentDir = Path.Combine(_root, "agent"); Options = new TelegramOptions { BotToken = "test", AllowedUserIds = [Updates.AllowedUser], WorkspaceRoot = Path.Combine(_root, "workspaces"), Model = FauxProvider.ModelKey, EditInterval = TimeSpan.FromMilliseconds(20), TypingInterval = TimeSpan.FromMilliseconds(50), InputDebounce = TimeSpan.FromMilliseconds(20), }; if (triggers is not null) Options.Triggers = triggers; var store = new InMemorySessionStore(); var services = new ServiceCollection(); if (time is not null) services.AddSingleton(time); // sessions render the prompt date from it configure?.Invoke(this, services); services.AddAgenticCore(o => { o.AgentDir = agentDir; o.DiscoverExtensionAssemblies = false; o.DefaultModel = FauxProvider.ModelKey; o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension)); o.SessionStoreFactory = _ => store; o.ProcessEnvironment.HardenHostProcess = false; core?.Invoke(o); }); _services = services.BuildServiceProvider(); ILoggerFactory loggers = NullLoggerFactory.Instance; var sender = new TelegramSender(Bot, NullLogger.Instance); var clock = time ?? TimeProvider.System; var coreOptions = _services.GetRequiredService>().Value; Settings = new ChatSettingsStore(ChatSettingsStore.DefaultPath(agentDir), coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local, NullLogger.Instance, clock); if (scheduler is not null) { if (string.IsNullOrWhiteSpace(scheduler.LegacyStorePath)) scheduler.LegacyStorePath = SchedulerOptions.DefaultLegacyStorePath(agentDir); Scheduler = new JobScheduler(scheduler, runner ?? new ScriptRunner(scheduler, _services.GetRequiredService(), NullLogger.Instance, clock), _services.GetRequiredService(), clock, NullLogger.Instance); } Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler); Impersonation = new Impersonation(Options); if (triggers is not null) { Jev = jev ?? new FakeJev(); Groups = new GroupRegistry(GroupRegistry.DefaultPath(agentDir), NullLogger.Instance, clock); var triggerStore = new TriggerStore(Options.WorkspaceRoot, triggers.MaxTriggersPerChat, NullLogger.Instance, clock); var delivery = new TelegramTriggerDelivery(() => Chats!, clock, NullLogger.Instance); _dispatcher = new TriggerDispatcher(delivery, triggers, clock, NullLogger.Instance); Engine = new TriggerEngine(triggerStore, new SnoozeQueue(triggers.MaxQueuedPerTrigger), Jev, _dispatcher, delivery, clock, TriggerLog); Triggers = new TriggerServices(Engine, Groups, new TelegramGroupMembership(Bot), triggers); Feed = new GroupFeed(Groups, Engine, Bot, Options, NullLogger.Instance); } Chats = new ChatRegistry(_services.GetRequiredService(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation, Triggers); if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger.Instance); var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger.Instance, speech); Composer = new InboundComposer(sender, media, NullLogger.Instance); Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler, Triggers), sender, Composer, NullLogger.Instance, Impersonation, Feed); } private readonly TriggerDispatcher? _dispatcher; /// Set when the host was built with triggers: options. public TriggerServices? Triggers { get; } public TriggerEngine? Engine { get; } public GroupRegistry? Groups { get; } public GroupFeed? Feed { get; } public IJevClient? Jev { get; } /// The engine's log lines (one per group message). public ListLogger TriggerLog { get; } = new(); /// Off until is set on . public Impersonation Impersonation { get; } /// The composer the router uses (content steps 1–3). public InboundComposer Composer { get; } /// The chat's workspace directory (its session's cwd). public string WorkspaceOf(long chatId = Updates.AllowedUser) => Chats.WorkspaceFor(chatId); public Task SendAsync(string text, long userId = Updates.AllowedUser) => Router.RouteAsync(Updates.Text(text, userId), CancellationToken.None); public Task RouteAsync(Update update) => Router.RouteAsync(update, CancellationToken.None); public async Task ChatAsync(long chatId = Updates.AllowedUser) => await Chats.GetAsync(chatId); /// Wait until the chat's inbox has flushed, its run has settled and everything it produced is rendered. /// With a , advance past the input window before settling, or the drain times out. public async Task SettleAsync(long chatId = Updates.AllowedUser) { var chat = await ChatAsync(chatId); await chat.Inbox.DrainAsync().WaitAsync(TimeSpan.FromSeconds(10)); 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)); } /// The last message sent to , as plain text (tags stripped, entities decoded). public string LastReply(long chatId = Updates.AllowedUser) => System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace( Bot.Sent.Last(s => s.ChatId.Identifier == chatId).Text, "<[^>]+>", "")); public IEnumerable Texts => Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value); public async ValueTask DisposeAsync() { if (Scheduler is not null) await Scheduler.StopAsync(CancellationToken.None); if (Feed is not null) await Feed.IdleAsync(); Engine?.Dispose(); _dispatcher?.Dispose(); await Chats.DisposeAsync(); await _services.DisposeAsync(); if (_ownsRoot) try { Directory.Delete(_root, true); } catch (IOException) { } } }