feat(google-calendar): extension project, options and per-chat token store

Adds the GoogleCalendar extension project (Abstractions only, Private=false), GoogleCalendarOptions with
env fallbacks and a secret-free ToString, GoogleCalendarException/Messages, ChatKeys (scope -> safe directory
name), StoredToken and GoogleTokenStore (atomic write, owner-only on Unix, reads never throw), and an empty
GoogleCalendarConnections stub for Task 3. The test project gains the extension reference and
Microsoft.Extensions.TimeProvider.Testing 10.9.0.
This commit is contained in:
EugeneTes
2026-09-26 20:01:18 +02:00
parent 6b3ee82489
commit 199ad47f64
11 changed files with 371 additions and 0 deletions

View File

@@ -3,6 +3,7 @@
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Anthropic/Volcanic.AgenticCore.Extensions.Anthropic.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
</Folder>
<Folder Name="/hosts/">

View File

@@ -0,0 +1,19 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Maps a session scope (stable per chat) to a directory name that is safe on every file system.</summary>
internal static partial class ChatKeys
{
public static string For(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
if (Safe().IsMatch(scope) && scope is not "." and not "..") return scope;
return "scope-" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope)))[..16];
}
[GeneratedRegex("^[A-Za-z0-9._-]{1,100}$")]
private static partial Regex Safe();
}

View File

@@ -0,0 +1,7 @@
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Process-wide per-chat connection state. See Task 3.</summary>
public sealed class GoogleCalendarConnections
{
public static GoogleCalendarConnections Shared { get; } = new();
}

View File

@@ -0,0 +1,15 @@
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>A Google Calendar failure the model or the user can read. <see cref="NotConnected"/> means "run /calendar_connect".</summary>
public sealed class GoogleCalendarException(string message, bool notConnected = false) : Exception(message)
{
public bool NotConnected { get; } = notConnected;
}
internal static class GoogleCalendarMessages
{
public const string PermissionsUrl = "https://myaccount.google.com/permissions";
public const string NotConnected = "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.";
public const string TokenRejected = "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.";
public const string NoRefreshToken = "Google returned no refresh token. Remove the app at " + PermissionsUrl + " and run /calendar_connect again.";
}

View File

@@ -0,0 +1,55 @@
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Configures the Google Calendar extension. The OAuth client is process-wide (one Google Cloud app for the bot);
/// accounts are per chat. Empty strings count as unset.
/// </summary>
public sealed record GoogleCalendarOptions
{
/// <summary>OAuth client id (type "TVs and Limited Input devices"). Else <c>GOOGLE_CLIENT_ID</c>.</summary>
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>
public string? TokenDirectory { get; init; }
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
public Func<string, string?>? GetEnv { get; init; }
/// <summary>HTTP client for Google; tests pass one over a fake handler.</summary>
public HttpClient? Http { get; init; }
/// <summary>Clock for polling and token expiry; tests pass a fake.</summary>
public TimeProvider? Time { get; init; }
/// <summary>Per-chat connection state. Defaults to <see cref="GoogleCalendarConnections.Shared"/>.</summary>
public GoogleCalendarConnections? Connections { get; init; }
private Func<string, string?> Env => GetEnv ?? Environment.GetEnvironmentVariable;
internal string? EffectiveClientId => NonEmpty(ClientId) ?? NonEmpty(Env("GOOGLE_CLIENT_ID"));
internal string? EffectiveClientSecret => NonEmpty(ClientSecret) ?? NonEmpty(Env("GOOGLE_CLIENT_SECRET"));
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 settings a connection needs, or null when the client id or secret is missing.</summary>
internal GoogleCalendarSettings? Resolve() =>
EffectiveClientId is { } id && EffectiveClientSecret is { } secret
? new GoogleCalendarSettings(id, secret, EffectiveTokenDirectory, Http ?? GoogleHttp.Default, Time ?? TimeProvider.System)
: null;
public override string ToString() =>
$"GoogleCalendarOptions {{ ClientId = {ClientId}, ClientSecret = {(string.IsNullOrEmpty(ClientSecret) ? "" : "***")}, TokenDirectory = {TokenDirectory} }}";
internal static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <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)
{
public override string ToString() => $"GoogleCalendarSettings {{ ClientId = {ClientId}, TokenRoot = {TokenRoot} }}";
}
internal static class GoogleHttp
{
/// <summary>One client for the process: connections of all chats share it.</summary>
public static readonly HttpClient Default = new() { Timeout = TimeSpan.FromSeconds(60) };
}

