forked from admin/VolcanicAgenticCore
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:
@@ -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`):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} }}";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user