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) { }
}
}