forked from admin/VolcanicAgenticCore
Tokens now live in <cwd>/.volcanic/google-calendar/token.json, as in the VolcanicSecretary reference, instead of <agentDir>/google-calendar/<chat key>/. GoogleCalendarOptions.TokenDirectory, when set, keeps the old per-chat layout under that root. Spec, docs and CLAUDE.md updated.
323 lines
19 KiB
C#
323 lines
19 KiB
C#
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, ctx.Cwd);
|
|
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());
|
|
}
|