feat(google-calendar): /calendar_connect device flow with automatic polling
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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; }
|
||||
public CancellationToken Token => _cancellation.Token;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
try
|
||||
{
|
||||
_connection.Store.Write(new StoredToken(result.RefreshToken!, result.Scope, primary.Account, primary.TimeZone, _connection.Settings.Time.GetUtcNow()));
|
||||
}
|
||||
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;
|
||||
}
|
||||
_connection.Invalidate();
|
||||
ui.Notify($"✅ Google Calendar connected as {primary.Account ?? "an unknown account"} (time zone {primary.TimeZone ?? "unknown"}).");
|
||||
}
|
||||
}
|
||||
@@ -111,14 +111,3 @@ internal sealed class GoogleCalendarConnection
|
||||
public override string ToString() => $"CachedToken {{ ExpiresAt = {ExpiresAt:O} }}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Placeholder until Task 7 moves this type to ConnectFlow.cs and gives it the polling loop.</summary>
|
||||
internal sealed class ConnectFlow
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
public CancellationToken Token => _cancellation.Token;
|
||||
public void Cancel()
|
||||
{
|
||||
try { _cancellation.Cancel(); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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 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()!;
|
||||
api.RegisterTool(new CalendarTool(_settings, Connections, CurrentZoneName));
|
||||
api.RegisterCommand(ConnectCommand.Name, ConnectCommand.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))
|
||||
: null;
|
||||
|
||||
private ValueTask Track(IExtensionContext ctx)
|
||||
{
|
||||
_chatZone = ctx.TimeZone;
|
||||
_scope = ctx.SessionManager.Scope;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Net;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
public class ConnectFlowTests
|
||||
{
|
||||
private const string Connect = "calendar_connect";
|
||||
|
||||
[Fact]
|
||||
public async Task The_link_and_code_arrive_in_one_notify()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
|
||||
var ui = new CalendarRecordingUi();
|
||||
var command = h.Run(Connect, h.Context(ui));
|
||||
await FlowDriver.ParkedAsync(h.Connection());
|
||||
|
||||
var (message, kind) = Assert.Single(ui.Notes);
|
||||
Assert.Equal("Connect Google Calendar:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n3. Approve access. I'll confirm here when it's done (code valid for 30 min).", message);
|
||||
Assert.Equal(NotifyKind.Info, kind);
|
||||
h.Connection().CancelPending();
|
||||
await command.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_pending_success_writes_the_store_and_notifies_once()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.Pending, ConnectHarness.Approved());
|
||||
var ui = new CalendarRecordingUi();
|
||||
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
|
||||
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
|
||||
Assert.Equal("Bearer at-1", Assert.Single(h.Http.To(ConnectHarness.PrimaryRoute)).Authorization);
|
||||
var stored = h.Connection().Store.Read();
|
||||
Assert.Equal(new StoredToken("rt-1", "https://www.googleapis.com/auth/calendar", "me@example.com", "Europe/Berlin", ConnectHarness.Now.AddSeconds(15)), stored);
|
||||
Assert.Equal(2, ui.Notes.Count);
|
||||
Assert.Equal(("✅ Google Calendar connected as me@example.com (time zone Europe/Berlin).", NotifyKind.Info), ui.Notes[1]);
|
||||
Assert.Null(h.Connection().Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Slow_down_widens_the_poll_interval_by_five_seconds()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.SlowDown, ConnectHarness.Pending, ConnectHarness.Approved());
|
||||
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(new CalendarRecordingUi())), h.Connection(), h.Time);
|
||||
|
||||
var polls = h.Http.To(ConnectHarness.TokenRoute).Select(r => r.At).ToList();
|
||||
Assert.Equal(ConnectHarness.Now.AddSeconds(5), polls[0]);
|
||||
Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)],
|
||||
polls.Zip(polls.Skip(1), (a, b) => b - a).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Denied_notifies_a_warning_and_writes_nothing()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Forbidden, """{"error":"access_denied"}"""));
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
Assert.Equal(("Google Calendar access was denied.", NotifyKind.Warning), ui.Notes[^1]);
|
||||
Assert.Null(h.Connection().Store.Read());
|
||||
Assert.Null(h.Connection().Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_token_from_google_notifies_the_expiry_warning()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_code_lifetime_ending_stops_polling_with_the_expiry_warning()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.ScriptDevice("dev-1", "ABCD-EFGH", expiresIn: 12, interval: 5);
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
Assert.Equal(2, h.Http.To(ConnectHarness.TokenRoute).Count); // at +5 and +10; +15 is past +12
|
||||
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Three_consecutive_transport_errors_fail_the_flow()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Fail(ConnectHarness.TokenRoute, "network down");
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
|
||||
Assert.Equal(("Google Calendar connection failed: network down", NotifyKind.Error), ui.Notes[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_transport_errors_then_approval_still_connects()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.On(ConnectHarness.TokenRoute, GoogleFakeHttp.Throws(), GoogleFakeHttp.Throws(),
|
||||
GoogleFakeHttp.Returns(ConnectHarness.Approved().Item1, ConnectHarness.Approved().Item2));
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
Assert.StartsWith("✅ Google Calendar connected", ui.Notes[^1].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_poll_is_reported_as_an_error()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
Assert.Equal(("Google Calendar connection failed: invalid_client: Unauthorized", NotifyKind.Error), ui.Notes[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_primary_lookup_still_saves_the_token()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
|
||||
h.Http.Json(ConnectHarness.PrimaryRoute, (HttpStatusCode.InternalServerError, "{}"));
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
var stored = h.Connection().Store.Read()!;
|
||||
Assert.Equal("rt-1", stored.RefreshToken);
|
||||
Assert.Null(stored.Account);
|
||||
Assert.Equal("✅ Google Calendar connected as an unknown account (time zone unknown).", ui.Notes[^1].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_store_write_failure_is_reported()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
|
||||
Directory.CreateDirectory(h.Connection().Store.Path); // a directory where token.json should go
|
||||
var ui = new CalendarRecordingUi();
|
||||
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
|
||||
Assert.StartsWith("Got a Google token but could not save it: ", ui.Notes[^1].Message);
|
||||
Assert.Equal(NotifyKind.Error, ui.Notes[^1].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_connect_resends_the_same_code_without_a_new_device_code_request()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
|
||||
var ui = new CalendarRecordingUi();
|
||||
var first = h.Run(Connect, h.Context(ui));
|
||||
await FlowDriver.ParkedAsync(h.Connection());
|
||||
h.Time.Advance(TimeSpan.FromSeconds(1));
|
||||
|
||||
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Single(h.Http.To(ConnectHarness.DeviceRoute));
|
||||
Assert.Equal("A Google Calendar connection is already waiting for approval:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n(30 min left.)", ui.Notes[1].Message);
|
||||
h.Connection().CancelPending();
|
||||
await first.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelling_a_pending_flow_is_silent_and_clears_it()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
|
||||
var ui = new CalendarRecordingUi();
|
||||
var command = h.Run(Connect, h.Context(ui));
|
||||
await FlowDriver.ParkedAsync(h.Connection());
|
||||
h.Connection().CancelPending();
|
||||
await command.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Single(ui.Notes); // only the link
|
||||
Assert.Null(h.Connection().Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_device_code_failure_is_reported_and_leaves_nothing_pending()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
h.Http.Json(ConnectHarness.DeviceRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
|
||||
var ui = new CalendarRecordingUi();
|
||||
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(("Google Calendar connection failed: Google refused the device code request: invalid_client", NotifyKind.Error), Assert.Single(ui.Notes));
|
||||
Assert.Null(h.Connection().Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_ui_the_command_appends_a_custom_message()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
await h.Run(Connect, h.Context(ui: null));
|
||||
var (message, delivery, triggerTurn) = Assert.Single(h.Api.Sent);
|
||||
Assert.Equal("calendar_connect", message.CustomType);
|
||||
Assert.Equal("/calendar_connect needs an interactive UI (a chat front end such as the Telegram bot).", message.Text);
|
||||
Assert.False(triggerTurn);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
public class GoogleCalendarExtensionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Without_a_client_it_registers_nothing_and_logs_one_info_line()
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
var api = new CalendarFakeApi(logger);
|
||||
var extension = new GoogleCalendarExtension(new GoogleCalendarOptions { GetEnv = _ => null });
|
||||
Assert.Equal("google-calendar", extension.Name);
|
||||
await extension.ActivateAsync(api, CancellationToken.None);
|
||||
Assert.Empty(api.Tools);
|
||||
Assert.Empty(api.Commands);
|
||||
var entry = Assert.Single(logger.Entries);
|
||||
Assert.Equal((LogLevel.Information, "google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET"), entry);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cid", null, "GOOGLE_CLIENT_SECRET")]
|
||||
[InlineData(null, "csecret", "GOOGLE_CLIENT_ID")]
|
||||
public async Task With_only_one_of_id_and_secret_it_logs_a_warning(string? id, string? secret, string missing)
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
var api = new CalendarFakeApi(logger);
|
||||
await new GoogleCalendarExtension(new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
|
||||
Assert.Empty(api.Tools);
|
||||
var entry = Assert.Single(logger.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains(missing, entry.Message);
|
||||
Assert.DoesNotContain("csecret", entry.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_it_registers_the_calendar_tool_and_the_connect_command()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
Assert.Equal("calendar", Assert.Single(h.Api.Tools).Name);
|
||||
Assert.Contains("calendar_connect", h.Api.Commands.Keys);
|
||||
Assert.All(h.Api.Commands.Values, c => Assert.NotNull(c.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_session_start_the_description_names_the_resolved_zone()
|
||||
{
|
||||
using var h = new ConnectHarness();
|
||||
var tool = Assert.Single(h.Api.Tools);
|
||||
Assert.Contains("the zone named in each result's header", tool.Description);
|
||||
|
||||
await h.Api.RaiseAsync(new SessionStartEvent(SessionStartReason.Startup), h.Context(null));
|
||||
Assert.Contains("without an offset are in Europe/Berlin", tool.Description);
|
||||
|
||||
h.Connection().Store.Write(new StoredToken("rt-1", TimeZone: "America/New_York"));
|
||||
Assert.Contains("without an offset are in America/New_York", Assert.Single(tool.PromptGuidelines));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
@@ -43,3 +49,154 @@ internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUser
|
||||
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
|
||||
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
|
||||
}
|
||||
|
||||
/// <summary>Records what the extension registers and sends; everything else is unsupported so misuse is loud.</summary>
|
||||
internal sealed class CalendarFakeApi(ILogger? logger = null) : IExtensionApi
|
||||
{
|
||||
public List<ITool> Tools { get; } = [];
|
||||
public Dictionary<string, CommandDefinition> Commands { get; } = new();
|
||||
public List<(Type Event, Delegate Handler)> Handlers { get; } = [];
|
||||
public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
|
||||
|
||||
public string ExtensionName => GoogleCalendarExtension.ExtensionName;
|
||||
public ILogger Logger => logger ?? NullLogger.Instance;
|
||||
public IEventBus Events => throw new NotSupportedException();
|
||||
|
||||
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent
|
||||
{
|
||||
Handlers.Add((typeof(TEvent), handler));
|
||||
return new Subscription();
|
||||
}
|
||||
|
||||
public async Task RaiseAsync<TEvent>(TEvent e, IExtensionContext ctx) where TEvent : ExtensionEvent
|
||||
{
|
||||
foreach (var (type, handler) in Handlers.Where(h => h.Event == typeof(TEvent)))
|
||||
await ((ExtensionHandler<TEvent>)handler)(e, ctx, CancellationToken.None);
|
||||
}
|
||||
|
||||
public void RegisterTool(ITool tool) => Tools.Add(tool);
|
||||
public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
|
||||
public void RegisterProvider(ProviderRegistration provider) => throw new NotSupportedException();
|
||||
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
|
||||
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
|
||||
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
|
||||
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
|
||||
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
|
||||
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
|
||||
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
|
||||
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
|
||||
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
|
||||
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
|
||||
public string? GetSessionName() => throw new NotSupportedException();
|
||||
public void SetSessionName(string? name) => throw new NotSupportedException();
|
||||
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
private sealed class Subscription : IDisposable { public void Dispose() { } }
|
||||
}
|
||||
|
||||
internal sealed class CalendarRecordingUi : IUserInterface
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private readonly List<(string Message, NotifyKind Kind)> _notes = [];
|
||||
public IReadOnlyList<(string Message, NotifyKind Kind)> Notes { get { lock (_lock) return _notes.ToList(); } }
|
||||
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
|
||||
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
|
||||
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
|
||||
public void Notify(string message, NotifyKind kind = NotifyKind.Info) { lock (_lock) _notes.Add((message, kind)); }
|
||||
public void SetStatus(string key, string? text) { }
|
||||
}
|
||||
|
||||
internal static class FlowDriver
|
||||
{
|
||||
/// <summary>Moves the fake clock exactly to each poll the flow schedules until the command returns.</summary>
|
||||
public static async Task CompleteAsync(Task command, GoogleCalendarConnection connection, FakeTimeProvider time)
|
||||
{
|
||||
await Eventually.UntilAsync(() => command.IsCompleted || connection.Pending is not null, "the flow to start");
|
||||
var flow = connection.Pending;
|
||||
var seen = 0;
|
||||
while (flow is not null)
|
||||
{
|
||||
await Eventually.UntilAsync(() => command.IsCompleted || flow.Parks > seen, "the next poll to be scheduled");
|
||||
if (command.IsCompleted) break;
|
||||
seen = flow.Parks;
|
||||
var due = flow.NextPollAt - time.GetUtcNow();
|
||||
if (due > TimeSpan.Zero) time.Advance(due);
|
||||
}
|
||||
await command.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>Waits until the chat's flow is parked on its first poll delay.</summary>
|
||||
public static async Task<ConnectFlow> ParkedAsync(GoogleCalendarConnection connection)
|
||||
{
|
||||
await Eventually.UntilAsync(() => connection.Pending is { Parks: > 0 }, "the flow to wait for its first poll");
|
||||
return connection.Pending!;
|
||||
}
|
||||
|
||||
/// <summary>Coarse driving for several flows at once: advance in steps until <paramref name="done"/>.</summary>
|
||||
public static async Task AdvanceUntilAsync(FakeTimeProvider time, Func<bool> done, TimeSpan step, int timeoutMs = 10000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!done())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("Timed out driving the flows");
|
||||
time.Advance(step);
|
||||
await Task.Delay(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The activated extension over fake Google, a fake clock and a temp token root.</summary>
|
||||
internal sealed class ConnectHarness : IDisposable
|
||||
{
|
||||
public static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
|
||||
public const string ScopeA = "telegram-111";
|
||||
public const string ScopeB = "telegram-222";
|
||||
public const string DeviceRoute = "POST /device/code";
|
||||
public const string TokenRoute = "POST /token";
|
||||
public const string PrimaryRoute = "GET /calendar/v3/calendars/primary";
|
||||
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName;
|
||||
|
||||
public ConnectHarness(ILogger? logger = null)
|
||||
{
|
||||
Http = new GoogleFakeHttp(Time);
|
||||
Api = new CalendarFakeApi(logger);
|
||||
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions
|
||||
{
|
||||
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = _root,
|
||||
Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
|
||||
});
|
||||
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
ScriptDevice("dev-1", "ABCD-EFGH");
|
||||
Http.Json(PrimaryRoute, """{"id":"me@example.com","timeZone":"Europe/Berlin"}""");
|
||||
}
|
||||
|
||||
public FakeTimeProvider Time { get; } = new(Now);
|
||||
public GoogleFakeHttp Http { get; }
|
||||
public GoogleCalendarConnections Connections { get; } = new();
|
||||
public CalendarFakeApi Api { get; }
|
||||
public GoogleCalendarExtension Extension { get; }
|
||||
public string TokenRoot => _root;
|
||||
|
||||
public void ScriptDevice(string deviceCode, string userCode, int expiresIn = 1800, int interval = 5) =>
|
||||
Http.Json(DeviceRoute, $$"""{"device_code":"{{deviceCode}}","user_code":"{{userCode}}","verification_url":"https://www.google.com/device","expires_in":{{expiresIn}},"interval":{{interval}}}""");
|
||||
|
||||
public static (System.Net.HttpStatusCode, string) Pending => (System.Net.HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}""");
|
||||
public static (System.Net.HttpStatusCode, string) SlowDown => (System.Net.HttpStatusCode.Forbidden, """{"error":"slow_down"}""");
|
||||
public static (System.Net.HttpStatusCode, string) Approved(string refreshToken = "rt-1", string accessToken = "at-1") =>
|
||||
(System.Net.HttpStatusCode.OK, $$"""{"access_token":"{{accessToken}}","expires_in":3599,"refresh_token":"{{refreshToken}}","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
|
||||
|
||||
public GoogleCalendarConnection Connection(string scope = ScopeA) => Extension.ConnectionFor(scope);
|
||||
|
||||
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui);
|
||||
|
||||
public Task Run(string command, CalendarFakeContext ctx) => Api.Commands[command].Handler("", ctx, CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var scope in new[] { ScopeA, ScopeB }) Connection(scope).CancelPending();
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user