Files
EugeneTes 881e7f0be9 fix(google-calendar): address review findings
- 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_*
2026-09-26 20:30:56 +02:00

148 lines
7.5 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}