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.
92 lines
4.1 KiB
C#
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);
|
|
});
|
|
}
|