Merge branch 'feat/google-calendar'

This commit is contained in:
EugeneTes
2026-09-26 21:24:34 +02:00
49 changed files with 8405 additions and 52 deletions

View File

@@ -78,9 +78,12 @@ Read `docs/architecture.md` first. Key points:
### Extensions (`extensions/`)
Extensions built on the public API only: `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
`ClaudeCode` + `ClaudeCode.Bridge`, and `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser
preset; `docs/mcp.md`). The Claude Code provider is the most intricate piece (`docs/claude-code-provider.md`):
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`); and 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`). The Claude Code provider is the most intricate piece
(`docs/claude-code-provider.md`):
- Every model call spawns a fresh `claude -p` with built-in tools off and replays the whole transcript as stream-json.
Core tools are exposed as MCP tools `mcp__volcanic__<alias>` through the Bridge, a tiny MCP server that **never

View File

@@ -17,6 +17,7 @@ Claude Code as a model provider without an API key.
| Anthropic Messages API provider | `extensions/Volcanic.AgenticCore.Extensions.Anthropic` | `ANTHROPIC_API_KEY` |
| OpenAI-compatible provider | `extensions/Volcanic.AgenticCore.Extensions.OpenAI` | OpenAI, OpenRouter, Groq, Ollama, llama.cpp, vLLM |
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
| Google Calendar (tool + connect commands) | `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`; one account per chat |
| Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list |
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API |
@@ -67,6 +68,7 @@ await session.PromptAsync("Explain this repository");
- [Writing extensions](docs/extensions.md): tools, commands, lifecycle hooks, providers, message injection, discovery.
- [Providers and models](docs/providers.md): model keys, authentication, the built-in provider extensions.
- [Telegram bot](docs/telegram-bot.md): setup, commands, security, connection recovery.
- [Google Calendar](docs/google-calendar.md): Google Cloud setup, connecting a chat, token storage, time zones.
- [Building a UI (Telegram guide)](docs/ui-integration.md): how to put a chat front end on the core.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor
command, automatic re-login when the subscription session expires.

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.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
</Folder>

View File

@@ -5,7 +5,7 @@
```
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
src/Volcanic.AgenticCore runtime, organised as vertical slices
extensions/* provider extensions and the MCP client, built on the public API only
extensions/* provider and feature extensions (MCP client, Google Calendar), built on the public API only
samples/* reference host
tests/* xunit
```
@@ -17,6 +17,9 @@ the streaming event protocol, `ITool`, the extension API and its events, session
`ISecretRedactor`). Extensions reference only this assembly, which is what allows them to be loaded from separate
directories.
`GoogleCalendar` is a feature extension: a tool and three commands, per-chat accounts through process-wide
`GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)).
## Vertical slices
The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features`

175
docs/google-calendar.md Normal file
View File

