forked from admin/VolcanicAgenticCore
token.json is sealed with AES-256-GCM (key derived from the OAuth client secret, bound to the chat), so the agent's processes can read the file but not the refresh token. Plain files, including VolcanicSecretary's refresh_token format, are rewritten sealed on first read. The scheduler gains IScriptEnvironment: extra per-run variables set after the process environment filter and redacted from the run's output. The Telegram host uses it to pass GOOGLE_ACCESS_TOKEN, refreshed to cover the job's timeout; the refresh token and client secret never reach the script.
200 lines
12 KiB
Markdown
200 lines
12 KiB
Markdown
# 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 its workspace, at `<workspace>/.volcanic/google-calendar/token.json` (in the
|
|
Telegram bot, `<WorkspaceRoot>/<chatId>/.volcanic/google-calendar/token.json`). It stays there across `/new`,
|
|
resume and bot restarts.
|
|
|
|
- The file is encrypted: `{ "version": 2, "sealed": "..." }`, AES-256-GCM with a key derived from the OAuth client
|
|
secret and bound to the chat. The agent can read the file with `read` or `bash` (it runs as the bot's OS user, and
|
|
on some disks, such as NTFS, file modes mean nothing), but it never sees the client secret, so it can't decrypt
|
|
the file. A file copied into another chat's folder doesn't open there either.
|
|
- **A new client secret disconnects every chat**: their files no longer decrypt, and each chat runs
|
|
`/calendar_connect` again.
|
|
- A plain file, written before encryption or copied from VolcanicSecretary (`{ "refresh_token": "..." }`), is read
|
|
once and rewritten encrypted.
|
|
- On Unix the file has mode 0600 and its directory 0700.
|
|
- Deleting a chat's workspace disconnects its calendar locally. Run `/calendar_disconnect` first to also revoke the
|
|
token at Google.
|
|
|
|
A host can keep tokens out of the workspaces with `GoogleCalendarOptions.TokenDirectory`. Each chat then gets
|
|
`<TokenDirectory>/<chat key>/token.json`, where the chat key is the session scope (`telegram-<chatId>`) when it matches
|
|
`[A-Za-z0-9._-]{1,100}`, and otherwise `scope-<16 hex digits>`, the start of the scope's SHA-256 hash.
|
|
|
|
The file holds the refresh token, the granted scope, the account, the calendar's time zone and the connection time.
|
|
The refresh token never appears in logs, tool results or error messages.
|
|
|
|
## Watcher scripts
|
|
|
|
In the Telegram bot, a chat's [watcher scripts](telegram-bot.md#scheduling) get `GOOGLE_ACCESS_TOKEN` when the chat
|
|
has a connected calendar. The bot refreshes it before each run so that it is valid for the job's timeout plus a minute,
|
|
up to Google's one-hour limit. A script calls the Calendar API with it directly:
|
|
|
|
```bash
|
|
curl -s -H "Authorization: Bearer $GOOGLE_ACCESS_TOKEN" \
|
|
"https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=$(date -u +%FT%TZ)&maxResults=5"
|
|
```
|
|
|
|
The script never gets the refresh token or the client secret: if it leaks the access token, that token only works on
|
|
this chat's calendar and expires within the hour. Its value is redacted from the script's output and failure alerts.
|
|
Without a connected calendar, or when Google rejects the stored token, the variable is absent. Keep calendar watchers'
|
|
timeouts short: a run that outlasts the token gets `401` from Google.
|
|
|
|
## 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 |
|