Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs
EugeneTes 5b780fac36 feat(telegram): per-chat time zones with /timezone, set_timezone and Telegram:DefaultTimeZone
ChatSettingsStore keeps each chat's IANA zone in <AgentDir>/telegram/chat-settings.json;
ChatTimeZoneService is the one path for a change (store, live session, scheduler re-evaluation,
reply text). ChatRegistry.TimeZoneFor now reads the store and new sessions start in the chat's zone.
/timezone, the set_timezone tool (ChatSettingsExtension), /status zone line, DEFAULT_TIMEZONE.

The store updates its cache under the write gate so the last file written is the newest state.
2026-09-27 10:29:29 +02:00

122 lines
6.4 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.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.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;
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)
{
_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);
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones);
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), sender, Composer, NullLogger<UpdateRouter>.Instance);
}
/// <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()
{
await Chats.DisposeAsync();
await _services.DisposeAsync();
if (_ownsRoot)
try { Directory.Delete(_root, true); } catch (IOException) { }
}
}