@@ -0,0 +1,175 @@
# Google Calendar
`extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` gives the agent a Google Calendar. Each chat connects its
own Google account from the chat itself, with no browser on the bot's machine. The Telegram bot loads it in every
chat once the OAuth client is configured.
## What it does
- A `calendar` tool with the actions `list`, `search`, `get`, `list_instances`, `create`, `update`, `delete` and
`calendars`. It's registered whenever the client is configured, connected or not. Before a chat connects, the tool
tells the model to ask the user to run `/calendar_connect`.
- Three commands: `/calendar_connect`, `/calendar_status` and `/calendar_disconnect`.
- One Google account per chat. The account is keyed by the session scope, which the Telegram bot sets to
`telegram-<chatId>`. A headless host uses the default scope derived from the working directory, so it gets one
account per workspace. The tool only uses the current chat's token, so nobody sees another chat's calendar through
the agent.
This controls what the agent shows to whom. It is not a security boundary between allow-listed users: every one of
them can run shell commands as the bot's OS user, and so could read another chat's token file.
## Google Cloud setup
You need one Google Cloud OAuth client for the bot. All chats share it; each chat still approves its own account.
1. Create a project at [console.cloud.google.com](https://console.cloud.google.com). Then go to
*APIs & Services → Library → Google Calendar API* and click **Enable**.
2. Set up the *OAuth consent screen* (in the newer console: *Google Auth Platform → Branding* and *Audience*). Pick
user type **External** and fill in the app name, a support email and the developer contact address. You don't need
to add scopes there: the bot requests `https://www.googleapis.com/auth/calendar` itself.
3. **Publish the app**: *Audience → Publishing status → Publish app*, so the status reads "In production". A handful
of users doesn't need Google's verification, and refresh tokens of a published app don't expire after 7 days.
4. **Expect the "unverified app" warning.** When a user approves access, Google first shows a page titled
"Google hasn't verified this app". It says the app requests access to sensitive info in the Google account and
that the user shouldn't use it until the developer verifies it with Google. The big button on it is
**Back to safety**. To go on, click the small **Advanced** link at the bottom left, then
**Go to <app name> (unsafe)**. On the consent screen that follows, allow "See, edit, share, and permanently delete
all the calendars you can access using Google Calendar" and press **Continue**. Each Google account sees this
once.
5. Go to *Credentials → Create credentials → OAuth client ID* and pick the application type
**TVs and Limited Input devices**. The device flow the bot uses only works with that type. Copy the client id and
the client secret.
## Configure the bot
Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, or `GoogleCalendar:ClientId` and `GoogleCalendar:ClientSecret` in
`appsettings.json` (`GoogleCalendar__ClientId` and `GoogleCalendar__ClientSecret` in the environment). A value in the
section wins over the environment variable.
```bash
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com \
GOOGLE_CLIENT_SECRET=GOCSPX-... \
TELEGRAM_BOT_TOKEN=123456:ABC... \
TELEGRAM_ALLOWED_USER_IDS=111111111 \
dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
- Empty values count as unset.
- With neither value set, the extension stays off and logs one information line:
`google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET`.
- With only one of them set, it stays off and logs a warning that names the missing one.
- With both set, every chat gets the tool and the commands, and the three commands appear in the bot's menu and in
`/help`.
The client secret is added to the process policy's `SecretValues`, so it shows up as `[REDACTED]` in tool output.
`GOOGLE_CLIENT_SECRET` is also on the default deny-list (`*_SECRET`), so `bash`, scheduled scripts and MCP servers
never see it in their environment. See [telegram-bot.md](telegram-bot.md#security).
## Connecting a chat
`/calendar_connect` sends one message with the link and the code:
```
Connect Google Calendar:
1. Open https://www.google.com/device
2. Enter the code ABCD-EFGH
3. Approve access. I'll confirm here when it's done (code valid for 30 min).
```
Open the link on any device, sign in with the Google account for this chat, enter the code and approve. The bot polls
Google on its own, so there's nothing more to type. When you approve, the chat gets:
```
✅ Google Calendar connected as you@gmail.com (time zone Europe/Berlin).
```
The account shown is the primary calendar's id, which is the account's email address.
- Sending `/calendar_connect` again while the bot waits re-sends the same link and code with the minutes left. It
doesn't start a second flow, because a new code would invalidate the one you may be typing.
- A denial ("Google Calendar access was denied."), an expired code ("The Google code expired. Run /calendar_connect
again.") and a failure ("Google Calendar connection failed: ...") each end the wait with one message.
- `/stop` doesn't cancel the wait; it only stops agent runs. `/calendar_disconnect` does cancel it.
- A restart of the bot loses a pending flow. Run `/calendar_connect` again.
- Connecting again while connected switches the chat to the newly approved account. The old token is replaced once the
new one arrives, but it is not revoked.
`/calendar_status` shows one of three things:
- `Connected as <account> since <date>, time zone <tz>.`
- `Not connected. Run /calendar_connect.`
- `Waiting for approval: open <url> and enter <code> (<N> min left).`
`/calendar_disconnect` cancels a pending flow, revokes the token at Google (best effort) and deletes the local token.
If Google can't be reached or refuses, the token is still deleted locally and the reply says so; remove the app by hand
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`.
- 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 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.
## Time zones
- `create` and `update` take an optional `timeZone` (an IANA id such as `Europe/Berlin`) for the event. Times without
an offset are then in that zone.
- Otherwise times without an offset (`2026-09-30T10:00`) are in the calendar's own time zone, which the bot reads when
the chat connects. If that's unknown, they are in the chat's zone.
- A time with an offset (`2026-09-30T10:00:00+02:00`) keeps its offset.
- Every tool result starts with the zone it uses, such as `Times in Europe/Berlin.` When the calendar's zone differs
from the chat's, the header names both:
`Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).`
- A date alone (`2026-09-30`) is an all-day event. All-day ends are exclusive, as in Google: an event on the 30th ends
on the 1st.
- In a `list` or `search` window, a `timeMax` date includes that day: `timeMin` and `timeMax` both `2026-09-30` cover
the whole 30th.
- For recurring events, `list_instances` returns the ids of single occurrences. `update` and `delete` detect whether an
id is one occurrence or a whole series, so `scope` (`instance` or `series`) only needs to be passed to be explicit.
"This and following" edits aren't supported.
## Testing status instead of publishing
You can leave the app in *Testing* status instead of publishing it:
- Add every user's Google account under *Audience → Test users*. Anyone else gets `access_denied`, which the bot
reports as "Google Calendar access was denied."
- Google expires refresh tokens of apps in testing status after 7 days. After that the tool reports that Google
rejected the stored calendar token, and the chat has to run `/calendar_connect` again, about once a week.
## Other hosts
The extension only depends on the public API, so any host can load it:
```csharp
services.AddSingleton(new GoogleCalendarOptions { ClientId = "...", ClientSecret = "..." });
services.AddAgenticCore(o => o.AddExtension<GoogleCalendarExtension>());
```
`AddExtension<T>()` creates one instance per session, which the extension needs because it keeps per-session fields.
Don't use the overload that takes a shared instance. Without the singleton, the extension reads `GOOGLE_CLIENT_ID`
and `GOOGLE_CLIENT_SECRET` from the environment. To add it for particular sessions only, pass an
`ExtensionRegistration` in `AgentSessionOptions.ExtraExtensions`, as the Telegram host does
(`GoogleCalendarSetup.Registration`). `/calendar_connect` needs a UI (`ctx.HasUi`) to show the link and code; without
one it replies that it needs an interactive UI.
## Troubleshooting
| Message | Cause and fix |
|---|---|
| "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat." | This chat never connected, or it disconnected. Run `/calendar_connect` |
| "Google rejected the stored calendar token (revoked or expired)." | The access was revoked, the account's password changed, or the app is in testing status and 7 days have passed. Run `/calendar_connect` again |
| "Google Calendar connection failed: Google refused the device code request: invalid_client ..." | Wrong client id or secret, or the OAuth client isn't of type *TVs and Limited Input devices* |
| "Google returned no refresh token." | Google already had a grant for this app and didn't issue a new token. Remove the app at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and run `/calendar_connect` again |
| No calendar tool or commands at all | The client id or secret is missing. Check the startup log for the `google-calendar disabled` line |

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
# Google Calendar tool and connect flow — design
Date: 2026-09-26
Status: draft
Status: implemented
Depends on: `2026-09-26-scheduler-design.md` §7–§8 (the chat's time zone, `IExtensionContext.TimeZone`).
## Problem
@@ -57,7 +57,7 @@ reshaped for this harness: an extension on the public API, one Google account pe
- **The OAuth app is published unverified ("In production").** This is the setup the owner already uses. Publishing
needs no Google verification for a handful of users, and it avoids testing status's 7-day refresh-token expiry, so
a chat connects once instead of weekly. Each user sees Google's "unverified app" warning once, on approval
("Advanced → Go to <app> (unsafe)"); the setup docs show it. Testing status still works (every user added as a test
("Advanced → Go to <app> (unsafe)"); the setup docs describe it in text. Testing status still works (every user added as a test
user, otherwise `access_denied`, which the flow reports as a denial), but then each chat has to reconnect about
weekly; the `invalid_grant` message covers it.
- **Command names use underscores** (`/calendar_connect`, `/calendar_status`, `/calendar_disconnect`). Telegram only
@@ -110,24 +110,34 @@ and GOOGLE_CLIENT_SECRET`). If only one of the two is set, it logs a `Warning` i
### 3. OAuth client — `GoogleOAuthClient`
- `StartDeviceFlowAsync(ct) → DeviceCode(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`. A non-2xx
response throws `GoogleCalendarException` with Google's `error`/`error_description`.
- `PollAsync(deviceCode, ct) → PollResult`: `Success(refreshToken, scope)`, `Pending`, `SlowDown`, `Denied`, `Expired`,
`Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google documents 428 for
`authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
- `StartDeviceFlowAsync(ct) → DeviceAuthorization(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`
(`verification_url`, or `verification_uri` when Google sends that; the interval defaults to 5 seconds). A non-2xx
response throws `GoogleCalendarException` with Google's `error`/`error_description`, never the client secret.
- `PollAsync(deviceCode, ct) → PollResult`, one record with a `PollStatus`: `Success` (refresh token, scope, access
token and its expiry; the connect flow uses the access token for `calendars/primary`), `Pending`, `SlowDown`,
`Denied`, `Expired`, `Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google
documents 428 for `authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
A success without `refresh_token` is `Failed("Google returned no refresh token. Remove the app at
https://myaccount.google.com/permissions and run /calendar_connect again.")`.
- `RefreshAsync(refreshToken, ct) → (accessToken, expiresAt)`. `invalid_grant` throws
https://myaccount.google.com/permissions and run /calendar_connect again.")`. A 5xx response without an `error`
field throws `HttpRequestException` and counts as a transport error in the flow (§5). `PollResult.ToString()` never
shows tokens.
- `RefreshAsync(refreshToken, ct) → AccessToken(Token, ExpiresAt)`. `invalid_grant` throws
`GoogleCalendarException(NotConnected: true, "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.")`.
- `RevokeAsync(token, ct)`: best effort; failures are reported but don't stop a disconnect.
Other errors throw `GoogleCalendarException("Google token refresh failed: <error>[: <description>]")`.
- `RevokeAsync(token, ct) → string?`: best effort; `null` when Google revoked the token, else the failure reason
(Google's error, `HTTP <status>` or the transport error). Failures are reported but don't stop a disconnect.
### 4. Connections — `GoogleCalendarConnections` (process-wide) and `GoogleCalendarConnection` (per chat)
`GoogleCalendarConnections.Shared` is explicit process-wide state, like `ClaudeLoginCoordinator.Shared`: extensions are
per session, but a chat's account outlives its sessions. `For(chatKey)` returns that chat's `GoogleCalendarConnection`
(created on first use, kept for the process lifetime) over its own `GoogleTokenStore`. Connections share nothing but
the OAuth client and the `HttpClient`, so chats are independent: separate access-token caches, separate refresh
locks, separate pending flows. The tool and the commands call `For(chatKey(ctx.SessionManager.Scope))`.
per session, but a chat's account outlives its sessions. `For(settings, scope)` returns that chat's
`GoogleCalendarConnection` (created on first use, kept for the process lifetime) over its own `GoogleTokenStore`.
Connections are keyed by the chat's full token directory (`<root>/<chat key>`, chat key = `ChatKeys.For(scope)`), and
the first caller's settings create the connection: all extension instances in a process share one configuration in
practice, and two token roots never share a connection. Connections share nothing but the OAuth client configuration
and the `HttpClient` (each holds its own stateless `GoogleOAuthClient`), so chats are independent: separate
access-token caches, separate refresh locks, separate pending flows. The tool and the commands call
`For(settings, ctx.SessionManager.Scope)`.
Per chat:
@@ -151,14 +161,15 @@ The handler (`ConnectCommand`) with `ctx.Ui`:
does) and stop.
2. A flow is already pending: re-send its link and code (`Notify`) with the remaining minutes, and stop. It does not
start a second flow: a new device code would invalidate the one the user may be typing. A flow pending in another
chat doesn't matter.
chat doesn't matter. A flow that is already cancelled or whose code has expired (still parked in its delay) doesn't
count: it is cancelled silently, cleared, and a new flow starts; `/calendar_status` doesn't show it either.
3. Otherwise `StartDeviceFlowAsync`, store it as the chat connection's `Pending`, and send **one** message:
`ui.Notify("Connect Google Calendar:\n1. Open <verification_url>\n2. Enter the code <USER-CODE>\n3. Approve access. I'll confirm here when it's done (code valid for <N> min).")`.
Link and code in one message, for the ordering reason in the re-login spec.
4. Poll with `PollAsync` every `Interval` seconds (Google says 5), delays through `TimeProvider`:
- `Pending`: keep going. `SlowDown`: add 5 s to the interval permanently (RFC 8628 §3.5).
- `Success`: call `GET calendars/primary` with the new token to learn `account` (`id`) and `timeZone`, write the
store, `Invalidate()` the cache, then `Notify("✅ Google Calendar connected as <account> (time zone <tz>).")`. If the
store (under the connection's flow gate, and only if the flow wasn't cancelled meanwhile), `Invalidate()` the cache, then `Notify("✅ Google Calendar connected as <account> (time zone <tz>).")`. If the
lookup fails, the token is still saved with those fields empty.
- `Denied`: `Notify("Google Calendar access was denied.", Warning)`.
- `Expired`, or `ExpiresAt` passed: `Notify("The Google code expired. Run /calendar_connect again.", Warning)`.
@@ -179,8 +190,9 @@ success; the old one is not revoked automatically.
- `/calendar_status`: `Connected as <account> since <date>, time zone <tz>.`, `Not connected. Run /calendar_connect.`,
or `Waiting for approval: open <url> and enter <code> (<N> min left).`
- `/calendar_disconnect`: cancels a pending flow, revokes the refresh token (best effort), deletes the file,
invalidates the cache, then `Calendar disconnected.` (with ` Revoking at Google failed: <reason>; remove the app at
- `/calendar_disconnect`: under the same flow gate a completing flow writes the store with, cancels a pending flow,
revokes the refresh token (best effort), deletes the file, invalidates the cache (so a flow that got its token just
before the disconnect can't write it afterwards), then `Calendar disconnected.` (with ` Revoking at Google failed: <reason>; remove the app at
https://myaccount.google.com/permissions.` appended on revoke failure).
The replies go through `ctx.Ui.Notify`. Without a UI they're appended as a `CustomMessage` with `triggerTurn: false`.
@@ -209,11 +221,27 @@ Behaviour follows the reference's recurring-events spec:
- **Scope resolution** for update/delete: fetch the event once. `recurringEventId` set → `instance`; `recurrence` set
→ `series`; neither → single (an explicit `scope` is ignored). An explicit `scope=instance` on a master and
`scope=series` on an occurrence are rejected with the reference's error texts. `recurrence` together with an
instance is rejected. Recurrence lines must start with `RRULE:`, `EXDATE:`, `RDATE:` or `EXRULE:`; Google is the
authority on the rest.
`scope=series` on an occurrence are rejected. `recurrence` together with an instance is rejected. Recurrence lines
must start with `RRULE:`, `EXDATE:`, `RDATE:` or `EXRULE:` (case-insensitive, trimmed); Google is the authority on
the rest. The reference implementation (`VolcanicSecretary/src/tools/calendar.ts`) is not in this repository, so
these are the texts (all thrown as `ArgumentException`, which the loop turns into an error tool result):
- missing fields: `create needs summary.`, `create needs start.`, `update needs eventId.`, `delete needs eventId.`
- any other `scope` value: `scope must be instance or series.`
- `scope=instance` on a master: `Event <id> is a recurring series. To change one date, call list_instances with
eventId=<id>, then pass that occurrence's id with scope=instance.`
- `scope=series` on an occurrence: `Event <id> is one occurrence of the recurring series <series>. To change the
whole series, pass eventId=<series> with scope=series.`
- `recurrence` on an occurrence: `recurrence can't be set on a single occurrence; update the series
(eventId=<series>) instead.`
- a bad recurrence line: `Recurrence line "<line>" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.`
- an empty patch: `update needs at least one field to change (summary, description, location, start, end or
recurrence).`
- **update** is a `PATCH` carrying only the fields given (empty patch → error). **delete** of an instance cancels one
occurrence; of a master deletes the series. The reply texts name which happened.
occurrence; of a master deletes the series. The reply texts name which happened, after the header line:
- create: `Created: <line>`
- update: `Updated this occurrence only: <line>`, `Updated the whole series: <line>` or `Updated: <line>`
- delete: `Cancelled one occurrence of "<summary>" (<range>); the rest of the series is unchanged.`,
`Deleted the recurring series "<summary>" and all its occurrences.` or `Deleted "<summary>" (<range>).`
- "This and following" edits, attendees, reminders, colours and conferencing are out of scope, as in the reference.
- `calendars` lists `calendarList` entries (`id · summary · accessRole · timeZone`, primary marked) so the model can
pass `calendarId`.
@@ -223,14 +251,17 @@ Behaviour follows the reference's recurring-events spec:
- Zone `Z` = the event's `timeZone` argument, else the resolved zone (§4).
- `YYYY-MM-DD` is an all-day date (`{date}`). All-day `end` is **exclusive**, as in Google. A missing `end` on create
means one day.
- A date-time with an offset (`2026-09-30T10:00:00+02:00` or `Z`) is sent as-is, as `dateTime`, with `timeZone: Z`.
- A date-time with an offset (`2026-09-30T10:00:00+02:00` or `Z`) keeps its instant and offset and is sent as
`dateTime`, with `timeZone: Z`. It is normalised to full RFC 3339 (`T` separator, seconds always present, so
`2026-09-30T10:00+02:00` goes out as `2026-09-30T10:00:00+02:00`); a canonical value goes out unchanged.
- A date-time without an offset (`2026-09-30T10:00`) is local wall time in `Z`: sent as `dateTime` without an offset
plus `timeZone: Z`, which Google interprets in that zone. Recurring events are expanded in `Z`, so a weekly 10:00
stays 10:00 across DST.
- A missing timed `end` on create means start + 1 hour. `start` and `end` must be the same kind (both dates or both
date-times).
- `timeMin`/`timeMax` must be RFC 3339 with an offset for Google. A date or local date-time is converted in `Z`
(`TimeZoneInfo`) before sending.
(`TimeZoneInfo`) before sending. A `timeMin` date is that day's midnight; a `timeMax` date includes that day, so it
becomes the next day's midnight (`timeMin=timeMax=2026-09-30` covers the whole 30th).
- Output times are rendered in the resolved zone with the zone in the header line of every result, e.g.
`Times in Europe/Berlin.` When the calendar's zone differs from the chat's (the system prompt shows the chat's), the
header says both: `Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).`, so the model
@@ -253,15 +284,21 @@ Behaviour follows the reference's recurring-events spec:
- Configuration: a `GoogleCalendar` section (`ClientId`, `ClientSecret`) with `GOOGLE_CLIENT_ID` and
`GOOGLE_CLIENT_SECRET` as environment shortcuts, bound into a `GoogleCalendarOptions` singleton. Empty values count
as unset (the Docker compose file passes both through, empty by default). The host adds the client secret to the
process environment hook's `SecretValues` (`2026-09-26-process-environment-hook-design.md`), and the default
deny-list already removes `GOOGLE_CLIENT_SECRET` from `bash`, scripts and MCP servers.
as unset (the Docker compose file passes both through, empty by default). The singleton is registered when
**either** value is set, so a half configuration still reaches the extension, which logs the warning naming the
missing value; with neither set nothing is registered. The host adds the client secret to the process environment
hook's `SecretValues` (`2026-09-26-process-environment-hook-design.md`), and the default deny-list already removes
`GOOGLE_CLIENT_SECRET` from `bash`, scripts and MCP servers.
- `ChatRegistry` and `BotSetupService` take the options as an optional constructor parameter (DI passes the default,
null, when the singleton isn't registered). New host code (section, options, menu, help lines, registration) lives
in `hosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs`, so the shared host files get one-line edits.
- `ChatRegistry.CreateAsync` adds the extension to `AgentSessionOptions.ExtraExtensions` for **every** chat when the
options are configured. It uses a factory registration (`ExtensionRegistration`) so each session gets its own
options are present. It uses a factory registration (`ExtensionRegistration`) so each session gets its own
instance over the shared `GoogleCalendarConnections`; the scope `telegram-<chatId>` picks the chat's account.
- `BotSetupService` adds `calendar_connect`, `calendar_status` and `calendar_disconnect` to the global command menu when
the calendar is configured. Every chat can use them.
- `/help` mentions the three commands when the extension is active.
- `BotSetupService` adds `calendar_connect`, `calendar_status` and `calendar_disconnect` to the global command menu only
when **both** values are set. Every chat can use them.
- `/help` mentions the three commands when the extension is active, detected from the session's registered commands
(`calendar_connect` present).
- Nothing else changes. The flow uses `Notify` only; the user's replies are never needed, so the pending-input
routing is untouched.
@@ -311,14 +348,16 @@ add to this test project) so polling and expiry run instantly.
store written and their own `Notify`; `/calendar_connect` twice in A reuses A's code while B gets a fresh one;
`/calendar_disconnect` in A leaves B's token and cache alone. Resolved zone: the calendar's stored zone, else
`ctx.TimeZone`; the two-zone header when they differ.
- **Telegram:** with `ChatHost`, every allowed chat lists the `calendar` tool.
`/calendar_connect` in a chat, with a fake handler that approves on the second poll, ends with the
"connected" message in that chat.
- **Telegram** (`tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs`): with `ChatHost`, every
allowed chat lists the `calendar` tool, and none does without the options. `/calendar_connect` in a chat, with a
fake handler that approves on the second poll, ends with the "connected" message in that chat. Options come from
the section, then the environment, empty counting as unset; the client secret lands in `SecretValues`; the menu and
`/help` list the commands only when the extension is active.
## Docs
New `docs/google-calendar.md` (Google Cloud setup: enable the Calendar API, configure the OAuth consent screen and
**publish the app** without verification, with a screenshot of the "unverified app" warning users click through,
**publish the app** without verification, with a text description of the "unverified app" warning users click through,
create an OAuth client of type *TVs and Limited Input devices*, set the env vars, the three commands, where the token
lives; testing status as an alternative, with its test-user list and 7-day refresh-token expiry). Update `docs/telegram-bot.md`
(configuration, one account per chat), the extensions list in `CLAUDE.md` and `docs/architecture.md`.
@@ -334,5 +373,6 @@ lives; testing status as an alternative, with its test-user list and 7-day refre
## Open questions
1. Should `calendar.events` + `calendar.calendarlist.readonly` replace the full `calendar` scope (least privilege), if
the device flow allows them?
1. ~~Should `calendar.events` + `calendar.calendarlist.readonly` replace the full `calendar` scope (least privilege), if
the device flow allows them?~~ Resolved: kept the full `calendar` scope; revisit if Google lists narrower scopes for
the device flow.

View File

@@ -22,7 +22,8 @@ Open the bot in Telegram and say hello.
## Configuration
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment):
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment; the Google
settings use the `GoogleCalendar` section):
| Setting | Default | Notes |
|---|---|---|
@@ -33,6 +34,8 @@ Environment variables, or the `Telegram` section of `appsettings.json` (`Telegra
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
| `GOOGLE_CLIENT_ID` / `GoogleCalendar:ClientId` | — | Optional. With the secret, every chat gets the `calendar` tool and the `/calendar_*` commands; see [google-calendar.md](google-calendar.md) |
| `GOOGLE_CLIENT_SECRET` / `GoogleCalendar:ClientSecret` | — | Optional; redacted from tool output and hidden from `bash` |
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through to `bash`, scheduled scripts and MCP servers despite the deny-list (e.g. `GH_TOKEN` for `gh`). Added to `ProcessEnvironment:Allow` |
| `ProcessEnvironment:Deny` / `ProcessEnvironment:Allow` | built-in deny-list | Added to the defaults (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, their `Section__Key` forms and the named provider credentials), never replacing them. Allowing a name that is denied by name stops startup |
| `ProcessEnvironment:HardenHostProcess` | `true` | Linux: the bot process is made non-dumpable. `ProcessEnvironment__HardenHostProcess=false` for a debugging session only |
@@ -85,11 +88,16 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and
| `/compact` | Summarise the conversation to free context |
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
| `/help` | Short help |
| `/calendar_connect` | Connect this chat's Google Calendar (link and code; the bot confirms when you approve) |
| `/calendar_status` | The connected account and time zone, or the pending code |
| `/calendar_disconnect` | Revoke and delete this chat's calendar token, or cancel a pending connect |
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` and
`/claude-code-login` work. `/mcp` lists the MCP servers (see [mcp.md](mcp.md#mcp)).
When an extension asks something, the question appears with buttons (or as a reply prompt for free text).
The `/calendar_*` commands exist only when both the Google client id and secret are set (`GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` or the `GoogleCalendar` section).
If the Claude subscription session behind the bot expires, the **first id in `AllowedUserIds`** (the bot owner)
gets the re-login link and code prompt in their own private chat, regardless of which chat's request triggered
it. That user must already have started a chat with the bot — Telegram bots cannot message a user first — or
@@ -100,6 +108,13 @@ never counts as the code. Don't ask the bot something else in the owner chat unt
question in that chat (for example from an extension) replaces the pending prompt and cancels the login. See
[claude-code-provider.md](claude-code-provider.md#session-expiry-and-re-login).
### Google Calendar
Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that
chat's account, so nobody sees another chat's calendar through the agent. The bot polls Google by itself after
sending the link and code and confirms in the chat once access is approved. Setup of the Google Cloud app, token
storage and time zones are in [google-calendar.md](google-calendar.md).
## Security
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids

View File

@@ -0,0 +1,91 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Replies through the UI, or as a custom message without a turn when there is none.</summary>
internal static class CommandReply
{
public static void Send(IExtensionApi api, IExtensionContext ctx, string customType, string text, NotifyKind kind = NotifyKind.Info)
{
if (ctx.HasUi) ctx.Ui.Notify(text, kind);
else api.SendMessage(CustomMessage.FromText(customType, text), MessageDelivery.Steer, triggerTurn: false);
}
}
/// <summary><c>/calendar_connect</c>: runs this chat's device flow to the end (the command already runs off the update loop).</summary>
internal static class ConnectCommand
{
public const string Name = "calendar_connect";
public const string Description = "Connect this chat's Google Calendar (link + code)";
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
{
if (!ctx.HasUi)
{
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 flow = await ConnectFlow.StartOrResendAsync(connection, ctx.Ui, ct).ConfigureAwait(false);
if (flow is not null) await flow.RunAsync(ctx.Ui, ctx.Logger).ConfigureAwait(false);
});
}
/// <summary><c>/calendar_status</c>.</summary>
internal static class StatusCommand
{
public const string Name = "calendar_status";
public const string Description = "Show this chat's Google Calendar connection";
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, (_, ctx, _) =>
{
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
CommandReply.Send(api, ctx, Name, Describe(connection));
return Task.CompletedTask;
});
internal static string Describe(GoogleCalendarConnection connection)
{
if (connection.Pending is { } flow)
{
var d = flow.Device;
return $"Waiting for approval: open {d.VerificationUrl} and enter {d.UserCode} ({ConnectFlow.MinutesLeft(d, connection.Settings.Time)} min left).";
}
if (connection.Store.Read() is not { } token) return "Not connected. Run /calendar_connect.";
var since = token.ConnectedAt?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) ?? "an unknown date";
return $"Connected as {token.Account ?? "an unknown account"} since {since}, time zone {token.TimeZone ?? "unknown"}.";
}
}
/// <summary><c>/calendar_disconnect</c>: cancel a pending flow, revoke (best effort), delete the file, drop the cache.</summary>
internal static class DisconnectCommand
{
public const string Name = "calendar_disconnect";
public const string Description = "Disconnect this chat's Google Calendar";
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
{
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
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);
try
{
connection.CancelPending();
var token = connection.Store.Read();
revokeError = token is null ? null : await connection.OAuth.RevokeAsync(token.RefreshToken, ct).ConfigureAwait(false);
connection.Store.Delete();
connection.Invalidate();
}
finally
{
connection.FlowGate.Release();
}
var text = "Calendar disconnected.";
if (revokeError is not null)
text += $" Revoking at Google failed: {revokeError}; remove the app at {GoogleCalendarMessages.PermissionsUrl}.";
CommandReply.Send(api, ctx, Name, text);
});
}

View File

@@ -0,0 +1,147 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal enum CalendarTimeKind { Date, Local, Offset }
/// <summary>A parsed tool time. <see cref="Raw"/> is the RFC 3339 text sent for an <see cref="CalendarTimeKind.Offset"/> value.</summary>
internal readonly record struct CalendarTimeValue(CalendarTimeKind Kind, string Raw, DateOnly Date, DateTime Local, DateTimeOffset Instant);
/// <summary>
/// The tool's time rules: <c>YYYY-MM-DD</c> is all-day (end exclusive), a date-time with an offset is sent as-is, one
/// without is wall time in the event zone (sent without an offset plus <c>timeZone</c>). Query bounds are RFC 3339.
/// </summary>
internal static class CalendarTime
{
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private static readonly string[] LocalFormats =
["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"];
private const string OffsetFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
private const string LocalFormat = "yyyy-MM-dd'T'HH:mm:ss";
private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'";
public static CalendarTimeValue Parse(string text, string field)
{
var s = (text ?? "").Trim();
if (DateOnly.TryParseExact(s, "yyyy-MM-dd", Inv, DateTimeStyles.None, out var date))
return new(CalendarTimeKind.Date, s, date, default, default);
if (HasOffset(s))
{
if (DateTimeOffset.TryParse(s, Inv, DateTimeStyles.None, out var instant))
return new(CalendarTimeKind.Offset, Rfc3339(instant, IsUtcDesignator(s)), default, default, instant);
}
else if (DateTime.TryParseExact(s, LocalFormats, Inv, DateTimeStyles.None, out var local))
{
return new(CalendarTimeKind.Local, s, default, DateTime.SpecifyKind(local, DateTimeKind.Unspecified), default);
}
throw new ArgumentException(
$"{field} must be YYYY-MM-DD, a local date-time like 2026-09-30T10:00, or one with an offset like 2026-09-30T10:00:00+02:00 (got \"{text}\").");
}
public static JsonObject ToEventTime(CalendarTimeValue value, string zoneId) => value.Kind switch
{
CalendarTimeKind.Date => new JsonObject { ["date"] = value.Date.ToString("yyyy-MM-dd", Inv) },
CalendarTimeKind.Local => new JsonObject { ["dateTime"] = value.Local.ToString(LocalFormat, Inv), ["timeZone"] = zoneId },
_ => new JsonObject { ["dateTime"] = value.Raw, ["timeZone"] = zoneId },
};
/// <summary>Start and end for a new event; a missing end is one day (all-day) or one hour (timed) after start.</summary>
public static (JsonObject Start, JsonObject End) ForCreate(string start, string? end, string zoneId)
{
var s = Parse(start, "start");
var e = end is null ? DefaultEnd(s) : Parse(end, "end");
EnsureSameKind(s, e);
return (ToEventTime(s, zoneId), ToEventTime(e, zoneId));
}
/// <summary>Only the sides that were given; both given must be the same kind.</summary>
public static (JsonObject? Start, JsonObject? End) ForUpdate(string? start, string? end, string zoneId)
{
CalendarTimeValue? s = start is null ? null : Parse(start, "start");
CalendarTimeValue? e = end is null ? null : Parse(end, "end");
if (s is { } sv && e is { } ev) EnsureSameKind(sv, ev);
return (s is { } a ? ToEventTime(a, zoneId) : null, e is { } b ? ToEventTime(b, zoneId) : null);
}
/// <summary>
/// A query bound (<c>timeMin</c>/<c>timeMax</c>) as RFC 3339 with an offset; dates and local times are in
/// <paramref name="zone"/>. A date is its midnight, or with <paramref name="dateIsInclusiveEnd"/> (<c>timeMax</c>)
/// the next day's midnight, so <c>timeMin=timeMax=2026-09-30</c> covers the whole 30th.
/// </summary>
public static string ToRfc3339(string text, TimeZoneInfo zone, string field, bool dateIsInclusiveEnd = false)
{
var value = Parse(text, field);
return value.Kind switch
{
CalendarTimeKind.Date => InZone((dateIsInclusiveEnd ? value.Date.AddDays(1) : value.Date).ToDateTime(TimeOnly.MinValue), zone),
CalendarTimeKind.Local => InZone(value.Local, zone),
_ => value.Instant.ToString(OffsetFormat, Inv),
};
}
public static string Utc(DateTimeOffset instant) => instant.ToUniversalTime().ToString(UtcFormat, Inv);
/// <summary>An event's start/end as <c>Wed 30 Sep 10:00–11:00</c> in <paramref name="zone"/>, or an all-day range.</summary>
public static string FormatRange(JsonElement start, JsonElement end, TimeZoneInfo zone)
{
if (start.TryGetProperty("date", out var startDate))
{
var s = DateOnly.ParseExact(startDate.GetString()!, "yyyy-MM-dd", Inv);
var last = end.TryGetProperty("date", out var endDate)
? DateOnly.ParseExact(endDate.GetString()!, "yyyy-MM-dd", Inv).AddDays(-1)
: s;
return last <= s ? $"{Day(s)} (all day)" : $"{Day(s)} – {Day(last)} (all day)";
}
var from = TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(start.GetProperty("dateTime").GetString()!, Inv), zone);
var to = end.TryGetProperty("dateTime", out var endTime)
? TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(endTime.GetString()!, Inv), zone)
: from;
return from.Date == to.Date
? $"{from.ToString("ddd d MMM HH:mm", Inv)}–{to.ToString("HH:mm", Inv)}"
: $"{from.ToString("ddd d MMM HH:mm", Inv)} – {to.ToString("ddd d MMM HH:mm", Inv)}";
}
/// <summary>The header line of every tool result; names both zones when the calendar's differs from the chat's.</summary>
public static string Header(TimeZoneInfo resolved, TimeZoneInfo chat)
{
var r = ZoneIds.Name(resolved);
var c = ZoneIds.Name(chat);
return r == c ? $"Times in {r}." : $"Times in {r} (the calendar's zone; this chat is in {c}).";
}
private static string Day(DateOnly d) => d.ToString("ddd d MMM", Inv);
private static string InZone(DateTime local, TimeZoneInfo zone) =>
new DateTimeOffset(local, zone.GetUtcOffset(local)).ToString(OffsetFormat, Inv);
private static string Rfc3339(DateTimeOffset instant, bool utc) =>
utc && instant.Offset == TimeSpan.Zero ? instant.ToString(UtcFormat, Inv) : instant.ToString(OffsetFormat, Inv);
private static CalendarTimeValue DefaultEnd(CalendarTimeValue start) => start.Kind switch
{
CalendarTimeKind.Date => start with { Date = start.Date.AddDays(1) },
CalendarTimeKind.Local => start with { Local = start.Local.AddHours(1) },
_ => start with
{
Instant = start.Instant.AddHours(1),
Raw = Rfc3339(start.Instant.AddHours(1), start.Raw.EndsWith('Z')),
},
};
private static void EnsureSameKind(CalendarTimeValue start, CalendarTimeValue end)
{
if ((start.Kind == CalendarTimeKind.Date) != (end.Kind == CalendarTimeKind.Date))
throw new ArgumentException("start and end must both be dates (YYYY-MM-DD) or both date-times.");
}
private static bool IsUtcDesignator(string s) => s.EndsWith('Z') || s.EndsWith('z');
private static bool HasOffset(string s)
{
if (IsUtcDesignator(s)) return true;
var t = s.IndexOfAny(['T', ' ']);
return t > 0 && s.AsSpan(t).IndexOfAny('+', '-') >= 0;
}
}

View File

@@ -0,0 +1,322 @@
using System.ComponentModel;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
public sealed record CalendarArgs(
[property: Description("list, search, get, list_instances, create, update, delete or calendars")] string Action,
[property: Description("Calendar id; default primary. action=calendars lists the others")] string? CalendarId = null,
[property: Description("Window start (list, search, list_instances): RFC 3339, a local date-time or a date")] string? TimeMin = null,
[property: Description("Window end, same forms as timeMin; a date includes that whole day")] string? TimeMax = null,
[property: Description("Free-text search (search only, required there)")] string? Query = null,
[property: Description("Maximum events to return; default 25, at most 250")] int? MaxResults = null,
[property: Description("list only: occurrences (default; every occurrence of recurring events) or series (recurring events once, with their RRULE)")] string? ListMode = null,
[property: Description("Event id (get, list_instances, update, delete)")] string? EventId = null,
[property: Description("update/delete of a recurring event: instance (one occurrence) or series; detected from the event when omitted")] string? Scope = null,
[property: Description("Title (create, update)")] string? Summary = null,
[property: Description("Notes (create, update)")] string? Description = null,
[property: Description("Location (create, update)")] string? Location = null,
[property: Description("YYYY-MM-DD for all day, or a date-time; without an offset it is wall time in timeZone")] string? Start = null,
[property: Description("Like start. All-day ends are exclusive. Default on create: one day / one hour after start")] string? End = null,
[property: Description("IANA zone for this event, e.g. Europe/Berlin; default the calendar's zone")] string? TimeZone = null,
[property: Description("RFC 5545 lines, e.g. [\"RRULE:FREQ=WEEKLY;BYDAY=MO,WE\"]; [] on update turns a series into a single event")] IReadOnlyList<string>? Recurrence = null);
public sealed record CalendarEventDetails(string Id, string? Summary, JsonElement? Start, JsonElement? End, string? RecurringEventId,
IReadOnlyList<string>? Recurrence, string? HtmlLink);
public sealed record CalendarResultDetails(string Action, IReadOnlyList<CalendarEventDetails> Events);
/// <summary>
/// The <c>calendar</c> tool. Always registered while the OAuth client is configured; without a token for this chat it
/// fails with the "/calendar_connect" hint. Uses only the chat's own connection (scope from the context).
/// </summary>
internal sealed partial class CalendarTool(GoogleCalendarSettings settings, GoogleCalendarConnections connections, Func<string?> currentZone) : Tool<CalendarArgs>
{
public const string ToolName = "calendar";
private const int DefaultMax = 25;
private const int MaxCap = 250;
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
public override string Name => ToolName;
public override string Description =>
"Read and change the Google Calendar this chat connected with /calendar_connect. Actions: list, search, get, " +
"list_instances, create, update, delete, calendars. " + Rules;
public override string? PromptSnippet => "calendar: read and change this chat's Google Calendar";
public override IReadOnlyList<string> PromptGuidelines => ["calendar: " + Rules];
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Parallel;
private string Rules =>
$"Times without an offset are in {currentZone() ?? "the zone named in each result's header"}; all-day end dates are exclusive " +
"(a one-day event on 2026-09-30 ends 2026-10-01); to change one date of a recurring event, get that occurrence's id with list_instances first.";
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 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.")
: resolved;
var run = new Run(args, new GoogleCalendarApi(connection), resolved, eventZone, CalendarTime.Header(resolved, ctx.TimeZone),
string.IsNullOrWhiteSpace(args.CalendarId) ? "primary" : args.CalendarId.Trim(), settings.Time.GetUtcNow(), cancellationToken);
var action = (args.Action ?? "").Trim().ToLowerInvariant();
return action switch
{
"list" => await ListAsync(run).ConfigureAwait(false),
"search" => await SearchAsync(run).ConfigureAwait(false),
"get" => await GetAsync(run).ConfigureAwait(false),
"list_instances" => await ListInstancesAsync(run).ConfigureAwait(false),
"calendars" => await CalendarsAsync(run).ConfigureAwait(false),
"create" or "update" or "delete" => await WriteAsync(action, run).ConfigureAwait(false),
_ => throw new ArgumentException($"Unknown action \"{args.Action}\". Use one of: list, search, get, list_instances, create, update, delete, calendars."),
};
}
/// <summary>Everything one call needs, resolved once.</summary>
private sealed record Run(CalendarArgs Args, GoogleCalendarApi Api, TimeZoneInfo Zone, TimeZoneInfo EventZone, string Header,
string CalendarId, DateTimeOffset Now, CancellationToken Ct)
{
public string EventZoneName => ZoneIds.Name(EventZone);
public int Max => Math.Clamp(Args.MaxResults ?? DefaultMax, 1, MaxCap);
public string Bound(string? given, DateTimeOffset fallback, string field) =>
given is { Length: > 0 } ? CalendarTime.ToRfc3339(given, EventZone, field, dateIsInclusiveEnd: field == "timeMax") : CalendarTime.Utc(fallback);
}
private async Task<ToolResult> ListAsync(Run run)
{
var mode = (run.Args.ListMode ?? "occurrences").Trim().ToLowerInvariant();
if (mode is not ("occurrences" or "series")) throw new ArgumentException("listMode must be occurrences or series.");
var query = new List<KeyValuePair<string, string>>
{
new("timeMin", run.Bound(run.Args.TimeMin, run.Now, "timeMin")),
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(7), "timeMax")),
new("singleEvents", mode == "occurrences" ? "true" : "false"),
};
if (mode == "occurrences") query.Add(new("orderBy", "startTime"));
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("list", run.CalendarId)).ConfigureAwait(false);
return Events("list", run, items);
}
private async Task<ToolResult> SearchAsync(Run run)
{
if (string.IsNullOrWhiteSpace(run.Args.Query)) throw new ArgumentException("search needs query.");
var query = new List<KeyValuePair<string, string>>
{
new("q", run.Args.Query.Trim()),
new("timeMin", run.Bound(run.Args.TimeMin, run.Now.AddDays(-30), "timeMin")),
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(365), "timeMax")),
new("singleEvents", "true"),
new("orderBy", "startTime"),
};
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("search", run.CalendarId)).ConfigureAwait(false);
return Events("search", run, items);
}
private async Task<ToolResult> GetAsync(Run run)
{
var id = Require(run.Args.EventId, "get needs eventId.");
var ev = await run.Api.SendAsync(HttpMethod.Get, GoogleCalendarApi.Event(run.CalendarId, id), null, new CalendarCall("get", run.CalendarId, id), run.Ct).ConfigureAwait(false);
var text = run.Header + "\n" + Line(ev, run.Zone);
if (Str(ev, "description") is { Length: > 0 } description) text += "\nDescription: " + description;
return Result("get", text, [ev]);
}
private async Task<ToolResult> ListInstancesAsync(Run run)
{
var id = Require(run.Args.EventId, "list_instances needs eventId.");
var query = new List<KeyValuePair<string, string>>();
if (run.Args.TimeMin is { Length: > 0 } min) query.Add(new("timeMin", CalendarTime.ToRfc3339(min, run.EventZone, "timeMin")));
if (run.Args.TimeMax is { Length: > 0 } max) query.Add(new("timeMax", CalendarTime.ToRfc3339(max, run.EventZone, "timeMax", dateIsInclusiveEnd: true)));
var items = await PageAsync(run, GoogleCalendarApi.Event(run.CalendarId, id) + "/instances", query, new CalendarCall("list_instances", run.CalendarId, id)).ConfigureAwait(false);
return Events("list_instances", run, items);
}
private static async Task<ToolResult> CalendarsAsync(Run run)
{
var items = await PageAsync(run, "users/me/calendarList", [], new CalendarCall("calendars", run.CalendarId)).ConfigureAwait(false);
var sb = new StringBuilder(run.Header);
foreach (var c in items)
{
sb.Append('\n').Append(Str(c, "id")).Append(" · ").Append(Str(c, "summary") ?? "(no name)")
.Append(" · ").Append(Str(c, "accessRole")).Append(" · ").Append(Str(c, "timeZone"));
if (c.TryGetProperty("primary", out var p) && p.ValueKind == JsonValueKind.True) sb.Append(" · primary");
}
return ToolResult.Text(sb.ToString(), new CalendarResultDetails("calendars", []));
}
/// <summary>Follows <c>nextPageToken</c> until <see cref="Run.Max"/> items are collected.</summary>
private static async Task<List<JsonElement>> PageAsync(Run run, string path, List<KeyValuePair<string, string>> query, CalendarCall call)
{
var items = new List<JsonElement>();
string? pageToken = null;
do
{
var pageQuery = new List<KeyValuePair<string, string>>(query) { new("maxResults", (run.Max - items.Count).ToString(Inv)) };
if (pageToken is not null) pageQuery.Add(new("pageToken", pageToken));
var page = await run.Api.SendAsync(HttpMethod.Get, path + "?" + GoogleCalendarApi.Query(pageQuery), null, call, run.Ct).ConfigureAwait(false);
if (page.TryGetProperty("items", out var list) && list.ValueKind == JsonValueKind.Array)
items.AddRange(list.EnumerateArray().Where(e => Str(e, "status") != "cancelled"));
pageToken = Str(page, "nextPageToken");
}
while (pageToken is not null && items.Count < run.Max);
return items.Take(run.Max).ToList();
}
private static ToolResult Events(string action, Run run, List<JsonElement> items)
{
var text = items.Count == 0 ? run.Header + "\nNo events." : run.Header + "\n" + string.Join("\n", items.Select(e => Line(e, run.Zone)));
return Result(action, text, items);
}
private static ToolResult Result(string action, string text, IEnumerable<JsonElement> events) =>
ToolResult.Text(text, new CalendarResultDetails(action, events.Select(Details).ToList()));
/// <summary><c>&lt;id&gt; · &lt;summary&gt; · &lt;range&gt;[ @ location][ · recurring (series=…)][ · RRULE=…]</c></summary>
internal static string Line(JsonElement ev, TimeZoneInfo zone)
{
var sb = new StringBuilder();
sb.Append(Str(ev, "id")).Append(" · ").Append(Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)");
if (ev.TryGetProperty("start", out var start))
sb.Append(" · ").Append(CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, zone));
if (Str(ev, "location") is { Length: > 0 } location) sb.Append(" @ ").Append(location);
if (Str(ev, "recurringEventId") is { Length: > 0 } series) sb.Append(" · recurring (series=").Append(series).Append(')');
if (Recurrence(ev)?.FirstOrDefault(r => r.StartsWith("RRULE:", StringComparison.Ordinal)) is { } rrule) sb.Append(" · RRULE=").Append(rrule[6..]);
return sb.ToString();
}
private static CalendarEventDetails Details(JsonElement ev) => new(
Str(ev, "id") ?? "", Str(ev, "summary"),
ev.TryGetProperty("start", out var s) ? s.Clone() : null, ev.TryGetProperty("end", out var e) ? e.Clone() : null,
Str(ev, "recurringEventId"), Recurrence(ev), Str(ev, "htmlLink"));
private static IReadOnlyList<string>? Recurrence(JsonElement ev) =>
ev.TryGetProperty("recurrence", out var r) && r.ValueKind == JsonValueKind.Array ? r.EnumerateArray().Select(x => x.GetString() ?? "").ToList() : null;
private static string? Str(JsonElement e, string name) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static string Require(string? value, string message) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException(message) : value.Trim();
internal enum EventKind { Single, Instance, Series }
private static readonly string[] RecurrencePrefixes = ["RRULE:", "EXDATE:", "RDATE:", "EXRULE:"];
private static Task<ToolResult> WriteAsync(string action, Run run) => action switch
{
"create" => CreateAsync(run),
"update" => UpdateAsync(run),
_ => DeleteAsync(run),
};
private static async Task<ToolResult> CreateAsync(Run run)
{
var a = run.Args;
var summary = Require(a.Summary, "create needs summary.");
var startText = Require(a.Start, "create needs start.");
if (a.Recurrence is not null) ValidateRecurrence(a.Recurrence);
var (start, end) = CalendarTime.ForCreate(startText, a.End, run.EventZoneName);
var body = new JsonObject { ["summary"] = summary, ["start"] = start, ["end"] = end };
if (a.Description is not null) body["description"] = a.Description;
if (a.Location is not null) body["location"] = a.Location;
if (a.Recurrence is { Count: > 0 }) body["recurrence"] = RecurrenceJson(a.Recurrence);
var created = await run.Api.SendAsync(HttpMethod.Post, GoogleCalendarApi.Events(run.CalendarId), body, new CalendarCall("create", run.CalendarId), run.Ct).ConfigureAwait(false);
return Result("create", $"{run.Header}\nCreated: {Line(created, run.Zone)}", [created]);
}
private static async Task<ToolResult> UpdateAsync(Run run)
{
var a = run.Args;
var id = Require(a.EventId, "update needs eventId.");
var call = new CalendarCall("update", run.CalendarId, id);
var path = GoogleCalendarApi.Event(run.CalendarId, id);
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
var kind = ResolveScope(ev, a.Scope, id);
var patch = new JsonObject();
if (a.Summary is not null) patch["summary"] = a.Summary;
if (a.Description is not null) patch["description"] = a.Description;
if (a.Location is not null) patch["location"] = a.Location;
var (start, end) = CalendarTime.ForUpdate(a.Start, a.End, run.EventZoneName);
if (start is not null) patch["start"] = start;
if (end is not null) patch["end"] = end;
if (a.Recurrence is not null)
{
if (kind == EventKind.Instance)
throw new ArgumentException($"recurrence can't be set on a single occurrence; update the series (eventId={Str(ev, "recurringEventId")}) instead.");
ValidateRecurrence(a.Recurrence);
patch["recurrence"] = RecurrenceJson(a.Recurrence);
}
if (patch.Count == 0)
throw new ArgumentException("update needs at least one field to change (summary, description, location, start, end or recurrence).");
var updated = await run.Api.SendAsync(HttpMethod.Patch, path, patch, call, run.Ct).ConfigureAwait(false);
var what = kind switch
{
EventKind.Instance => "Updated this occurrence only",
EventKind.Series => "Updated the whole series",
_ => "Updated",
};
return Result("update", $"{run.Header}\n{what}: {Line(updated, run.Zone)}", [updated]);
}
private static async Task<ToolResult> DeleteAsync(Run run)
{
var id = Require(run.Args.EventId, "delete needs eventId.");
var call = new CalendarCall("delete", run.CalendarId, id);
var path = GoogleCalendarApi.Event(run.CalendarId, id);
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
var kind = ResolveScope(ev, run.Args.Scope, id);
await run.Api.SendAsync(HttpMethod.Delete, path, null, call, run.Ct).ConfigureAwait(false);
var summary = Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)";
var range = ev.TryGetProperty("start", out var start) ? CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, run.Zone) : "";
var text = kind switch
{
EventKind.Instance => $"Cancelled one occurrence of \"{summary}\" ({range}); the rest of the series is unchanged.",
EventKind.Series => $"Deleted the recurring series \"{summary}\" and all its occurrences.",
_ => $"Deleted \"{summary}\" ({range}).",
};
return Result("delete", $"{run.Header}\n{text}", [ev]);
}
/// <summary><c>recurringEventId</c> → instance; <c>recurrence</c> → series; neither → single (explicit scope ignored).</summary>
internal static EventKind ResolveScope(JsonElement ev, string? requested, string eventId)
{
var scope = requested?.Trim().ToLowerInvariant();
if (scope is not (null or "" or "instance" or "series")) throw new ArgumentException("scope must be instance or series.");
if (Str(ev, "recurringEventId") is { Length: > 0 } series)
{
if (scope == "series")
throw new ArgumentException($"Event {eventId} is one occurrence of the recurring series {series}. To change the whole series, pass eventId={series} with scope=series.");
return EventKind.Instance;
}
if (Recurrence(ev) is { Count: > 0 })
{
if (scope == "instance")
throw new ArgumentException($"Event {eventId} is a recurring series. To change one date, call list_instances with eventId={eventId}, then pass that occurrence's id with scope=instance.");
return EventKind.Series;
}
return EventKind.Single;
}
/// <summary>Every line must be an RFC 5545 RRULE, EXDATE, RDATE or EXRULE property.</summary>
internal static void ValidateRecurrence(IReadOnlyList<string> lines)
{
foreach (var line in lines)
{
var trimmed = (line ?? "").Trim();
if (!RecurrencePrefixes.Any(p => trimmed.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
throw new ArgumentException($"Recurrence line \"{line}\" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.");
}
}
private static JsonArray RecurrenceJson(IReadOnlyList<string> lines) => new(lines.Select(r => (JsonNode)r.Trim()).ToArray());
}

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}\z")] // \z, not $: '$' also matches before a trailing newline
private static partial Regex Safe();
}

