Files
EugeneTes c3a0cf234c feat(google-calendar): store each chat's token in its workspace
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.
2026-09-28 20:42:58 +02:00

92 lines
4.1 KiB
C#

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);
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);
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);
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);
});
}