forked from admin/VolcanicAgenticCore
Merge branch 'feat/google-calendar'
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user