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

@@ -83,8 +83,8 @@ Read `docs/architecture.md` first. Key points:
Extensions built on the public API only: providers `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
`ClaudeCode` + `ClaudeCode.Bridge`; `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser
preset; `docs/mcp.md`); the `GoogleCalendar` feature extension (`calendar` tool,
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens under
`<agentDir>/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens in the chat
workspace under `.volcanic/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
`JobScheduler` on `TimeProvider`, `jobs.json` under `<agentDir>/scheduler/`, the per-chat `schedule` tool, watcher
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece
(`docs/claude-code-provider.md`):

View File

@@ -131,8 +131,8 @@ The image sets these; normally leave them alone:
| Volume | Path | Contents |
|---|---|---|
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/` |
| `agent` | `/data/agent` | Sessions (`sessions/telegram-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json`, Google Calendar tokens (`google-calendar/`) |
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`) |
| `agent` | `/data/agent` | Sessions (`sessions/telegram-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json` |
| `claude` | `/data/claude` | The Claude login (credentials and onboarding state) |
Named volumes survive redeploys. Coolify prefixes them with the resource's uuid and deletes them only when you delete

View File

@@ -106,17 +106,19 @@ at [myaccount.google.com/permissions](https://myaccount.google.com/permissions).
## Where the token lives
Each chat's refresh token is stored in `<agentDir>/google-calendar/<chat key>/token.json`. The agent dir is
`$VOLCANIC_AGENT_DIR`, or `~/.volcanic/agent` when that's unset. Hosts can move the root with
`GoogleCalendarOptions.TokenDirectory`.
Each chat's refresh token is stored in its workspace, at `<workspace>/.volcanic/google-calendar/token.json` (in the
Telegram bot, `<WorkspaceRoot>/<chatId>/.volcanic/google-calendar/token.json`). It stays there across `/new`,
resume and bot restarts.
- On Unix the file has mode 0600 and its directory 0700.
- The directory is outside every chat workspace, so the agent doesn't come across it while it reads and searches the
workspace.
- The chat key is the session scope (`telegram-<chatId>`) when it matches `[A-Za-z0-9._-]{1,100}`. Any other scope
becomes `scope-<16 hex digits>`, the start of the scope's SHA-256 hash.
- Removing a chat from the allow-list doesn't delete its directory. Run `/calendar_disconnect` first, or delete the
directory yourself.
- The file is inside the workspace, so the agent can read it with `read` or `bash` and could send it with `send`.
The refresh token gives full access to that chat's calendar; treat the workspace accordingly.
- Deleting a chat's workspace disconnects its calendar locally. Run `/calendar_disconnect` first to also revoke the
token at Google.
A host can keep tokens out of the workspaces with `GoogleCalendarOptions.TokenDirectory`. Each chat then gets
`<TokenDirectory>/<chat key>/token.json`, where the chat key is the session scope (`telegram-<chatId>`) when it matches
`[A-Za-z0-9._-]{1,100}`, and otherwise `scope-<16 hex digits>`, the start of the scope's SHA-256 hash.
The file holds the refresh token, the granted scope, the account, the calendar's time zone and the connection time.
The refresh token never appears in logs, tool results or error messages.

View File

@@ -30,11 +30,13 @@ reshaped for this harness: an extension on the public API, one Google account pe
`SessionId`. A headless host gets the default scope derived from the cwd, so one account per workspace. **No
Abstractions change.** Tokens are only needed from tools and commands, which always have a context, so nothing is
keyed during activation.
- **Tokens live outside every workspace, one directory per chat:**
`<agentDir>/google-calendar/<chat key>/token.json` (agent dir = `VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`), mode
0600, directory 0700. The chat key is the scope when it matches `[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else
`scope-<first 16 hex of SHA-256(scope)>`. The reference kept the token in the workspace, which the agent's
`read`/`bash` tools browse routinely. The root is overridable through `GoogleCalendarOptions.TokenDirectory`.
- **Tokens live in each chat's workspace:** `<cwd>/.volcanic/google-calendar/token.json`, mode 0600, directory 0700,
as in the reference. (Changed after implementation, at the owner's request; the first version kept them under
`<agentDir>/google-calendar/`.) The workspace is per chat and survives `/new` and resume. The agent's `read`,
`bash` and `send` tools can reach the file; that's accepted, since `bash` could already read a token anywhere under
the bot's OS user. A host that wants tokens out of the workspaces sets `GoogleCalendarOptions.TokenDirectory`: each
chat then gets `<root>/<chat key>/token.json`, where the chat key is the scope when it matches
`[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else `scope-<first 16 hex of SHA-256(scope)>`.
- **Scope: `https://www.googleapis.com/auth/calendar`**, the same as the reference. Confirmed working with
the device flow in practice, although Google's limited-input scope list doesn't mention it. We ask for one scope
and nothing else (no `openid`/`email`); the account shown by `/calendar_status` is the primary calendar's id, which
@@ -86,7 +88,7 @@ public sealed record GoogleCalendarOptions
{
public string? ClientId { get; init; } // else GOOGLE_CLIENT_ID
public string? ClientSecret { get; init; } // else GOOGLE_CLIENT_SECRET
public string? TokenDirectory { get; init; } // else <agentDir>/google-calendar; one <chat key>/token.json per chat
public string? TokenDirectory { get; init; } // else <cwd>/.volcanic/google-calendar/token.json; set: <root>/<chat key>/token.json
public Func<string, string?>? GetEnv { get; init; }
public HttpClient? Http { get; init; } // tests pass a FakeHttpHandler client
public TimeProvider? Time { get; init; } // tests pass a fake clock for polling and token expiry

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} }}";
}

