forked from admin/VolcanicAgenticCore
Merge branch 'feat/google-calendar'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
175
docs/google-calendar.md
Normal 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 |
|
||||
4411
docs/superpowers/plans/2026-09-26-google-calendar.md
Normal file
4411
docs/superpowers/plans/2026-09-26-google-calendar.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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><id> · <summary> · <range>[ @ 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());
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"}).");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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><root>/<chat key></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} }}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
/// <summary>A Google Calendar failure the model or the user can read. <see cref="NotConnected"/> means "run /calendar_connect".</summary>
|
||||
public sealed class GoogleCalendarException(string message, bool notConnected = false) : Exception(message)
|
||||
{
|
||||
public bool NotConnected { get; } = notConnected;
|
||||
}
|
||||
|
||||
internal static class GoogleCalendarMessages
|
||||
{
|
||||
public const string PermissionsUrl = "https://myaccount.google.com/permissions";
|
||||
public const string NotConnected = "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.";
|
||||
public const string TokenRejected = "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.";
|
||||
public const string NoRefreshToken = "Google returned no refresh token. Remove the app at " + PermissionsUrl + " and run /calendar_connect again.";
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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><agentDir>/google-calendar</c>.</summary>
|
||||
public string? TokenDirectory { get; init; }
|
||||
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
|
||||
public Func<string, string?>? GetEnv { get; init; }
|
||||
/// <summary>HTTP client for Google; tests pass one over a fake handler.</summary>
|
||||
public HttpClient? Http { get; init; }
|
||||
/// <summary>Clock for polling and token expiry; tests pass a fake.</summary>
|
||||
public TimeProvider? Time { get; init; }
|
||||
/// <summary>Per-chat connection state. Defaults to <see cref="GoogleCalendarConnections.Shared"/>.</summary>
|
||||
public GoogleCalendarConnections? Connections { get; init; }
|
||||
|
||||
private Func<string, string?> Env => GetEnv ?? Environment.GetEnvironmentVariable;
|
||||
|
||||
internal string? EffectiveClientId => NonEmpty(ClientId) ?? NonEmpty(Env("GOOGLE_CLIENT_ID"));
|
||||
internal string? EffectiveClientSecret => NonEmpty(ClientSecret) ?? NonEmpty(Env("GOOGLE_CLIENT_SECRET"));
|
||||
|
||||
/// <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) };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,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!);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
57
hosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs
Normal file
57
hosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
|
||||
public class GoogleCalendarOptionsTests
|
||||
{
|
||||
private static Func<string, string?> Env(params (string Name, string Value)[] vars) =>
|
||||
name => vars.FirstOrDefault(v => v.Name == name).Value;
|
||||
|
||||
[Fact]
|
||||
public void Explicit_values_win_over_the_environment()
|
||||
{
|
||||
var options = new GoogleCalendarOptions
|
||||
{
|
||||
ClientId = "cid", ClientSecret = "csecret",
|
||||
GetEnv = Env(("GOOGLE_CLIENT_ID", "env-id"), ("GOOGLE_CLIENT_SECRET", "env-secret")),
|
||||
};
|
||||
Assert.Equal("cid", options.EffectiveClientId);
|
||||
Assert.Equal("csecret", options.EffectiveClientSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_values_count_as_unset_and_fall_back_to_the_environment()
|
||||
{
|
||||
var options = new GoogleCalendarOptions
|
||||
{
|
||||
ClientId = "", ClientSecret = " ",
|
||||
GetEnv = Env(("GOOGLE_CLIENT_ID", "env-id"), ("GOOGLE_CLIENT_SECRET", "env-secret")),
|
||||
};
|
||||
Assert.Equal("env-id", options.EffectiveClientId);
|
||||
Assert.Equal("env-secret", options.EffectiveClientSecret);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("cid", null)]
|
||||
[InlineData(null, "csecret")]
|
||||
[InlineData("", "")]
|
||||
public void Resolve_returns_null_unless_both_id_and_secret_are_set(string? id, string? secret)
|
||||
{
|
||||
var options = new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = _ => null };
|
||||
Assert.Null(options.Resolve());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_fills_http_time_and_token_root()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "gcal-root");
|
||||
var settings = new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "csecret", TokenDirectory = root, GetEnv = _ => null }.Resolve();
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal(root, settings.TokenRoot);
|
||||
Assert.Same(TimeProvider.System, settings.Time);
|
||||
Assert.NotNull(settings.Http);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_directory_defaults_under_the_agent_dir()
|
||||
{
|
||||
var agentDir = Path.Combine(Path.GetTempPath(), "agent-dir");
|
||||
Assert.Equal(Path.Combine(agentDir, "google-calendar"),
|
||||
new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
|
||||
Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent", "google-calendar"),
|
||||
new GoogleCalendarOptions { GetEnv = _ => null }.EffectiveTokenDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_never_shows_the_client_secret()
|
||||
{
|
||||
var options = new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "super-secret-value", GetEnv = _ => null };
|
||||
Assert.DoesNotContain("super-secret-value", options.ToString());
|
||||
Assert.DoesNotContain("super-secret-value", options.Resolve()!.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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}\"}}";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user