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:
@@ -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/">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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><agentDir>/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) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user