forked from admin/VolcanicAgenticCore
Merge branch 'feat/google-calendar'
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>Replies through the UI, or as a custom message without a turn when there is none.</summary>
|
||||
internal static class CommandReply
|
||||
{
|
||||
public static void Send(IExtensionApi api, IExtensionContext ctx, string customType, string text, NotifyKind kind = NotifyKind.Info)
|
||||
{
|
||||
if (ctx.HasUi) ctx.Ui.Notify(text, kind);
|
||||
else api.SendMessage(CustomMessage.FromText(customType, text), MessageDelivery.Steer, triggerTurn: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>/calendar_connect</c>: runs this chat's device flow to the end (the command already runs off the update loop).</summary>
|
||||
internal static class ConnectCommand
|
||||
{
|
||||
public const string Name = "calendar_connect";
|
||||
public const string Description = "Connect this chat's Google Calendar (link + code)";
|
||||
|
||||
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
|
||||
{
|
||||
if (!ctx.HasUi)
|
||||
{
|
||||
CommandReply.Send(api, ctx, Name, $"/{Name} needs an interactive UI (a chat front end such as the Telegram bot).");
|
||||
return;
|
||||
}
|
||||
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
|
||||
var flow = await ConnectFlow.StartOrResendAsync(connection, ctx.Ui, ct).ConfigureAwait(false);
|
||||
if (flow is not null) await flow.RunAsync(ctx.Ui, ctx.Logger).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary><c>/calendar_status</c>.</summary>
|
||||
internal static class StatusCommand
|
||||
{
|
||||
public const string Name = "calendar_status";
|
||||
public const string Description = "Show this chat's Google Calendar connection";
|
||||
|
||||
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, (_, ctx, _) =>
|
||||
{
|
||||
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
|
||||
CommandReply.Send(api, ctx, Name, Describe(connection));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
internal static string Describe(GoogleCalendarConnection connection)
|
||||
{
|
||||
if (connection.Pending is { } flow)
|
||||
{
|
||||
var d = flow.Device;
|
||||
return $"Waiting for approval: open {d.VerificationUrl} and enter {d.UserCode} ({ConnectFlow.MinutesLeft(d, connection.Settings.Time)} min left).";
|
||||
}
|
||||
if (connection.Store.Read() is not { } token) return "Not connected. Run /calendar_connect.";
|
||||
var since = token.ConnectedAt?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) ?? "an unknown date";
|
||||
return $"Connected as {token.Account ?? "an unknown account"} since {since}, time zone {token.TimeZone ?? "unknown"}.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>/calendar_disconnect</c>: cancel a pending flow, revoke (best effort), delete the file, drop the cache.</summary>
|
||||
internal static class DisconnectCommand
|
||||
{
|
||||
public const string Name = "calendar_disconnect";
|
||||
public const string Description = "Disconnect this chat's Google Calendar";
|
||||
|
||||
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
|
||||
{
|
||||
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
|
||||
string? revokeError;
|
||||
// The gate a completing connect flow writes its token under: after CancelPending it can no longer write.
|
||||
await connection.FlowGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
connection.CancelPending();
|
||||
var token = connection.Store.Read();
|
||||
revokeError = token is null ? null : await connection.OAuth.RevokeAsync(token.RefreshToken, ct).ConfigureAwait(false);
|
||||
connection.Store.Delete();
|
||||
connection.Invalidate();
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.FlowGate.Release();
|
||||
}
|
||||
var text = "Calendar disconnected.";
|
||||
if (revokeError is not null)
|
||||
text += $" Revoking at Google failed: {revokeError}; remove the app at {GoogleCalendarMessages.PermissionsUrl}.";
|
||||
CommandReply.Send(api, ctx, Name, text);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
public sealed record CalendarArgs(
|
||||
[property: Description("list, search, get, list_instances, create, update, delete or calendars")] string Action,
|
||||
[property: Description("Calendar id; default primary. action=calendars lists the others")] string? CalendarId = null,
|
||||
[property: Description("Window start (list, search, list_instances): RFC 3339, a local date-time or a date")] string? TimeMin = null,
|
||||
[property: Description("Window end, same forms as timeMin; a date includes that whole day")] string? TimeMax = null,
|
||||
[property: Description("Free-text search (search only, required there)")] string? Query = null,
|
||||
[property: Description("Maximum events to return; default 25, at most 250")] int? MaxResults = null,
|
||||
[property: Description("list only: occurrences (default; every occurrence of recurring events) or series (recurring events once, with their RRULE)")] string? ListMode = null,
|
||||
[property: Description("Event id (get, list_instances, update, delete)")] string? EventId = null,
|
||||
[property: Description("update/delete of a recurring event: instance (one occurrence) or series; detected from the event when omitted")] string? Scope = null,
|
||||
[property: Description("Title (create, update)")] string? Summary = null,
|
||||
[property: Description("Notes (create, update)")] string? Description = null,
|
||||
[property: Description("Location (create, update)")] string? Location = null,
|
||||
[property: Description("YYYY-MM-DD for all day, or a date-time; without an offset it is wall time in timeZone")] string? Start = null,
|
||||
[property: Description("Like start. All-day ends are exclusive. Default on create: one day / one hour after start")] string? End = null,
|
||||
[property: Description("IANA zone for this event, e.g. Europe/Berlin; default the calendar's zone")] string? TimeZone = null,
|
||||
[property: Description("RFC 5545 lines, e.g. [\"RRULE:FREQ=WEEKLY;BYDAY=MO,WE\"]; [] on update turns a series into a single event")] IReadOnlyList<string>? Recurrence = null);
|
||||
|
||||
public sealed record CalendarEventDetails(string Id, string? Summary, JsonElement? Start, JsonElement? End, string? RecurringEventId,
|
||||
IReadOnlyList<string>? Recurrence, string? HtmlLink);
|
||||
|
||||
public sealed record CalendarResultDetails(string Action, IReadOnlyList<CalendarEventDetails> Events);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>calendar</c> tool. Always registered while the OAuth client is configured; without a token for this chat it
|
||||
/// fails with the "/calendar_connect" hint. Uses only the chat's own connection (scope from the context).
|
||||
/// </summary>
|
||||
internal sealed partial class CalendarTool(GoogleCalendarSettings settings, GoogleCalendarConnections connections, Func<string?> currentZone) : Tool<CalendarArgs>
|
||||
{
|
||||
public const string ToolName = "calendar";
|
||||
private const int DefaultMax = 25;
|
||||
private const int MaxCap = 250;
|
||||
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
|
||||
|
||||
public override string Name => ToolName;
|
||||
|
||||
public override string Description =>
|
||||
"Read and change the Google Calendar this chat connected with /calendar_connect. Actions: list, search, get, " +
|
||||
"list_instances, create, update, delete, calendars. " + Rules;
|
||||
|
||||
public override string? PromptSnippet => "calendar: read and change this chat's Google Calendar";
|
||||
|
||||
public override IReadOnlyList<string> PromptGuidelines => ["calendar: " + Rules];
|
||||
|
||||
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Parallel;
|
||||
|
||||
private string Rules =>
|
||||
$"Times without an offset are in {currentZone() ?? "the zone named in each result's header"}; all-day end dates are exclusive " +
|
||||
"(a one-day event on 2026-09-30 ends 2026-10-01); to change one date of a recurring event, get that occurrence's id with list_instances first.";
|
||||
|
||||
protected override async Task<ToolResult> ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = invocation.Context;
|
||||
var connection = connections.For(settings, ctx.SessionManager.Scope);
|
||||
var resolved = connection.ResolveTimeZone(ctx.TimeZone, ctx.Logger);
|
||||
var eventZone = args.TimeZone is { Length: > 0 } requested
|
||||
? ZoneIds.TryFind(requested, out var z) ? z : throw new ArgumentException($"Unknown time zone \"{requested}\". Use an IANA id such as Europe/Berlin.")
|
||||
: resolved;
|
||||
var run = new Run(args, new GoogleCalendarApi(connection), resolved, eventZone, CalendarTime.Header(resolved, ctx.TimeZone),
|
||||
string.IsNullOrWhiteSpace(args.CalendarId) ? "primary" : args.CalendarId.Trim(), settings.Time.GetUtcNow(), cancellationToken);
|
||||
var action = (args.Action ?? "").Trim().ToLowerInvariant();
|
||||
return action switch
|
||||
{
|
||||
"list" => await ListAsync(run).ConfigureAwait(false),
|
||||
"search" => await SearchAsync(run).ConfigureAwait(false),
|
||||
"get" => await GetAsync(run).ConfigureAwait(false),
|
||||
"list_instances" => await ListInstancesAsync(run).ConfigureAwait(false),
|
||||
"calendars" => await CalendarsAsync(run).ConfigureAwait(false),
|
||||
"create" or "update" or "delete" => await WriteAsync(action, run).ConfigureAwait(false),
|
||||
_ => throw new ArgumentException($"Unknown action \"{args.Action}\". Use one of: list, search, get, list_instances, create, update, delete, calendars."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Everything one call needs, resolved once.</summary>
|
||||
private sealed record Run(CalendarArgs Args, GoogleCalendarApi Api, TimeZoneInfo Zone, TimeZoneInfo EventZone, string Header,
|
||||
string CalendarId, DateTimeOffset Now, CancellationToken Ct)
|
||||
{
|
||||
public string EventZoneName => ZoneIds.Name(EventZone);
|
||||
public int Max => Math.Clamp(Args.MaxResults ?? DefaultMax, 1, MaxCap);
|
||||
public string Bound(string? given, DateTimeOffset fallback, string field) =>
|
||||
given is { Length: > 0 } ? CalendarTime.ToRfc3339(given, EventZone, field, dateIsInclusiveEnd: field == "timeMax") : CalendarTime.Utc(fallback);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ListAsync(Run run)
|
||||
{
|
||||
var mode = (run.Args.ListMode ?? "occurrences").Trim().ToLowerInvariant();
|
||||
if (mode is not ("occurrences" or "series")) throw new ArgumentException("listMode must be occurrences or series.");
|
||||
var query = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new("timeMin", run.Bound(run.Args.TimeMin, run.Now, "timeMin")),
|
||||
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(7), "timeMax")),
|
||||
new("singleEvents", mode == "occurrences" ? "true" : "false"),
|
||||
};
|
||||
if (mode == "occurrences") query.Add(new("orderBy", "startTime"));
|
||||
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("list", run.CalendarId)).ConfigureAwait(false);
|
||||
return Events("list", run, items);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> SearchAsync(Run run)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(run.Args.Query)) throw new ArgumentException("search needs query.");
|
||||
var query = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new("q", run.Args.Query.Trim()),
|
||||
new("timeMin", run.Bound(run.Args.TimeMin, run.Now.AddDays(-30), "timeMin")),
|
||||
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(365), "timeMax")),
|
||||
new("singleEvents", "true"),
|
||||
new("orderBy", "startTime"),
|
||||
};
|
||||
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("search", run.CalendarId)).ConfigureAwait(false);
|
||||
return Events("search", run, items);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> GetAsync(Run run)
|
||||
{
|
||||
var id = Require(run.Args.EventId, "get needs eventId.");
|
||||
var ev = await run.Api.SendAsync(HttpMethod.Get, GoogleCalendarApi.Event(run.CalendarId, id), null, new CalendarCall("get", run.CalendarId, id), run.Ct).ConfigureAwait(false);
|
||||
var text = run.Header + "\n" + Line(ev, run.Zone);
|
||||
if (Str(ev, "description") is { Length: > 0 } description) text += "\nDescription: " + description;
|
||||
return Result("get", text, [ev]);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ListInstancesAsync(Run run)
|
||||
{
|
||||
var id = Require(run.Args.EventId, "list_instances needs eventId.");
|
||||
var query = new List<KeyValuePair<string, string>>();
|
||||
if (run.Args.TimeMin is { Length: > 0 } min) query.Add(new("timeMin", CalendarTime.ToRfc3339(min, run.EventZone, "timeMin")));
|
||||
if (run.Args.TimeMax is { Length: > 0 } max) query.Add(new("timeMax", CalendarTime.ToRfc3339(max, run.EventZone, "timeMax", dateIsInclusiveEnd: true)));
|
||||
var items = await PageAsync(run, GoogleCalendarApi.Event(run.CalendarId, id) + "/instances", query, new CalendarCall("list_instances", run.CalendarId, id)).ConfigureAwait(false);
|
||||
return Events("list_instances", run, items);
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> CalendarsAsync(Run run)
|
||||
{
|
||||
var items = await PageAsync(run, "users/me/calendarList", [], new CalendarCall("calendars", run.CalendarId)).ConfigureAwait(false);
|
||||
var sb = new StringBuilder(run.Header);
|
||||
foreach (var c in items)
|
||||
{
|
||||
sb.Append('\n').Append(Str(c, "id")).Append(" · ").Append(Str(c, "summary") ?? "(no name)")
|
||||
.Append(" · ").Append(Str(c, "accessRole")).Append(" · ").Append(Str(c, "timeZone"));
|
||||
if (c.TryGetProperty("primary", out var p) && p.ValueKind == JsonValueKind.True) sb.Append(" · primary");
|
||||
}
|
||||
return ToolResult.Text(sb.ToString(), new CalendarResultDetails("calendars", []));
|
||||
}
|
||||
|
||||
/// <summary>Follows <c>nextPageToken</c> until <see cref="Run.Max"/> items are collected.</summary>
|
||||
private static async Task<List<JsonElement>> PageAsync(Run run, string path, List<KeyValuePair<string, string>> query, CalendarCall call)
|
||||
{
|
||||
var items = new List<JsonElement>();
|
||||
string? pageToken = null;
|
||||
do
|
||||
{
|
||||
var pageQuery = new List<KeyValuePair<string, string>>(query) { new("maxResults", (run.Max - items.Count).ToString(Inv)) };
|
||||
if (pageToken is not null) pageQuery.Add(new("pageToken", pageToken));
|
||||
var page = await run.Api.SendAsync(HttpMethod.Get, path + "?" + GoogleCalendarApi.Query(pageQuery), null, call, run.Ct).ConfigureAwait(false);
|
||||
if (page.TryGetProperty("items", out var list) && list.ValueKind == JsonValueKind.Array)
|
||||
items.AddRange(list.EnumerateArray().Where(e => Str(e, "status") != "cancelled"));
|
||||
pageToken = Str(page, "nextPageToken");
|
||||
}
|
||||
while (pageToken is not null && items.Count < run.Max);
|
||||
return items.Take(run.Max).ToList();
|
||||
}
|
||||
|
||||
private static ToolResult Events(string action, Run run, List<JsonElement> items)
|
||||
{
|
||||
var text = items.Count == 0 ? run.Header + "\nNo events." : run.Header + "\n" + string.Join("\n", items.Select(e => Line(e, run.Zone)));
|
||||
return Result(action, text, items);
|
||||
}
|
||||
|
||||
private static ToolResult Result(string action, string text, IEnumerable<JsonElement> events) =>
|
||||
ToolResult.Text(text, new CalendarResultDetails(action, events.Select(Details).ToList()));
|
||||
|
||||
/// <summary><c><id> · <summary> · <range>[ @ location][ · recurring (series=…)][ · RRULE=…]</c></summary>
|
||||
internal static string Line(JsonElement ev, TimeZoneInfo zone)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(Str(ev, "id")).Append(" · ").Append(Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)");
|
||||
if (ev.TryGetProperty("start", out var start))
|
||||
sb.Append(" · ").Append(CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, zone));
|
||||
if (Str(ev, "location") is { Length: > 0 } location) sb.Append(" @ ").Append(location);
|
||||
if (Str(ev, "recurringEventId") is { Length: > 0 } series) sb.Append(" · recurring (series=").Append(series).Append(')');
|
||||
if (Recurrence(ev)?.FirstOrDefault(r => r.StartsWith("RRULE:", StringComparison.Ordinal)) is { } rrule) sb.Append(" · RRULE=").Append(rrule[6..]);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static CalendarEventDetails Details(JsonElement ev) => new(
|
||||
Str(ev, "id") ?? "", Str(ev, "summary"),
|
||||
ev.TryGetProperty("start", out var s) ? s.Clone() : null, ev.TryGetProperty("end", out var e) ? e.Clone() : null,
|
||||
Str(ev, "recurringEventId"), Recurrence(ev), Str(ev, "htmlLink"));
|
||||
|
||||
private static IReadOnlyList<string>? Recurrence(JsonElement ev) =>
|
||||
ev.TryGetProperty("recurrence", out var r) && r.ValueKind == JsonValueKind.Array ? r.EnumerateArray().Select(x => x.GetString() ?? "").ToList() : null;
|
||||
|
||||
private static string? Str(JsonElement e, string name) =>
|
||||
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static string Require(string? value, string message) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException(message) : value.Trim();
|
||||
|
||||
internal enum EventKind { Single, Instance, Series }
|
||||
|
||||
private static readonly string[] RecurrencePrefixes = ["RRULE:", "EXDATE:", "RDATE:", "EXRULE:"];
|
||||
|
||||
private static Task<ToolResult> WriteAsync(string action, Run run) => action switch
|
||||
{
|
||||
"create" => CreateAsync(run),
|
||||
"update" => UpdateAsync(run),
|
||||
_ => DeleteAsync(run),
|
||||
};
|
||||
|
||||
private static async Task<ToolResult> CreateAsync(Run run)
|
||||
{
|
||||
var a = run.Args;
|
||||
var summary = Require(a.Summary, "create needs summary.");
|
||||
var startText = Require(a.Start, "create needs start.");
|
||||
if (a.Recurrence is not null) ValidateRecurrence(a.Recurrence);
|
||||
var (start, end) = CalendarTime.ForCreate(startText, a.End, run.EventZoneName);
|
||||
var body = new JsonObject { ["summary"] = summary, ["start"] = start, ["end"] = end };
|
||||
if (a.Description is not null) body["description"] = a.Description;
|
||||
if (a.Location is not null) body["location"] = a.Location;
|
||||
if (a.Recurrence is { Count: > 0 }) body["recurrence"] = RecurrenceJson(a.Recurrence);
|
||||
var created = await run.Api.SendAsync(HttpMethod.Post, GoogleCalendarApi.Events(run.CalendarId), body, new CalendarCall("create", run.CalendarId), run.Ct).ConfigureAwait(false);
|
||||
return Result("create", $"{run.Header}\nCreated: {Line(created, run.Zone)}", [created]);
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> UpdateAsync(Run run)
|
||||
{
|
||||
var a = run.Args;
|
||||
var id = Require(a.EventId, "update needs eventId.");
|
||||
var call = new CalendarCall("update", run.CalendarId, id);
|
||||
var path = GoogleCalendarApi.Event(run.CalendarId, id);
|
||||
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
|
||||
var kind = ResolveScope(ev, a.Scope, id);
|
||||
|
||||
var patch = new JsonObject();
|
||||
if (a.Summary is not null) patch["summary"] = a.Summary;
|
||||
if (a.Description is not null) patch["description"] = a.Description;
|
||||
if (a.Location is not null) patch["location"] = a.Location;
|
||||
var (start, end) = CalendarTime.ForUpdate(a.Start, a.End, run.EventZoneName);
|
||||
if (start is not null) patch["start"] = start;
|
||||
if (end is not null) patch["end"] = end;
|
||||
if (a.Recurrence is not null)
|
||||
{
|
||||
if (kind == EventKind.Instance)
|
||||
throw new ArgumentException($"recurrence can't be set on a single occurrence; update the series (eventId={Str(ev, "recurringEventId")}) instead.");
|
||||
ValidateRecurrence(a.Recurrence);
|
||||
patch["recurrence"] = RecurrenceJson(a.Recurrence);
|
||||
}
|
||||
if (patch.Count == 0)
|
||||
throw new ArgumentException("update needs at least one field to change (summary, description, location, start, end or recurrence).");
|
||||
|
||||
var updated = await run.Api.SendAsync(HttpMethod.Patch, path, patch, call, run.Ct).ConfigureAwait(false);
|
||||
var what = kind switch
|
||||
{
|
||||
EventKind.Instance => "Updated this occurrence only",
|
||||
EventKind.Series => "Updated the whole series",
|
||||
_ => "Updated",
|
||||
};
|
||||
return Result("update", $"{run.Header}\n{what}: {Line(updated, run.Zone)}", [updated]);
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> DeleteAsync(Run run)
|
||||
{
|
||||
var id = Require(run.Args.EventId, "delete needs eventId.");
|
||||
var call = new CalendarCall("delete", run.CalendarId, id);
|
||||
var path = GoogleCalendarApi.Event(run.CalendarId, id);
|
||||
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
|
||||
var kind = ResolveScope(ev, run.Args.Scope, id);
|
||||
await run.Api.SendAsync(HttpMethod.Delete, path, null, call, run.Ct).ConfigureAwait(false);
|
||||
var summary = Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)";
|
||||
var range = ev.TryGetProperty("start", out var start) ? CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, run.Zone) : "";
|
||||
var text = kind switch
|
||||
{
|
||||
EventKind.Instance => $"Cancelled one occurrence of \"{summary}\" ({range}); the rest of the series is unchanged.",
|
||||
EventKind.Series => $"Deleted the recurring series \"{summary}\" and all its occurrences.",
|
||||
_ => $"Deleted \"{summary}\" ({range}).",
|
||||
};
|
||||
return Result("delete", $"{run.Header}\n{text}", [ev]);
|
||||
}
|
||||
|
||||
/// <summary><c>recurringEventId</c> → instance; <c>recurrence</c> → series; neither → single (explicit scope ignored).</summary>
|
||||
internal static EventKind ResolveScope(JsonElement ev, string? requested, string eventId)
|
||||
{
|
||||
var scope = requested?.Trim().ToLowerInvariant();
|
||||
if (scope is not (null or "" or "instance" or "series")) throw new ArgumentException("scope must be instance or series.");
|
||||
if (Str(ev, "recurringEventId") is { Length: > 0 } series)
|
||||
{
|
||||
if (scope == "series")
|
||||
throw new ArgumentException($"Event {eventId} is one occurrence of the recurring series {series}. To change the whole series, pass eventId={series} with scope=series.");
|
||||
return EventKind.Instance;
|
||||
}
|
||||
if (Recurrence(ev) is { Count: > 0 })
|
||||
{
|
||||
if (scope == "instance")
|
||||
throw new ArgumentException($"Event {eventId} is a recurring series. To change one date, call list_instances with eventId={eventId}, then pass that occurrence's id with scope=instance.");
|
||||
return EventKind.Series;
|
||||
}
|
||||
return EventKind.Single;
|
||||
}
|
||||
|
||||
/// <summary>Every line must be an RFC 5545 RRULE, EXDATE, RDATE or EXRULE property.</summary>
|
||||
internal static void ValidateRecurrence(IReadOnlyList<string> lines)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = (line ?? "").Trim();
|
||||
if (!RecurrencePrefixes.Any(p => trimmed.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new ArgumentException($"Recurrence line \"{line}\" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonArray RecurrenceJson(IReadOnlyList<string> lines) => new(lines.Select(r => (JsonNode)r.Trim()).ToArray());
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>Maps a session scope (stable per chat) to a directory name that is safe on every file system.</summary>
|
||||
internal static partial class ChatKeys
|
||||
{
|
||||
public static string For(string scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
if (Safe().IsMatch(scope) && scope is not "." and not "..") return scope;
|
||||
return "scope-" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope)))[..16];
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9._-]{1,100}\z")] // \z, not $: '$' also matches before a trailing newline
|
||||
private static partial Regex Safe();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>
|
||||
/// One chat's device flow. It polls on its own cancellation source, not the command's token: <c>/stop</c> leaves it
|
||||
/// alone, <c>/calendar_disconnect</c> cancels it silently, and it ends by itself at the code's expiry.
|
||||
/// </summary>
|
||||
internal sealed class ConnectFlow
|
||||
{
|
||||
private const int MaxTransportErrors = 3;
|
||||
private static readonly TimeSpan SlowDownStep = TimeSpan.FromSeconds(5); // RFC 8628 §3.5
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private readonly GoogleCalendarConnection _connection;
|
||||
private int _parks;
|
||||
|
||||
private ConnectFlow(GoogleCalendarConnection connection, DeviceAuthorization device)
|
||||
{
|
||||
_connection = connection;
|
||||
Device = device;
|
||||
Interval = device.Interval;
|
||||
}
|
||||
|
||||
public DeviceAuthorization Device { get; }
|
||||
public TimeSpan Interval { get; private set; }
|
||||
|
||||
/// <summary>False once the flow was cancelled or its code expired: such a flow is never re-sent or shown.</summary>
|
||||
public bool IsLive(TimeProvider time) => !_cancellation.IsCancellationRequested && time.GetUtcNow() < Device.ExpiresAt;
|
||||
|
||||
/// <summary>How many poll delays the flow has entered (tests drive the fake clock with it).</summary>
|
||||
internal int Parks => Volatile.Read(ref _parks);
|
||||
|
||||
/// <summary>When the current delay ends.</summary>
|
||||
internal DateTimeOffset NextPollAt { get; private set; }
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
try { _cancellation.Cancel(); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
public static int MinutesLeft(DeviceAuthorization device, TimeProvider time) =>
|
||||
Math.Max(1, (int)Math.Ceiling((device.ExpiresAt - time.GetUtcNow()).TotalMinutes));
|
||||
|
||||
/// <summary>Re-sends a pending flow's link and code, or starts a new flow and sends its link. Null when nothing more to do.</summary>
|
||||
public static async Task<ConnectFlow?> StartOrResendAsync(GoogleCalendarConnection connection, IUserInterface ui, CancellationToken cancellationToken)
|
||||
{
|
||||
var time = connection.Settings.Time;
|
||||
await connection.FlowGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (connection.Pending is { } pending)
|
||||
{
|
||||
var d = pending.Device;
|
||||
ui.Notify($"A Google Calendar connection is already waiting for approval:\n1. Open {d.VerificationUrl}\n2. Enter the code {d.UserCode}\n({MinutesLeft(d, time)} min left.)");
|
||||
return null;
|
||||
}
|
||||
DeviceAuthorization device;
|
||||
try
|
||||
{
|
||||
device = await connection.OAuth.StartDeviceFlowAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException || ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
|
||||
return null;
|
||||
}
|
||||
var flow = new ConnectFlow(connection, device);
|
||||
connection.Begin(flow);
|
||||
ui.Notify($"Connect Google Calendar:\n1. Open {device.VerificationUrl}\n2. Enter the code {device.UserCode}\n3. Approve access. I'll confirm here when it's done (code valid for {MinutesLeft(device, time)} min).");
|
||||
return flow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.FlowGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(IUserInterface ui, ILogger logger)
|
||||
{
|
||||
var time = _connection.Settings.Time;
|
||||
var ct = _cancellation.Token;
|
||||
var transportErrors = 0;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
NextPollAt = time.GetUtcNow() + Interval;
|
||||
Interlocked.Increment(ref _parks);
|
||||
await Task.Delay(Interval, time, ct).ConfigureAwait(false);
|
||||
if (time.GetUtcNow() >= Device.ExpiresAt)
|
||||
{
|
||||
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
PollResult result;
|
||||
try
|
||||
{
|
||||
result = await _connection.OAuth.PollAsync(Device.DeviceCode, ct).ConfigureAwait(false);
|
||||
transportErrors = 0;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
if (++transportErrors >= MaxTransportErrors)
|
||||
{
|
||||
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
case PollStatus.Pending:
|
||||
continue;
|
||||
case PollStatus.SlowDown:
|
||||
Interval += SlowDownStep;
|
||||
continue;
|
||||
case PollStatus.Denied:
|
||||
ui.Notify("Google Calendar access was denied.", NotifyKind.Warning);
|
||||
return;
|
||||
case PollStatus.Expired:
|
||||
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
|
||||
return;
|
||||
case PollStatus.Failed:
|
||||
ui.Notify($"Google Calendar connection failed: {result.Message}", NotifyKind.Error);
|
||||
return;
|
||||
default:
|
||||
await CompleteAsync(result, ui, logger, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// cancelled by /calendar_disconnect or shutdown: silent
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connection.End(this);
|
||||
_cancellation.Dispose(); // a late Cancel() swallows ObjectDisposedException
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(PollResult result, IUserInterface ui, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var primary = new PrimaryCalendar(null, null);
|
||||
try
|
||||
{
|
||||
primary = await GoogleCalendarApi.GetPrimaryAsync(_connection.Settings.Http, result.AccessToken!, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException or System.Text.Json.JsonException
|
||||
|| ex is TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("google-calendar: looking up the primary calendar failed: {Error}", ex.Message);
|
||||
}
|
||||
// Under the gate /calendar_disconnect also takes: once it has cancelled the flow, the token is never written.
|
||||
await _connection.FlowGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_connection.Store.Write(new StoredToken(result.RefreshToken!, result.Scope, primary.Account, primary.TimeZone, _connection.Settings.Time.GetUtcNow()));
|
||||
_connection.Invalidate();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
ui.Notify($"Got a Google token but could not save it: {ex.Message}", NotifyKind.Error);
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connection.FlowGate.Release();
|
||||
}
|
||||
ui.Notify($"✅ Google Calendar connected as {primary.Account ?? "an unknown account"} (time zone {primary.TimeZone ?? "unknown"}).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
internal readonly record struct CalendarCall(string Action, string CalendarId, string? EventId = null);
|
||||
|
||||
internal sealed record PrimaryCalendar(string? Account, string? TimeZone);
|
||||
|
||||
/// <summary>Calendar v3 over raw HTTP for one chat: bearer token from the connection, one refresh-and-retry on 401.</summary>
|
||||
internal sealed class GoogleCalendarApi(GoogleCalendarConnection connection)
|
||||
{
|
||||
public const string BaseUrl = "https://www.googleapis.com/calendar/v3/";
|
||||
|
||||
public static string Events(string calendarId) => $"calendars/{Uri.EscapeDataString(calendarId)}/events";
|
||||
|
||||
public static string Event(string calendarId, string eventId) => $"{Events(calendarId)}/{Uri.EscapeDataString(eventId)}";
|
||||
|
||||
public static string Query(IEnumerable<KeyValuePair<string, string>> pairs) =>
|
||||
string.Join("&", pairs.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
|
||||
|
||||
public async Task<JsonElement> SendAsync(HttpMethod method, string pathAndQuery, JsonObject? body, CalendarCall call, CancellationToken cancellationToken)
|
||||
{
|
||||
var http = connection.Settings.Http;
|
||||
var response = await SendOnceAsync(http, method, pathAndQuery, body,
|
||||
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
response.Dispose();
|
||||
connection.Invalidate();
|
||||
response = await SendOnceAsync(http, method, pathAndQuery, body,
|
||||
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
using (response)
|
||||
{
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode) return string.IsNullOrWhiteSpace(text) ? VolcanicJson.EmptyObject : VolcanicJson.Parse(text);
|
||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone && call.EventId is not null)
|
||||
throw new GoogleCalendarException($"Event {call.EventId} not found in {call.CalendarId}.");
|
||||
throw new GoogleCalendarException($"Google Calendar {call.Action} failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The account (primary calendar id = email) and zone, with a token that isn't stored yet.</summary>
|
||||
public static async Task<PrimaryCalendar> GetPrimaryAsync(HttpClient http, string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await SendOnceAsync(http, HttpMethod.Get, "calendars/primary", null, accessToken, cancellationToken).ConfigureAwait(false);
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new GoogleCalendarException($"calendars/primary failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
|
||||
var json = VolcanicJson.Parse(text);
|
||||
return new PrimaryCalendar(
|
||||
json.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
json.TryGetProperty("timeZone", out var zone) ? zone.GetString() : null);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> SendOnceAsync(HttpClient http, HttpMethod method, string pathAndQuery, JsonObject? body, string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, BaseUrl + pathAndQuery);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
if (body is not null) request.Content = new StringContent(body.ToJsonString(VolcanicJson.Options), Encoding.UTF8, "application/json");
|
||||
return await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string? ErrorMessage(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(text) is JsonObject o && o["error"] is JsonObject e && e["message"] is JsonValue m && m.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit process-wide state (like <c>ClaudeLoginCoordinator.Shared</c>): extensions are per session, but a chat's
|
||||
/// Google account, access-token cache and pending connect flow outlive its sessions. One
|
||||
/// <see cref="GoogleCalendarConnection"/> per chat token directory, created on first use.
|
||||
/// </summary>
|
||||
public sealed class GoogleCalendarConnections
|
||||
{
|
||||
/// <summary>The process-wide instance the extension uses unless its options pass another.</summary>
|
||||
public static GoogleCalendarConnections Shared { get; } = new();
|
||||
|
||||
private readonly ConcurrentDictionary<string, GoogleCalendarConnection> _connections = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The connection for a session scope, keyed by its token directory (<c><root>/<chat key></c>).
|
||||
/// The first caller's settings create it.
|
||||
/// </summary>
|
||||
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
var directory = Path.GetFullPath(Path.Combine(settings.TokenRoot, ChatKeys.For(scope)));
|
||||
return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings, new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName))));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One chat's Google account: token file, access-token cache (single-flight refresh), pending connect flow.</summary>
|
||||
internal sealed class GoogleCalendarConnection
|
||||
{
|
||||
private static readonly TimeSpan RefreshMargin = TimeSpan.FromSeconds(60);
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
private CachedToken? _cached;
|
||||
private ConnectFlow? _pending;
|
||||
private int _unknownZoneLogged;
|
||||
|
||||
public GoogleCalendarConnection(GoogleCalendarSettings settings, GoogleTokenStore store)
|
||||
{
|
||||
Settings = settings;
|
||||
Store = store;
|
||||
OAuth = new GoogleOAuthClient(settings.Http, settings.ClientId, settings.ClientSecret, settings.Time);
|
||||
}
|
||||
|
||||
public GoogleCalendarSettings Settings { get; }
|
||||
public GoogleTokenStore Store { get; }
|
||||
public GoogleOAuthClient OAuth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialises "is a flow pending? else start one" so two /calendar_connect in one chat can't both start, and the
|
||||
/// flow's final token write with /calendar_disconnect so a disconnected chat can't end up connected.
|
||||
/// </summary>
|
||||
public SemaphoreSlim FlowGate { get; } = new(1, 1);
|
||||
|
||||
/// <summary>This chat's in-flight device flow, if any; a cancelled or expired one is cancelled, cleared and not returned.</summary>
|
||||
public ConnectFlow? Pending
|
||||
{
|
||||
get
|
||||
{
|
||||
var flow = Volatile.Read(ref _pending);
|
||||
if (flow is null || flow.IsLive(Settings.Time)) return flow;
|
||||
flow.Cancel(); // an expired flow still parked in its delay ends silently instead of reporting later
|
||||
End(flow);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registers <paramref name="flow"/> as pending. Callers hold <see cref="FlowGate"/>.</summary>
|
||||
public void Begin(ConnectFlow flow) => Volatile.Write(ref _pending, flow);
|
||||
|
||||
/// <summary>Clears the pending flow if it is still <paramref name="flow"/>.</summary>
|
||||
public void End(ConnectFlow flow) => Interlocked.CompareExchange(ref _pending, null, flow);
|
||||
|
||||
/// <summary>Cancels the pending flow silently (used by /calendar_disconnect).</summary>
|
||||
public void CancelPending() => Volatile.Read(ref _pending)?.Cancel();
|
||||
|
||||
/// <summary>
|
||||
/// A valid access token: the cached one while it has more than 60 s left and belongs to the stored refresh
|
||||
/// token, else one refresh shared by all concurrent callers.
|
||||
/// </summary>
|
||||
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
|
||||
if (Fresh(stored) is { } token) return token;
|
||||
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
|
||||
if (Fresh(stored) is { } again) return again;
|
||||
var access = await OAuth.RefreshAsync(stored.RefreshToken, cancellationToken).ConfigureAwait(false);
|
||||
Volatile.Write(ref _cached, new CachedToken(stored.RefreshToken, access.Token, access.ExpiresAt));
|
||||
return access.Token;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the cached access token (after a 401, a reconnect or a disconnect).</summary>
|
||||
public void Invalidate() => Volatile.Write(ref _cached, null);
|
||||
|
||||
/// <summary>The calendar's own zone (stored at connect), else the chat's zone.</summary>
|
||||
public TimeZoneInfo ResolveTimeZone(TimeZoneInfo chatZone, ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chatZone);
|
||||
var id = Store.Read()?.TimeZone;
|
||||
if (string.IsNullOrWhiteSpace(id)) return chatZone;
|
||||
if (ZoneIds.TryFind(id, out var zone)) return zone;
|
||||
// Claim the log-once flag only when there is a logger: a logger-less call (e.g. the tool description) must not use it up.
|
||||
if (logger is not null && Interlocked.Exchange(ref _unknownZoneLogged, 1) == 0)
|
||||
logger.LogWarning("google-calendar: the calendar's time zone {Zone} is unknown on this machine; using the chat's zone {ChatZone}", id, ZoneIds.Name(chatZone));
|
||||
return chatZone;
|
||||
}
|
||||
|
||||
private string? Fresh(StoredToken stored) =>
|
||||
Volatile.Read(ref _cached) is { } cached && cached.RefreshToken == stored.RefreshToken
|
||||
&& cached.ExpiresAt - Settings.Time.GetUtcNow() > RefreshMargin
|
||||
? cached.AccessToken
|
||||
: null;
|
||||
|
||||
private sealed record CachedToken(string RefreshToken, string AccessToken, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public override string ToString() => $"CachedToken {{ ExpiresAt = {ExpiresAt:O} }}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>A Google Calendar failure the model or the user can read. <see cref="NotConnected"/> means "run /calendar_connect".</summary>
|
||||
public sealed class GoogleCalendarException(string message, bool notConnected = false) : Exception(message)
|
||||
{
|
||||
public bool NotConnected { get; } = notConnected;
|
||||
}
|
||||
|
||||
internal static class GoogleCalendarMessages
|
||||
{
|
||||
public const string PermissionsUrl = "https://myaccount.google.com/permissions";
|
||||
public const string NotConnected = "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.";
|
||||
public const string TokenRejected = "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.";
|
||||
public const string NoRefreshToken = "Google returned no refresh token. Remove the app at " + PermissionsUrl + " and run /calendar_connect again.";
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>
|
||||
/// Google Calendar for every chat: the <c>calendar</c> tool and <c>/calendar_connect</c>, <c>/calendar_status</c>,
|
||||
/// <c>/calendar_disconnect</c>. Each chat (session scope) connects its own account. Inactive, with one log line,
|
||||
/// until <c>GOOGLE_CLIENT_ID</c> and <c>GOOGLE_CLIENT_SECRET</c> are set. Instantiated per session.
|
||||
/// </summary>
|
||||
public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = null) : IExtension
|
||||
{
|
||||
public const string ExtensionName = "google-calendar";
|
||||
|
||||
private readonly GoogleCalendarOptions _options = options ?? new GoogleCalendarOptions();
|
||||
private GoogleCalendarSettings? _settings;
|
||||
private ILogger? _logger;
|
||||
private volatile string? _scope;
|
||||
private volatile TimeZoneInfo? _chatZone;
|
||||
|
||||
public string Name => ExtensionName;
|
||||
|
||||
internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared;
|
||||
|
||||
internal GoogleCalendarConnection ConnectionFor(string scope) =>
|
||||
Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope);
|
||||
|
||||
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
var id = _options.EffectiveClientId;
|
||||
var secret = _options.EffectiveClientSecret;
|
||||
if (id is null || secret is null)
|
||||
{
|
||||
if (id is null && secret is null)
|
||||
api.Logger.LogInformation("google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET");
|
||||
else
|
||||
api.Logger.LogWarning("google-calendar disabled: {Missing} is not set (both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are needed)",
|
||||
id is null ? "GOOGLE_CLIENT_ID" : "GOOGLE_CLIENT_SECRET");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
_settings = _options.Resolve()!;
|
||||
_logger = api.Logger;
|
||||
api.RegisterTool(new CalendarTool(_settings, Connections, CurrentZoneName));
|
||||
api.RegisterCommand(ConnectCommand.Name, ConnectCommand.Create(api, this));
|
||||
api.RegisterCommand(StatusCommand.Name, StatusCommand.Create(api, this));
|
||||
api.RegisterCommand(DisconnectCommand.Name, DisconnectCommand.Create(api, this));
|
||||
api.On<SessionStartEvent>((_, ctx, _) => Track(ctx));
|
||||
api.On<BeforeAgentStartEvent>((_, ctx, _) => Track(ctx));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>The zone the tool's description and guideline name: the calendar's, else the chat's. Null before the session starts.</summary>
|
||||
internal string? CurrentZoneName() =>
|
||||
_scope is { } scope && _settings is not null
|
||||
? ZoneIds.Name(ConnectionFor(scope).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger))
|
||||
: null;
|
||||
|
||||
private ValueTask Track(IExtensionContext ctx)
|
||||
{
|
||||
_chatZone = ctx.TimeZone;
|
||||
_scope = ctx.SessionManager.Scope;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Google Calendar extension. The OAuth client is process-wide (one Google Cloud app for the bot);
|
||||
/// accounts are per chat. Empty strings count as unset.
|
||||
/// </summary>
|
||||
public sealed record GoogleCalendarOptions
|
||||
{
|
||||
/// <summary>OAuth client id (type "TVs and Limited Input devices"). Else <c>GOOGLE_CLIENT_ID</c>.</summary>
|
||||
public string? ClientId { get; init; }
|
||||
/// <summary>OAuth client secret. Else <c>GOOGLE_CLIENT_SECRET</c>.</summary>
|
||||
public string? ClientSecret { get; init; }
|
||||
/// <summary>Root of the per-chat token directories. Else <c><agentDir>/google-calendar</c>.</summary>
|
||||
public string? TokenDirectory { get; init; }
|
||||
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
|
||||
public Func<string, string?>? GetEnv { get; init; }
|
||||
/// <summary>HTTP client for Google; tests pass one over a fake handler.</summary>
|
||||
public HttpClient? Http { get; init; }
|
||||
/// <summary>Clock for polling and token expiry; tests pass a fake.</summary>
|
||||
public TimeProvider? Time { get; init; }
|
||||
/// <summary>Per-chat connection state. Defaults to <see cref="GoogleCalendarConnections.Shared"/>.</summary>
|
||||
public GoogleCalendarConnections? Connections { get; init; }
|
||||
|
||||
private Func<string, string?> Env => GetEnv ?? Environment.GetEnvironmentVariable;
|
||||
|
||||
internal string? EffectiveClientId => NonEmpty(ClientId) ?? NonEmpty(Env("GOOGLE_CLIENT_ID"));
|
||||
internal string? EffectiveClientSecret => NonEmpty(ClientSecret) ?? NonEmpty(Env("GOOGLE_CLIENT_SECRET"));
|
||||
|
||||
/// <summary>True when both the client id and the secret are set (here or in the environment): the extension registers its tool and commands.</summary>
|
||||
public bool IsConfigured => EffectiveClientId is not null && EffectiveClientSecret is not null;
|
||||
|
||||
internal string EffectiveTokenDirectory => NonEmpty(TokenDirectory) ?? Path.Combine(
|
||||
NonEmpty(Env("VOLCANIC_AGENT_DIR")) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent"),
|
||||
"google-calendar");
|
||||
|
||||
/// <summary>The settings a connection needs, or null when the client id or secret is missing.</summary>
|
||||
internal GoogleCalendarSettings? Resolve() =>
|
||||
EffectiveClientId is { } id && EffectiveClientSecret is { } secret
|
||||
? new GoogleCalendarSettings(id, secret, EffectiveTokenDirectory, Http ?? GoogleHttp.Default, Time ?? TimeProvider.System)
|
||||
: null;
|
||||
|
||||
public override string ToString() =>
|
||||
$"GoogleCalendarOptions {{ ClientId = {ClientId}, ClientSecret = {(string.IsNullOrEmpty(ClientSecret) ? "" : "***")}, TokenDirectory = {TokenDirectory} }}";
|
||||
|
||||
internal static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>Resolved, validated configuration shared by every chat's connection.</summary>
|
||||
internal sealed record GoogleCalendarSettings(string ClientId, string ClientSecret, string TokenRoot, HttpClient Http, TimeProvider Time)
|
||||
{
|
||||
public override string ToString() => $"GoogleCalendarSettings {{ ClientId = {ClientId}, TokenRoot = {TokenRoot} }}";
|
||||
}
|
||||
|
||||
internal static class GoogleHttp
|
||||
{
|
||||
/// <summary>One client for the process: connections of all chats share it.</summary>
|
||||
public static readonly HttpClient Default = new() { Timeout = TimeSpan.FromSeconds(60) };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
internal sealed record DeviceAuthorization(string DeviceCode, string UserCode, string VerificationUrl, DateTimeOffset ExpiresAt, TimeSpan Interval);
|
||||
|
||||
internal enum PollStatus { Success, Pending, SlowDown, Denied, Expired, Failed }
|
||||
|
||||
/// <summary>One poll of the token endpoint. <see cref="ToString"/> never shows tokens.</summary>
|
||||
internal sealed record PollResult(PollStatus Status, string? RefreshToken = null, string? Scope = null, string? AccessToken = null,
|
||||
DateTimeOffset AccessTokenExpiresAt = default, string? Message = null)
|
||||
{
|
||||
public static readonly PollResult Pending = new(PollStatus.Pending);
|
||||
public static readonly PollResult SlowDown = new(PollStatus.SlowDown);
|
||||
public static readonly PollResult Denied = new(PollStatus.Denied);
|
||||
public static readonly PollResult Expired = new(PollStatus.Expired);
|
||||
|
||||
public static PollResult Succeeded(string refreshToken, string? scope, string accessToken, DateTimeOffset expiresAt) =>
|
||||
new(PollStatus.Success, refreshToken, scope, accessToken, expiresAt);
|
||||
|
||||
public static PollResult Failed(string message) => new(PollStatus.Failed, Message: message);
|
||||
|
||||
public override string ToString() => Message is null ? Status.ToString() : $"{Status}: {Message}";
|
||||
}
|
||||
|
||||
internal sealed record AccessToken(string Token, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public override string ToString() => $"AccessToken {{ ExpiresAt = {ExpiresAt:O} }}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Google's OAuth 2.0 device flow for limited-input devices, refresh and revoke, over raw HTTP. Error mapping reads
|
||||
/// the JSON <c>error</c> field whatever the status code (Google documents 428 and 403 besides 400). Messages never
|
||||
/// contain tokens or the client secret.
|
||||
/// </summary>
|
||||
internal sealed class GoogleOAuthClient(HttpClient http, string clientId, string clientSecret, TimeProvider time)
|
||||
{
|
||||
public const string DeviceCodeUrl = "https://oauth2.googleapis.com/device/code";
|
||||
public const string TokenUrl = "https://oauth2.googleapis.com/token";
|
||||
public const string RevokeUrl = "https://oauth2.googleapis.com/revoke";
|
||||
public const string CalendarScope = "https://www.googleapis.com/auth/calendar";
|
||||
public const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
public async Task<DeviceAuthorization> StartDeviceFlowAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, json) = await PostAsync(DeviceCodeUrl, [new("client_id", clientId), new("scope", CalendarScope)], cancellationToken).ConfigureAwait(false);
|
||||
if (!IsSuccess(status) || json is null || Str(json, "device_code") is not { } deviceCode || Str(json, "user_code") is not { } userCode)
|
||||
throw new GoogleCalendarException($"Google refused the device code request: {Describe(json, status)}");
|
||||
var url = Str(json, "verification_url") ?? Str(json, "verification_uri") ?? "https://www.google.com/device";
|
||||
var expiresIn = Int(json, "expires_in") ?? 1800;
|
||||
var interval = Math.Max(1, Int(json, "interval") ?? 5);
|
||||
return new DeviceAuthorization(deviceCode, userCode, url, time.GetUtcNow().AddSeconds(expiresIn), TimeSpan.FromSeconds(interval));
|
||||
}
|
||||
|
||||
public async Task<PollResult> PollAsync(string deviceCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, json) = await PostAsync(TokenUrl,
|
||||
[new("client_id", clientId), new("client_secret", clientSecret), new("device_code", deviceCode), new("grant_type", DeviceGrantType)],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (json is not null && Str(json, "error") is { } error)
|
||||
{
|
||||
return error switch
|
||||
{
|
||||
"authorization_pending" => PollResult.Pending,
|
||||
"slow_down" => PollResult.SlowDown,
|
||||
"access_denied" => PollResult.Denied,
|
||||
"expired_token" => PollResult.Expired,
|
||||
_ => PollResult.Failed(Describe(json, status)),
|
||||
};
|
||||
}
|
||||
if ((int)status >= 500) throw new HttpRequestException($"Google returned HTTP {(int)status}.");
|
||||
if (!IsSuccess(status) || json is null) return PollResult.Failed(Describe(json, status));
|
||||
if (Str(json, "refresh_token") is not { Length: > 0 } refreshToken) return PollResult.Failed(GoogleCalendarMessages.NoRefreshToken);
|
||||
return PollResult.Succeeded(refreshToken, Str(json, "scope"), Str(json, "access_token") ?? "",
|
||||
time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
|
||||
}
|
||||
|
||||
public async Task<AccessToken> RefreshAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, json) = await PostAsync(TokenUrl,
|
||||
[new("client_id", clientId), new("client_secret", clientSecret), new("refresh_token", refreshToken), new("grant_type", "refresh_token")],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (json is not null && Str(json, "error") == "invalid_grant")
|
||||
throw new GoogleCalendarException(GoogleCalendarMessages.TokenRejected, notConnected: true);
|
||||
if (!IsSuccess(status) || json is null || Str(json, "access_token") is not { Length: > 0 } accessToken)
|
||||
throw new GoogleCalendarException($"Google token refresh failed: {Describe(json, status)}");
|
||||
return new AccessToken(accessToken, time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
|
||||
}
|
||||
|
||||
/// <summary>Best effort. Null when Google revoked the token, else the reason.</summary>
|
||||
public async Task<string?> RevokeAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (status, json) = await PostAsync(RevokeUrl, [new("token", token)], cancellationToken).ConfigureAwait(false);
|
||||
return IsSuccess(status) ? null : Describe(json, status);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return ex.Message; // HttpClient timeout
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(HttpStatusCode Status, JsonObject? Json)> PostAsync(string url, KeyValuePair<string, string>[] form, CancellationToken cancellationToken)
|
||||
{
|
||||
using var content = new FormUrlEncodedContent(form);
|
||||
using var response = await http.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
JsonObject? json = null;
|
||||
try { json = JsonNode.Parse(text) as JsonObject; }
|
||||
catch (System.Text.Json.JsonException) { }
|
||||
return (response.StatusCode, json);
|
||||
}
|
||||
|
||||
private static bool IsSuccess(HttpStatusCode status) => (int)status is >= 200 and < 300;
|
||||
|
||||
private static string Describe(JsonObject? json, HttpStatusCode status)
|
||||
{
|
||||
var error = json is null ? null : Str(json, "error");
|
||||
var description = json is null ? null : Str(json, "error_description");
|
||||
return (error, description) switch
|
||||
{
|
||||
({ } e, { } d) => $"{e}: {d}",
|
||||
({ } e, null) => e,
|
||||
_ => $"HTTP {(int)status}",
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Str(JsonObject json, string name) =>
|
||||
json[name] is JsonValue value && value.TryGetValue<string>(out var s) ? s : null;
|
||||
|
||||
private static int? Int(JsonObject json, string name) => json[name] switch
|
||||
{
|
||||
JsonValue v when v.TryGetValue<int>(out var i) => i,
|
||||
JsonValue v when v.TryGetValue<string>(out var s) && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) => p,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>What <c>token.json</c> holds for one chat. <see cref="ToString"/> hides the refresh token.</summary>
|
||||
internal sealed record StoredToken(string RefreshToken, string? Scope = null, string? Account = null, string? TimeZone = null, DateTimeOffset? ConnectedAt = null)
|
||||
{
|
||||
public override string ToString() => $"StoredToken {{ Account = {Account}, TimeZone = {TimeZone}, ConnectedAt = {ConnectedAt:O} }}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One chat's <c>token.json</c>. Reads never throw (unreadable = not connected); writes are atomic
|
||||
/// (<c>token.json.tmp</c> then a move) and owner-only on Unix.
|
||||
/// </summary>
|
||||
internal sealed class GoogleTokenStore(string path)
|
||||
{
|
||||
public const string FileName = "token.json";
|
||||
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
|
||||
public string Path { get; } = path;
|
||||
|
||||
public StoredToken? Read()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(Path)) return null;
|
||||
var text = File.ReadAllText(Path);
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
var token = VolcanicJson.Deserialize<StoredToken>(text);
|
||||
return string.IsNullOrWhiteSpace(token?.RefreshToken) ? null : token;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(StoredToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
var directory = System.IO.Path.GetDirectoryName(Path)!;
|
||||
CreatePrivateDirectory(System.IO.Path.GetDirectoryName(directory)!);
|
||||
CreatePrivateDirectory(directory);
|
||||
var temp = Path + ".tmp";
|
||||
var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
JsonSerializer.Serialize(stream, token, VolcanicJson.Indented);
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PrivateFile); // a leftover .tmp keeps its old mode otherwise
|
||||
File.Move(temp, Path, overwrite: true);
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
if (File.Exists(Path)) File.Delete(Path);
|
||||
}
|
||||
|
||||
private static void CreatePrivateDirectory(string directory)
|
||||
{
|
||||
if (Directory.Exists(directory)) return;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
return;
|
||||
}
|
||||
Directory.CreateDirectory(directory, PrivateDirectory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>Google names zones by IANA id; Windows may hand out Windows ids.</summary>
|
||||
internal static class ZoneIds
|
||||
{
|
||||
/// <summary>The IANA id (Google's zone names), converting a Windows id when needed.</summary>
|
||||
public static string Name(TimeZoneInfo zone) =>
|
||||
zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : zone.Id;
|
||||
|
||||
public static bool TryFind(string id, out TimeZoneInfo zone) => TimeZoneInfo.TryFindSystemTimeZoneById(id, out zone!);
|
||||
}
|
||||
Reference in New Issue
Block a user