View File

@@ -0,0 +1,177 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// One chat's device flow. It polls on its own cancellation source, not the command's token: <c>/stop</c> leaves it
/// alone, <c>/calendar_disconnect</c> cancels it silently, and it ends by itself at the code's expiry.
/// </summary>
internal sealed class ConnectFlow
{
private const int MaxTransportErrors = 3;
private static readonly TimeSpan SlowDownStep = TimeSpan.FromSeconds(5); // RFC 8628 §3.5
private readonly CancellationTokenSource _cancellation = new();
private readonly GoogleCalendarConnection _connection;
private int _parks;
private ConnectFlow(GoogleCalendarConnection connection, DeviceAuthorization device)
{
_connection = connection;
Device = device;
Interval = device.Interval;
}
public DeviceAuthorization Device { get; }
public TimeSpan Interval { get; private set; }
/// <summary>False once the flow was cancelled or its code expired: such a flow is never re-sent or shown.</summary>
public bool IsLive(TimeProvider time) => !_cancellation.IsCancellationRequested && time.GetUtcNow() < Device.ExpiresAt;
/// <summary>How many poll delays the flow has entered (tests drive the fake clock with it).</summary>
internal int Parks => Volatile.Read(ref _parks);
/// <summary>When the current delay ends.</summary>
internal DateTimeOffset NextPollAt { get; private set; }
public void Cancel()
{
try { _cancellation.Cancel(); } catch (ObjectDisposedException) { }
}
public static int MinutesLeft(DeviceAuthorization device, TimeProvider time) =>
Math.Max(1, (int)Math.Ceiling((device.ExpiresAt - time.GetUtcNow()).TotalMinutes));
/// <summary>Re-sends a pending flow's link and code, or starts a new flow and sends its link. Null when nothing more to do.</summary>
public static async Task<ConnectFlow?> StartOrResendAsync(GoogleCalendarConnection connection, IUserInterface ui, CancellationToken cancellationToken)
{
var time = connection.Settings.Time;
await connection.FlowGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (connection.Pending is { } pending)
{
var d = pending.Device;
ui.Notify($"A Google Calendar connection is already waiting for approval:\n1. Open {d.VerificationUrl}\n2. Enter the code {d.UserCode}\n({MinutesLeft(d, time)} min left.)");
return null;
}
DeviceAuthorization device;
try
{
device = await connection.OAuth.StartDeviceFlowAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException || ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
return null;
}
var flow = new ConnectFlow(connection, device);
connection.Begin(flow);
ui.Notify($"Connect Google Calendar:\n1. Open {device.VerificationUrl}\n2. Enter the code {device.UserCode}\n3. Approve access. I'll confirm here when it's done (code valid for {MinutesLeft(device, time)} min).");
return flow;
}
finally
{
connection.FlowGate.Release();
}
}
public async Task RunAsync(IUserInterface ui, ILogger logger)
{
var time = _connection.Settings.Time;
var ct = _cancellation.Token;
var transportErrors = 0;
try
{
while (true)
{
NextPollAt = time.GetUtcNow() + Interval;
Interlocked.Increment(ref _parks);
await Task.Delay(Interval, time, ct).ConfigureAwait(false);
if (time.GetUtcNow() >= Device.ExpiresAt)
{
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
return;
}
PollResult result;
try
{
result = await _connection.OAuth.PollAsync(Device.DeviceCode, ct).ConfigureAwait(false);
transportErrors = 0;
}
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException && !ct.IsCancellationRequested)
{
if (++transportErrors >= MaxTransportErrors)
{
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
return;
}
continue;
}
switch (result.Status)
{
case PollStatus.Pending:
continue;
case PollStatus.SlowDown:
Interval += SlowDownStep;
continue;
case PollStatus.Denied:
ui.Notify("Google Calendar access was denied.", NotifyKind.Warning);
return;
case PollStatus.Expired:
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
return;
case PollStatus.Failed:
ui.Notify($"Google Calendar connection failed: {result.Message}", NotifyKind.Error);
return;
default:
await CompleteAsync(result, ui, logger, ct).ConfigureAwait(false);
return;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// cancelled by /calendar_disconnect or shutdown: silent
}
finally
{
_connection.End(this);
_cancellation.Dispose(); // a late Cancel() swallows ObjectDisposedException
}
}
private async Task CompleteAsync(PollResult result, IUserInterface ui, ILogger logger, CancellationToken ct)
{
var primary = new PrimaryCalendar(null, null);
try
{
primary = await GoogleCalendarApi.GetPrimaryAsync(_connection.Settings.Http, result.AccessToken!, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException or System.Text.Json.JsonException
|| ex is TaskCanceledException && !ct.IsCancellationRequested)
{
logger.LogWarning("google-calendar: looking up the primary calendar failed: {Error}", ex.Message);
}
// Under the gate /calendar_disconnect also takes: once it has cancelled the flow, the token is never written.
await _connection.FlowGate.WaitAsync(ct).ConfigureAwait(false);
try
{
ct.ThrowIfCancellationRequested();
_connection.Store.Write(new StoredToken(result.RefreshToken!, result.Scope, primary.Account, primary.TimeZone, _connection.Settings.Time.GetUtcNow()));
_connection.Invalidate();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
ui.Notify($"Got a Google token but could not save it: {ex.Message}", NotifyKind.Error);
return;
}
finally
{
_connection.FlowGate.Release();
}
ui.Notify($"✅ Google Calendar connected as {primary.Account ?? "an unknown account"} (time zone {primary.TimeZone ?? "unknown"}).");
}
}

View File

@@ -0,0 +1,80 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal readonly record struct CalendarCall(string Action, string CalendarId, string? EventId = null);
internal sealed record PrimaryCalendar(string? Account, string? TimeZone);
/// <summary>Calendar v3 over raw HTTP for one chat: bearer token from the connection, one refresh-and-retry on 401.</summary>
internal sealed class GoogleCalendarApi(GoogleCalendarConnection connection)
{
public const string BaseUrl = "https://www.googleapis.com/calendar/v3/";
public static string Events(string calendarId) => $"calendars/{Uri.EscapeDataString(calendarId)}/events";
public static string Event(string calendarId, string eventId) => $"{Events(calendarId)}/{Uri.EscapeDataString(eventId)}";
public static string Query(IEnumerable<KeyValuePair<string, string>> pairs) =>
string.Join("&", pairs.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
public async Task<JsonElement> SendAsync(HttpMethod method, string pathAndQuery, JsonObject? body, CalendarCall call, CancellationToken cancellationToken)
{
var http = connection.Settings.Http;
var response = await SendOnceAsync(http, method, pathAndQuery, body,
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
connection.Invalidate();
response = await SendOnceAsync(http, method, pathAndQuery, body,
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
}
using (response)
{
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode) return string.IsNullOrWhiteSpace(text) ? VolcanicJson.EmptyObject : VolcanicJson.Parse(text);
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone && call.EventId is not null)
throw new GoogleCalendarException($"Event {call.EventId} not found in {call.CalendarId}.");
throw new GoogleCalendarException($"Google Calendar {call.Action} failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
}
}
/// <summary>The account (primary calendar id = email) and zone, with a token that isn't stored yet.</summary>
public static async Task<PrimaryCalendar> GetPrimaryAsync(HttpClient http, string accessToken, CancellationToken cancellationToken)
{
using var response = await SendOnceAsync(http, HttpMethod.Get, "calendars/primary", null, accessToken, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
throw new GoogleCalendarException($"calendars/primary failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
var json = VolcanicJson.Parse(text);
return new PrimaryCalendar(
json.TryGetProperty("id", out var id) ? id.GetString() : null,
json.TryGetProperty("timeZone", out var zone) ? zone.GetString() : null);
}
private static async Task<HttpResponseMessage> SendOnceAsync(HttpClient http, HttpMethod method, string pathAndQuery, JsonObject? body, string accessToken, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, BaseUrl + pathAndQuery);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
if (body is not null) request.Content = new StringContent(body.ToJsonString(VolcanicJson.Options), Encoding.UTF8, "application/json");
return await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
private static string? ErrorMessage(string text)
{
try
{
return JsonNode.Parse(text) is JsonObject o && o["error"] is JsonObject e && e["message"] is JsonValue m && m.TryGetValue<string>(out var s) ? s : null;
}
catch (JsonException)
{
return null;
}
}
}

View File

@@ -0,0 +1,127 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Explicit process-wide state (like <c>ClaudeLoginCoordinator.Shared</c>): extensions are per session, but a chat's
/// Google account, access-token cache and pending connect flow outlive its sessions. One
/// <see cref="GoogleCalendarConnection"/> per chat token directory, created on first use.
/// </summary>
public sealed class GoogleCalendarConnections
{
/// <summary>The process-wide instance the extension uses unless its options pass another.</summary>
public static GoogleCalendarConnections Shared { get; } = new();
private readonly ConcurrentDictionary<string, GoogleCalendarConnection> _connections = new(StringComparer.Ordinal);
/// <summary>
/// The connection for a session scope, keyed by its token directory (<c>&lt;root&gt;/&lt;chat key&gt;</c>).
/// The first caller's settings create it.
/// </summary>
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope)
{
ArgumentNullException.ThrowIfNull(settings);
var directory = Path.GetFullPath(Path.Combine(settings.TokenRoot, ChatKeys.For(scope)));
return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings, new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName))));
}
}
/// <summary>One chat's Google account: token file, access-token cache (single-flight refresh), pending connect flow.</summary>
internal sealed class GoogleCalendarConnection
{
private static readonly TimeSpan RefreshMargin = TimeSpan.FromSeconds(60);
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private CachedToken? _cached;
private ConnectFlow? _pending;
private int _unknownZoneLogged;
public GoogleCalendarConnection(GoogleCalendarSettings settings, GoogleTokenStore store)
{
Settings = settings;
Store = store;
OAuth = new GoogleOAuthClient(settings.Http, settings.ClientId, settings.ClientSecret, settings.Time);
}
public GoogleCalendarSettings Settings { get; }
public GoogleTokenStore Store { get; }
public GoogleOAuthClient OAuth { get; }
/// <summary>
/// Serialises "is a flow pending? else start one" so two /calendar_connect in one chat can't both start, and the
/// flow's final token write with /calendar_disconnect so a disconnected chat can't end up connected.
/// </summary>
public SemaphoreSlim FlowGate { get; } = new(1, 1);
/// <summary>This chat's in-flight device flow, if any; a cancelled or expired one is cancelled, cleared and not returned.</summary>
public ConnectFlow? Pending
{
get
{
var flow = Volatile.Read(ref _pending);
if (flow is null || flow.IsLive(Settings.Time)) return flow;
flow.Cancel(); // an expired flow still parked in its delay ends silently instead of reporting later
End(flow);
return null;
}
}
/// <summary>Registers <paramref name="flow"/> as pending. Callers hold <see cref="FlowGate"/>.</summary>
public void Begin(ConnectFlow flow) => Volatile.Write(ref _pending, flow);
/// <summary>Clears the pending flow if it is still <paramref name="flow"/>.</summary>
public void End(ConnectFlow flow) => Interlocked.CompareExchange(ref _pending, null, flow);
/// <summary>Cancels the pending flow silently (used by /calendar_disconnect).</summary>
public void CancelPending() => Volatile.Read(ref _pending)?.Cancel();
/// <summary>
/// A valid access token: the cached one while it has more than 60 s left and belongs to the stored refresh
/// token, else one refresh shared by all concurrent callers.
/// </summary>
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
var stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored) is { } token) return token;
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored) is { } again) return again;
var access = await OAuth.RefreshAsync(stored.RefreshToken, cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _cached, new CachedToken(stored.RefreshToken, access.Token, access.ExpiresAt));
return access.Token;
}
finally
{
_refreshLock.Release();
}
}
/// <summary>Drops the cached access token (after a 401, a reconnect or a disconnect).</summary>
public void Invalidate() => Volatile.Write(ref _cached, null);
/// <summary>The calendar's own zone (stored at connect), else the chat's zone.</summary>
public TimeZoneInfo ResolveTimeZone(TimeZoneInfo chatZone, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(chatZone);
var id = Store.Read()?.TimeZone;
if (string.IsNullOrWhiteSpace(id)) return chatZone;
if (ZoneIds.TryFind(id, out var zone)) return zone;
// Claim the log-once flag only when there is a logger: a logger-less call (e.g. the tool description) must not use it up.
if (logger is not null && Interlocked.Exchange(ref _unknownZoneLogged, 1) == 0)
logger.LogWarning("google-calendar: the calendar's time zone {Zone} is unknown on this machine; using the chat's zone {ChatZone}", id, ZoneIds.Name(chatZone));
return chatZone;
}
private string? Fresh(StoredToken stored) =>
Volatile.Read(ref _cached) is { } cached && cached.RefreshToken == stored.RefreshToken
&& cached.ExpiresAt - Settings.Time.GetUtcNow() > RefreshMargin
? cached.AccessToken
: null;
private sealed record CachedToken(string RefreshToken, string AccessToken, DateTimeOffset ExpiresAt)
{
public override string ToString() => $"CachedToken {{ ExpiresAt = {ExpiresAt:O} }}";
}
}

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,67 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Google Calendar for every chat: the <c>calendar</c> tool and <c>/calendar_connect</c>, <c>/calendar_status</c>,
/// <c>/calendar_disconnect</c>. Each chat (session scope) connects its own account. Inactive, with one log line,
/// until <c>GOOGLE_CLIENT_ID</c> and <c>GOOGLE_CLIENT_SECRET</c> are set. Instantiated per session.
/// </summary>
public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = null) : IExtension
{
public const string ExtensionName = "google-calendar";
private readonly GoogleCalendarOptions _options = options ?? new GoogleCalendarOptions();
private GoogleCalendarSettings? _settings;
private ILogger? _logger;
private volatile string? _scope;
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);
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
var id = _options.EffectiveClientId;
var secret = _options.EffectiveClientSecret;
if (id is null || secret is null)
{
if (id is null && secret is null)
api.Logger.LogInformation("google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET");
else
api.Logger.LogWarning("google-calendar disabled: {Missing} is not set (both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are needed)",
id is null ? "GOOGLE_CLIENT_ID" : "GOOGLE_CLIENT_SECRET");
return ValueTask.CompletedTask;
}
_settings = _options.Resolve()!;
_logger = api.Logger;
api.RegisterTool(new CalendarTool(_settings, Connections, CurrentZoneName));
api.RegisterCommand(ConnectCommand.Name, ConnectCommand.Create(api, this));
api.RegisterCommand(StatusCommand.Name, StatusCommand.Create(api, this));
api.RegisterCommand(DisconnectCommand.Name, DisconnectCommand.Create(api, this));
api.On<SessionStartEvent>((_, ctx, _) => Track(ctx));
api.On<BeforeAgentStartEvent>((_, ctx, _) => Track(ctx));
return ValueTask.CompletedTask;
}
/// <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))
: null;
private ValueTask Track(IExtensionContext ctx)
{
_chatZone = ctx.TimeZone;
_scope = ctx.SessionManager.Scope;
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,58 @@
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"));
/// <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 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,144 @@
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal sealed record DeviceAuthorization(string DeviceCode, string UserCode, string VerificationUrl, DateTimeOffset ExpiresAt, TimeSpan Interval);
internal enum PollStatus { Success, Pending, SlowDown, Denied, Expired, Failed }
/// <summary>One poll of the token endpoint. <see cref="ToString"/> never shows tokens.</summary>
internal sealed record PollResult(PollStatus Status, string? RefreshToken = null, string? Scope = null, string? AccessToken = null,
DateTimeOffset AccessTokenExpiresAt = default, string? Message = null)
{
public static readonly PollResult Pending = new(PollStatus.Pending);
public static readonly PollResult SlowDown = new(PollStatus.SlowDown);
public static readonly PollResult Denied = new(PollStatus.Denied);
public static readonly PollResult Expired = new(PollStatus.Expired);
public static PollResult Succeeded(string refreshToken, string? scope, string accessToken, DateTimeOffset expiresAt) =>
new(PollStatus.Success, refreshToken, scope, accessToken, expiresAt);
public static PollResult Failed(string message) => new(PollStatus.Failed, Message: message);
public override string ToString() => Message is null ? Status.ToString() : $"{Status}: {Message}";
}
internal sealed record AccessToken(string Token, DateTimeOffset ExpiresAt)
{
public override string ToString() => $"AccessToken {{ ExpiresAt = {ExpiresAt:O} }}";
}
/// <summary>
/// Google's OAuth 2.0 device flow for limited-input devices, refresh and revoke, over raw HTTP. Error mapping reads
/// the JSON <c>error</c> field whatever the status code (Google documents 428 and 403 besides 400). Messages never
/// contain tokens or the client secret.
/// </summary>
internal sealed class GoogleOAuthClient(HttpClient http, string clientId, string clientSecret, TimeProvider time)
{
public const string DeviceCodeUrl = "https://oauth2.googleapis.com/device/code";
public const string TokenUrl = "https://oauth2.googleapis.com/token";
public const string RevokeUrl = "https://oauth2.googleapis.com/revoke";
public const string CalendarScope = "https://www.googleapis.com/auth/calendar";
public const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
public async Task<DeviceAuthorization> StartDeviceFlowAsync(CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(DeviceCodeUrl, [new("client_id", clientId), new("scope", CalendarScope)], cancellationToken).ConfigureAwait(false);
if (!IsSuccess(status) || json is null || Str(json, "device_code") is not { } deviceCode || Str(json, "user_code") is not { } userCode)
throw new GoogleCalendarException($"Google refused the device code request: {Describe(json, status)}");
var url = Str(json, "verification_url") ?? Str(json, "verification_uri") ?? "https://www.google.com/device";
var expiresIn = Int(json, "expires_in") ?? 1800;
var interval = Math.Max(1, Int(json, "interval") ?? 5);
return new DeviceAuthorization(deviceCode, userCode, url, time.GetUtcNow().AddSeconds(expiresIn), TimeSpan.FromSeconds(interval));
}
public async Task<PollResult> PollAsync(string deviceCode, CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(TokenUrl,
[new("client_id", clientId), new("client_secret", clientSecret), new("device_code", deviceCode), new("grant_type", DeviceGrantType)],
cancellationToken).ConfigureAwait(false);
if (json is not null && Str(json, "error") is { } error)
{
return error switch
{
"authorization_pending" => PollResult.Pending,
"slow_down" => PollResult.SlowDown,
"access_denied" => PollResult.Denied,
"expired_token" => PollResult.Expired,
_ => PollResult.Failed(Describe(json, status)),
};
}
if ((int)status >= 500) throw new HttpRequestException($"Google returned HTTP {(int)status}.");
if (!IsSuccess(status) || json is null) return PollResult.Failed(Describe(json, status));
if (Str(json, "refresh_token") is not { Length: > 0 } refreshToken) return PollResult.Failed(GoogleCalendarMessages.NoRefreshToken);
return PollResult.Succeeded(refreshToken, Str(json, "scope"), Str(json, "access_token") ?? "",
time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
}
public async Task<AccessToken> RefreshAsync(string refreshToken, CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(TokenUrl,
[new("client_id", clientId), new("client_secret", clientSecret), new("refresh_token", refreshToken), new("grant_type", "refresh_token")],
cancellationToken).ConfigureAwait(false);
if (json is not null && Str(json, "error") == "invalid_grant")
throw new GoogleCalendarException(GoogleCalendarMessages.TokenRejected, notConnected: true);
if (!IsSuccess(status) || json is null || Str(json, "access_token") is not { Length: > 0 } accessToken)
throw new GoogleCalendarException($"Google token refresh failed: {Describe(json, status)}");
return new AccessToken(accessToken, time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
}
/// <summary>Best effort. Null when Google revoked the token, else the reason.</summary>
public async Task<string?> RevokeAsync(string token, CancellationToken cancellationToken)
{
try
{
var (status, json) = await PostAsync(RevokeUrl, [new("token", token)], cancellationToken).ConfigureAwait(false);
return IsSuccess(status) ? null : Describe(json, status);
}
catch (HttpRequestException ex)
{
return ex.Message;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
return ex.Message; // HttpClient timeout
}
}
private async Task<(HttpStatusCode Status, JsonObject? Json)> PostAsync(string url, KeyValuePair<string, string>[] form, CancellationToken cancellationToken)
{
using var content = new FormUrlEncodedContent(form);
using var response = await http.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
JsonObject? json = null;
try { json = JsonNode.Parse(text) as JsonObject; }
catch (System.Text.Json.JsonException) { }
return (response.StatusCode, json);
}
private static bool IsSuccess(HttpStatusCode status) => (int)status is >= 200 and < 300;
private static string Describe(JsonObject? json, HttpStatusCode status)
{
var error = json is null ? null : Str(json, "error");
var description = json is null ? null : Str(json, "error_description");
return (error, description) switch
{
({ } e, { } d) => $"{e}: {d}",
({ } e, null) => e,
_ => $"HTTP {(int)status}",
};
}
private static string? Str(JsonObject json, string name) =>
json[name] is JsonValue value && value.TryGetValue<string>(out var s) ? s : null;
private static int? Int(JsonObject json, string name) => json[name] switch
{
JsonValue v when v.TryGetValue<int>(out var i) => i,
JsonValue v when v.TryGetValue<string>(out var s) && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) => p,
_ => null,
};
}

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>
internal 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,11 @@
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Google names zones by IANA id; Windows may hand out Windows ids.</summary>
internal static class ZoneIds
{
/// <summary>The IANA id (Google's zone names), converting a Windows id when needed.</summary>
public static string Name(TimeZoneInfo zone) =>
zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : zone.Id;
public static bool TryFind(string id, out TimeZoneInfo zone) => TimeZoneInfo.TryFindSystemTimeZoneById(id, out zone!);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
@@ -12,7 +13,8 @@ namespace Volcanic.AgenticCore.Telegram;
/// (<c>AllowedUserIds[0]</c>) the bot is back. That message is never retried: a 403 (the owner never started the bot)
/// is logged by <see cref="TelegramSender"/> and that's all.
/// </summary>
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, TelegramSender sender, ILogger<BotSetupService> logger) : BackgroundService
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, TelegramSender sender, ILogger<BotSetupService> logger,
GoogleCalendarOptions? calendar = null) : BackgroundService
{
public const string OnlineText = "🟢 Online.";
@@ -37,7 +39,7 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
try
{
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(ChatCommands.Menu, cancellationToken: stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar), cancellationToken: stoppingToken).ConfigureAwait(false);
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
return true;
}

View File

@@ -119,7 +119,7 @@ public sealed class ChatCommands(TelegramSender sender)
/sessions, /resume <id> – earlier conversations
/compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level
""";
""" + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
private static string Status(ChatContext chat)
{

View File

@@ -4,6 +4,7 @@ using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Inbound;
@@ -64,7 +65,8 @@ public sealed class ChatRegistry(
TelegramSender sender,
TelegramOptions options,
ILoggerFactory loggerFactory,
TimeProvider? time = null) : IAsyncDisposable
TimeProvider? time = null,
GoogleCalendarOptions? calendar = null) : IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
@@ -112,6 +114,7 @@ public sealed class ChatRegistry(
SessionId = last?.SessionId,
Model = options.Model,
Ui = ui,
ExtraExtensions = calendar is null ? null : [GoogleCalendarSetup.Registration(calendar)],
}).ConfigureAwait(false);
}
catch

