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:
EugeneTes
2026-09-27 10:29:29 +02:00
parent a0d886eef3
commit 5b780fac36
15 changed files with 608 additions and 15 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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()
{

View File

@@ -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>&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)
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) { }
}
}

View 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());
}
}