View File

@@ -28,29 +28,43 @@ public class GoogleCalendarConnectionTests : IDisposable
private GoogleCalendarConnection Connected(string scope = "telegram-1", string refreshToken = "rt-1", string? zone = null)
{
var connection = _connections.For(_settings, scope);
var connection = _connections.For(_settings, scope, _root);
connection.Store.Write(new StoredToken(refreshToken, TimeZone: zone));
return connection;
}
private int Refreshes => _http.To("POST /token").Count;
[Fact]
public void Without_a_token_root_each_chat_keeps_its_token_in_its_workspace()
{
var settings = _settings with { TokenRoot = null };
var wsA = Path.Combine(_root, "workspaces", "1");
var wsB = Path.Combine(_root, "workspaces", "2");
var a = _connections.For(settings, "telegram-1", wsA);
var b = _connections.For(settings, "telegram-2", wsB);
Assert.Equal(Path.Combine(wsA, ".volcanic", "google-calendar", "token.json"), a.Store.Path);
Assert.Equal(Path.Combine(wsB, ".volcanic", "google-calendar", "token.json"), b.Store.Path);
Assert.Same(a, _connections.For(settings, "telegram-1", wsA));
Assert.NotSame(a, b);
}
[Fact]
public void Two_scopes_get_two_token_files_under_their_own_directories()
{
var a = _connections.For(_settings, "telegram-1");
var b = _connections.For(_settings, "telegram-2");
var a = _connections.For(_settings, "telegram-1", _root);
var b = _connections.For(_settings, "telegram-2", _root);
Assert.Equal(Path.Combine(_root, "telegram-1", "token.json"), a.Store.Path);
Assert.Equal(Path.Combine(_root, "telegram-2", "token.json"), b.Store.Path);
Assert.Same(a, _connections.For(_settings, "telegram-1"));
Assert.Same(a, _connections.For(_settings, "telegram-1", _root));
Assert.NotSame(a, b);
Assert.StartsWith(Path.Combine(_root, "scope-"), _connections.For(_settings, "../escape").Store.Path);
Assert.StartsWith(Path.Combine(_root, "scope-"), _connections.For(_settings, "../escape", _root).Store.Path);
}
[Fact]
public async Task Not_connected_throws_the_connect_hint()
{
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _connections.For(_settings, "telegram-1").GetAccessTokenAsync(CancellationToken.None));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _connections.For(_settings, "telegram-1", _root).GetAccessTokenAsync(CancellationToken.None));
Assert.True(ex.NotConnected);
Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message);
Assert.Empty(_http.Requests);
@@ -137,7 +151,7 @@ public class GoogleCalendarConnectionTests : IDisposable
public void Resolved_zone_falls_back_to_the_chat_zone()
{
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
Assert.Same(berlin, _connections.For(_settings, "telegram-9").ResolveTimeZone(berlin)); // not connected
Assert.Same(berlin, _connections.For(_settings, "telegram-9", _root).ResolveTimeZone(berlin)); // not connected
Assert.Same(berlin, Connected(zone: null).ResolveTimeZone(berlin));
}

