forked from admin/VolcanicAgenticCore
- log an unknown stored zone once even when the tool description reads it first - serialise the flow's token write with /calendar_disconnect under the flow gate - a cancelled or expired pending flow is cleared, not re-sent or shown - chat keys anchor with \z so a trailing newline is hashed - a timeMax date includes that whole day (spec updated) - dispose the flow's CancellationTokenSource, drop ConnectFlow.Token, Begin is a plain write - GoogleCalendarOptions.IsConfigured replaces the host's copy of the rule; StoredToken is internal - test BotSetupService's menu through DI, tokens absent from tool results - Telegram.Tests csproj reference placed as in telegram-message-context; ChatHost passes calendar by name - docs: per-event timeZone, both config sources for /calendar_*
148 lines
7.5 KiB
C#
148 lines
7.5 KiB
C#
using System.Globalization;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Nodes;
|
||
|
||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||
|
||
internal enum CalendarTimeKind { Date, Local, Offset }
|
||
|
||
/// <summary>A parsed tool time. <see cref="Raw"/> is the RFC 3339 text sent for an <see cref="CalendarTimeKind.Offset"/> value.</summary>
|
||
internal readonly record struct CalendarTimeValue(CalendarTimeKind Kind, string Raw, DateOnly Date, DateTime Local, DateTimeOffset Instant);
|
||
|
||
/// <summary>
|
||
/// The tool's time rules: <c>YYYY-MM-DD</c> is all-day (end exclusive), a date-time with an offset is sent as-is, one
|
||
/// without is wall time in the event zone (sent without an offset plus <c>timeZone</c>). Query bounds are RFC 3339.
|
||
/// </summary>
|
||
internal static class CalendarTime
|
||
{
|
||
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
|
||
private static readonly string[] LocalFormats =
|
||
["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"];
|
||
private const string OffsetFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
|
||
private const string LocalFormat = "yyyy-MM-dd'T'HH:mm:ss";
|
||
private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'";
|
||
|
||
public static CalendarTimeValue Parse(string text, string field)
|
||
{
|
||
var s = (text ?? "").Trim();
|
||
if (DateOnly.TryParseExact(s, "yyyy-MM-dd", Inv, DateTimeStyles.None, out var date))
|
||
return new(CalendarTimeKind.Date, s, date, default, default);
|
||
if (HasOffset(s))
|
||
{
|
||
if (DateTimeOffset.TryParse(s, Inv, DateTimeStyles.None, out var instant))
|
||
return new(CalendarTimeKind.Offset, Rfc3339(instant, IsUtcDesignator(s)), default, default, instant);
|
||
}
|
||
else if (DateTime.TryParseExact(s, LocalFormats, Inv, DateTimeStyles.None, out var local))
|
||
{
|
||
return new(CalendarTimeKind.Local, s, default, DateTime.SpecifyKind(local, DateTimeKind.Unspecified), default);
|
||
}
|
||
throw new ArgumentException(
|
||
$"{field} must be YYYY-MM-DD, a local date-time like 2026-09-30T10:00, or one with an offset like 2026-09-30T10:00:00+02:00 (got \"{text}\").");
|
||
}
|
||
|
||
public static JsonObject ToEventTime(CalendarTimeValue value, string zoneId) => value.Kind switch
|
||
{
|
||
CalendarTimeKind.Date => new JsonObject { ["date"] = value.Date.ToString("yyyy-MM-dd", Inv) },
|
||
CalendarTimeKind.Local => new JsonObject { ["dateTime"] = value.Local.ToString(LocalFormat, Inv), ["timeZone"] = zoneId },
|
||
_ => new JsonObject { ["dateTime"] = value.Raw, ["timeZone"] = zoneId },
|
||
};
|
||
|
||
/// <summary>Start and end for a new event; a missing end is one day (all-day) or one hour (timed) after start.</summary>
|
||
public static (JsonObject Start, JsonObject End) ForCreate(string start, string? end, string zoneId)
|
||
{
|
||
var s = Parse(start, "start");
|
||
var e = end is null ? DefaultEnd(s) : Parse(end, "end");
|
||
EnsureSameKind(s, e);
|
||
return (ToEventTime(s, zoneId), ToEventTime(e, zoneId));
|
||
}
|
||
|
||
/// <summary>Only the sides that were given; both given must be the same kind.</summary>
|
||
public static (JsonObject? Start, JsonObject? End) ForUpdate(string? start, string? end, string zoneId)
|
||
{
|
||
CalendarTimeValue? s = start is null ? null : Parse(start, "start");
|
||
CalendarTimeValue? e = end is null ? null : Parse(end, "end");
|
||
if (s is { } sv && e is { } ev) EnsureSameKind(sv, ev);
|
||
return (s is { } a ? ToEventTime(a, zoneId) : null, e is { } b ? ToEventTime(b, zoneId) : null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A query bound (<c>timeMin</c>/<c>timeMax</c>) as RFC 3339 with an offset; dates and local times are in
|
||
/// <paramref name="zone"/>. A date is its midnight, or with <paramref name="dateIsInclusiveEnd"/> (<c>timeMax</c>)
|
||
/// the next day's midnight, so <c>timeMin=timeMax=2026-09-30</c> covers the whole 30th.
|
||
/// </summary>
|
||
public static string ToRfc3339(string text, TimeZoneInfo zone, string field, bool dateIsInclusiveEnd = false)
|
||
{
|
||
var value = Parse(text, field);
|
||
return value.Kind switch
|
||
{
|
||
CalendarTimeKind.Date => InZone((dateIsInclusiveEnd ? value.Date.AddDays(1) : value.Date).ToDateTime(TimeOnly.MinValue), zone),
|
||
CalendarTimeKind.Local => InZone(value.Local, zone),
|
||
_ => value.Instant.ToString(OffsetFormat, Inv),
|
||
};
|
||
}
|
||
|
||
public static string Utc(DateTimeOffset instant) => instant.ToUniversalTime().ToString(UtcFormat, Inv);
|
||
|
||
/// <summary>An event's start/end as <c>Wed 30 Sep 10:00–11:00</c> in <paramref name="zone"/>, or an all-day range.</summary>
|
||
public static string FormatRange(JsonElement start, JsonElement end, TimeZoneInfo zone)
|
||
{
|
||
if (start.TryGetProperty("date", out var startDate))
|
||
{
|
||
var s = DateOnly.ParseExact(startDate.GetString()!, "yyyy-MM-dd", Inv);
|
||
var last = end.TryGetProperty("date", out var endDate)
|
||
? DateOnly.ParseExact(endDate.GetString()!, "yyyy-MM-dd", Inv).AddDays(-1)
|
||
: s;
|
||
return last <= s ? $"{Day(s)} (all day)" : $"{Day(s)} – {Day(last)} (all day)";
|
||
}
|
||
var from = TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(start.GetProperty("dateTime").GetString()!, Inv), zone);
|
||
var to = end.TryGetProperty("dateTime", out var endTime)
|
||
? TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(endTime.GetString()!, Inv), zone)
|
||
: from;
|
||
return from.Date == to.Date
|
||
? $"{from.ToString("ddd d MMM HH:mm", Inv)}–{to.ToString("HH:mm", Inv)}"
|
||
: $"{from.ToString("ddd d MMM HH:mm", Inv)} – {to.ToString("ddd d MMM HH:mm", Inv)}";
|
||
}
|
||
|
||
/// <summary>The header line of every tool result; names both zones when the calendar's differs from the chat's.</summary>
|
||
public static string Header(TimeZoneInfo resolved, TimeZoneInfo chat)
|
||
{
|
||
var r = ZoneIds.Name(resolved);
|
||
var c = ZoneIds.Name(chat);
|
||
return r == c ? $"Times in {r}." : $"Times in {r} (the calendar's zone; this chat is in {c}).";
|
||
}
|
||
|
||
private static string Day(DateOnly d) => d.ToString("ddd d MMM", Inv);
|
||
|
||
private static string InZone(DateTime local, TimeZoneInfo zone) =>
|
||
new DateTimeOffset(local, zone.GetUtcOffset(local)).ToString(OffsetFormat, Inv);
|
||
|
||
private static string Rfc3339(DateTimeOffset instant, bool utc) =>
|
||
utc && instant.Offset == TimeSpan.Zero ? instant.ToString(UtcFormat, Inv) : instant.ToString(OffsetFormat, Inv);
|
||
|
||
private static CalendarTimeValue DefaultEnd(CalendarTimeValue start) => start.Kind switch
|
||
{
|
||
CalendarTimeKind.Date => start with { Date = start.Date.AddDays(1) },
|
||
CalendarTimeKind.Local => start with { Local = start.Local.AddHours(1) },
|
||
_ => start with
|
||
{
|
||
Instant = start.Instant.AddHours(1),
|
||
Raw = Rfc3339(start.Instant.AddHours(1), start.Raw.EndsWith('Z')),
|
||
},
|
||
};
|
||
|
||
private static void EnsureSameKind(CalendarTimeValue start, CalendarTimeValue end)
|
||
{
|
||
if ((start.Kind == CalendarTimeKind.Date) != (end.Kind == CalendarTimeKind.Date))
|
||
throw new ArgumentException("start and end must both be dates (YYYY-MM-DD) or both date-times.");
|
||
}
|
||
|
||
private static bool IsUtcDesignator(string s) => s.EndsWith('Z') || s.EndsWith('z');
|
||
|
||
private static bool HasOffset(string s)
|
||
{
|
||
if (IsUtcDesignator(s)) return true;
|
||
var t = s.IndexOfAny(['T', ' ']);
|
||
return t > 0 && s.AsSpan(t).IndexOfAny('+', '-') >= 0;
|
||
}
|
||
}
|