forked from admin/VolcanicAgenticCore
While on, the admin's updates are routed as the target user's (allow-list, chat, workspace, session, settings, jobs) and the target chat's output goes to the admin's chat. The target's own updates are dropped with a notice that an admin is working with their account. In memory; /impersonate 0 stops.
149 lines
8.1 KiB
C#
149 lines
8.1 KiB
C#
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.Testing;
|
||
|
||
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
|
||
|
||
/// <summary>
|
||
/// The bot's chat side wired to a <see cref="FakeBotClient"/> and the <see cref="FauxProvider"/>; <paramref name="configure"/>
|
||
/// adds services and <paramref name="core"/> adjusts the core options (extensions, tools) before the provider is built;
|
||
/// either may capture the host, whose <see cref="Chats"/> exist by the time a session runs.
|
||
/// </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;
|
||
private readonly bool _ownsRoot;
|
||
|
||
/// <summary>Per-chat settings (<c><root>/agent/telegram/chat-settings.json</c>); the default zone is the core option.</summary>
|
||
public ChatSettingsStore Settings { get; }
|
||
|
||
public ChatTimeZoneService Zones { get; }
|
||
|
||
/// <summary>The host's root directory; a caller-supplied root survives disposal (e.g. for a restart).</summary>
|
||
public string Root => _root;
|
||
|
||
/// <summary>The scheduler when the host was built with <c>scheduler:</c> options; start it with <see cref="StartSchedulerAsync"/>.</summary>
|
||
public JobScheduler? Scheduler { get; }
|
||
|
||
public TelegramJobDelivery? Delivery { get; }
|
||
|
||
/// <summary><see cref="Updates.AllowedUser"/> as a scheduler target.</summary>
|
||
public const string Target = "1001";
|
||
|
||
public Task StartSchedulerAsync() => Scheduler!.StartAsync(Delivery!, CancellationToken.None);
|
||
|
||
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? 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)
|
||
{
|
||
_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),
|
||
};
|
||
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<TelegramSender>.Instance);
|
||
var clock = time ?? TimeProvider.System;
|
||
var coreOptions = _services.GetRequiredService<Microsoft.Extensions.Options.IOptions<AgenticCoreOptions>>().Value;
|
||
Settings = new ChatSettingsStore(ChatSettingsStore.DefaultPath(agentDir), coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local, NullLogger<ChatSettingsStore>.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<IProcessSpawnPolicy>(), NullLogger<ScriptRunner>.Instance, clock),
|
||
_services.GetRequiredService<ISecretRedactor>(), clock, NullLogger<JobScheduler>.Instance);
|
||
}
|
||
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
|
||
Impersonation = new Impersonation(Options);
|
||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation);
|
||
if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger<TelegramJobDelivery>.Instance);
|
||
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
|
||
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
|
||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation);
|
||
}
|
||
|
||
/// <summary>Off until <see cref="TelegramOptions.AdminUserId"/> is set on <see cref="Options"/>.</summary>
|
||
public Impersonation Impersonation { get; }
|
||
|
||
/// <summary>The composer the router uses (content steps 1–3).</summary>
|
||
public InboundComposer Composer { get; }
|
||
|
||
/// <summary>The chat's workspace directory (its session's cwd).</summary>
|
||
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<ChatContext> ChatAsync(long chatId = Updates.AllowedUser) => await Chats.GetAsync(chatId);
|
||
|
||
/// <summary>Wait until the chat's inbox has flushed, its run has settled and everything it produced is rendered.</summary>
|
||
/// <remarks>With a <see cref="Microsoft.Extensions.Time.Testing.FakeTimeProvider"/>, advance past the input window before settling, or the drain times out.</remarks>
|
||
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));
|
||
}
|
||
|
||
/// <summary>The last message sent to <paramref name="chatId"/>, as plain text (tags stripped, entities decoded).</summary>
|
||
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<string> 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);
|
||
await Chats.DisposeAsync();
|
||
await _services.DisposeAsync();
|
||
if (_ownsRoot)
|
||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||
}
|
||
}
|