View File

@@ -54,13 +54,13 @@ public class GoogleCalendarOptionsTests
}
[Fact]
public void Token_directory_defaults_under_the_agent_dir()
public void Token_directory_defaults_to_the_chat_workspace()
{
// No root: each chat's token goes to <cwd>/.volcanic/google-calendar, whatever VOLCANIC_AGENT_DIR says.
var agentDir = Path.Combine(Path.GetTempPath(), "agent-dir");
Assert.Equal(Path.Combine(agentDir, "google-calendar"),
new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent", "google-calendar"),
new GoogleCalendarOptions { GetEnv = _ => null }.EffectiveTokenDirectory);
Assert.Null(new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
Assert.Null(new GoogleCalendarOptions { TokenDirectory = " ", GetEnv = _ => null }.EffectiveTokenDirectory);
Assert.Null(new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "csecret", GetEnv = _ => null }.Resolve()!.TokenRoot);
}
[Fact]

View File

@@ -28,6 +28,20 @@ public class PerChatTests
Assert.False(Directory.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeB)));
}
[Fact]
public async Task Without_a_token_directory_connecting_writes_the_token_into_the_chat_workspace()
{
using var h = new ConnectHarness(workspaceTokens: true);
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
await FlowDriver.CompleteAsync(h.Run("calendar_connect", h.Context(new CalendarRecordingUi(), ConnectHarness.ScopeA)), h.Connection(ConnectHarness.ScopeA), h.Time);
Assert.True(File.Exists(Path.Combine(h.Workspace(ConnectHarness.ScopeA), ".volcanic", "google-calendar", "token.json")));
Assert.False(Directory.Exists(Path.Combine(h.Workspace(ConnectHarness.ScopeB), ".volcanic")));
var ui = new CalendarRecordingUi();
await h.Run("calendar_status", h.Context(ui, ConnectHarness.ScopeA));
Assert.StartsWith("Connected as me@example.com", Assert.Single(ui.Notes).Message);
}
[Fact]
public async Task Concurrent_flows_in_two_chats_each_end_with_their_own_store_and_notify()
{

View File

@@ -26,9 +26,9 @@ internal sealed class FakeSessionManager(string scope) : IReadOnlySessionManager
}
/// <summary>A command/tool context for one chat: its scope, its zone and (optionally) a UI.</summary>
internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUserInterface? ui = null, ILogger? logger = null) : IExtensionCommandContext
internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUserInterface? ui = null, ILogger? logger = null, string? cwd = null) : IExtensionCommandContext
{
public string Cwd => Path.GetTempPath();
public string Cwd => cwd ?? Path.GetTempPath();
public string SessionId => "session-1";
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
@@ -159,13 +159,14 @@ internal sealed class ConnectHarness : IDisposable
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName;
public ConnectHarness(ILogger? logger = null)
/// <param name="workspaceTokens">Leave <c>TokenDirectory</c> unset, so tokens go to each chat's workspace (<see cref="Workspace"/>).</param>
public ConnectHarness(ILogger? logger = null, bool workspaceTokens = false)
{
Http = new GoogleFakeHttp(Time);
Api = new CalendarFakeApi(logger);
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions
{
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = _root,
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = workspaceTokens ? null : _root,
Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
});
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult();
@@ -188,9 +189,12 @@ internal sealed class ConnectHarness : IDisposable
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);
/// <summary>The chat's workspace (its session cwd).</summary>
public string Workspace(string scope = ScopeA) => Path.Combine(_root, "workspaces", scope);
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui);
public GoogleCalendarConnection Connection(string scope = ScopeA) => Extension.ConnectionFor(scope, Workspace(scope));
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui, cwd: Workspace(scope));
public Task Run(string command, CalendarFakeContext ctx) => Api.Commands[command].Handler("", ctx, CancellationToken.None);

View File

@@ -29,7 +29,7 @@ internal sealed class CalendarToolHarness : IDisposable
public GoogleFakeHttp Http { get; }
public GoogleCalendarSettings Settings { get; }
public GoogleCalendarConnections Connections { get; } = new();
public GoogleCalendarConnection Connection => Connections.For(Settings, Scope);
public GoogleCalendarConnection Connection => Connections.For(Settings, Scope, Path.GetTempPath());
public TimeZoneInfo ChatZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public CalendarTool Tool { get; }