Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatSettingsStore.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

107 lines
4.5 KiB
C#

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