View File

@@ -0,0 +1,57 @@
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Extensions;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>GoogleCalendar</c> configuration section. <c>GOOGLE_CLIENT_ID</c>/<c>GOOGLE_CLIENT_SECRET</c> fill empty values.</summary>
public sealed class GoogleCalendarSection
{
public const string SectionName = "GoogleCalendar";
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
}
/// <summary>Everything the Telegram host does for Google Calendar, kept out of the shared host files.</summary>
public static class GoogleCalendarSetup
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "calendar_connect", Description = "Connect your Google Calendar" },
new() { Command = "calendar_status", Description = "Google Calendar connection" },
new() { Command = "calendar_disconnect", Description = "Disconnect Google Calendar" },
];
/// <summary>Null when neither the client id nor the secret is set; empty values count as unset.</summary>
public static GoogleCalendarOptions? Options(GoogleCalendarSection section, Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(section);
ArgumentNullException.ThrowIfNull(getEnv);
var id = NonEmpty(section.ClientId) ?? NonEmpty(getEnv("GOOGLE_CLIENT_ID"));
var secret = NonEmpty(section.ClientSecret) ?? NonEmpty(getEnv("GOOGLE_CLIENT_SECRET"));
return id is null && secret is null ? null : new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = getEnv };
}
/// <summary>True when both the client id and the secret are set, i.e. the extension will register its tool and commands.</summary>
public static bool IsActive(GoogleCalendarOptions? options) => options?.IsConfigured == true;
/// <summary>The bot's command menu, with the calendar commands appended when the calendar is active.</summary>
public static IReadOnlyList<BotCommand> MenuFor(IReadOnlyList<BotCommand> menu, GoogleCalendarOptions? options) =>
IsActive(options) ? [.. menu, .. Menu] : menu;
/// <summary>Extra <c>/help</c> lines when the session has the calendar commands.</summary>
public static string HelpLines(IReadOnlyList<CommandInfo> sessionCommands)
{
ArgumentNullException.ThrowIfNull(sessionCommands);
return sessionCommands.Any(c => c.Name == "calendar_connect")
? "\n/calendar_connect – connect this chat's Google Calendar\n/calendar_status, /calendar_disconnect – check or remove the connection"
: "";
}
/// <summary>A new extension instance per session over the shared connections; the session scope picks the chat's account.</summary>
public static ExtensionRegistration Registration(GoogleCalendarOptions options) =>
new(GoogleCalendarExtension.ExtensionName, _ => new GoogleCalendarExtension(options), "telegram");
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -23,8 +23,11 @@ var builder = Host.CreateApplicationBuilder(args);
var options = builder.Configuration.GetSection(TelegramOptions.SectionName).Get<TelegramOptions>() ?? new TelegramOptions();
options.ApplyEnvironment(Environment.GetEnvironmentVariable);
var processSection = builder.Configuration.GetSection(ProcessEnvironmentSection.SectionName).Get<ProcessEnvironmentSection>() ?? new ProcessEnvironmentSection();
var calendar = GoogleCalendarSetup.Options(
builder.Configuration.GetSection(GoogleCalendarSection.SectionName).Get<GoogleCalendarSection>() ?? new GoogleCalendarSection(),
Environment.GetEnvironmentVariable);
var processEnvironment = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable);
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).ToList();
@@ -36,6 +39,7 @@ if (errors.Count > 0)
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(mcp);
if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
// OwnerLoginChannel lazily (at call time, not here) to avoid a construction cycle: ChatRegistry depends on the

