forked from admin/VolcanicAgenticCore
- 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_*
128 lines
6.1 KiB
C#
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><root>/<chat key></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} }}";
|
|
}
|
|
}
|