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),
|
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
|
`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,
|
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
|
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens in the chat
|
||||||
`<agentDir>/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
|
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
|
`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
|
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece
|
||||||
(`docs/claude-code-provider.md`):
|
(`docs/claude-code-provider.md`):
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ The image sets these; normally leave them alone:
|
|||||||
|
|
||||||
| Volume | Path | Contents |
|
| 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/` |
|
| `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`, Google Calendar tokens (`google-calendar/`) |
|
| `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) |
|
| `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
|
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
|
## Where the token lives
|
||||||
|
|
||||||
Each chat's refresh token is stored in `<agentDir>/google-calendar/<chat key>/token.json`. The agent dir is
|
Each chat's refresh token is stored in its workspace, at `<workspace>/.volcanic/google-calendar/token.json` (in the
|
||||||
`$VOLCANIC_AGENT_DIR`, or `~/.volcanic/agent` when that's unset. Hosts can move the root with
|
Telegram bot, `<WorkspaceRoot>/<chatId>/.volcanic/google-calendar/token.json`). It stays there across `/new`,
|
||||||
`GoogleCalendarOptions.TokenDirectory`.
|
resume and bot restarts.
|
||||||
|
|
||||||
- On Unix the file has mode 0600 and its directory 0700.
|
- 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
|
- The file is inside the workspace, so the agent can read it with `read` or `bash` and could send it with `send`.
|
||||||
workspace.
|
The refresh token gives full access to that chat's calendar; treat the workspace accordingly.
|
||||||
- The chat key is the session scope (`telegram-<chatId>`) when it matches `[A-Za-z0-9._-]{1,100}`. Any other scope
|
- Deleting a chat's workspace disconnects its calendar locally. Run `/calendar_disconnect` first to also revoke the
|
||||||
becomes `scope-<16 hex digits>`, the start of the scope's SHA-256 hash.
|
token at Google.
|
||||||
- Removing a chat from the allow-list doesn't delete its directory. Run `/calendar_disconnect` first, or delete the
|
|
||||||
directory yourself.
|
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 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.
|
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
|
`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
|
Abstractions change.** Tokens are only needed from tools and commands, which always have a context, so nothing is
|
||||||
keyed during activation.
|
keyed during activation.
|
||||||
- **Tokens live outside every workspace, one directory per chat:**
|
- **Tokens live in each chat's workspace:** `<cwd>/.volcanic/google-calendar/token.json`, mode 0600, directory 0700,
|
||||||
`<agentDir>/google-calendar/<chat key>/token.json` (agent dir = `VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`), mode
|
as in the reference. (Changed after implementation, at the owner's request; the first version kept them under
|
||||||
0600, directory 0700. The chat key is the scope when it matches `[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else
|
`<agentDir>/google-calendar/`.) The workspace is per chat and survives `/new` and resume. The agent's `read`,
|
||||||
`scope-<first 16 hex of SHA-256(scope)>`. The reference kept the token in the workspace, which the agent's
|
`bash` and `send` tools can reach the file; that's accepted, since `bash` could already read a token anywhere under
|
||||||
`read`/`bash` tools browse routinely. The root is overridable through `GoogleCalendarOptions.TokenDirectory`.
|
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
|
- **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
|
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
|
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? ClientId { get; init; } // else GOOGLE_CLIENT_ID
|
||||||
public string? ClientSecret { get; init; } // else GOOGLE_CLIENT_SECRET
|
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 Func<string, string?>? GetEnv { get; init; }
|
||||||
public HttpClient? Http { get; init; } // tests pass a FakeHttpHandler client
|
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
|
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).");
|
CommandReply.Send(api, ctx, Name, $"/{Name} needs an interactive UI (a chat front end such as the Telegram bot).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
|
var connection = extension.ConnectionFor(ctx);
|
||||||
var flow = await ConnectFlow.StartOrResendAsync(connection, ctx.Ui, ct).ConfigureAwait(false);
|
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);
|
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, _) =>
|
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));
|
CommandReply.Send(api, ctx, Name, Describe(connection));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
@@ -67,7 +67,7 @@ internal static class DisconnectCommand
|
|||||||
|
|
||||||
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
|
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;
|
string? revokeError;
|
||||||
// The gate a completing connect flow writes its token under: after CancelPending it can no longer write.
|
// The gate a completing connect flow writes its token under: after CancelPending it can no longer write.
|
||||||
await connection.FlowGate.WaitAsync(ct).ConfigureAwait(false);
|
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)
|
protected override async Task<ToolResult> ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ctx = invocation.Context;
|
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 resolved = connection.ResolveTimeZone(ctx.TimeZone, ctx.Logger);
|
||||||
var eventZone = args.TimeZone is { Length: > 0 } requested
|
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.")
|
? 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>
|
/// <summary>The process-wide instance the extension uses unless its options pass another.</summary>
|
||||||
public static GoogleCalendarConnections Shared { get; } = new();
|
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);
|
private readonly ConcurrentDictionary<string, GoogleCalendarConnection> _connections = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The connection for a session scope, keyed by its token directory (<c><root>/<chat key></c>).
|
/// The connection for a chat, keyed by its token directory: <c><workspace>/.volcanic/google-calendar</c>, or
|
||||||
/// The first caller's settings create it.
|
/// <c><root>/<chat key></c> when the options set a token root. The first caller's settings create it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope)
|
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope, string workspace)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(settings);
|
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))));
|
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 GoogleCalendarSettings? _settings;
|
||||||
private ILogger? _logger;
|
private ILogger? _logger;
|
||||||
private volatile string? _scope;
|
private volatile string? _scope;
|
||||||
|
private volatile string? _cwd;
|
||||||
private volatile TimeZoneInfo? _chatZone;
|
private volatile TimeZoneInfo? _chatZone;
|
||||||
|
|
||||||
public string Name => ExtensionName;
|
public string Name => ExtensionName;
|
||||||
|
|
||||||
internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared;
|
internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared;
|
||||||
|
|
||||||
internal GoogleCalendarConnection ConnectionFor(string scope) =>
|
internal GoogleCalendarConnection ConnectionFor(IExtensionContext ctx) => ConnectionFor(ctx.SessionManager.Scope, ctx.Cwd);
|
||||||
Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope);
|
|
||||||
|
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)
|
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>
|
/// <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() =>
|
internal string? CurrentZoneName() =>
|
||||||
_scope is { } scope && _settings is not null
|
_scope is { } scope && _cwd is { } cwd && _settings is not null
|
||||||
? ZoneIds.Name(ConnectionFor(scope).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger))
|
? ZoneIds.Name(ConnectionFor(scope, cwd).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
private ValueTask Track(IExtensionContext ctx)
|
private ValueTask Track(IExtensionContext ctx)
|
||||||
{
|
{
|
||||||
_chatZone = ctx.TimeZone;
|
_chatZone = ctx.TimeZone;
|
||||||
|
_cwd = ctx.Cwd;
|
||||||
_scope = ctx.SessionManager.Scope;
|
_scope = ctx.SessionManager.Scope;
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public sealed record GoogleCalendarOptions
|
|||||||
public string? ClientId { get; init; }
|
public string? ClientId { get; init; }
|
||||||
/// <summary>OAuth client secret. Else <c>GOOGLE_CLIENT_SECRET</c>.</summary>
|
/// <summary>OAuth client secret. Else <c>GOOGLE_CLIENT_SECRET</c>.</summary>
|
||||||
public string? ClientSecret { get; init; }
|
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; }
|
public string? TokenDirectory { get; init; }
|
||||||
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
|
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
|
||||||
public Func<string, string?>? GetEnv { get; init; }
|
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>
|
/// <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;
|
public bool IsConfigured => EffectiveClientId is not null && EffectiveClientSecret is not null;
|
||||||
|
|
||||||
internal string EffectiveTokenDirectory => NonEmpty(TokenDirectory) ?? Path.Combine(
|
/// <summary>The configured token root, or null to keep each chat's token in its workspace.</summary>
|
||||||
NonEmpty(Env("VOLCANIC_AGENT_DIR")) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent"),
|
internal string? EffectiveTokenDirectory => NonEmpty(TokenDirectory);
|
||||||
"google-calendar");
|
|
||||||
|
|
||||||
/// <summary>The settings a connection needs, or null when the client id or secret is missing.</summary>
|
/// <summary>The settings a connection needs, or null when the client id or secret is missing.</summary>
|
||||||
internal GoogleCalendarSettings? Resolve() =>
|
internal GoogleCalendarSettings? Resolve() =>
|
||||||
@@ -46,7 +48,7 @@ public sealed record GoogleCalendarOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolved, validated configuration shared by every chat's connection.</summary>
|
/// <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} }}";
|
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)
|
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));
|
connection.Store.Write(new StoredToken(refreshToken, TimeZone: zone));
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int Refreshes => _http.To("POST /token").Count;
|
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]
|
[Fact]
|
||||||
public void Two_scopes_get_two_token_files_under_their_own_directories()
|
public void Two_scopes_get_two_token_files_under_their_own_directories()
|
||||||
{
|
{
|
||||||
var a = _connections.For(_settings, "telegram-1");
|
var a = _connections.For(_settings, "telegram-1", _root);
|
||||||
var b = _connections.For(_settings, "telegram-2");
|
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-1", "token.json"), a.Store.Path);
|
||||||
Assert.Equal(Path.Combine(_root, "telegram-2", "token.json"), b.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.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]
|
[Fact]
|
||||||
public async Task Not_connected_throws_the_connect_hint()
|
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.True(ex.NotConnected);
|
||||||
Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message);
|
Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message);
|
||||||
Assert.Empty(_http.Requests);
|
Assert.Empty(_http.Requests);
|
||||||
@@ -137,7 +151,7 @@ public class GoogleCalendarConnectionTests : IDisposable
|
|||||||
public void Resolved_zone_falls_back_to_the_chat_zone()
|
public void Resolved_zone_falls_back_to_the_chat_zone()
|
||||||
{
|
{
|
||||||
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
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));
|
Assert.Same(berlin, Connected(zone: null).ResolveTimeZone(berlin));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ public class GoogleCalendarOptionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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");
|
var agentDir = Path.Combine(Path.GetTempPath(), "agent-dir");
|
||||||
Assert.Equal(Path.Combine(agentDir, "google-calendar"),
|
Assert.Null(new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
|
||||||
new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
|
Assert.Null(new GoogleCalendarOptions { TokenDirectory = " ", GetEnv = _ => null }.EffectiveTokenDirectory);
|
||||||
Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent", "google-calendar"),
|
Assert.Null(new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "csecret", GetEnv = _ => null }.Resolve()!.TokenRoot);
|
||||||
new GoogleCalendarOptions { GetEnv = _ => null }.EffectiveTokenDirectory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ public class PerChatTests
|
|||||||
Assert.False(Directory.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeB)));
|
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]
|
[Fact]
|
||||||
public async Task Concurrent_flows_in_two_chats_each_end_with_their_own_store_and_notify()
|
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>
|
/// <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 string SessionId => "session-1";
|
||||||
public bool HasUi => ui is not null;
|
public bool HasUi => ui is not null;
|
||||||
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
|
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
|
||||||
@@ -159,13 +159,14 @@ internal sealed class ConnectHarness : IDisposable
|
|||||||
|
|
||||||
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName;
|
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);
|
Http = new GoogleFakeHttp(Time);
|
||||||
Api = new CalendarFakeApi(logger);
|
Api = new CalendarFakeApi(logger);
|
||||||
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions
|
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,
|
Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
|
||||||
});
|
});
|
||||||
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
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") =>
|
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"}""");
|
(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);
|
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 GoogleFakeHttp Http { get; }
|
||||||
public GoogleCalendarSettings Settings { get; }
|
public GoogleCalendarSettings Settings { get; }
|
||||||
public GoogleCalendarConnections Connections { get; } = new();
|
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 TimeZoneInfo ChatZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||||
public CalendarTool Tool { get; }
|
public CalendarTool Tool { get; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user