Add spec for in-bot Google Calendar connect flow
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user