Merge branch 'feat/google-calendar'

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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