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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
106
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatSettingsStore.cs
Normal file
106
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatSettingsStore.cs
Normal 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><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);
|
||||
}
|
||||
@@ -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 <IANA name>, 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) + ".";
|
||||
}
|
||||
}
|
||||
25
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatTimeZones.cs
Normal file
25
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatTimeZones.cs
Normal 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();
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
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