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.
This commit is contained in:
EugeneTes
2026-09-28 20:42:58 +02:00
parent 809261b29e
commit c3a0cf234c
14 changed files with 102 additions and 55 deletions

View File

@@ -27,7 +27,7 @@ internal static class ConnectCommand
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 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);
});
@@ -41,7 +41,7 @@ internal static class StatusCommand
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, (_, ctx, _) =>
{
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
var connection = extension.ConnectionFor(ctx);
CommandReply.Send(api, ctx, Name, Describe(connection));
return Task.CompletedTask;
});
@@ -67,7 +67,7 @@ internal static class DisconnectCommand
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
{
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
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);

View File

@@ -61,7 +61,7 @@ internal sealed partial class CalendarTool(GoogleCalendarSettings settings, Goog
protected override async Task<ToolResult> ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var ctx = invocation.Context;
var connection = connections.For(settings, ctx.SessionManager.Scope);
var connection = connections.For(settings, ctx.SessionManager.Scope, ctx.Cwd);
var resolved = connection.ResolveTimeZone(ctx.TimeZone, ctx.Logger);
var eventZone = args.TimeZone is { Length: > 0 } requested
? ZoneIds.TryFind(requested, out var z) ? z : throw new ArgumentException($"Unknown time zone \"{requested}\". Use an IANA id such as Europe/Berlin.")

View File

@@ -13,16 +13,21 @@ public sealed class GoogleCalendarConnections
/// <summary>The process-wide instance the extension uses unless its options pass another.</summary>
public static GoogleCalendarConnections Shared { get; } = new();
/// <summary>Where a chat's token lives inside its workspace when no token root is configured.</summary>
public static readonly string WorkspaceTokenDirectory = Path.Combine(".volcanic", "google-calendar");
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.
/// The connection for a chat, keyed by its token directory: <c>&lt;workspace&gt;/.volcanic/google-calendar</c>, or
/// <c>&lt;root&gt;/&lt;chat key&gt;</c> when the options set a token root. The first caller's settings create it.
/// </summary>
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope)
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope, string workspace)
{
ArgumentNullException.ThrowIfNull(settings);
var directory = Path.GetFullPath(Path.Combine(settings.TokenRoot, ChatKeys.For(scope)));
var directory = Path.GetFullPath(settings.TokenRoot is { } root
? Path.Combine(root, ChatKeys.For(scope))
: Path.Combine(workspace, WorkspaceTokenDirectory));
return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings, new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName))));
}
}

View File

@@ -17,14 +17,17 @@ public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = nul
private GoogleCalendarSettings? _settings;
private ILogger? _logger;
private volatile string? _scope;
private volatile string? _cwd;
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);
internal GoogleCalendarConnection ConnectionFor(IExtensionContext ctx) => ConnectionFor(ctx.SessionManager.Scope, ctx.Cwd);
internal GoogleCalendarConnection ConnectionFor(string scope, string workspace) =>
Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope, workspace);
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
@@ -54,13 +57,14 @@ public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = nul
/// <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, _logger))
_scope is { } scope && _cwd is { } cwd && _settings is not null
? ZoneIds.Name(ConnectionFor(scope, cwd).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger))
: null;
private ValueTask Track(IExtensionContext ctx)
{
_chatZone = ctx.TimeZone;
_cwd = ctx.Cwd;
_scope = ctx.SessionManager.Scope;
return ValueTask.CompletedTask;
}

View File

@@ -10,7 +10,10 @@ public sealed record GoogleCalendarOptions
public string? ClientId { get; init; }
/// <summary>OAuth client secret. Else <c>GOOGLE_CLIENT_SECRET</c>.</summary>
public string? ClientSecret { get; init; }
/// <summary>Root of the per-chat token directories. Else <c>&lt;agentDir&gt;/google-calendar</c>.</summary>
/// <summary>
/// Root of per-chat token directories (<c>&lt;root&gt;/&lt;chat key&gt;/token.json</c>). Unset (the default): each chat's token
/// lives in its workspace, <c>&lt;cwd&gt;/.volcanic/google-calendar/token.json</c>.
/// </summary>
public string? TokenDirectory { get; init; }
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
public Func<string, string?>? GetEnv { get; init; }
@@ -29,9 +32,8 @@ public sealed record GoogleCalendarOptions
/// <summary>True when both the client id and the secret are set (here or in the environment): the extension registers its tool and commands.</summary>
public bool IsConfigured => EffectiveClientId is not null && EffectiveClientSecret is not null;
internal string EffectiveTokenDirectory => NonEmpty(TokenDirectory) ?? Path.Combine(
NonEmpty(Env("VOLCANIC_AGENT_DIR")) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent"),
"google-calendar");
/// <summary>The configured token root, or null to keep each chat's token in its workspace.</summary>
internal string? EffectiveTokenDirectory => NonEmpty(TokenDirectory);
/// <summary>The settings a connection needs, or null when the client id or secret is missing.</summary>
internal GoogleCalendarSettings? Resolve() =>
@@ -46,7 +48,7 @@ public sealed record GoogleCalendarOptions
}
/// <summary>Resolved, validated configuration shared by every chat's connection.</summary>
internal sealed record GoogleCalendarSettings(string ClientId, string ClientSecret, string TokenRoot, HttpClient Http, TimeProvider Time)
internal sealed record GoogleCalendarSettings(string ClientId, string ClientSecret, string? TokenRoot, HttpClient Http, TimeProvider Time)
{
public override string ToString() => $"GoogleCalendarSettings {{ ClientId = {ClientId}, TokenRoot = {TokenRoot} }}";
}