Files
Playa/extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/GoogleCalendarConnections.cs
EugeneTes 881e7f0be9 fix(google-calendar): address review findings
- log an unknown stored zone once even when the tool description reads it first
- serialise the flow's token write with /calendar_disconnect under the flow gate
- a cancelled or expired pending flow is cleared, not re-sent or shown
- chat keys anchor with \z so a trailing newline is hashed
- a timeMax date includes that whole day (spec updated)
- dispose the flow's CancellationTokenSource, drop ConnectFlow.Token, Begin is a plain write
- GoogleCalendarOptions.IsConfigured replaces the host's copy of the rule; StoredToken is internal
- test BotSetupService's menu through DI, tokens absent from tool results
- Telegram.Tests csproj reference placed as in telegram-message-context; ChatHost passes calendar by name
- docs: per-event timeZone, both config sources for /calendar_*
2026-09-26 20:30:56 +02:00

128 lines
6.1 KiB
C#

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>&lt;root&gt;/&lt;chat key&gt;</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} }}";
}
}