forked from admin/VolcanicAgenticCore
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock; a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step - Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00) - core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering - Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir - ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule - tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage - docs: spec notes for the above; README row for the scheduler
67 lines
3.8 KiB
C#
67 lines
3.8 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using Volcanic.AgenticCore.Extensions.Scheduler;
|
|
using Volcanic.AgenticCore.Features.Agent;
|
|
using Volcanic.AgenticCore.Hosting;
|
|
using Volcanic.AgenticCore.Telegram.Scheduling;
|
|
|
|
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)
|
|
{
|
|
/// <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 = TelegramJobDelivery.TargetOf(chatId);
|
|
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) + ".";
|
|
}
|
|
}
|