View File

@@ -0,0 +1,70 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>What <c>token.json</c> holds for one chat. <see cref="ToString"/> hides the refresh token.</summary>
public sealed record StoredToken(string RefreshToken, string? Scope = null, string? Account = null, string? TimeZone = null, DateTimeOffset? ConnectedAt = null)
{
public override string ToString() => $"StoredToken {{ Account = {Account}, TimeZone = {TimeZone}, ConnectedAt = {ConnectedAt:O} }}";
}
/// <summary>
/// One chat's <c>token.json</c>. Reads never throw (unreadable = not connected); writes are atomic
/// (<c>token.json.tmp</c> then a move) and owner-only on Unix.
/// </summary>
internal sealed class GoogleTokenStore(string path)
{
public const string FileName = "token.json";
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
public string Path { get; } = path;
public StoredToken? Read()
{
try
{
if (!File.Exists(Path)) return null;
var text = File.ReadAllText(Path);
if (string.IsNullOrWhiteSpace(text)) return null;
var token = VolcanicJson.Deserialize<StoredToken>(text);
return string.IsNullOrWhiteSpace(token?.RefreshToken) ? null : token;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException)
{
return null;
}
}
public void Write(StoredToken token)
{
ArgumentNullException.ThrowIfNull(token);
var directory = System.IO.Path.GetDirectoryName(Path)!;
CreatePrivateDirectory(System.IO.Path.GetDirectoryName(directory)!);
CreatePrivateDirectory(directory);
var temp = Path + ".tmp";
var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
using (var stream = new FileStream(temp, options))
JsonSerializer.Serialize(stream, token, VolcanicJson.Indented);
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PrivateFile); // a leftover .tmp keeps its old mode otherwise
File.Move(temp, Path, overwrite: true);
}
public void Delete()
{
if (File.Exists(Path)) File.Delete(Path);
}
private static void CreatePrivateDirectory(string directory)
{
if (Directory.Exists(directory)) return;
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(directory);
return;
}
Directory.CreateDirectory(directory, PrivateDirectory);
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using System.Security.Cryptography;
using System.Text;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class ChatKeysTests
{
[Theory]
[InlineData("telegram-111111111")]
[InlineData("a.b_c-D9")]
public void A_safe_scope_is_its_own_key(string scope) => Assert.Equal(scope, ChatKeys.For(scope));
[Theory]
[InlineData("a/b")]
[InlineData("..")]
[InlineData(".")]
[InlineData("")]
[InlineData("has space")]
[InlineData("c:\\x")]
public void An_unsafe_scope_maps_to_a_hashed_key(string scope)
{
var expected = "scope-" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope)))[..16];
Assert.Equal(expected, ChatKeys.For(scope));
}
[Fact]
public void A_scope_longer_than_100_characters_is_hashed()
{
Assert.Equal(new string('a', 100), ChatKeys.For(new string('a', 100)));
Assert.StartsWith("scope-", ChatKeys.For(new string('a', 101)));
}
}

View File

