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.
107 lines
4.5 KiB
C#
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><AgentDir>/telegram/chat-settings.json</c>,
|
|
/// <c>{ "version": 1, "chats": { "<chatId>": { "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);
|
|
}
|