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:
@@ -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);
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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><root>/<chat key></c>).
|
||||
/// The first caller's settings create it.
|
||||
/// The connection for a chat, keyed by its token directory: <c><workspace>/.volcanic/google-calendar</c>, or
|
||||
/// <c><root>/<chat key></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))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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><agentDir>/google-calendar</c>.</summary>
|
||||
/// <summary>
|
||||
/// Root of per-chat token directories (<c><root>/<chat key>/token.json</c>). Unset (the default): each chat's token
|
||||
/// lives in its workspace, <c><cwd>/.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} }}";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user