View File

@@ -1,3 +1,4 @@
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Processes;
namespace Volcanic.AgenticCore.Telegram;
@@ -7,8 +8,9 @@ public static class TelegramProcessEnvironment
{
public const string AllowVariable = "PROCESS_ENV_ALLOW";
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c> and the bot token to <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv)
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token and the Google client secret to <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv,
GoogleCalendarOptions? calendar = null)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(telegram);
@@ -20,6 +22,7 @@ public static class TelegramProcessEnvironment
target.Allow.AddRange(allow.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
target.HardenHostProcess = section.HardenHostProcess;
if (!string.IsNullOrWhiteSpace(telegram.BotToken)) target.SecretValues.Add(telegram.BotToken);
if (!string.IsNullOrWhiteSpace(calendar?.ClientSecret)) target.SecretValues.Add(calendar.ClientSecret);
}
/// <summary>The startup log line: names only, never values.</summary>

View File

@@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />

View File

@@ -0,0 +1,134 @@
using System.Net;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarCommandsTests
{
private const string Status = "calendar_status";
private const string Disconnect = "calendar_disconnect";
[Fact]
public async Task Status_when_not_connected()
{
using var h = new ConnectHarness();
var ui = new CalendarRecordingUi();
await h.Run(Status, h.Context(ui));
Assert.Equal(("Not connected. Run /calendar_connect.", NotifyKind.Info), Assert.Single(ui.Notes));
}
[Fact]
public async Task Status_when_connected_shows_account_date_and_zone()
{
using var h = new ConnectHarness();
h.Connection().Store.Write(new StoredToken("rt-1", null, "me@example.com", "Europe/Berlin", ConnectHarness.Now));
var ui = new CalendarRecordingUi();
await h.Run(Status, h.Context(ui));
Assert.Equal("Connected as me@example.com since 2026-09-26, time zone Europe/Berlin.", Assert.Single(ui.Notes).Message);
}
[Fact]
public async Task Status_while_waiting_shows_the_link_code_and_minutes_left()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var connect = h.Run("calendar_connect", h.Context(new CalendarRecordingUi()));
await FlowDriver.ParkedAsync(h.Connection());
var ui = new CalendarRecordingUi();
await h.Run(Status, h.Context(ui));
Assert.Equal("Waiting for approval: open https://www.google.com/device and enter ABCD-EFGH (30 min left).", Assert.Single(ui.Notes).Message);
h.Connection().CancelPending();
await connect.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Disconnect_cancels_a_pending_flow_silently()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var connect = h.Run("calendar_connect", h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
await h.Run(Disconnect, h.Context(ui));
await connect.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, ui.Notes.Count); // the link, then the disconnect reply; no flow message
Assert.Equal("Calendar disconnected.", ui.Notes[1].Message);
Assert.Null(h.Connection().Pending);
Assert.Empty(h.Http.To("POST /revoke")); // nothing stored, nothing to revoke
}
[Fact]
public async Task Disconnect_while_the_flow_completes_leaves_the_chat_disconnected()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
var primaryEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var primaryRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
h.Http.On(ConnectHarness.PrimaryRoute, async _ =>
{
primaryEntered.TrySetResult();
await primaryRelease.Task; // ignores cancellation: the lookup returns after the disconnect
return GoogleFakeHttp.Response(HttpStatusCode.OK, """{"id":"me@example.com","timeZone":"Europe/Berlin"}""");
});
var ui = new CalendarRecordingUi();
var connect = h.Run("calendar_connect", h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Time.Advance(TimeSpan.FromSeconds(5));
await primaryEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
await h.Run(Disconnect, h.Context(ui));
primaryRelease.SetResult();
await connect.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(File.Exists(h.Connection().Store.Path));
Assert.Equal(["Calendar disconnected."], ui.Notes.Skip(1).Select(n => n.Message).ToArray()); // no "connected"
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Disconnect_revokes_deletes_and_invalidates()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, """{"access_token":"at-1","expires_in":3600}""");
h.Http.Json("POST /revoke", "{}");
var connection = h.Connection();
connection.Store.Write(new StoredToken("rt-1"));
await connection.GetAccessTokenAsync(CancellationToken.None); // cache primed
var ui = new CalendarRecordingUi();
await h.Run(Disconnect, h.Context(ui));
Assert.Equal("rt-1", Assert.Single(h.Http.To("POST /revoke")).Form["token"]);
Assert.False(File.Exists(connection.Store.Path));
Assert.Equal("Calendar disconnected.", Assert.Single(ui.Notes).Message);
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => connection.GetAccessTokenAsync(CancellationToken.None));
Assert.True(ex.NotConnected);
}
[Fact]
public async Task Disconnect_with_a_revoke_failure_still_deletes_and_links_the_permissions_page()
{
using var h = new ConnectHarness();
h.Http.Json("POST /revoke", (HttpStatusCode.BadRequest, """{"error":"invalid_token"}"""));
h.Connection().Store.Write(new StoredToken("rt-1"));
var ui = new CalendarRecordingUi();
await h.Run(Disconnect, h.Context(ui));
Assert.False(File.Exists(h.Connection().Store.Path));
Assert.Equal("Calendar disconnected. Revoking at Google failed: invalid_token; remove the app at https://myaccount.google.com/permissions.",
Assert.Single(ui.Notes).Message);
}
[Fact]
public async Task Without_a_ui_status_is_a_custom_message_without_a_turn()
{
using var h = new ConnectHarness();
await h.Run(Status, h.Context(ui: null));
var (message, _, triggerTurn) = Assert.Single(h.Api.Sent);
Assert.Equal("calendar_status", message.CustomType);
Assert.Equal("Not connected. Run /calendar_connect.", message.Text);
Assert.False(triggerTurn);
}
}

View File

@@ -0,0 +1,134 @@
using System.Text.Json;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarTimeTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
private static JsonElement El(string json) => JsonDocument.Parse(json).RootElement.Clone();
[Fact]
public void A_date_is_an_all_day_value()
{
var value = CalendarTime.Parse("2026-09-30", "start");
Assert.Equal(CalendarTimeKind.Date, value.Kind);
Assert.Equal("""{"date":"2026-09-30"}""", CalendarTime.ToEventTime(value, "Europe/Berlin").ToJsonString());
}
[Theory]
[InlineData("2026-09-30T10:00:00+02:00")]
[InlineData("2026-09-30T08:00:00Z")]
public void An_offset_date_time_is_sent_as_is_with_the_zone(string text)
{
var json = CalendarTime.ToEventTime(CalendarTime.Parse(text, "start"), "Europe/Berlin");
Assert.Equal(text, json["dateTime"]!.GetValue<string>());
Assert.Equal("Europe/Berlin", json["timeZone"]!.GetValue<string>());
}
[Fact]
public void An_offset_date_time_without_seconds_is_sent_as_full_rfc3339()
{
var json = CalendarTime.ToEventTime(CalendarTime.Parse("2026-09-30T10:00+02:00", "start"), "Europe/Berlin");
Assert.Equal("2026-09-30T10:00:00+02:00", json["dateTime"]!.GetValue<string>());
}
[Theory]
[InlineData("2026-09-30T10:00")]
[InlineData("2026-09-30T10:00:00")]
[InlineData("2026-09-30 10:00")]
public void A_local_date_time_is_sent_without_offset_with_the_zone(string text)
{
var json = CalendarTime.ToEventTime(CalendarTime.Parse(text, "start"), "Europe/Berlin");
Assert.Equal("""{"dateTime":"2026-09-30T10:00:00","timeZone":"Europe/Berlin"}""", json.ToJsonString());
}
[Fact]
public void Create_all_day_defaults_end_to_the_next_day()
{
var (start, end) = CalendarTime.ForCreate("2026-09-30", null, "Europe/Berlin");
Assert.Equal("""{"date":"2026-09-30"}""", start.ToJsonString());
Assert.Equal("""{"date":"2026-10-01"}""", end.ToJsonString());
}
[Fact]
public void Create_timed_defaults_end_to_one_hour_later()
{
var (_, local) = CalendarTime.ForCreate("2026-09-30T10:00", null, "Europe/Berlin");
Assert.Equal("2026-09-30T11:00:00", local["dateTime"]!.GetValue<string>());
var (_, offset) = CalendarTime.ForCreate("2026-09-30T10:00:00+02:00", null, "Europe/Berlin");
Assert.Equal("2026-09-30T11:00:00+02:00", offset["dateTime"]!.GetValue<string>());
}
[Theory]
[InlineData("2026-09-30", "2026-09-30T11:00")]
[InlineData("2026-09-30T10:00", "2026-10-01")]
public void Mixed_date_and_date_time_is_rejected(string start, string end)
{
var ex = Assert.Throws<ArgumentException>(() => CalendarTime.ForCreate(start, end, "Europe/Berlin"));
Assert.Equal("start and end must both be dates (YYYY-MM-DD) or both date-times.", ex.Message);
Assert.Throws<ArgumentException>(() => CalendarTime.ForUpdate(start, end, "Europe/Berlin"));
}
[Fact]
public void Update_sends_only_the_given_side()
{
var (start, end) = CalendarTime.ForUpdate("2026-09-30T09:00", null, "Europe/Berlin");
Assert.NotNull(start);
Assert.Null(end);
}
[Fact]
public void An_unparsable_time_is_rejected_with_the_field_name()
{
var ex = Assert.Throws<ArgumentException>(() => CalendarTime.Parse("next tuesday", "timeMin"));
Assert.StartsWith("timeMin must be YYYY-MM-DD", ex.Message);
Assert.Contains("next tuesday", ex.Message);
}
[Theory]
[InlineData("2026-09-30", "2026-09-30T00:00:00+02:00")]
[InlineData("2026-12-01T10:00", "2026-12-01T10:00:00+01:00")]
[InlineData("2026-09-30T10:00:00-04:00", "2026-09-30T10:00:00-04:00")]
[InlineData("2026-09-30T08:00:00Z", "2026-09-30T08:00:00+00:00")]
public void TimeMin_is_rfc3339_with_an_offset_in_the_zone(string text, string expected) =>
Assert.Equal(expected, CalendarTime.ToRfc3339(text, Berlin, "timeMin"));
[Theory]
[InlineData("2026-09-30", "2026-10-01T00:00:00+02:00")] // a date is inclusive: its whole day is in the window
[InlineData("2026-10-25", "2026-10-26T00:00:00+01:00")] // the next midnight's offset: Berlin leaves DST on the 25th
[InlineData("2026-09-30T18:00", "2026-09-30T18:00:00+02:00")]
[InlineData("2026-09-30T18:00:00Z", "2026-09-30T18:00:00+00:00")]
public void TimeMax_as_a_date_ends_at_the_next_midnight_in_the_zone(string text, string expected) =>
Assert.Equal(expected, CalendarTime.ToRfc3339(text, Berlin, "timeMax", dateIsInclusiveEnd: true));
[Fact]
public void Utc_formats_with_Z() =>
Assert.Equal("2026-09-26T08:00:00Z", CalendarTime.Utc(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.FromHours(2))));
[Fact]
public void Formats_a_timed_range_in_the_zone()
{
var start = El("""{"dateTime":"2026-09-30T10:00:00+02:00","timeZone":"Europe/Berlin"}""");
var end = El("""{"dateTime":"2026-09-30T11:00:00+02:00"}""");
Assert.Equal("Wed 30 Sep 10:00–11:00", CalendarTime.FormatRange(start, end, Berlin));
Assert.Equal("Wed 30 Sep 04:00–05:00", CalendarTime.FormatRange(start, end, NewYork));
Assert.Equal("Wed 30 Sep 23:00 – Thu 1 Oct 01:00",
CalendarTime.FormatRange(El("""{"dateTime":"2026-09-30T23:00:00+02:00"}"""), El("""{"dateTime":"2026-10-01T01:00:00+02:00"}"""), Berlin));
}
[Fact]
public void Formats_an_all_day_range_with_an_exclusive_end()
{
Assert.Equal("Wed 30 Sep (all day)", CalendarTime.FormatRange(El("""{"date":"2026-09-30"}"""), El("""{"date":"2026-10-01"}"""), Berlin));
Assert.Equal("Wed 30 Sep – Fri 2 Oct (all day)", CalendarTime.FormatRange(El("""{"date":"2026-09-30"}"""), El("""{"date":"2026-10-03"}"""), Berlin));
}
[Fact]
public void Header_names_one_zone_or_both()
{
Assert.Equal("Times in Europe/Berlin.", CalendarTime.Header(Berlin, Berlin));
Assert.Equal("Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).", CalendarTime.Header(NewYork, Berlin));
}
}

View File

