forked from admin/VolcanicAgenticCore
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.
122 lines
6.4 KiB
C#
122 lines
6.4 KiB
C#
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><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;
|
||
|
||
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) { }
|
||
}
|
||
}
|