Add spec for in-bot Google Calendar connect flow

This commit is contained in:
EugeneTes
2026-09-23 09:21:30 +00:00
parent 288506a390
commit 6d430b8c0c

View File

@@ -0,0 +1,370 @@
# In-bot Google Calendar connect flow
## Goal
Replace the manual "run an OAuth device-flow bootstrap script and paste the resulting refresh token into `.env`" ceremony with two Telegram commands the owner runs inside the bot:
1. `/connect_calendar` — bot starts a Google device-authorization flow and replies with the verification URL + user code.
2. `/calendar_done` — after the owner has approved the request on Google's page, this command tells the bot to exchange the device code for a refresh token, save it, and report success.
No background polling on the bot's side, no restart, no `.env` edit. The static OAuth client (`GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET`) still lives in env because it belongs to the deployer, not the owner.
## Non-goals
- **Multiple Google accounts.** The bot has one owner (`TELEGRAM_OWNER_ID`); one refresh token is enough.
- **Additional Google APIs (Gmail, Drive, Contacts).** Scope stays at `https://www.googleapis.com/auth/calendar`.
- **Automatic reconnect on `invalid_grant`.** The existing "re-run the bootstrap" error message already tells the owner what to do; the "bootstrap" is now `/connect_calendar` instead of a script.
- **`/disconnect_calendar`.** Not asked for; deleting `workspace/.secretary/google-token.json` by hand is a fine escape hatch until it comes up.
- **Background polling of Google's token endpoint.** Explicitly rejected in favour of an owner-triggered `/calendar_done`. Simpler control flow, no timers to cancel on shutdown, no chance of a polling reply colliding with an active agent run.
- **Hot-swapping the tool in and out of the agent's tool list.** The calendar tool is registered whenever `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set. If there's no refresh token, the tool self-reports "not connected" on invocation. The tool list stays stable across a connect.
## Architecture
Three pieces:
1. **Token store** (new `src/tools/google-token-store.ts`) — file-backed storage for the refresh token at `workspace/.secretary/google-token.json`.
2. **Device-flow client** (new `src/telegram/google-device-flow.ts`) — thin wrapper over Google's two device-flow endpoints. Testable in isolation with a mocked `fetch`.
3. **Bridge commands** (edits in `src/telegram/bridge.ts`) — two new slash commands that use the client and the store, plus a single in-memory `pendingDevice` variable for the flow state.
Existing calendar tool (`src/tools/calendar.ts`) is refactored to read the refresh token from the store on each `getAccessToken()` call rather than closing over a static string.
### Token store
```ts
// src/tools/google-token-store.ts
export interface GoogleTokenStore {
read(): string | null; // returns refresh_token or null
write(refreshToken: string): void;
}
export function createGoogleTokenStore(workspaceDir: string): GoogleTokenStore;
```
- Path: `path.join(workspaceDir, ".secretary", "google-token.json")`.
- File shape: `{ "refresh_token": "1//0e..." }`. One key today, easy to extend.
- `write()` uses `fs.writeFileSync` with `mode: 0o600` and creates the `.secretary` directory if it doesn't exist (already ensured by `ensureWorkspace`, but be defensive — this may be called before/without the scheduler).
- `read()` returns `null` if the file doesn't exist, is empty, or fails to parse. Never throws — an unreadable token file is treated the same as an unset one, so the "not connected" branch takes over.
- Synchronous API is fine: each call happens once per calendar tool invocation and once per `/calendar_done`, and the file is a few hundred bytes.
### Device-flow client
```ts
// src/telegram/google-device-flow.ts
export interface DeviceCode {
deviceCode: string;
userCode: string;
verificationUrl: string;
expiresAt: number; // ms since epoch, computed from expires_in
interval: number; // seconds — informational; we don't poll
}
export interface DeviceFlowClient {
start(): Promise<DeviceCode>;
complete(deviceCode: string): Promise<CompleteResult>;
}
export type CompleteResult =
| { kind: "success"; refreshToken: string }
| { kind: "pending" }
| { kind: "denied" }
| { kind: "expired" }
| { kind: "slow_down" }
| { kind: "error"; message: string };
export function createDeviceFlowClient(opts: {
clientId: string;
clientSecret: string;
scope?: string; // default "https://www.googleapis.com/auth/calendar"
fetchFn?: typeof fetch; // injectable for tests
}): DeviceFlowClient;
```
`start()` POSTs to `https://oauth2.googleapis.com/device/code` with `client_id` and `scope`, unwraps `{ device_code, user_code, verification_url, expires_in, interval }`, computes `expiresAt = Date.now() + expires_in * 1000`, returns the `DeviceCode`.
`complete()` POSTs to `https://oauth2.googleapis.com/token` with:
- `client_id`, `client_secret`
- `device_code`
- `grant_type=urn:ietf:params:oauth:grant-type:device_code`
Google returns HTTP 200 with `{ access_token, refresh_token, ... }` on success, or HTTP 400 with `{ error: "..." }` for the intermediate states. Map errors to `CompleteResult` kinds:
| Google `error` | `CompleteResult` |
| --- | --- |
| `authorization_pending` | `{ kind: "pending" }` |
| `slow_down` | `{ kind: "slow_down" }` (we don't poll, but surface it so tests can assert the mapping) |
| `access_denied` | `{ kind: "denied" }` |
| `expired_token` | `{ kind: "expired" }` |
| anything else, or a non-2xx/non-400 response | `{ kind: "error"; message: <error + description or status text> }` |
Success requires `refresh_token` to be present in the response body. Google only issues it on the *first* authorization for a given client+account; if the owner has connected this client to their Google account before and the app hasn't been revoked, subsequent completions may return only an access token. If `refresh_token` is missing on success, return `{ kind: "error"; message: "Google returned no refresh token. Revoke this app at https://myaccount.google.com/permissions and try again." }`.
### Bridge commands
State (module-level in `bridge.ts`, single owner so no map):
```ts
interface PendingDevice {
deviceCode: string;
userCode: string;
verificationUrl: string;
expiresAt: number;
}
let pendingDevice: PendingDevice | null = null;
```
Deps additions to the bridge init: `deviceFlow: DeviceFlowClient | null`, `tokenStore: GoogleTokenStore | null`. Both are `null` when `google` config is absent (i.e. `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` unset). In that case, both commands reply *"Google Calendar isn't configured on this bot. Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the environment first."*
Command bodies:
**`/connect_calendar`**
1. If `deviceFlow` is null → reply the "not configured" message.
2. Call `deviceFlow.start()`. On failure, reply *"Couldn't start Google device flow: `<message>`."*
3. Set `pendingDevice` to the returned `DeviceCode` (overwrites any prior pending flow).
4. Reply:
> Go to `<verificationUrl>` and enter code **`<userCode>`**.
>
> When you've approved it there, send `/calendar_done` here and I'll finish the connection.
>
> Code expires in `<N>` minutes.
`N` is `Math.round((expiresAt - now) / 60000)`.
**`/calendar_done`**
1. If `deviceFlow` or `tokenStore` is null → "not configured" message.
2. If `pendingDevice` is null → *"No pending calendar connection. Run /connect_calendar first."*
3. If `pendingDevice.expiresAt <= Date.now()` → clear it, reply *"That code expired. Run /connect_calendar to start over."*
4. Call `deviceFlow.complete(pendingDevice.deviceCode)`. Dispatch on result:
| Result | Reply | Effect on `pendingDevice` |
| --- | --- | --- |
| `success` | *"✅ Calendar connected."* | cleared |
| `pending` | *"Google says you haven't approved it yet. Approve at `<url>` (code `<userCode>`), then send /calendar_done again."* | kept |
| `slow_down` | same as `pending` (owner-driven, not our concern to throttle) | kept |
| `denied` | *"You denied the request. Run /connect_calendar to try again."* | cleared |
| `expired` | *"That code expired. Run /connect_calendar to start over."* | cleared |
| `error` | *"Couldn't finish Google connection: `<message>`."* | kept (so retry works) |
5. On `success`, `tokenStore.write(refreshToken)` before the reply — if the write throws, reply *"Got a refresh token but failed to save it: `<message>`."* and leave `pendingDevice` cleared (the token is lost either way; the owner can just re-run).
Both commands go through the existing owner-only guard (`ctx.from?.id !== config.telegramOwnerId → ignore`).
Add both to `BOT_COMMANDS`:
```ts
{ command: "connect_calendar", description: "Link your Google Calendar" },
{ command: "calendar_done", description: "Finish linking Google Calendar" },
```
### Calendar tool refactor
Change `createCalendarTool` to depend on the store instead of a static token:
```ts
// before
export function createCalendarTool(google: GoogleConfig, timezone: string) { ... }
// after
export interface CalendarDeps {
clientId: string;
clientSecret: string;
tokenStore: GoogleTokenStore;
timezone: string;
}
export function createCalendarTool(deps: CalendarDeps) { ... }
```
Inside `getAccessToken`:
1. Read `deps.tokenStore.read()`. If null → throw `Error("Google Calendar is not connected. Run /connect_calendar in the bot to link your account.")` — the tool wrapper turns thrown errors into tool-error text, which the agent surfaces to the owner naturally.
2. Reuse the cached access token if still valid (unchanged from today).
3. Otherwise, POST to `https://oauth2.googleapis.com/token` with the current refresh token. Existing `invalid_grant` handling stays but its message becomes: *"Google refresh token was rejected (invalid_grant). Run /connect_calendar in the bot to relink."*
Cache invalidation: if `tokenStore.read()` returns a different refresh token than we last minted an access token for, drop the cached access token. Cheap: keep `cachedForRefresh: string | null` alongside `cachedToken`.
### Wiring in `main.ts`
```ts
if (config.google) {
const tokenStore = createGoogleTokenStore(config.workspaceDir);
if (config.google.refreshToken && tokenStore.read() === null) {
tokenStore.write(config.google.refreshToken);
logger.warn(
"GOOGLE_REFRESH_TOKEN env var seeded into workspace/.secretary/google-token.json. " +
"You can remove GOOGLE_REFRESH_TOKEN from .env now — future updates come through /connect_calendar."
);
}
customTools.push(createCalendarTool({
clientId: config.google.clientId,
clientSecret: config.google.clientSecret,
tokenStore,
timezone: config.timezone,
}));
logger.info("calendar tool enabled");
deviceFlow = createDeviceFlowClient({
clientId: config.google.clientId,
clientSecret: config.google.clientSecret,
});
tokenStoreForBridge = tokenStore;
} else {
logger.info("calendar tool disabled (set GOOGLE_CLIENT_ID/SECRET to enable)");
}
```
Then pass `deviceFlow` and `tokenStore` into `registerBridge`.
### Config change
`src/config.ts`:
- `GoogleConfig.refreshToken` becomes `string | undefined`.
- The current "all three or none" validation becomes "if any of client_id/client_secret is set, both must be set; refresh_token is optional and, when present, will be migrated to the workspace store on startup".
- Log a `warn` at load time if `GOOGLE_REFRESH_TOKEN` is set: it's deprecated and will be migrated at startup.
## Data flow
Happy path:
```
owner: /connect_calendar
→ bridge.commandHandler("connect_calendar") called
→ deviceFlow.start() → { deviceCode, userCode: "ABCD-EFGH", verificationUrl, expiresAt }
→ pendingDevice = <above>
→ outbound.sendPlain("Go to https://google.com/device and enter code ABCD-EFGH ...")
owner: (opens https://google.com/device, enters ABCD-EFGH, signs in, approves)
owner: /calendar_done
→ bridge.commandHandler("calendar_done")
→ pendingDevice exists, not expired
→ deviceFlow.complete(deviceCode) → { kind: "success", refreshToken: "1//0e..." }
→ tokenStore.write("1//0e...")
→ pendingDevice = null
→ outbound.sendPlain("✅ Calendar connected.")
owner: "what's on my calendar tomorrow?"
→ agent calls calendar tool with action=list
→ getAccessToken() reads store → mints access token → GET events
→ agent replies with the list
```
Not-yet-approved path:
```
owner: /calendar_done (before approving on Google's page)
→ deviceFlow.complete(deviceCode) → { kind: "pending" }
→ pendingDevice stays
→ outbound.sendPlain("Google says you haven't approved it yet ...")
```
Expired-before-completion path:
```
owner: /connect_calendar
→ pendingDevice set with expiresAt = now + 15 min
owner: (waits 20 minutes)
owner: /calendar_done
→ pendingDevice.expiresAt < now
→ pendingDevice = null
→ outbound.sendPlain("That code expired. Run /connect_calendar to start over.")
```
Restart mid-flow:
```
owner: /connect_calendar → gets code
[bot restarts]
owner: /calendar_done → pendingDevice is null (in-memory only) → "No pending calendar connection..."
```
Fine: run `/connect_calendar` again. The device flow is cheap, and this only matters in the rare case that the bot restarts in the ~15-minute window between the two commands.
## Testing
New unit test file `src/telegram/google-device-flow.test.ts`:
1. `start()` posts the right body, parses response, computes `expiresAt` from `expires_in`.
2. `complete()` returns `success` and extracts `refresh_token`.
3. `complete()` returns `pending` for HTTP 400 + `authorization_pending`.
4. `complete()` returns `slow_down` for HTTP 400 + `slow_down`.
5. `complete()` returns `denied` for `access_denied`.
6. `complete()` returns `expired` for `expired_token`.
7. `complete()` returns `error` with message for HTTP 500 / unknown error code.
8. `complete()` returns `error` when success body is missing `refresh_token`.
New unit test file `src/tools/google-token-store.test.ts`:
1. `read()` returns null when the file doesn't exist.
2. `read()` returns null when the file is empty / not JSON / missing the key.
3. `write()` creates the `.secretary` directory if absent.
4. `write()` then `read()` round-trips the token.
5. `write()` uses mode 0600 on POSIX (skip on Windows via a runtime check).
6. `write()` twice replaces the previous token.
Extensions to `src/telegram/bridge.test.ts` (or a new test file if bridge is already crowded):
1. `/connect_calendar` when `deviceFlow` is null → "not configured" message.
2. `/connect_calendar` success → sends URL + code message, `pendingDevice` set.
3. `/connect_calendar` twice in a row → second call overwrites `pendingDevice`, previous code is gone.
4. `/calendar_done` with null `pendingDevice` → "no pending" message.
5. `/calendar_done` with expired `pendingDevice` → "expired" message and state cleared.
6. `/calendar_done` success → `tokenStore.write` called with the token, "connected" message, state cleared.
7. `/calendar_done` pending → "haven't approved yet" message, state kept.
8. `/calendar_done` denied → "you denied" message, state cleared.
9. `/calendar_done` error → error message, state kept.
10. `/calendar_done` when `tokenStore.write` throws → "got a token but failed to save" message, state cleared.
Extensions to `src/tools/calendar.test.ts`:
1. When `tokenStore.read()` returns null → tool throws with the "not connected" message (or the wrapper returns it as tool-error text — assert whichever the current test setup already validates).
2. When the refresh token in the store changes between calls, the cached access token is dropped and a new one is minted.
3. `invalid_grant` from Google produces the updated error message that references `/connect_calendar`.
Config test additions:
1. Setting only `GOOGLE_CLIENT_ID` throws (still requires both id and secret).
2. Setting id + secret without refresh token no longer throws; `google.refreshToken` is `undefined`.
3. Setting id + secret + refresh token still parses; `google.refreshToken` is populated (for the startup migration path).
## README + `.env.example` updates
`.env.example` — reword the Google block:
```
# Google Calendar tool. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from a Google Cloud OAuth
# client of type "TVs and Limited Input devices" (scope: https://www.googleapis.com/auth/calendar).
# Then in Telegram, run /connect_calendar and follow the two-step flow — no refresh token needed here.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# GOOGLE_REFRESH_TOKEN= (deprecated: still read once at startup and migrated into
# workspace/.secretary/google-token.json, then ignored)
```
README — replace the current "bootstrap the refresh token" mention with:
> **Google Calendar.** In Google Cloud Console, create an OAuth 2.0 client of type *TVs and Limited Input devices* and enable the *Google Calendar API*. Put `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env`. Then in Telegram, run `/connect_calendar` — the bot will reply with a `https://google.com/device` link and a short code. Approve on that page, send `/calendar_done` back to the bot, and you're linked. The refresh token lives in `workspace/.secretary/google-token.json`.
Add commands to the `/status` / commands list line: `/connect_calendar`, `/calendar_done`.
Add a manual E2E step:
> 21. `/connect_calendar` from a fresh workspace (no `google-token.json`) → bot replies with a `https://google.com/device` URL and a code. Approve on that page, then `/calendar_done` → bot replies "✅ Calendar connected" and `workspace/.secretary/google-token.json` now exists. Ask "what's on my calendar tomorrow?" → the calendar tool answers (was previously "not connected"). Delete the token file, ask again → tool replies "not connected", run the flow to relink.
## Error handling
- Google network errors during `start()` / `complete()`: surface Google's error message verbatim; the owner can retry.
- Filesystem errors on `write()`: catch, message the owner, clear pending state.
- Filesystem errors on `read()`: return null (calendar tool self-reports "not connected").
- Bot restart mid-flow: pending state is lost (in-memory only); `/calendar_done` reports "no pending connection". Documented as expected.
- Race between two `/connect_calendar` calls: the second overwrites the first; the first code becomes unusable. Fine — owner just uses the code from the most recent message.
## Rejected alternatives
- **Poll Google's token endpoint in the background after `/connect_calendar`.** Rejected per user request: adds a timer we have to cancel on shutdown, a message-send that can collide with an active agent run, and complicates the mental model ("when will the bot reply?"). Owner-triggered `/calendar_done` is unambiguous.
- **Expose the flow as an agent tool instead of two slash commands.** Rejected: this is auth, not a task for the LLM. Slash commands are direct, unambiguous, and don't consume tokens or risk the model deciding to run the flow at odd moments.
- **Store the refresh token in `MEMORY.md` or `.env` at runtime.** Rejected: `MEMORY.md` is model-visible and the token doesn't belong there; rewriting `.env` at runtime is racy and requires a restart to pick up.
- **Hot-swap the calendar tool in and out of the agent's tool list when the token store changes.** Rejected: Pi's tool list is set at backend construction time; changing it mid-run means restarting the agent host. Cheaper for the always-registered tool to self-report "not connected" and instruct the owner what to do.
- **A single `/calendar` command that starts a flow on first call and completes it on the second.** Rejected: it's not obvious from the command name whether it's a start or a completion, and the reply the bot would have to give ("if you meant start over instead of finish, say X") is worse than having two clearly-named commands.
- **Web-server redirect flow (loopback or hosted callback URL).** Rejected: requires infrastructure (a public HTTPS endpoint or an ephemeral localhost port), which is a lot of moving parts for the same end-user UX.