@@ -0,0 +1,277 @@
using System.Net;
using System.Text.Json;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarToolReadTests
{
private static readonly string Weekly = Ev.Timed("ev-1_20260930T080000Z", "Standup", "2026-09-30T10:00:00+02:00", "2026-09-30T10:15:00+02:00", recurringEventId: "ev-1");
private static readonly string Single = Ev.Timed("ev-2", "Dentist", "2026-10-01T14:00:00+02:00", "2026-10-01T15:00:00+02:00", location: "Main St 1");
[Fact]
public async Task List_defaults_to_the_next_seven_days_as_single_events_ordered_by_start()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Single));
var result = await h.RunAsync(new { action = "list" });
var request = Assert.Single(h.CalendarRequests);
Assert.Equal("Bearer at-1", request.Authorization);
Assert.Equal("2026-09-26T08:00:00Z", request.Query["timeMin"]);
Assert.Equal("2026-10-03T08:00:00Z", request.Query["timeMax"]);
Assert.Equal("true", request.Query["singleEvents"]);
Assert.Equal("startTime", request.Query["orderBy"]);
Assert.Equal("25", request.Query["maxResults"]);
Assert.Equal("Times in Europe/Berlin.\nev-2 · Dentist · Thu 1 Oct 14:00–15:00 @ Main St 1", result.TextContent);
}
[Fact]
public async Task List_marks_recurring_occurrences_with_their_series()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
Assert.Contains("ev-1_20260930T080000Z · Standup · Wed 30 Sep 10:00–10:15 · recurring (series=ev-1)", result.TextContent);
}
[Fact]
public async Task List_series_mode_requests_masters_and_shows_the_rrule()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Ev.Timed("ev-1", "Standup", "2026-09-28T10:00:00+02:00", "2026-09-28T10:15:00+02:00",
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"])));
var result = await h.RunAsync(new { action = "list", listMode = "series" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("false", query["singleEvents"]);
Assert.False(query.ContainsKey("orderBy"));
Assert.Contains("ev-1 · Standup · Mon 28 Sep 10:00–10:15 · RRULE=FREQ=WEEKLY;BYDAY=MO,WE", result.TextContent);
}
[Fact]
public async Task List_rejects_an_unknown_list_mode()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "list", listMode = "everything" }));
Assert.Equal("listMode must be occurrences or series.", ex.Message);
}
[Fact]
public async Task TimeMin_given_as_a_date_is_sent_as_rfc3339_in_the_zone()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
await h.RunAsync(new { action = "list", timeMin = "2026-09-30", timeMax = "2026-10-01T18:00" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("2026-09-30T00:00:00+02:00", query["timeMin"]);
Assert.Equal("2026-10-01T18:00:00+02:00", query["timeMax"]);
}
[Fact]
public async Task Max_results_is_capped_at_250()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
await h.RunAsync(new { action = "list", maxResults = 1000 });
Assert.Equal("250", Assert.Single(h.CalendarRequests).Query["maxResults"]);
}
[Fact]
public async Task Search_requires_query()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "search" }));
Assert.Equal("search needs query.", ex.Message);
Assert.Empty(h.CalendarRequests);
}
[Fact]
public async Task Search_sends_q_and_the_default_window()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Single));
await h.RunAsync(new { action = "search", query = "dentist" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("dentist", query["q"]);
Assert.Equal("2026-08-27T08:00:00Z", query["timeMin"]);
Assert.Equal("2027-09-26T08:00:00Z", query["timeMax"]);
}
[Fact]
public async Task Get_renders_one_event_with_its_description()
{
using var h = new CalendarToolHarness();
var withDescription = Single.TrimEnd('}') + ",\"description\":\"Bring the card\"}";
h.Http.Json("GET /calendar/v3/calendars/primary/events/ev-2", withDescription);
var result = await h.RunAsync(new { action = "get", eventId = "ev-2" });
Assert.Equal("Times in Europe/Berlin.\nev-2 · Dentist · Thu 1 Oct 14:00–15:00 @ Main St 1\nDescription: Bring the card", result.TextContent);
}
[Fact]
public async Task List_instances_follows_next_page_tokens_up_to_max_results()
{
using var h = new CalendarToolHarness();
string Occ(int day) => Ev.Timed($"ev-1_202610{day:00}", "Standup", $"2026-10-{day:00}T10:00:00+02:00", $"2026-10-{day:00}T10:15:00+02:00", recurringEventId: "ev-1");
h.Http.Json("GET /calendar/v3/calendars/primary/events/ev-1/instances",
(HttpStatusCode.OK, Ev.Page("p2", Occ(5), Occ(7), Occ(12))),
(HttpStatusCode.OK, Ev.Page("p3", Occ(14), Occ(19))));
var result = await h.RunAsync(new { action = "list_instances", eventId = "ev-1", maxResults = 4 });
var requests = h.CalendarRequests;
Assert.Equal(2, requests.Count);
Assert.False(requests[0].Query.ContainsKey("pageToken"));
Assert.Equal("p2", requests[1].Query["pageToken"]);
Assert.Equal(5, result.TextContent.Split('\n').Length); // header + 4 occurrences
Assert.DoesNotContain("ev-1_20261019", result.TextContent);
}
[Fact]
public async Task Calendars_lists_entries_with_primary_marked()
{
using var h = new CalendarToolHarness();
h.Http.Json("GET /calendar/v3/users/me/calendarList", """
{"items":[{"id":"me@example.com","summary":"Me","accessRole":"owner","timeZone":"Europe/Berlin","primary":true},
{"id":"family@group.calendar.google.com","summary":"Family","accessRole":"writer","timeZone":"Europe/Berlin"}]}
""");
var result = await h.RunAsync(new { action = "calendars" });
Assert.Contains("me@example.com · Me · owner · Europe/Berlin · primary", result.TextContent);
Assert.Contains("family@group.calendar.google.com · Family · writer · Europe/Berlin", result.TextContent);
}
[Fact]
public async Task The_header_names_both_zones_when_the_calendar_and_the_chat_differ()
{
using var h = new CalendarToolHarness(calendarZone: "America/New_York");
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
Assert.StartsWith("Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).\n", result.TextContent);
Assert.Contains("Wed 30 Sep 04:00–04:15", result.TextContent);
}
[Fact]
public async Task Without_a_stored_zone_the_chat_zone_is_used()
{
using var h = new CalendarToolHarness(calendarZone: null) { ChatZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") };
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
Assert.StartsWith("Times in Asia/Tokyo.\n", result.TextContent);
Assert.Contains("Wed 30 Sep 17:00–17:15", result.TextContent);
}
[Fact]
public async Task Details_carry_the_raw_events()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
var details = result.Details!.Value;
Assert.Equal("list", details.GetProperty("action").GetString());
var ev = Assert.Single(details.GetProperty("events").EnumerateArray().ToList());
Assert.Equal("ev-1", ev.GetProperty("recurringEventId").GetString());
Assert.Equal("2026-09-30T10:00:00+02:00", ev.GetProperty("start").GetProperty("dateTime").GetString());
Assert.StartsWith("https://calendar.google.com/", ev.GetProperty("htmlLink").GetString());
foreach (var secret in new[] { "rt-1", "at-1" }) // the refresh and access tokens never reach the model
{
Assert.DoesNotContain(secret, result.TextContent);
Assert.DoesNotContain(secret, details.GetRawText());
}
}
[Fact]
public async Task TimeMax_given_as_the_same_date_as_timeMin_covers_that_whole_day()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
await h.RunAsync(new { action = "list", timeMin = "2026-09-30", timeMax = "2026-09-30" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("2026-09-30T00:00:00+02:00", query["timeMin"]);
Assert.Equal("2026-10-01T00:00:00+02:00", query["timeMax"]);
}
[Fact]
public async Task Without_a_connection_the_tool_tells_the_model_to_ask_for_calendar_connect()
{
using var h = new CalendarToolHarness(connected: false);
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.True(ex.NotConnected);
Assert.Contains("/calendar_connect in this chat", ex.Message);
Assert.Empty(h.Http.Requests);
}
[Fact]
public async Task Invalid_grant_tells_the_model_to_ask_for_a_reconnect()
{
using var h = new CalendarToolHarness();
h.Http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.Equal("Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.", ex.Message);
}
[Fact]
public async Task A_401_from_the_events_endpoint_refreshes_once_and_retries()
{
using var h = new CalendarToolHarness();
h.Http.Json("POST /token", (HttpStatusCode.OK, """{"access_token":"at-1","expires_in":3600}"""), (HttpStatusCode.OK, """{"access_token":"at-2","expires_in":3600}"""));
h.Http.Json(CalendarToolHarness.EventsRoute,
(HttpStatusCode.Unauthorized, """{"error":{"code":401,"message":"Invalid Credentials"}}"""),
(HttpStatusCode.OK, Ev.List(Single)));
var result = await h.RunAsync(new { action = "list" });
Assert.Equal(2, h.Http.To("POST /token").Count);
Assert.Equal("Bearer at-2", h.CalendarRequests[1].Authorization);
Assert.Contains("Dentist", result.TextContent);
}
[Fact]
public async Task A_second_401_is_reported_not_retried_again()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, (HttpStatusCode.Unauthorized, """{"error":{"code":401,"message":"Invalid Credentials"}}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.Equal("Google Calendar list failed: 401 Invalid Credentials", ex.Message);
Assert.Equal(2, h.CalendarRequests.Count);
}
[Theory]
[InlineData(HttpStatusCode.NotFound)]
[InlineData(HttpStatusCode.Gone)]
public async Task A_missing_event_names_the_event_and_calendar(HttpStatusCode status)
{
using var h = new CalendarToolHarness();
h.Http.Json("GET /calendar/v3/calendars/primary/events/nope", (status, """{"error":{"code":404,"message":"Not Found"}}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "get", eventId = "nope" }));
Assert.Equal("Event nope not found in primary.", ex.Message);
}
[Fact]
public async Task Other_errors_carry_the_status_and_googles_message()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, (HttpStatusCode.Forbidden, """{"error":{"code":403,"message":"Rate Limit Exceeded"}}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.Equal("Google Calendar list failed: 403 Rate Limit Exceeded", ex.Message);
Assert.Single(h.CalendarRequests); // no retry
}
[Fact]
public async Task An_unknown_action_is_rejected()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "purge" }));
Assert.StartsWith("Unknown action \"purge\".", ex.Message);
}
[Fact]
public void Description_and_guideline_state_the_time_rules()
{
using var h = new CalendarToolHarness();
var tool = new CalendarTool(h.Settings, h.Connections, () => "Europe/Berlin");
Assert.Contains("without an offset are in Europe/Berlin", tool.Description);
var guideline = Assert.Single(tool.PromptGuidelines);
Assert.Contains("Europe/Berlin", guideline);
Assert.Contains("exclusive", guideline);
Assert.Contains("list_instances", guideline);
Assert.Equal(Abstractions.Tools.ToolExecutionMode.Parallel, tool.ExecutionMode);
Assert.Equal(JsonValueKind.Object, tool.Parameters.ValueKind);
Assert.Equal("action", tool.Parameters.GetProperty("required")[0].GetString());
}
}

View File

@@ -0,0 +1,226 @@
using System.Net;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarToolWriteTests
{
private const string EventsPost = "POST /calendar/v3/calendars/primary/events";
private static string EventRoute(string method, string id) => $"{method} /calendar/v3/calendars/primary/events/{id}";
private static readonly string Master = Ev.Timed("ev-1", "Standup", "2026-09-28T10:00:00+02:00", "2026-09-28T10:15:00+02:00", recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"]);
private static readonly string Occurrence = Ev.Timed("ev-1_20260928", "Standup", "2026-09-28T10:00:00+02:00", "2026-09-28T10:15:00+02:00", recurringEventId: "ev-1");
private static readonly string Plain = Ev.Timed("ev-2", "Dentist", "2026-10-01T14:00:00+02:00", "2026-10-01T15:00:00+02:00");
private static CalendarToolHarness WithEvent(string id, string json)
{
var h = new CalendarToolHarness();
h.Http.Json(EventRoute("GET", id), json);
h.Http.Json(EventRoute("PATCH", id), json);
h.Http.Json(EventRoute("DELETE", id), (HttpStatusCode.NoContent, ""));
return h;
}
private static JsonObject PostBody(CalendarToolHarness h) => Assert.Single(h.Http.To(EventsPost)).Json;
private static JsonObject PatchBody(CalendarToolHarness h, string id) => Assert.Single(h.Http.To(EventRoute("PATCH", id))).Json;
[Fact]
public async Task Create_sends_recurrence_in_the_body()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Master);
var result = await h.RunAsync(new { action = "create", summary = "Standup", start = "2026-09-28T10:00", end = "2026-09-28T10:15", recurrence = new[] { "RRULE:FREQ=WEEKLY;BYDAY=MO,WE" } });
var body = PostBody(h);
Assert.Equal("""["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]""", body["recurrence"]!.ToJsonString());
Assert.Equal("Standup", body["summary"]!.GetValue<string>());
Assert.StartsWith("Times in Europe/Berlin.\nCreated: ev-1 · Standup", result.TextContent);
}
[Fact]
public async Task Create_rejects_recurrence_lines_with_an_unknown_prefix()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary = "x", start = "2026-09-28", recurrence = new[] { "FREQ=WEEKLY" } }));
Assert.Equal("Recurrence line \"FREQ=WEEKLY\" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.", ex.Message);
Assert.Empty(h.CalendarRequests);
}
[Fact]
public async Task Create_sends_an_offset_less_date_time_without_offset_and_with_the_zone()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Plain);
await h.RunAsync(new { action = "create", summary = "Dentist", start = "2026-10-01T14:00" });
var body = PostBody(h);
Assert.Equal("""{"dateTime":"2026-10-01T14:00:00","timeZone":"Europe/Berlin"}""", body["start"]!.ToJsonString());
Assert.Equal("""{"dateTime":"2026-10-01T15:00:00","timeZone":"Europe/Berlin"}""", body["end"]!.ToJsonString());
}
[Fact]
public async Task Create_uses_the_time_zone_argument_for_the_event()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Plain);
await h.RunAsync(new { action = "create", summary = "Call", start = "2026-10-01T09:00", timeZone = "America/New_York" });
Assert.Equal("America/New_York", PostBody(h)["start"]!["timeZone"]!.GetValue<string>());
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary = "Call", start = "2026-10-01T09:00", timeZone = "Mars/Base" }));
Assert.StartsWith("Unknown time zone \"Mars/Base\"", ex.Message);
}
[Fact]
public async Task Create_all_day_defaults_end_to_the_next_day()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Ev.AllDay("ev-3", "Holiday", "2026-10-03", "2026-10-04"));
await h.RunAsync(new { action = "create", summary = "Holiday", start = "2026-10-03" });
var body = PostBody(h);
Assert.Equal("""{"date":"2026-10-03"}""", body["start"]!.ToJsonString());
Assert.Equal("""{"date":"2026-10-04"}""", body["end"]!.ToJsonString());
}
[Fact]
public async Task Create_rejects_mixed_date_and_date_time()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary = "x", start = "2026-10-03", end = "2026-10-03T12:00" }));
Assert.Equal("start and end must both be dates (YYYY-MM-DD) or both date-times.", ex.Message);
}
[Theory]
[InlineData(null, "2026-10-03", "create needs summary.")]
[InlineData("x", null, "create needs start.")]
public async Task Create_needs_summary_and_start(string? summary, string? start, string message)
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary, start }));
Assert.Equal(message, ex.Message);
}
[Fact]
public async Task Update_auto_detects_an_instance()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var result = await h.RunAsync(new { action = "update", eventId = "ev-1_20260928", summary = "Standup (moved)" });
Assert.Contains("\nUpdated this occurrence only: ev-1_20260928", result.TextContent);
}
[Fact]
public async Task Update_auto_detects_a_series()
{
using var h = WithEvent("ev-1", Master);
var result = await h.RunAsync(new { action = "update", eventId = "ev-1", location = "Room 2" });
Assert.Contains("\nUpdated the whole series: ev-1", result.TextContent);
}
[Fact]
public async Task Explicit_instance_scope_on_a_series_is_rejected()
{
using var h = WithEvent("ev-1", Master);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-1", scope = "instance", summary = "x" }));
Assert.Equal("Event ev-1 is a recurring series. To change one date, call list_instances with eventId=ev-1, then pass that occurrence's id with scope=instance.", ex.Message);
Assert.Empty(h.Http.To(EventRoute("PATCH", "ev-1")));
}
[Fact]
public async Task Explicit_series_scope_on_an_occurrence_is_rejected()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "delete", eventId = "ev-1_20260928", scope = "series" }));
Assert.Equal("Event ev-1_20260928 is one occurrence of the recurring series ev-1. To change the whole series, pass eventId=ev-1 with scope=series.", ex.Message);
Assert.Empty(h.Http.To(EventRoute("DELETE", "ev-1_20260928")));
}
[Fact]
public async Task An_explicit_scope_on_a_single_event_is_ignored()
{
using var h = WithEvent("ev-2", Plain);
var result = await h.RunAsync(new { action = "update", eventId = "ev-2", scope = "series", summary = "Dentist!" });
Assert.Contains("\nUpdated: ev-2", result.TextContent);
}
[Fact]
public async Task An_invalid_scope_value_is_rejected()
{
using var h = WithEvent("ev-2", Plain);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-2", scope = "following", summary = "x" }));
Assert.Equal("scope must be instance or series.", ex.Message);
}
[Fact]
public async Task Recurrence_on_an_instance_is_rejected()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-1_20260928", recurrence = new[] { "RRULE:FREQ=DAILY" } }));
Assert.Equal("recurrence can't be set on a single occurrence; update the series (eventId=ev-1) instead.", ex.Message);
}
[Fact]
public async Task Update_patch_carries_only_the_given_fields()
{
using var h = WithEvent("ev-2", Plain);
await h.RunAsync(new { action = "update", eventId = "ev-2", summary = "New title" });
Assert.Equal("""{"summary":"New title"}""", PatchBody(h, "ev-2").ToJsonString());
}
[Fact]
public async Task Update_times_follow_the_time_rules()
{
using var h = WithEvent("ev-2", Plain);
await h.RunAsync(new { action = "update", eventId = "ev-2", start = "2026-10-01T16:00", end = "2026-10-01T17:00" });
var body = PatchBody(h, "ev-2");
Assert.Equal("""{"dateTime":"2026-10-01T16:00:00","timeZone":"Europe/Berlin"}""", body["start"]!.ToJsonString());
Assert.Equal(["start", "end"], body.Select(p => p.Key).ToArray());
}
[Fact]
public async Task Update_with_nothing_to_change_is_rejected()
{
using var h = WithEvent("ev-2", Plain);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-2" }));
Assert.Equal("update needs at least one field to change (summary, description, location, start, end or recurrence).", ex.Message);
}
[Fact]
public async Task Update_with_an_empty_recurrence_makes_the_series_a_single_event()
{
using var h = WithEvent("ev-1", Master);
await h.RunAsync(new { action = "update", eventId = "ev-1", recurrence = Array.Empty<string>() });
Assert.Equal("""{"recurrence":[]}""", PatchBody(h, "ev-1").ToJsonString());
}
[Fact]
public async Task Delete_of_an_instance_cancels_one_occurrence()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var result = await h.RunAsync(new { action = "delete", eventId = "ev-1_20260928" });
Assert.Single(h.Http.To(EventRoute("DELETE", "ev-1_20260928")));
Assert.EndsWith("Cancelled one occurrence of \"Standup\" (Mon 28 Sep 10:00–10:15); the rest of the series is unchanged.", result.TextContent);
}
[Fact]
public async Task Delete_of_a_series_deletes_all_occurrences()
{
using var h = WithEvent("ev-1", Master);
var result = await h.RunAsync(new { action = "delete", eventId = "ev-1" });
Assert.EndsWith("Deleted the recurring series \"Standup\" and all its occurrences.", result.TextContent);
}
[Fact]
public async Task Delete_of_a_single_event_deletes_it()
{
using var h = WithEvent("ev-2", Plain);
var result = await h.RunAsync(new { action = "delete", eventId = "ev-2" });
Assert.EndsWith("Deleted \"Dentist\" (Thu 1 Oct 14:00–15:00).", result.TextContent);
}
[Theory]
[InlineData("update", "update needs eventId.")]
[InlineData("delete", "delete needs eventId.")]
public async Task Update_and_delete_need_an_event_id(string action, string message)
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action }));
Assert.Equal(message, ex.Message);
}
}

View File

