forked from admin/VolcanicAgenticCore
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.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class ChatSettingsStoreTests : IDisposable
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("chat-settings-").FullName;
|
||||
private string FilePath => ChatSettingsStore.DefaultPath(_root);
|
||||
|
||||
private ChatSettingsStore Store() =>
|
||||
new(FilePath, Berlin, NullLogger<ChatSettingsStore>.Instance, new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 14, 3, 0, TimeSpan.Zero)));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_file_uses_the_default()
|
||||
{
|
||||
var store = Store();
|
||||
Assert.Same(Berlin, store.TimeZoneFor(1001));
|
||||
Assert.False(store.HasOwnZone(1001));
|
||||
Assert.EndsWith(Path.Combine("telegram", "chat-settings.json"), FilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_persists_and_a_new_store_reads_it()
|
||||
{
|
||||
await Store().SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
|
||||
|
||||
var reloaded = Store();
|
||||
Assert.Equal("Asia/Tokyo", reloaded.TimeZoneFor(1001).Id);
|
||||
Assert.True(reloaded.HasOwnZone(1001));
|
||||
Assert.Same(Berlin, reloaded.TimeZoneFor(2002));
|
||||
Assert.Equal(["chat-settings.json"], Directory.GetFiles(Path.GetDirectoryName(FilePath)!).Select(Path.GetFileName)); // no temp file left
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_format_is_versioned_camel_case()
|
||||
{
|
||||
await Store().SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
|
||||
var json = await File.ReadAllTextAsync(FilePath);
|
||||
Assert.Contains("\"version\": 1", json);
|
||||
Assert.Contains("\"1001\": {", json);
|
||||
Assert.Contains("\"timeZone\": \"Asia/Tokyo\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_removes_the_entry()
|
||||
{
|
||||
var store = Store();
|
||||
await store.SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
|
||||
await store.SetTimeZoneAsync(1001, null, CancellationToken.None);
|
||||
Assert.False(store.HasOwnZone(1001));
|
||||
Assert.False(Store().HasOwnZone(1001));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corrupt_file_is_set_aside_and_loading_starts_empty()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
File.WriteAllText(FilePath, "{ nope");
|
||||
|
||||
var store = Store();
|
||||
|
||||
Assert.False(store.HasOwnZone(1001));
|
||||
Assert.True(File.Exists(FilePath + ".corrupt-20260926140300"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_stored_id_is_ignored()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
File.WriteAllText(FilePath, """{ "version": 1, "chats": { "1001": { "timeZone": "Mars/Olympus" }, "2002": { "timeZone": "Asia/Tokyo" } } }""");
|
||||
|
||||
var store = Store();
|
||||
|
||||
Assert.Same(Berlin, store.TimeZoneFor(1001));
|
||||
Assert.Equal("Asia/Tokyo", store.TimeZoneFor(2002).Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class ChatTimeZonesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Iana_ids_parse()
|
||||
{
|
||||
Assert.True(ChatTimeZones.TryParse(" Asia/Tokyo ", out var zone, out _));
|
||||
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(zone));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows_ids_are_converted_to_iana()
|
||||
{
|
||||
Assert.True(ChatTimeZones.TryParse("Tokyo Standard Time", out var zone, out _));
|
||||
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(zone));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Local")]
|
||||
[InlineData("+03:00")]
|
||||
[InlineData("GMT+3")]
|
||||
[InlineData("UTC-5")]
|
||||
[InlineData("Mars/Olympus")]
|
||||
[InlineData("")]
|
||||
public void Rejects_local_offsets_and_unknown_names(string input)
|
||||
{
|
||||
Assert.False(ChatTimeZones.TryParse(input, out _, out var error));
|
||||
Assert.Equal($"Unknown time zone \"{input}\". Use an IANA name such as Europe/Berlin or America/New_York.", error);
|
||||
}
|
||||
}
|
||||
@@ -160,4 +160,21 @@ public sealed class FormattingAndOptionsTests
|
||||
options.Speech.BaseUrl = url;
|
||||
Assert.Contains("Telegram:Speech:BaseUrl must be an absolute http(s) URL.", options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTimeZone_comes_from_the_environment_and_unknown_ids_fail_validation()
|
||||
{
|
||||
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
||||
options.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? " Asia/Tokyo " : null);
|
||||
Assert.Equal("Asia/Tokyo", options.DefaultTimeZone);
|
||||
Assert.Equal("Asia/Tokyo", options.ConfiguredTimeZone()!.Id);
|
||||
Assert.Empty(options.Validate());
|
||||
|
||||
var empty = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
||||
empty.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? "" : null);
|
||||
Assert.Null(empty.ConfiguredTimeZone()); // empty = unset: the machine's zone
|
||||
|
||||
var bad = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], DefaultTimeZone = "Mars/Olympus" };
|
||||
Assert.Contains(bad.Validate(), e => e.StartsWith("Telegram:DefaultTimeZone: Unknown time zone \"Mars/Olympus\"", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class MessageContextChatTests
|
||||
await FlushAsync(host, time, parts: 2);
|
||||
await host.SettleAsync();
|
||||
|
||||
// The zone comes from the ChatRegistry.TimeZoneFor seam (the session's zone until the scheduler's ChatSettingsStore).
|
||||
// The zone comes from the ChatRegistry.TimeZoneFor seam (the chat's zone from ChatSettingsStore, which defaults to AgenticCoreOptions.DefaultTimeZone).
|
||||
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(host.Chats.TimeZoneFor(Updates.AllowedUser)));
|
||||
var lines = Prompt(host).Split('\n');
|
||||
Assert.Equal(MessageContext.TimestampHeader(SentAt(comment), Tokyo), lines[0]);
|
||||
@@ -64,6 +64,24 @@ public sealed class MessageContextChatTests
|
||||
Assert.Single(lines, l => TimestampLine.IsMatch(l)); // one per batch, not per part
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_timezone_Asia_Tokyo_the_next_messages_header_says_Tokyo()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(core: o => o.DefaultTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"), time: time);
|
||||
host.Faux.Enqueue(FauxTurn.Say("It's evening in Tokyo."));
|
||||
|
||||
await host.SendAsync("/timezone Asia/Tokyo"); // a bot command: handled at once, no inbox window
|
||||
var message = Updates.Text("what time is it?");
|
||||
await host.RouteAsync(message);
|
||||
await FlushAsync(host, time, parts: 1);
|
||||
await host.SettleAsync();
|
||||
|
||||
var first = Prompt(host).Split('\n')[0];
|
||||
Assert.Equal(MessageContext.TimestampHeader(SentAt(message), Tokyo), first);
|
||||
Assert.EndsWith(" Asia/Tokyo]", first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_text_waits_for_the_window_and_a_forward_inside_it_joins_the_request()
|
||||
{
|
||||
|
||||
@@ -27,12 +27,23 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
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)
|
||||
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null)
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
|
||||
_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",
|
||||
@@ -45,10 +56,11 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
};
|
||||
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 = Path.Combine(_root, "agent");
|
||||
o.AgentDir = agentDir;
|
||||
o.DiscoverExtensionAssemblies = false;
|
||||
o.DefaultModel = FauxProvider.ModelKey;
|
||||
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
|
||||
@@ -59,10 +71,14 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
_services = services.BuildServiceProvider();
|
||||
ILoggerFactory loggers = NullLoggerFactory.Instance;
|
||||
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
|
||||
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), sender, Composer, NullLogger<UpdateRouter>.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>
|
||||
@@ -88,12 +104,18 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
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();
|
||||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||||
if (_ownsRoot)
|
||||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
135
tests/Volcanic.AgenticCore.Telegram.Tests/TimeZoneChatTests.cs
Normal file
135
tests/Volcanic.AgenticCore.Telegram.Tests/TimeZoneChatTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
/// <summary>Per-chat time zones through <c>/timezone</c>, the <c>set_timezone</c> tool, a restart and <c>/status</c>.</summary>
|
||||
public sealed class TimeZoneChatTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // 14:03 Berlin, 21:03 Tokyo
|
||||
private const long OtherUser = 2002;
|
||||
|
||||
private static ChatHost Host(string? root = null) =>
|
||||
new(core: o => o.DefaultTimeZone = Berlin, time: new FakeTimeProvider(Now), root: root);
|
||||
|
||||
[Fact]
|
||||
public async Task Timezone_sets_the_zone_the_session_and_the_next_system_prompt()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
|
||||
Assert.Equal("Time zone set to Asia/Tokyo (UTC+09:00). Now 26 Sep 21:03.", host.LastReply());
|
||||
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
|
||||
var chat = await host.ChatAsync();
|
||||
Assert.Equal("Asia/Tokyo", chat.Session.TimeZone.Id);
|
||||
|
||||
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
||||
await chat.Session.PromptAsync("hi"); // straight to the session: the inbox window runs on the fake clock
|
||||
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.Faux.Requests.Last().SystemPrompt);
|
||||
Assert.Contains("Current date: 2026-09-26 (Saturday)", host.Faux.Requests.Last().SystemPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_zone_replies_with_the_error_and_stores_nothing()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Mars/Olympus");
|
||||
Assert.Equal("Unknown time zone \"Mars/Olympus\". Use an IANA name such as Europe/Berlin or America/New_York.", host.LastReply());
|
||||
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
|
||||
Assert.False(File.Exists(Volcanic.AgenticCore.Telegram.Chats.ChatSettingsStore.DefaultPath(Path.Combine(host.Root, "agent"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_returns_to_the_default()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
await host.SendAsync("/timezone reset");
|
||||
|
||||
Assert.Equal("Time zone reset to the default, Europe/Berlin (UTC+02:00). Now 26 Sep 14:03.", host.LastReply());
|
||||
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
|
||||
Assert.Equal("Europe/Berlin", (await host.ChatAsync()).Session.TimeZone.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timezone_alone_shows_the_zone_and_whether_it_is_the_default()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone");
|
||||
Assert.Equal("Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.", host.LastReply());
|
||||
await host.SendAsync("/timezone America/New_York");
|
||||
await host.SendAsync("/timezone");
|
||||
Assert.StartsWith("Time zone: America/New_York (UTC-04:00). Change it", host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_restart_keeps_the_zone()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("tz-restart-").FullName;
|
||||
try
|
||||
{
|
||||
await using (var first = Host(root)) await first.SendAsync("/timezone Asia/Tokyo");
|
||||
await using var second = Host(root);
|
||||
Assert.Equal("Asia/Tokyo", (await second.ChatAsync()).Session.TimeZone.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scripted_set_timezone_call_does_the_same_as_the_command()
|
||||
{
|
||||
await using var host = Host();
|
||||
host.Faux.Enqueue(FauxTurn.Tool("set_timezone", new { zone = "Asia/Tokyo" }), FauxTurn.Say("Done: Tokyo time."));
|
||||
var chat = await host.ChatAsync();
|
||||
|
||||
await chat.Session.PromptAsync("I moved to Tokyo");
|
||||
|
||||
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
|
||||
Assert.Equal("Asia/Tokyo", chat.Session.TimeZone.Id);
|
||||
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
|
||||
Assert.False(result.IsError);
|
||||
Assert.StartsWith("Time zone set to Asia/Tokyo (UTC+09:00).", result.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scripted_set_timezone_with_an_unknown_zone_is_an_error_result()
|
||||
{
|
||||
await using var host = Host();
|
||||
host.Faux.Enqueue(FauxTurn.Tool("set_timezone", new { zone = "Mars/Olympus" }), FauxTurn.Say("Sorry."));
|
||||
var chat = await host.ChatAsync();
|
||||
|
||||
await chat.Session.PromptAsync("I live on Mars");
|
||||
|
||||
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
|
||||
Assert.True(result.IsError);
|
||||
Assert.StartsWith("Unknown time zone \"Mars/Olympus\"", result.Text);
|
||||
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_chats_zone_is_untouched()
|
||||
{
|
||||
await using var host = Host();
|
||||
host.Options.AllowedUserIds.Add(OtherUser);
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
await host.SendAsync("/timezone", userId: OtherUser);
|
||||
|
||||
Assert.Equal("Europe/Berlin", host.Settings.TimeZoneFor(OtherUser).Id);
|
||||
Assert.StartsWith("Time zone: Europe/Berlin (UTC+02:00, default).", host.LastReply(OtherUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_shows_the_time_zone()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
await host.SendAsync("/status");
|
||||
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.LastReply());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user