@@ -0,0 +1,73 @@
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleCalendarOptionsTests
{
private static Func<string, string?> Env(params (string Name, string Value)[] vars) =>
name => vars.FirstOrDefault(v => v.Name == name).Value;
[Fact]
public void Explicit_values_win_over_the_environment()
{
var options = new GoogleCalendarOptions
{
ClientId = "cid", ClientSecret = "csecret",
GetEnv = Env(("GOOGLE_CLIENT_ID", "env-id"), ("GOOGLE_CLIENT_SECRET", "env-secret")),
};
Assert.Equal("cid", options.EffectiveClientId);
Assert.Equal("csecret", options.EffectiveClientSecret);
}
[Fact]
public void Empty_values_count_as_unset_and_fall_back_to_the_environment()
{
var options = new GoogleCalendarOptions
{
ClientId = "", ClientSecret = " ",
GetEnv = Env(("GOOGLE_CLIENT_ID", "env-id"), ("GOOGLE_CLIENT_SECRET", "env-secret")),
};
Assert.Equal("env-id", options.EffectiveClientId);
Assert.Equal("env-secret", options.EffectiveClientSecret);
}
[Theory]
[InlineData(null, null)]
[InlineData("cid", null)]
[InlineData(null, "csecret")]
[InlineData("", "")]
public void Resolve_returns_null_unless_both_id_and_secret_are_set(string? id, string? secret)
{
var options = new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = _ => null };
Assert.Null(options.Resolve());
}
[Fact]
public void Resolve_fills_http_time_and_token_root()
{
var root = Path.Combine(Path.GetTempPath(), "gcal-root");
var settings = new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "csecret", TokenDirectory = root, GetEnv = _ => null }.Resolve();
Assert.NotNull(settings);
Assert.Equal(root, settings.TokenRoot);
Assert.Same(TimeProvider.System, settings.Time);
Assert.NotNull(settings.Http);
}
[Fact]
public void Token_directory_defaults_under_the_agent_dir()
{
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);
}
[Fact]
public void ToString_never_shows_the_client_secret()
{
var options = new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "super-secret-value", GetEnv = _ => null };
Assert.DoesNotContain("super-secret-value", options.ToString());
Assert.DoesNotContain("super-secret-value", options.Resolve()!.ToString());
}
}

View File

@@ -0,0 +1,87 @@
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleTokenStoreTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("gcal-store-").FullName;
private string TokenPath => Path.Combine(_root, "telegram-1", GoogleTokenStore.FileName);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
private static readonly StoredToken Sample = new("1//0e-refresh", "https://www.googleapis.com/auth/calendar",
"user@gmail.com", "Europe/Berlin", new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
[Fact]
public void Round_trips_a_token()
{
var store = new GoogleTokenStore(TokenPath);
store.Write(Sample);
Assert.Equal(Sample, store.Read());
var json = File.ReadAllText(TokenPath);
Assert.Contains("\"refreshToken\"", json);
Assert.Contains("\"timeZone\"", json);
Assert.NotEqual(0xEF, File.ReadAllBytes(TokenPath)[0]); // no BOM
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("{}")]
[InlineData("{\"refreshToken\":\"\"}")]
[InlineData("{\"account\":\"user@gmail.com\"}")]
public void Read_returns_null_for_missing_empty_garbage_or_keyless_files(string? content)
{
if (content is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(TokenPath)!);
File.WriteAllText(TokenPath, content);
}
Assert.Null(new GoogleTokenStore(TokenPath).Read());
}
[Fact]
public void Write_replaces_the_file_atomically_and_leaves_no_temp_file()
{
var store = new GoogleTokenStore(TokenPath);
store.Write(Sample);
File.WriteAllText(TokenPath + ".tmp", "stale leftover");
store.Write(Sample with { RefreshToken = "1//0e-second" });
Assert.Equal("1//0e-second", store.Read()!.RefreshToken);
Assert.False(File.Exists(TokenPath + ".tmp"));
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(TokenPath)!));
}
[Fact]
public void Write_creates_an_owner_only_file_and_directory_on_unix()
{
if (OperatingSystem.IsWindows()) return;
new GoogleTokenStore(TokenPath).Write(Sample);
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(TokenPath));
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
File.GetUnixFileMode(Path.GetDirectoryName(TokenPath)!));
}
[Fact]
public void Delete_removes_the_file_and_tolerates_a_missing_one()
{
var store = new GoogleTokenStore(TokenPath);
store.Delete(); // nothing there yet, not even the directory
store.Write(Sample);
store.Delete();
Assert.False(File.Exists(TokenPath));
Assert.Null(store.Read());
}
[Fact]
public void ToString_never_shows_the_refresh_token()
{
Assert.DoesNotContain("1//0e-refresh", Sample.ToString());
Assert.Contains("user@gmail.com", Sample.ToString());
}
}

View File

@@ -10,6 +10,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
</ItemGroup>
<ItemGroup>
<None Include="ClaudeCode/Fixtures/*" CopyToOutputDirectory="PreserveNewest" />
@@ -21,5 +22,6 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
</ItemGroup>
</Project>