@@ -0,0 +1,34 @@
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")]
[InlineData("telegram-1\n")] // a trailing newline: '$' alone would accept it
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,247 @@
using System.Net;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class ConnectFlowTests
{
private const string Connect = "calendar_connect";
[Fact]
public async Task The_link_and_code_arrive_in_one_notify()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var command = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
var (message, kind) = Assert.Single(ui.Notes);
Assert.Equal("Connect Google Calendar:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n3. Approve access. I'll confirm here when it's done (code valid for 30 min).", message);
Assert.Equal(NotifyKind.Info, kind);
h.Connection().CancelPending();
await command.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Pending_pending_success_writes_the_store_and_notifies_once()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.Pending, ConnectHarness.Approved());
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
Assert.Equal("Bearer at-1", Assert.Single(h.Http.To(ConnectHarness.PrimaryRoute)).Authorization);
var stored = h.Connection().Store.Read();
Assert.Equal(new StoredToken("rt-1", "https://www.googleapis.com/auth/calendar", "me@example.com", "Europe/Berlin", ConnectHarness.Now.AddSeconds(15)), stored);
Assert.Equal(2, ui.Notes.Count);
Assert.Equal(("✅ Google Calendar connected as me@example.com (time zone Europe/Berlin).", NotifyKind.Info), ui.Notes[1]);
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Slow_down_widens_the_poll_interval_by_five_seconds()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.SlowDown, ConnectHarness.Pending, ConnectHarness.Approved());
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(new CalendarRecordingUi())), h.Connection(), h.Time);
var polls = h.Http.To(ConnectHarness.TokenRoute).Select(r => r.At).ToList();
Assert.Equal(ConnectHarness.Now.AddSeconds(5), polls[0]);
Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)],
polls.Zip(polls.Skip(1), (a, b) => b - a).ToArray());
}
[Fact]
public async Task Denied_notifies_a_warning_and_writes_nothing()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Forbidden, """{"error":"access_denied"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("Google Calendar access was denied.", NotifyKind.Warning), ui.Notes[^1]);
Assert.Null(h.Connection().Store.Read());
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Expired_token_from_google_notifies_the_expiry_warning()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
}
[Fact]
public async Task The_code_lifetime_ending_stops_polling_with_the_expiry_warning()
{
using var h = new ConnectHarness();
h.ScriptDevice("dev-1", "ABCD-EFGH", expiresIn: 12, interval: 5);
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(2, h.Http.To(ConnectHarness.TokenRoute).Count); // at +5 and +10; +15 is past +12
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
}
[Fact]
public async Task Three_consecutive_transport_errors_fail_the_flow()
{
using var h = new ConnectHarness();
h.Http.Fail(ConnectHarness.TokenRoute, "network down");
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
Assert.Equal(("Google Calendar connection failed: network down", NotifyKind.Error), ui.Notes[^1]);
}
[Fact]
public async Task Two_transport_errors_then_approval_still_connects()
{
using var h = new ConnectHarness();
h.Http.On(ConnectHarness.TokenRoute, GoogleFakeHttp.Throws(), GoogleFakeHttp.Throws(),
GoogleFakeHttp.Returns(ConnectHarness.Approved().Item1, ConnectHarness.Approved().Item2));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.StartsWith("✅ Google Calendar connected", ui.Notes[^1].Message);
}
[Fact]
public async Task A_failed_poll_is_reported_as_an_error()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("Google Calendar connection failed: invalid_client: Unauthorized", NotifyKind.Error), ui.Notes[^1]);
}
[Fact]
public async Task A_failed_primary_lookup_still_saves_the_token()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
h.Http.Json(ConnectHarness.PrimaryRoute, (HttpStatusCode.InternalServerError, "{}"));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
var stored = h.Connection().Store.Read()!;
Assert.Equal("rt-1", stored.RefreshToken);
Assert.Null(stored.Account);
Assert.Equal("✅ Google Calendar connected as an unknown account (time zone unknown).", ui.Notes[^1].Message);
}
[Fact]
public async Task A_store_write_failure_is_reported()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
Directory.CreateDirectory(h.Connection().Store.Path); // a directory where token.json should go
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.StartsWith("Got a Google token but could not save it: ", ui.Notes[^1].Message);
Assert.Equal(NotifyKind.Error, ui.Notes[^1].Kind);
}
[Fact]
public async Task A_second_connect_resends_the_same_code_without_a_new_device_code_request()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var first = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Time.Advance(TimeSpan.FromSeconds(1));
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(h.Http.To(ConnectHarness.DeviceRoute));
Assert.Equal("A Google Calendar connection is already waiting for approval:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n(30 min left.)", ui.Notes[1].Message);
h.Connection().CancelPending();
await first.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task A_connect_after_the_code_expired_starts_a_new_flow_instead_of_resending_the_dead_code()
{
using var h = new ConnectHarness();
h.ScriptDevice("dev-1", "ABCD-EFGH", expiresIn: 10, interval: 30); // parked until +30, dead at +10
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var first = h.Run(Connect, h.Context(ui));
var stale = await FlowDriver.ParkedAsync(h.Connection());
h.Time.Advance(TimeSpan.FromSeconds(11));
var statusUi = new CalendarRecordingUi();
await h.Run("calendar_status", h.Context(statusUi));
Assert.Equal("Not connected. Run /calendar_connect.", Assert.Single(statusUi.Notes).Message);
await first.WaitAsync(TimeSpan.FromSeconds(5)); // the stale flow was cancelled: it ends silently
h.ScriptDevice("dev-2", "WXYZ-1234");
var second = h.Run(Connect, h.Context(ui));
var fresh = await FlowDriver.ParkedAsync(h.Connection());
Assert.NotSame(stale, fresh);
Assert.Equal(2, h.Http.To(ConnectHarness.DeviceRoute).Count);
Assert.Equal(2, ui.Notes.Count); // two links, no "already waiting", no late expiry warning
Assert.Contains("WXYZ-1234", ui.Notes[1].Message);
h.Connection().CancelPending();
await second.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task A_cancelled_flow_that_has_not_ended_yet_is_not_resent()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var first = h.Run(Connect, h.Context(ui));
var stale = await FlowDriver.ParkedAsync(h.Connection());
stale.Cancel();
Assert.Null(h.Connection().Pending);
await first.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Cancelling_a_pending_flow_is_silent_and_clears_it()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var command = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Connection().CancelPending();
await command.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(ui.Notes); // only the link
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task A_device_code_failure_is_reported_and_leaves_nothing_pending()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.DeviceRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
var ui = new CalendarRecordingUi();
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(("Google Calendar connection failed: Google refused the device code request: invalid_client", NotifyKind.Error), Assert.Single(ui.Notes));
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Without_a_ui_the_command_appends_a_custom_message()
{
using var h = new ConnectHarness();
await h.Run(Connect, h.Context(ui: null));
var (message, delivery, triggerTurn) = Assert.Single(h.Api.Sent);
Assert.Equal("calendar_connect", message.CustomType);
Assert.Equal("/calendar_connect needs an interactive UI (a chat front end such as the Telegram bot).", message.Text);
Assert.False(triggerTurn);
Assert.Empty(h.Http.Requests);
}
}

View File

@@ -0,0 +1,156 @@
using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleCalendarConnectionTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
private readonly string _root = Directory.CreateTempSubdirectory("gcal-conn-").FullName;
private readonly FakeTimeProvider _time = new(Now);
private readonly GoogleFakeHttp _http;
private readonly GoogleCalendarSettings _settings;
private readonly GoogleCalendarConnections _connections = new();
public GoogleCalendarConnectionTests()
{
_http = new GoogleFakeHttp(_time);
_settings = new GoogleCalendarSettings("cid", "csecret", _root, _http.CreateClient(), _time);
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3600}""");
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
private GoogleCalendarConnection Connected(string scope = "telegram-1", string refreshToken = "rt-1", string? zone = null)
{
var connection = _connections.For(_settings, scope);
connection.Store.Write(new StoredToken(refreshToken, TimeZone: zone));
return connection;
}
private int Refreshes => _http.To("POST /token").Count;
[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");
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.NotSame(a, b);
Assert.StartsWith(Path.Combine(_root, "scope-"), _connections.For(_settings, "../escape").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));
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);
}
[Fact]
public async Task Reuses_the_cached_access_token_while_valid()
{
var connection = Connected();
Assert.Equal("at-1", await connection.GetAccessTokenAsync(CancellationToken.None));
_time.Advance(TimeSpan.FromSeconds(3539));
Assert.Equal("at-1", await connection.GetAccessTokenAsync(CancellationToken.None));
Assert.Equal(1, Refreshes);
}
[Fact]
public async Task Refreshes_within_60_seconds_of_expiry()
{
var connection = Connected();
await connection.GetAccessTokenAsync(CancellationToken.None);
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
_time.Advance(TimeSpan.FromSeconds(3541));
Assert.Equal("at-2", await connection.GetAccessTokenAsync(CancellationToken.None));
Assert.Equal(2, Refreshes);
}
[Fact]
public async Task A_changed_refresh_token_drops_the_cache()
{
var connection = Connected(refreshToken: "rt-1");
await connection.GetAccessTokenAsync(CancellationToken.None);
connection.Store.Write(new StoredToken("rt-2")); // a reconnect wrote a new token
await connection.GetAccessTokenAsync(CancellationToken.None);
Assert.Equal(2, Refreshes);
Assert.Equal("rt-2", _http.To("POST /token")[1].Form["refresh_token"]);
}
[Fact]
public async Task Concurrent_callers_share_one_refresh()
{
var connection = Connected();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_http.On("POST /token", async _ =>
{
await gate.Task;
return GoogleFakeHttp.Response(HttpStatusCode.OK, """{"access_token":"at-9","expires_in":3600}""");
});
var calls = Enumerable.Range(0, 5).Select(_ => connection.GetAccessTokenAsync(CancellationToken.None)).ToList();
await Eventually.UntilAsync(() => Refreshes == 1, "the first refresh");
gate.SetResult();
Assert.All(await Task.WhenAll(calls), t => Assert.Equal("at-9", t));
Assert.Equal(1, Refreshes);
}
[Fact]
public async Task Invalidate_forces_a_refresh()
{
var connection = Connected();
await connection.GetAccessTokenAsync(CancellationToken.None);
connection.Invalidate();
await connection.GetAccessTokenAsync(CancellationToken.None);
Assert.Equal(2, Refreshes);
}
[Fact]
public async Task Invalid_grant_throws_not_connected_and_keeps_the_file()
{
var connection = Connected();
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => connection.GetAccessTokenAsync(CancellationToken.None));
Assert.True(ex.NotConnected);
Assert.True(File.Exists(connection.Store.Path));
}
[Fact]
public void Resolved_zone_is_the_calendars_stored_zone()
{
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var zone = Connected(zone: "America/New_York").ResolveTimeZone(berlin);
Assert.Equal("America/New_York", ZoneIds.Name(zone));
}
[Fact]
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, Connected(zone: null).ResolveTimeZone(berlin));
}
[Fact]
public void An_unknown_stored_zone_is_logged_once_and_falls_back()
{
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var logger = new ListLogger();
var connection = Connected(zone: "Mars/Olympus_Mons");
Assert.Same(berlin, connection.ResolveTimeZone(berlin, logger));
Assert.Same(berlin, connection.ResolveTimeZone(berlin, logger));
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains("Mars/Olympus_Mons", entry.Message);
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleCalendarExtensionTests
{
[Fact]
public async Task Without_a_client_it_registers_nothing_and_logs_one_info_line()
{
var logger = new ListLogger();
var api = new CalendarFakeApi(logger);
var extension = new GoogleCalendarExtension(new GoogleCalendarOptions { GetEnv = _ => null });
Assert.Equal("google-calendar", extension.Name);
await extension.ActivateAsync(api, CancellationToken.None);
Assert.Empty(api.Tools);
Assert.Empty(api.Commands);
var entry = Assert.Single(logger.Entries);
Assert.Equal((LogLevel.Information, "google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET"), entry);
}
[Theory]
[InlineData("cid", null, "GOOGLE_CLIENT_SECRET")]
[InlineData(null, "csecret", "GOOGLE_CLIENT_ID")]
public async Task With_only_one_of_id_and_secret_it_logs_a_warning(string? id, string? secret, string missing)
{
var logger = new ListLogger();
var api = new CalendarFakeApi(logger);
await new GoogleCalendarExtension(new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
Assert.Empty(api.Tools);
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains(missing, entry.Message);
Assert.DoesNotContain("csecret", entry.Message);
}
[Fact]
public void Configured_it_registers_the_calendar_tool_and_three_commands()
{
using var h = new ConnectHarness();
Assert.Equal("calendar", Assert.Single(h.Api.Tools).Name);
Assert.Equal(["calendar_connect", "calendar_disconnect", "calendar_status"], h.Api.Commands.Keys.Order().ToArray());
Assert.All(h.Api.Commands.Values, c => Assert.NotNull(c.Description));
}
[Fact]
public async Task After_session_start_the_description_names_the_resolved_zone()
{
using var h = new ConnectHarness();
var tool = Assert.Single(h.Api.Tools);
Assert.Contains("the zone named in each result's header", tool.Description);
await h.Api.RaiseAsync(new SessionStartEvent(SessionStartReason.Startup), h.Context(null));
Assert.Contains("without an offset are in Europe/Berlin", tool.Description);
h.Connection().Store.Write(new StoredToken("rt-1", TimeZone: "America/New_York"));
Assert.Contains("without an offset are in America/New_York", Assert.Single(tool.PromptGuidelines));
}
[Fact]
public async Task An_unknown_stored_zone_read_first_by_the_description_is_still_logged_exactly_once()
{
var logger = new ListLogger();
using var h = new ConnectHarness(logger);
var tool = Assert.Single(h.Api.Tools);
await h.Api.RaiseAsync(new SessionStartEvent(SessionStartReason.Startup), h.Context(null));
h.Connection().Store.Write(new StoredToken("rt-1", TimeZone: "Mars/Olympus_Mons"));
Assert.Contains("without an offset are in Europe/Berlin", tool.Description); // read before any tool call
_ = tool.PromptGuidelines;
h.Connection().ResolveTimeZone(ConnectHarness.Berlin, logger); // what the tool's execution does
var entry = Assert.Single(logger.Entries, e => e.Level == LogLevel.Warning);
Assert.Contains("Mars/Olympus_Mons", entry.Message);
}
[Fact]
public void A_logger_less_zone_lookup_does_not_use_up_the_log_once_flag()
{
using var h = new ConnectHarness();
var connection = h.Connection();
connection.Store.Write(new StoredToken("rt-1", TimeZone: "Mars/Olympus_Mons"));
connection.ResolveTimeZone(ConnectHarness.Berlin); // no logger
var logger = new ListLogger();
connection.ResolveTimeZone(ConnectHarness.Berlin, logger);
connection.ResolveTimeZone(ConnectHarness.Berlin, logger);
Assert.Single(logger.Entries);
}
}

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,173 @@
using System.Net;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleOAuthClientTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _time = new(Now);
private readonly GoogleFakeHttp _http;
private readonly GoogleOAuthClient _client;
public GoogleOAuthClientTests()
{
_http = new GoogleFakeHttp(_time);
_client = new GoogleOAuthClient(_http.CreateClient(), "cid", "csecret", _time);
}
[Fact]
public async Task Start_posts_the_client_id_and_the_calendar_scope()
{
_http.Json("POST /device/code", """{"device_code":"dev-1","user_code":"ABCD-EFGH","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}""");
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
var request = Assert.Single(_http.Requests);
Assert.Equal("https://oauth2.googleapis.com/device/code", request.Uri.ToString());
Assert.Equal(new Dictionary<string, string> { ["client_id"] = "cid", ["scope"] = "https://www.googleapis.com/auth/calendar" }, request.Form);
Assert.Equal(new DeviceAuthorization("dev-1", "ABCD-EFGH", "https://www.google.com/device", Now.AddSeconds(1800), TimeSpan.FromSeconds(5)), device);
}
[Fact]
public async Task Start_accepts_verification_uri_and_defaults_the_interval_to_5_seconds()
{
_http.Json("POST /device/code", """{"device_code":"d","user_code":"U","verification_uri":"https://example.test/device","expires_in":600}""");
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
Assert.Equal("https://example.test/device", device.VerificationUrl);
Assert.Equal(TimeSpan.FromSeconds(5), device.Interval);
}
[Fact]
public async Task Start_error_throws_with_googles_error_and_description()
{
_http.Json("POST /device/code", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"The OAuth client was not found."}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.StartDeviceFlowAsync(CancellationToken.None));
Assert.Contains("invalid_client: The OAuth client was not found.", ex.Message);
Assert.DoesNotContain("csecret", ex.Message);
}
[Fact]
public async Task Poll_posts_the_device_code_grant()
{
_http.Json("POST /token", (HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}"""));
await _client.PollAsync("dev-1", CancellationToken.None);
var form = Assert.Single(_http.Requests).Form;
Assert.Equal("cid", form["client_id"]);
Assert.Equal("csecret", form["client_secret"]);
Assert.Equal("dev-1", form["device_code"]);
Assert.Equal("urn:ietf:params:oauth:grant-type:device_code", form["grant_type"]);
}
[Theory]
[InlineData(428)]
[InlineData(400)]
public async Task Poll_maps_authorization_pending_whatever_the_status(int status)
{
_http.Json("POST /token", ((HttpStatusCode)status, """{"error":"authorization_pending","error_description":"Precondition Required"}"""));
Assert.Equal(PollStatus.Pending, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_slow_down_at_403()
{
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"slow_down"}"""));
Assert.Equal(PollStatus.SlowDown, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_access_denied()
{
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"access_denied","error_description":"Forbidden"}"""));
Assert.Equal(PollStatus.Denied, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_expired_token()
{
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
Assert.Equal(PollStatus.Expired, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_an_unknown_error_to_failed_with_its_text()
{
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Failed, result.Status);
Assert.Equal("invalid_client: Unauthorized", result.Message);
}
[Fact]
public async Task Poll_500_without_an_error_field_is_a_transport_error()
{
_http.Json("POST /token", (HttpStatusCode.InternalServerError, "<html>oops</html>"));
await Assert.ThrowsAsync<HttpRequestException>(() => _client.PollAsync("dev-1", CancellationToken.None));
}
[Fact]
public async Task Poll_success_returns_the_refresh_and_access_tokens()
{
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599,"refresh_token":"rt-1","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Success, result.Status);
Assert.Equal("rt-1", result.RefreshToken);
Assert.Equal("at-1", result.AccessToken);
Assert.Equal("https://www.googleapis.com/auth/calendar", result.Scope);
Assert.Equal(Now.AddSeconds(3599), result.AccessTokenExpiresAt);
Assert.DoesNotContain("rt-1", result.ToString());
Assert.DoesNotContain("at-1", result.ToString());
}
[Fact]
public async Task Poll_success_without_a_refresh_token_fails_with_the_permissions_hint()
{
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599}""");
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Failed, result.Status);
Assert.Equal("Google returned no refresh token. Remove the app at https://myaccount.google.com/permissions and run /calendar_connect again.", result.Message);
}
[Fact]
public async Task Refresh_returns_the_access_token_and_its_expiry()
{
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
var token = await _client.RefreshAsync("rt-1", CancellationToken.None);
Assert.Equal(new AccessToken("at-2", Now.AddSeconds(3600)), token);
var form = Assert.Single(_http.Requests).Form;
Assert.Equal("refresh_token", form["grant_type"]);
Assert.Equal("rt-1", form["refresh_token"]);
Assert.Equal("csecret", form["client_secret"]);
}
[Fact]
public async Task Refresh_invalid_grant_throws_not_connected_without_the_token()
{
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant","error_description":"Token has been expired or revoked."}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-secret-1", CancellationToken.None));
Assert.True(ex.NotConnected);
Assert.Equal("Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.", ex.Message);
Assert.DoesNotContain("rt-secret-1", ex.ToString());
}
[Fact]
public async Task Refresh_other_errors_throw_with_googles_text()
{
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-1", CancellationToken.None));
Assert.False(ex.NotConnected);
Assert.Equal("Google token refresh failed: invalid_client", ex.Message);
}
[Fact]
public async Task Revoke_posts_the_token_and_reports_failures()
{
_http.Json("POST /revoke", (HttpStatusCode.OK, "{}"), (HttpStatusCode.BadRequest, """{"error":"invalid_token"}"""));
Assert.Null(await _client.RevokeAsync("rt-1", CancellationToken.None));
Assert.Equal("rt-1", _http.To("POST /revoke")[0].Form["token"]);
Assert.Equal("invalid_token", await _client.RevokeAsync("rt-1", CancellationToken.None));
_http.Fail("POST /revoke", "no route to host");
Assert.Equal("no route to host", await _client.RevokeAsync("rt-1", CancellationToken.None));
}
}

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

@@ -0,0 +1,103 @@
using System.Net;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class PerChatTests
{
private static Task<ToolResult> ListIn(ConnectHarness h, string scope) =>
Assert.Single(h.Api.Tools).ExecuteAsync(new ToolCallContent("c1", "calendar", VolcanicJson.ToElement(new { action = "list" })),
new ToolInvocation("c1", h.Context(null, scope), null), CancellationToken.None);
[Fact]
public async Task Connecting_chat_a_leaves_chat_b_not_connected()
{
using var h = new ConnectHarness();
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);
var ui = new CalendarRecordingUi();
await h.Run("calendar_status", h.Context(ui, ConnectHarness.ScopeB));
Assert.Equal("Not connected. Run /calendar_connect.", Assert.Single(ui.Notes).Message);
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => ListIn(h, ConnectHarness.ScopeB));
Assert.True(ex.NotConnected);
Assert.True(File.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeA, "token.json")));
Assert.False(Directory.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeB)));
}
[Fact]
public async Task Concurrent_flows_in_two_chats_each_end_with_their_own_store_and_notify()
{
using var h = new ConnectHarness();
var devices = 0;
h.Http.Reply(ConnectHarness.DeviceRoute, _ => Interlocked.Increment(ref devices) == 1
? (HttpStatusCode.OK, """{"device_code":"dev-A","user_code":"AAAA-AAAA","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}""")
: (HttpStatusCode.OK, """{"device_code":"dev-B","user_code":"BBBB-BBBB","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"""));
h.Http.Reply(ConnectHarness.TokenRoute, r => r.Form["device_code"] == "dev-A" ? ConnectHarness.Approved("rt-A", "at-A") : ConnectHarness.Approved("rt-B", "at-B"));
h.Http.Reply(ConnectHarness.PrimaryRoute, r => r.Authorization == "Bearer at-A"
? (HttpStatusCode.OK, """{"id":"a@example.com","timeZone":"Europe/Berlin"}""")
: (HttpStatusCode.OK, """{"id":"b@example.com","timeZone":"Asia/Tokyo"}"""));
var uiA = new CalendarRecordingUi();
var uiB = new CalendarRecordingUi();
var a = h.Run("calendar_connect", h.Context(uiA, ConnectHarness.ScopeA));
await Eventually.UntilAsync(() => h.Connection(ConnectHarness.ScopeA).Pending is not null, "flow A");
var b = h.Run("calendar_connect", h.Context(uiB, ConnectHarness.ScopeB));
await FlowDriver.AdvanceUntilAsync(h.Time, () => a.IsCompleted && b.IsCompleted, TimeSpan.FromSeconds(1));
Assert.Contains("AAAA-AAAA", uiA.Notes[0].Message);
Assert.Contains("BBBB-BBBB", uiB.Notes[0].Message);
Assert.Equal("✅ Google Calendar connected as a@example.com (time zone Europe/Berlin).", uiA.Notes[^1].Message);
Assert.Equal("✅ Google Calendar connected as b@example.com (time zone Asia/Tokyo).", uiB.Notes[^1].Message);
Assert.Equal("rt-A", h.Connection(ConnectHarness.ScopeA).Store.Read()!.RefreshToken);
Assert.Equal("rt-B", h.Connection(ConnectHarness.ScopeB).Store.Read()!.RefreshToken);
}
[Fact]
public async Task Connect_twice_in_a_reuses_a_code_while_b_gets_a_fresh_one()
{
using var h = new ConnectHarness();
var devices = 0;
h.Http.Reply(ConnectHarness.DeviceRoute, _ => (HttpStatusCode.OK,
$$"""{"device_code":"dev-{{Interlocked.Increment(ref devices)}}","user_code":"CODE-{{devices}}","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"""));
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var uiA = new CalendarRecordingUi();
var uiB = new CalendarRecordingUi();
var first = h.Run("calendar_connect", h.Context(uiA, ConnectHarness.ScopeA));
await FlowDriver.ParkedAsync(h.Connection(ConnectHarness.ScopeA));
await h.Run("calendar_connect", h.Context(uiA, ConnectHarness.ScopeA)).WaitAsync(TimeSpan.FromSeconds(5));
var second = h.Run("calendar_connect", h.Context(uiB, ConnectHarness.ScopeB));
await FlowDriver.ParkedAsync(h.Connection(ConnectHarness.ScopeB));
Assert.Contains("CODE-1", uiA.Notes[1].Message);
Assert.Contains("CODE-2", uiB.Notes[0].Message);
Assert.Equal(2, h.Http.To(ConnectHarness.DeviceRoute).Count);
h.Connection(ConnectHarness.ScopeA).CancelPending();
h.Connection(ConnectHarness.ScopeB).CancelPending();
await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Disconnect_in_a_leaves_b_token_and_cache_alone()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, """{"access_token":"at-x","expires_in":3600}""");
h.Http.Json("POST /revoke", "{}");
h.Connection(ConnectHarness.ScopeA).Store.Write(new StoredToken("rt-A"));
h.Connection(ConnectHarness.ScopeB).Store.Write(new StoredToken("rt-B"));
await h.Connection(ConnectHarness.ScopeB).GetAccessTokenAsync(CancellationToken.None);
var refreshes = h.Http.To(ConnectHarness.TokenRoute).Count;
await h.Run("calendar_disconnect", h.Context(new CalendarRecordingUi(), ConnectHarness.ScopeA));
Assert.Equal("rt-A", Assert.Single(h.Http.To("POST /revoke")).Form["token"]);
Assert.Null(h.Connection(ConnectHarness.ScopeA).Store.Read());
Assert.Equal("rt-B", h.Connection(ConnectHarness.ScopeB).Store.Read()!.RefreshToken);
await h.Connection(ConnectHarness.ScopeB).GetAccessTokenAsync(CancellationToken.None);
Assert.Equal(refreshes, h.Http.To(ConnectHarness.TokenRoute).Count); // B's cache survived
}
}

View File

