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

@@ -7,7 +7,7 @@ using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
public sealed class ChatCommands(TelegramSender sender)
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null)
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
@@ -18,6 +18,7 @@ public sealed class ChatCommands(TelegramSender sender)
new() { Command = "resume", Description = "Resume a conversation: /resume <id>" },
new() { Command = "compact", Description = "Summarise the conversation to free context" },
new() { Command = "thinking", Description = "Show or set the thinking level" },
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
new() { Command = "help", Description = "What this bot does" },
];
@@ -97,6 +98,10 @@ public sealed class ChatCommands(TelegramSender sender)
await ReplyAsync(chat, await ThinkingAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "timezone":
await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
default:
return false;
}
@@ -119,9 +124,10 @@ public sealed class ChatCommands(TelegramSender sender)
/sessions, /resume <id> – earlier conversations
/compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level
/timezone [IANA name|reset] – this chat's time zone
""" + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
private static string Status(ChatContext chat)
private string Status(ChatContext chat)
{
var session = chat.Session;
var sb = new StringBuilder();
@@ -131,6 +137,8 @@ public sealed class ChatCommands(TelegramSender sender)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Messages:** {session.Messages.Count}");
if (session.GetContextUsage() is { } usage)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
var zone = zones?.Describe(chat.ChatId) ?? ChatTimeZoneService.Describe(session.TimeZone, DateTimeOffset.UtcNow);
sb.AppendLine(CultureInfo.InvariantCulture, $"**Time zone:** {zone}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
return sb.ToString();
@@ -163,6 +171,16 @@ public sealed class ChatCommands(TelegramSender sender)
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
}
private async Task<string> TimeZoneAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (zones is null) return "Time zones aren't configured on this bot.";
if (arg.Length == 0) return zones.Show(chat.ChatId);
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) return await zones.SetAsync(chat.ChatId, null, cancellationToken).ConfigureAwait(false);
return ChatTimeZones.TryParse(arg, out var zone, out var error)
? await zones.SetAsync(chat.ChatId, zone, cancellationToken).ConfigureAwait(false)
: error;
}
private static async Task<string> ThinkingAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
var session = chat.Session;

View File

@@ -6,6 +6,7 @@ using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering;
@@ -69,7 +70,9 @@ public sealed class ChatRegistry(
TelegramOptions options,
ILoggerFactory loggerFactory,
TimeProvider? time = null,
GoogleCalendarOptions? calendar = null) : IAsyncDisposable
GoogleCalendarOptions? calendar = null,
ChatSettingsStore? settings = null,
ChatTimeZoneService? zones = null) : IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
@@ -108,6 +111,9 @@ public sealed class ChatRegistry(
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
var channel = new TelegramChatChannel(chatId, sender, renderer);
var extras = new List<ExtensionRegistration>();
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
AgentSession session;
try
{
@@ -119,7 +125,8 @@ public sealed class ChatRegistry(
Model = options.Model,
Ui = ui,
Channel = channel,
ExtraExtensions = calendar is null ? null : [GoogleCalendarSetup.Registration(calendar)],
TimeZone = settings?.TimeZoneFor(chatId),
ExtraExtensions = extras,
}).ConfigureAwait(false);
}
catch
@@ -139,11 +146,11 @@ public sealed class ChatRegistry(
}
/// <summary>
/// The zone for a chat's timestamp headers: the chat session's zone, else the machine's. This is the one place that
/// switches to the per-chat setting (<c>ChatSettingsStore.TimeZoneFor</c>, scheduler spec) when that lands.
/// Read at flush time, so a zone change applies to the next batch.
/// The zone for a chat's timestamp headers: the chat's setting (<see cref="ChatSettingsStore.TimeZoneFor"/>), which also
/// feeds the session's zone. Without a store (bare test setups) the session's zone, else the machine's. Read at flush
/// time, so a <c>/timezone</c> change applies to the next batch. Never creates a session.
/// </summary>
public TimeZoneInfo TimeZoneFor(long chatId) => Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
public TimeZoneInfo TimeZoneFor(long chatId) => settings?.TimeZoneFor(chatId) ?? Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
/// <summary>A flushed batch becomes one prompt; while a run is active it steers it and each message gets a 👀 reaction.</summary>
internal Task FlushAsync(ChatContext chat, IReadOnlyList<InboundPart> parts)

View File

@@ -0,0 +1,34 @@
using System.ComponentModel;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Arguments of <c>set_timezone</c>.</summary>
public sealed record SetTimeZoneArgs([property: Description("IANA time zone, e.g. Europe/Berlin or America/New_York")] string Zone);
/// <summary>Host extension per chat: the <c>set_timezone</c> tool, whether or not the scheduler is enabled.</summary>
public sealed class ChatSettingsExtension(long chatId, ChatTimeZoneService zones) : IExtension
{
public const string ExtensionName = "telegram-chat-settings";
public const string ToolName = "set_timezone";
public string Name => ExtensionName;
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
api.RegisterTool(new DelegateTool<SetTimeZoneArgs>(
ToolName,
"Set this chat's time zone (an IANA name). It applies to the date in your instructions, bash, scheduled jobs and message timestamps.",
async (args, _, ct) =>
{
if (!ChatTimeZones.TryParse(args.Zone, out var zone, out var error)) throw new ToolExecutionException(ToolResult.Text(error));
return ToolResult.Text(await zones.SetAsync(chatId, zone, ct).ConfigureAwait(false));
},
promptSnippet: "set_timezone: change this chat's time zone",
promptGuidelines: ["Use set_timezone only when the user tells you their location or time zone changed; confirm the new zone in your reply."],
executionMode: ToolExecutionMode.Sequential));
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,106 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// Per-chat settings outside every workspace: <c>&lt;AgentDir&gt;/telegram/chat-settings.json</c>,
/// <c>{ "version": 1, "chats": { "&lt;chatId&gt;": { "timeZone": "Asia/Tokyo" } } }</c>. Loaded once and cached; written
/// atomically (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty; a stored
/// zone that no longer resolves is logged and ignored (the chat follows the default). Exists with the scheduler disabled.
/// </summary>
public sealed class ChatSettingsStore
{
public const int Version = 1;
private readonly string _path;
private readonly ILogger _logger;
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly Lock _lock = new();
private readonly Dictionary<long, TimeZoneInfo> _zones = [];
public ChatSettingsStore(string path, TimeZoneInfo defaultZone, ILogger<ChatSettingsStore> logger, TimeProvider? time = null)
{
ArgumentNullException.ThrowIfNull(defaultZone);
_path = path;
_logger = logger;
DefaultZone = defaultZone;
Load(time ?? TimeProvider.System);
}
public static string DefaultPath(string agentDir) => Path.Combine(agentDir, "telegram", "chat-settings.json");
/// <summary><c>Telegram:DefaultTimeZone</c>, else the machine's zone. Chats without their own zone follow it.</summary>
public TimeZoneInfo DefaultZone { get; }
public TimeZoneInfo TimeZoneFor(long chatId)
{
lock (_lock) return _zones.GetValueOrDefault(chatId) ?? DefaultZone;
}
public bool HasOwnZone(long chatId)
{
lock (_lock) return _zones.ContainsKey(chatId);
}
/// <summary>Stores <paramref name="ianaId"/> for the chat; null removes the entry (back to the default).</summary>
public async Task SetTimeZoneAsync(long chatId, string? ianaId, CancellationToken cancellationToken)
{
var zone = ianaId is null ? null : TimeZoneInfo.FindSystemTimeZoneById(ianaId); // throws before anything changes
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Under the write gate, so the snapshot written last is also the newest one.
SettingsFile snapshot;
lock (_lock)
{
if (zone is null) _zones.Remove(chatId);
else _zones[chatId] = zone;
snapshot = new SettingsFile(Version, _zones.ToDictionary(
kv => kv.Key.ToString(CultureInfo.InvariantCulture),
kv => new ChatSettings(TimeZoneIds.Name(kv.Value))));
}
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var temp = $"{_path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(snapshot, VolcanicJson.Indented), CancellationToken.None).ConfigureAwait(false);
File.Move(temp, _path, overwrite: true);
}
finally
{
_writeGate.Release();
}
}
private void Load(TimeProvider time)
{
if (!File.Exists(_path)) return;
SettingsFile? file;
try
{
file = VolcanicJson.Deserialize<SettingsFile>(File.ReadAllText(_path));
}
catch (JsonException)
{
file = null;
}
if (file is not { Version: Version, Chats: not null })
{
var aside = $"{_path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(_path, aside, overwrite: true);
_logger.LogError("Chat settings {Path} could not be read; moved to {Aside}, every chat follows the default zone", _path, aside);
return;
}
foreach (var (key, settings) in file.Chats)
{
if (!long.TryParse(key, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId) || settings?.TimeZone is not { } id) continue;
if (TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone)) _zones[chatId] = zone;
else _logger.LogWarning("Chat {ChatId}: stored time zone {Zone} is unknown here; the chat follows the default", chatId, id);
}
}
private sealed record SettingsFile(int Version, Dictionary<string, ChatSettings>? Chats);
private sealed record ChatSettings(string? TimeZone);
}

View File

@@ -0,0 +1,67 @@
using System.Globalization;
using System.Text;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The one path for a chat's zone change (<c>/timezone</c> and <c>set_timezone</c>): store it, apply it to the live
/// session (from its next run), re-evaluate the chat's cron jobs, and word the reply. <paramref name="liveSession"/>
/// finds the chat's session without creating one; it's a function so DI has no ChatRegistry ↔ service cycle.
/// </summary>
public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, AgentSession?> liveSession, TimeProvider time, JobScheduler? scheduler = null)
{
public TimeZoneInfo TimeZoneFor(long chatId) => store.TimeZoneFor(chatId);
/// <summary>Set (<paramref name="zone"/>) or reset (null) the chat's zone and return the confirmation.</summary>
public async Task<string> SetAsync(long chatId, TimeZoneInfo? zone, CancellationToken cancellationToken)
{
await store.SetTimeZoneAsync(chatId, zone is null ? null : TimeZoneIds.Name(zone), cancellationToken).ConfigureAwait(false);
var effective = store.TimeZoneFor(chatId);
liveSession(chatId)?.SetTimeZone(effective);
var now = time.GetUtcNow();
var text = new StringBuilder(zone is null
? $"Time zone reset to the default, {Describe(effective, now)}."
: $"Time zone set to {Describe(effective, now)}.");
text.Append(" Now ").Append(JobFormat.DayTime(now, effective)).Append('.');
if (scheduler is { IsStarted: true })
{
var target = chatId.ToString(CultureInfo.InvariantCulture);
var change = await scheduler.TimeZoneChangedAsync(target, cancellationToken).ConfigureAwait(false);
text.Append(JobClause(change, scheduler.List(target), effective));
}
return text.ToString();
}
/// <summary><c>Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone &lt;IANA name&gt;, e.g. /timezone America/New_York.</c></summary>
public string Show(long chatId)
{
var zone = store.TimeZoneFor(chatId);
var marker = store.HasOwnZone(chatId) ? "" : ", default";
return $"Time zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(time.GetUtcNow()))}{marker}). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.";
}
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>, the chat's zone now (for <c>/status</c>).</summary>
public string Describe(long chatId) => Describe(store.TimeZoneFor(chatId), time.GetUtcNow());
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>.</summary>
public static string Describe(TimeZoneInfo zone, DateTimeOffset at) => $"{TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(at))})";
/// <summary><c> 2 recurring jobs now follow this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).</c> Empty without jobs.</summary>
private static string JobClause(ZoneChange change, IReadOnlyList<JobView> jobs, TimeZoneInfo zone)
{
var parts = new List<string>();
if (change.CronJobs > 0)
parts.Add(change.CronJobs == 1 ? "1 recurring job now follows this zone" : $"{change.CronJobs.ToString(CultureInfo.InvariantCulture)} recurring jobs now follow this zone");
if (change.OneOffs > 0)
{
var times = string.Join(", ", jobs.Where(v => v.Job.Schedule.At is not null).Select(v => JobFormat.DayTime(v.Job.Schedule.At!.Value, zone)));
parts.Add(change.OneOffs == 1
? $"1 one-off job keeps its time (now {times} here)"
: $"{change.OneOffs.ToString(CultureInfo.InvariantCulture)} one-off jobs keep their times (now {times} here)");
}
return parts.Count == 0 ? "" : " " + string.Join("; ", parts) + ".";
}
}

View File

@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Parses what a user (<c>/timezone</c>) or the agent (<c>set_timezone</c>) calls a zone. The stored form is the IANA id.</summary>
public static partial class ChatTimeZones
{
/// <summary>Accepts IANA ids and Windows ids (converted); rejects <c>Local</c> and bare offsets such as <c>+03:00</c> or <c>GMT+3</c>.</summary>
public static bool TryParse(string? input, out TimeZoneInfo zone, out string error)
{
var text = input?.Trim() ?? "";
zone = TimeZoneInfo.Utc;
error = $"Unknown time zone \"{text}\". Use an IANA name such as Europe/Berlin or America/New_York.";
if (text.Length == 0 || text.Equals("Local", StringComparison.OrdinalIgnoreCase) || BareOffset().IsMatch(text)) return false;
if (!TimeZoneInfo.TryFindSystemTimeZoneById(text, out var found)) return false;
if (TimeZoneIds.ToIana(found) is not { } iana || !TimeZoneInfo.TryFindSystemTimeZoneById(iana, out var resolved)) return false;
zone = resolved;
error = "";
return true;
}
[GeneratedRegex(@"^(utc|gmt)?\s*[+-]\s*\d", RegexOptions.IgnoreCase)]
private static partial Regex BareOffset();
}

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Extensions.Mcp;
@@ -60,6 +61,7 @@ builder.Services.AddAgenticCore(o =>
{
o.DefaultModel = options.Model;
o.ProcessEnvironment = processEnvironment;
o.DefaultTimeZone = options.ConfiguredTimeZone();
o.AddExtension<ClaudeCodeProviderExtension>();
o.AddExtension<McpExtension>();
});
@@ -68,6 +70,17 @@ builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay,
builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton(sp => new ChatSettingsStore(
ChatSettingsStore.DefaultPath(sp.GetRequiredService<IOptions<AgenticCoreOptions>>().Value.AgentDir),
options.ConfiguredTimeZone() ?? TimeZoneInfo.Local,
sp.GetRequiredService<ILogger<ChatSettingsStore>>(),
sp.GetRequiredService<TimeProvider>()));
// Finds a chat's live session lazily (at call time), so ChatRegistry <-> ChatTimeZoneService is no construction cycle.
builder.Services.AddSingleton(sp => new ChatTimeZoneService(
sp.GetRequiredService<ChatSettingsStore>(),
chatId => sp.GetRequiredService<ChatRegistry>().Find(chatId)?.Session,
sp.GetRequiredService<TimeProvider>(),
sp.GetService<Volcanic.AgenticCore.Extensions.Scheduler.JobScheduler>()));
builder.Services.AddSingleton<ChatCommands>();
builder.Services.AddSingleton<InboundMedia>();
builder.Services.AddSingleton<InboundComposer>();
@@ -88,6 +101,7 @@ if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else
logger.LogWarning("Claude Code (claude) was not found on PATH; requests will fail until it is installed and logged in. Send /claude-code-doctor to the bot for details.");
logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local));
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);
var polling = host.Services.GetRequiredService<PollingService>();

View File

@@ -44,6 +44,13 @@ public sealed class TelegramOptions
/// <summary>Speech to text for voice notes and audio (<c>Telegram:Speech</c>).</summary>
public SpeechOptions Speech { get; set; } = new();
/// <summary>IANA zone for chats that haven't set one with <c>/timezone</c>; empty = the machine's zone. Also <c>DEFAULT_TIMEZONE</c>.</summary>
public string? DefaultTimeZone { get; set; }
/// <summary>The configured default zone, or null when unset (then <see cref="TimeZoneInfo.Local"/> applies).</summary>
public TimeZoneInfo? ConfiguredTimeZone() =>
!string.IsNullOrWhiteSpace(DefaultTimeZone) && Chats.ChatTimeZones.TryParse(DefaultTimeZone, out var zone, out _) ? zone : null;
/// <summary>Reads the environment-variable shortcuts the README documents, when the section leaves them empty.</summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
@@ -60,6 +67,8 @@ public sealed class TelegramOptions
Speech.ApiKey = key.Trim();
if (string.IsNullOrWhiteSpace(Speech.Model) && getEnv("STT_MODEL") is { } model && !string.IsNullOrWhiteSpace(model))
Speech.Model = model.Trim();
if (string.IsNullOrWhiteSpace(DefaultTimeZone) && getEnv("DEFAULT_TIMEZONE") is { } tz && !string.IsNullOrWhiteSpace(tz))
DefaultTimeZone = tz.Trim();
}
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
@@ -81,6 +90,8 @@ public sealed class TelegramOptions
// On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it.
if (!Uri.TryCreate(Speech?.BaseUrl, UriKind.Absolute, out var sttUri) || sttUri.Scheme is not ("http" or "https"))
errors.Add("Telegram:Speech:BaseUrl must be an absolute http(s) URL.");
if (!string.IsNullOrWhiteSpace(DefaultTimeZone) && !Chats.ChatTimeZones.TryParse(DefaultTimeZone, out _, out var zoneError))
errors.Add("Telegram:DefaultTimeZone: " + zoneError);
return errors;
}

View File

@@ -8,6 +8,7 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />

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