Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs
EugeneTes 92a0061fdd feat(telegram): ADMIN_USER_ID may /impersonate another user
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.
2026-10-05 07:24:07 +00:00

149 lines
8.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>&lt;root&gt;/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) { }
}
}