@@ -0,0 +1,202 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
internal sealed class FakeSessionManager(string scope) : IReadOnlySessionManager
{
public string SessionId => "session-1";
public string Scope => scope;
public string Cwd => Path.GetTempPath();
public string? Name => null;
public string? LeafId => null;
public IReadOnlyList<SessionEntry> GetEntries() => [];
public IReadOnlyList<SessionEntry> GetBranch() => [];
public SessionEntry? GetEntry(string id) => null;
}
/// <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
{
public string Cwd => Path.GetTempPath();
public string SessionId => "session-1";
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager { get; } = new FakeSessionManager(scope);
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public TimeZoneInfo TimeZone => zone;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => logger ?? NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
}
/// <summary>Records what the extension registers and sends; everything else is unsupported so misuse is loud.</summary>
internal sealed class CalendarFakeApi(ILogger? logger = null) : IExtensionApi
{
public List<ITool> Tools { get; } = [];
public Dictionary<string, CommandDefinition> Commands { get; } = new();
public List<(Type Event, Delegate Handler)> Handlers { get; } = [];
public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
public string ExtensionName => GoogleCalendarExtension.ExtensionName;
public ILogger Logger => logger ?? NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent
{
Handlers.Add((typeof(TEvent), handler));
return new Subscription();
}
public async Task RaiseAsync<TEvent>(TEvent e, IExtensionContext ctx) where TEvent : ExtensionEvent
{
foreach (var (type, handler) in Handlers.Where(h => h.Event == typeof(TEvent)))
await ((ExtensionHandler<TEvent>)handler)(e, ctx, CancellationToken.None);
}
public void RegisterTool(ITool tool) => Tools.Add(tool);
public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
public void RegisterProvider(ProviderRegistration provider) => throw new NotSupportedException();
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
private sealed class Subscription : IDisposable { public void Dispose() { } }
}
internal sealed class CalendarRecordingUi : IUserInterface
{
private readonly Lock _lock = new();
private readonly List<(string Message, NotifyKind Kind)> _notes = [];
public IReadOnlyList<(string Message, NotifyKind Kind)> Notes { get { lock (_lock) return _notes.ToList(); } }
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public void Notify(string message, NotifyKind kind = NotifyKind.Info) { lock (_lock) _notes.Add((message, kind)); }
public void SetStatus(string key, string? text) { }
}
internal static class FlowDriver
{
/// <summary>Moves the fake clock exactly to each poll the flow schedules until the command returns.</summary>
public static async Task CompleteAsync(Task command, GoogleCalendarConnection connection, FakeTimeProvider time)
{
await Eventually.UntilAsync(() => command.IsCompleted || connection.Pending is not null, "the flow to start");
var flow = connection.Pending;
var seen = 0;
while (flow is not null)
{
await Eventually.UntilAsync(() => command.IsCompleted || flow.Parks > seen, "the next poll to be scheduled");
if (command.IsCompleted) break;
seen = flow.Parks;
var due = flow.NextPollAt - time.GetUtcNow();
if (due > TimeSpan.Zero) time.Advance(due);
}
await command.WaitAsync(TimeSpan.FromSeconds(10));
}
/// <summary>Waits until the chat's flow is parked on its first poll delay.</summary>
public static async Task<ConnectFlow> ParkedAsync(GoogleCalendarConnection connection)
{
await Eventually.UntilAsync(() => connection.Pending is { Parks: > 0 }, "the flow to wait for its first poll");
return connection.Pending!;
}
/// <summary>Coarse driving for several flows at once: advance in steps until <paramref name="done"/>.</summary>
public static async Task AdvanceUntilAsync(FakeTimeProvider time, Func<bool> done, TimeSpan step, int timeoutMs = 10000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!done())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("Timed out driving the flows");
time.Advance(step);
await Task.Delay(5);
}
}
}
/// <summary>The activated extension over fake Google, a fake clock and a temp token root.</summary>
internal sealed class ConnectHarness : IDisposable
{
public static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
public const string ScopeA = "telegram-111";
public const string ScopeB = "telegram-222";
public const string DeviceRoute = "POST /device/code";
public const string TokenRoute = "POST /token";
public const string PrimaryRoute = "GET /calendar/v3/calendars/primary";
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName;
public ConnectHarness(ILogger? logger = null)
{
Http = new GoogleFakeHttp(Time);
Api = new CalendarFakeApi(logger);
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions
{
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = _root,
Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
});
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult();
ScriptDevice("dev-1", "ABCD-EFGH");
Http.Json(PrimaryRoute, """{"id":"me@example.com","timeZone":"Europe/Berlin"}""");
}
public FakeTimeProvider Time { get; } = new(Now);
public GoogleFakeHttp Http { get; }
public GoogleCalendarConnections Connections { get; } = new();
public CalendarFakeApi Api { get; }
public GoogleCalendarExtension Extension { get; }
public string TokenRoot => _root;
public void ScriptDevice(string deviceCode, string userCode, int expiresIn = 1800, int interval = 5) =>
Http.Json(DeviceRoute, $$"""{"device_code":"{{deviceCode}}","user_code":"{{userCode}}","verification_url":"https://www.google.com/device","expires_in":{{expiresIn}},"interval":{{interval}}}""");
public static (System.Net.HttpStatusCode, string) Pending => (System.Net.HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}""");
public static (System.Net.HttpStatusCode, string) SlowDown => (System.Net.HttpStatusCode.Forbidden, """{"error":"slow_down"}""");
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);
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui);
public Task Run(string command, CalendarFakeContext ctx) => Api.Commands[command].Handler("", ctx, CancellationToken.None);
public void Dispose()
{
foreach (var scope in new[] { ScopeA, ScopeB }) Connection(scope).CancelPending();
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
}

View File

@@ -0,0 +1,75 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
/// <summary>A connected chat (refresh token rt-1, access token at-1), the tool, and scripted Google routes.</summary>
internal sealed class CalendarToolHarness : IDisposable
{
public static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
public const string Scope = "telegram-111";
public const string EventsRoute = "GET /calendar/v3/calendars/primary/events";
private readonly string _root = Directory.CreateTempSubdirectory("gcal-tool-").FullName;
public CalendarToolHarness(bool connected = true, string? calendarZone = "Europe/Berlin")
{
Http = new GoogleFakeHttp(Time);
Settings = new GoogleCalendarSettings("cid", "csecret", _root, Http.CreateClient(), Time);
Tool = new CalendarTool(Settings, Connections, () => null);
Http.Json("POST /token", """{"access_token":"at-1","expires_in":3600}""");
if (connected) Connection.Store.Write(new StoredToken("rt-1", GoogleOAuthClient.CalendarScope, "me@example.com", calendarZone, Now));
}
public FakeTimeProvider Time { get; } = new(Now);
public GoogleFakeHttp Http { get; }
public GoogleCalendarSettings Settings { get; }
public GoogleCalendarConnections Connections { get; } = new();
public GoogleCalendarConnection Connection => Connections.For(Settings, Scope);
public TimeZoneInfo ChatZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public CalendarTool Tool { get; }
public IReadOnlyList<RecordedRequest> CalendarRequests => Http.Requests.Where(r => r.Uri.Host == "www.googleapis.com").ToList();
public Task<ToolResult> RunAsync(object args)
{
var call = new ToolCallContent("call-1", CalendarTool.ToolName, VolcanicJson.ToElement(args));
return Tool.ExecuteAsync(call, new ToolInvocation("call-1", new CalendarFakeContext(Scope, ChatZone), null), CancellationToken.None);
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
}
/// <summary>Google event JSON as the Calendar API returns it.</summary>
internal static class Ev
{
public static string Timed(string id, string summary, string start, string end, string? recurringEventId = null,
string[]? recurrence = null, string? location = null)
{
var ev = new JsonObject
{
["id"] = id, ["summary"] = summary, ["htmlLink"] = $"https://calendar.google.com/event?eid={id}",
["start"] = new JsonObject { ["dateTime"] = start, ["timeZone"] = "Europe/Berlin" },
["end"] = new JsonObject { ["dateTime"] = end, ["timeZone"] = "Europe/Berlin" },
};
if (recurringEventId is not null) ev["recurringEventId"] = recurringEventId;
if (recurrence is not null) ev["recurrence"] = new JsonArray(recurrence.Select(r => (JsonNode)r).ToArray());
if (location is not null) ev["location"] = location;
return ev.ToJsonString();
}
public static string AllDay(string id, string summary, string start, string end) =>
new JsonObject { ["id"] = id, ["summary"] = summary, ["start"] = new JsonObject { ["date"] = start }, ["end"] = new JsonObject { ["date"] = end } }.ToJsonString();
public static string List(params string[] items) => $"{{\"items\":[{string.Join(",", items)}]}}";
public static string Page(string? nextPageToken, params string[] items) =>
nextPageToken is null ? List(items) : $"{{\"items\":[{string.Join(",", items)}],\"nextPageToken\":\"{nextPageToken}\"}}";
}

View File

@@ -0,0 +1,14 @@
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
internal static class Eventually
{
public static async Task UntilAsync(Func<bool> condition, string what, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException($"Timed out waiting for {what}");
await Task.Delay(5);
}
}
}

View File

@@ -0,0 +1,90 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
/// <summary>One request as Google would have seen it, with the fake clock's time.</summary>
internal sealed record RecordedRequest(HttpMethod Method, Uri Uri, string? Body, string? Authorization, DateTimeOffset At)
{
public string Route => $"{Method.Method} {Uri.AbsolutePath}";
public IReadOnlyDictionary<string, string> Form => Parse(Body);
public IReadOnlyDictionary<string, string> Query => Parse(Uri.Query.TrimStart('?'));
public JsonObject Json => JsonNode.Parse(Body ?? "{}")!.AsObject();
private static Dictionary<string, string> Parse(string? encoded) =>
(encoded ?? "").Split('&', StringSplitOptions.RemoveEmptyEntries)
.Select(pair => pair.Split('=', 2))
.ToDictionary(p => Decode(p[0]), p => p.Length > 1 ? Decode(p[1]) : "");
private static string Decode(string s) => Uri.UnescapeDataString(s.Replace('+', ' '));
}
/// <summary>
/// Scripted Google endpoints keyed by <c>"METHOD /path"</c> (host ignored). A route serves its responders in order and
/// repeats the last one. Unrouted requests get a 501 so a missing script is loud.
/// </summary>
internal sealed class GoogleFakeHttp(TimeProvider? time = null) : HttpMessageHandler
{
private readonly Lock _lock = new();
private readonly Dictionary<string, (Func<RecordedRequest, Task<HttpResponseMessage>>[] Responders, int Served)> _routes = new();
private readonly List<RecordedRequest> _requests = [];
public IReadOnlyList<RecordedRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
public IReadOnlyList<RecordedRequest> To(string route) { lock (_lock) return _requests.Where(r => r.Route == route).ToList(); }
/// <summary>Replaces the route's script.</summary>
public GoogleFakeHttp On(string route, params Func<RecordedRequest, Task<HttpResponseMessage>>[] responders)
{
lock (_lock) _routes[route] = (responders, 0);
return this;
}
public GoogleFakeHttp Json(string route, params (HttpStatusCode Status, string Body)[] responses) =>
On(route, responses.Select(r => (Func<RecordedRequest, Task<HttpResponseMessage>>)(_ => Task.FromResult(Response(r.Status, r.Body)))).ToArray());
public GoogleFakeHttp Json(string route, string body) => Json(route, (HttpStatusCode.OK, body));
public GoogleFakeHttp Reply(string route, Func<RecordedRequest, (HttpStatusCode Status, string Body)> respond) =>
On(route, r =>
{
var (status, body) = respond(r);
return Task.FromResult(Response(status, body));
});
public static Func<RecordedRequest, Task<HttpResponseMessage>> Throws(string message = "network down") =>
_ => Task.FromException<HttpResponseMessage>(new HttpRequestException(message));
public static Func<RecordedRequest, Task<HttpResponseMessage>> Returns(HttpStatusCode status, string body) =>
_ => Task.FromResult(Response(status, body));
public GoogleFakeHttp Fail(string route, string message = "network down") => On(route, Throws(message));
public static HttpResponseMessage Response(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
public HttpClient CreateClient() => new(this, disposeHandler: false);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
var recorded = new RecordedRequest(request.Method, request.RequestUri!, body, request.Headers.Authorization?.ToString(),
(time ?? TimeProvider.System).GetUtcNow());
Func<RecordedRequest, Task<HttpResponseMessage>>? responder = null;
lock (_lock)
{
_requests.Add(recorded);
if (_routes.TryGetValue(recorded.Route, out var route))
{
responder = route.Responders[Math.Min(route.Served, route.Responders.Length - 1)];
_routes[recorded.Route] = (route.Responders, route.Served + 1);
}
}
if (responder is null)
return Response(HttpStatusCode.NotImplemented, $$$"""{"error":{"code":501,"message":"no fake route for {{{recorded.Route}}}"}}""");
var response = await responder(recorded);
response.RequestMessage = request;
return response;
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
internal sealed class ListLogger : ILogger
{
private readonly Lock _lock = new();
private readonly List<(LogLevel Level, string Message)> _entries = [];
public IReadOnlyList<(LogLevel Level, string Message)> Entries { get { lock (_lock) return _entries.ToList(); } }
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (_lock) _entries.Add((logLevel, formatter(state, exception)));
}
}

View File

@@ -10,7 +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.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
</ItemGroup>
<ItemGroup>
<None Include="ClaudeCode/Fixtures/*" CopyToOutputDirectory="PreserveNewest" />
@@ -22,6 +22,7 @@
<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" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,163 @@
using System.Net;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Telegram.Bot;
using Telegram.Bot.Requests;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public class GoogleCalendarHostTests : IDisposable
{
private readonly string _tokens = Directory.CreateTempSubdirectory("gcal-telegram-").FullName;
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 9, 26, 8, 0, 0, TimeSpan.Zero));
public void Dispose()
{
try { Directory.Delete(_tokens, recursive: true); } catch (IOException) { }
}
/// <summary>Google that approves on the second poll.</summary>
private sealed class ScriptedGoogle : HttpMessageHandler
{
private int _polls;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(request.RequestUri!.AbsolutePath switch
{
"/device/code" => Json(HttpStatusCode.OK, """{"device_code":"dev-1","user_code":"ABCD-EFGH","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"""),
"/token" => Interlocked.Increment(ref _polls) < 2
? Json(HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}""")
: Json(HttpStatusCode.OK, """{"access_token":"at-1","expires_in":3599,"refresh_token":"rt-1","scope":"https://www.googleapis.com/auth/calendar"}"""),
"/calendar/v3/calendars/primary" => Json(HttpStatusCode.OK, """{"id":"owner@example.com","timeZone":"Europe/Berlin"}"""),
_ => Json(HttpStatusCode.NotImplemented, "{}"),
});
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
}
private GoogleCalendarOptions Calendar() => new()
{
ClientId = "cid.apps.googleusercontent.com", ClientSecret = "GOCSPX-test-secret", TokenDirectory = _tokens,
Http = new HttpClient(new ScriptedGoogle()), Time = _time, Connections = new GoogleCalendarConnections(), GetEnv = _ => null,
};
[Fact]
public void Options_come_from_the_section_then_the_environment_and_treat_empty_as_unset()
{
var env = new Dictionary<string, string> { ["GOOGLE_CLIENT_ID"] = "env-id", ["GOOGLE_CLIENT_SECRET"] = "env-secret" };
var fromEnv = GoogleCalendarSetup.Options(new GoogleCalendarSection { ClientId = "", ClientSecret = " " }, n => env.GetValueOrDefault(n));
Assert.Equal(("env-id", "env-secret"), (fromEnv!.ClientId, fromEnv.ClientSecret));
var fromSection = GoogleCalendarSetup.Options(new GoogleCalendarSection { ClientId = "s-id", ClientSecret = "s-secret" }, n => env.GetValueOrDefault(n));
Assert.Equal(("s-id", "s-secret"), (fromSection!.ClientId, fromSection.ClientSecret));
}
[Fact]
public void Options_are_null_when_neither_value_is_set()
{
Assert.Null(GoogleCalendarSetup.Options(new GoogleCalendarSection(), _ => ""));
var half = GoogleCalendarSetup.Options(new GoogleCalendarSection { ClientId = "id" }, _ => null);
Assert.NotNull(half); // the extension logs the warning
Assert.False(GoogleCalendarSetup.IsActive(half));
}
[Fact]
public void Configure_adds_the_client_secret_to_the_secret_values()
{
var target = new ProcessEnvironmentOptions { HardenHostProcess = false };
TelegramProcessEnvironment.Configure(target, new TelegramOptions { BotToken = "123456:bot-token-value" }, new ProcessEnvironmentSection(), _ => null, Calendar());
Assert.Contains("GOCSPX-test-secret", target.SecretValues);
}
[Fact]
public void The_menu_gets_the_calendar_commands_only_when_both_values_are_set()
{
Assert.Same(ChatCommands.Menu, GoogleCalendarSetup.MenuFor(ChatCommands.Menu, null));
Assert.Same(ChatCommands.Menu, GoogleCalendarSetup.MenuFor(ChatCommands.Menu, new GoogleCalendarOptions { ClientId = "id", GetEnv = _ => null }));
var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, Calendar());
Assert.Equal(["calendar_connect", "calendar_status", "calendar_disconnect"], menu.Skip(ChatCommands.Menu.Count).Select(c => c.Command).ToArray());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Bot_setup_publishes_the_calendar_commands_only_when_the_calendar_is_configured(bool configured)
{
var bot = new FakeBotClient();
var services = new ServiceCollection()
.AddSingleton<ITelegramBotClient>(bot)
.AddSingleton(new TelegramOptions())
.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>))
.AddSingleton<TelegramSender>()
.AddSingleton<BotSetupService>();
if (configured) services.AddSingleton(Calendar()); // as Program.cs registers it
using var provider = services.BuildServiceProvider();
var setup = provider.GetRequiredService<BotSetupService>();
await setup.StartAsync(CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!bot.Requests.OfType<SetMyCommandsRequest>().Any() && DateTime.UtcNow < deadline) await Task.Delay(10);
await setup.StopAsync(CancellationToken.None);
var commands = Assert.Single(bot.Requests.OfType<SetMyCommandsRequest>()).Commands.Select(c => c.Command).ToList();
var calendarCommands = new[] { "calendar_connect", "calendar_status", "calendar_disconnect" };
if (configured) Assert.Equal(calendarCommands, commands.Skip(ChatCommands.Menu.Count).ToArray());
else Assert.DoesNotContain(commands, c => calendarCommands.Contains(c));
Assert.Equal(ChatCommands.Menu.Select(c => c.Command), commands.Take(ChatCommands.Menu.Count));
}
[Fact]
public async Task Every_allowed_chat_lists_the_calendar_tool()
{
await using var host = new ChatHost(calendar: Calendar());
host.Options.AllowedUserIds.Add(222222222);
Assert.Contains("calendar", (await host.ChatAsync()).Session.ActiveTools);
Assert.Contains("calendar", (await host.ChatAsync(222222222)).Session.ActiveTools);
}
[Fact]
public async Task Without_calendar_options_no_chat_has_the_tool()
{
await using var host = new ChatHost();
Assert.DoesNotContain("calendar", (await host.ChatAsync()).Session.ActiveTools);
}
[Fact]
public async Task Help_mentions_the_calendar_commands_when_the_extension_is_active()
{
await using var host = new ChatHost(calendar: Calendar());
await host.SendAsync("/help");
await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("/calendar_connect")), "the help text");
}
[Fact]
public async Task Help_leaves_out_the_calendar_commands_without_the_extension()
{
await using var host = new ChatHost();
await host.SendAsync("/help");
await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("/compact")), "the help text");
Assert.DoesNotContain(host.Texts, t => t.Contains("/calendar_connect"));
}
[Fact]
public async Task Calendar_connect_in_a_chat_ends_with_the_connected_message()
{
await using var host = new ChatHost(calendar: Calendar());
await host.SendAsync("/calendar_connect");
await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("ABCD-EFGH")), "the link and code");
await Wait.UntilAsync(() =>
{
_time.Advance(TimeSpan.FromSeconds(5));
return host.Texts.Any(t => t.Contains("Google Calendar connected as owner@example.com"));
}, "the connected message");
Assert.True(File.Exists(Path.Combine(_tokens, ChatRegistry.ScopeFor(Updates.AllowedUser), "token.json")));
}
}

View File

@@ -28,7 +28,8 @@ public sealed class ChatHost : IAsyncDisposable
private readonly ServiceProvider _services;
private readonly string _root;
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null)
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null)
{
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
Options = new TelegramOptions
@@ -57,7 +58,7 @@ public sealed class ChatHost : IAsyncDisposable
_services = services.BuildServiceProvider();
ILoggerFactory loggers = NullLoggerFactory.Instance;
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender,
new InboundComposer(sender, NullLogger<InboundComposer>.Instance), NullLogger<UpdateRouter>.Instance);
}