20 KiB
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:
/connect_calendar— bot starts a Google device-authorization flow and replies with the verification URL + user code./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_calendarinstead of a script. /disconnect_calendar. Not asked for; deletingworkspace/.secretary/google-token.jsonby 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_IDandGOOGLE_CLIENT_SECRETare 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:
- Token store (new
src/tools/google-token-store.ts) — file-backed storage for the refresh token atworkspace/.secretary/google-token.json. - Device-flow client (new
src/telegram/google-device-flow.ts) — thin wrapper over Google's two device-flow endpoints. Testable in isolation with a mockedfetch. - Bridge commands (edits in
src/telegram/bridge.ts) — two new slash commands that use the client and the store, plus a single in-memorypendingDevicevariable 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
// 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()usesfs.writeFileSyncwithmode: 0o600and creates the.secretarydirectory if it doesn't exist (already ensured byensureWorkspace, but be defensive — this may be called before/without the scheduler).read()returnsnullif 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
// 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_secretdevice_codegrant_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):
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
-
If
deviceFlowis null → reply the "not configured" message. -
Call
deviceFlow.start(). On failure, reply "Couldn't start Google device flow:<message>." -
Set
pendingDeviceto the returnedDeviceCode(overwrites any prior pending flow). -
Reply:
Go to
<verificationUrl>and enter code<userCode>.When you've approved it there, send
/calendar_donehere and I'll finish the connection.Code expires in
<N>minutes.NisMath.round((expiresAt - now) / 60000).
/calendar_done
-
If
deviceFlowortokenStoreis null → "not configured" message. -
If
pendingDeviceis null → "No pending calendar connection. Run /connect_calendar first." -
If
pendingDevice.expiresAt <= Date.now()→ clear it, reply "That code expired. Run /connect_calendar to start over." -
Call
deviceFlow.complete(pendingDevice.deviceCode). Dispatch on result:Result Reply Effect on pendingDevicesuccess"✅ Calendar connected." cleared pending"Google says you haven't approved it yet. Approve at <url>(code<userCode>), then send /calendar_done again."kept slow_downsame 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) -
On
success,tokenStore.write(refreshToken)before the reply — if the write throws, reply "Got a refresh token but failed to save it:<message>." and leavependingDevicecleared (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:
{ 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:
// 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:
- Read
deps.tokenStore.read(). If null → throwError("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. - Reuse the cached access token if still valid (unchanged from today).
- Otherwise, POST to
https://oauth2.googleapis.com/tokenwith the current refresh token. Existinginvalid_granthandling 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
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.refreshTokenbecomesstring | 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
warnat load time ifGOOGLE_REFRESH_TOKENis 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:
start()posts the right body, parses response, computesexpiresAtfromexpires_in.complete()returnssuccessand extractsrefresh_token.complete()returnspendingfor HTTP 400 +authorization_pending.complete()returnsslow_downfor HTTP 400 +slow_down.complete()returnsdeniedforaccess_denied.complete()returnsexpiredforexpired_token.complete()returnserrorwith message for HTTP 500 / unknown error code.complete()returnserrorwhen success body is missingrefresh_token.
New unit test file src/tools/google-token-store.test.ts:
read()returns null when the file doesn't exist.read()returns null when the file is empty / not JSON / missing the key.write()creates the.secretarydirectory if absent.write()thenread()round-trips the token.write()uses mode 0600 on POSIX (skip on Windows via a runtime check).write()twice replaces the previous token.
Extensions to src/telegram/bridge.test.ts (or a new test file if bridge is already crowded):
/connect_calendarwhendeviceFlowis null → "not configured" message./connect_calendarsuccess → sends URL + code message,pendingDeviceset./connect_calendartwice in a row → second call overwritespendingDevice, previous code is gone./calendar_donewith nullpendingDevice→ "no pending" message./calendar_donewith expiredpendingDevice→ "expired" message and state cleared./calendar_donesuccess →tokenStore.writecalled with the token, "connected" message, state cleared./calendar_donepending → "haven't approved yet" message, state kept./calendar_donedenied → "you denied" message, state cleared./calendar_doneerror → error message, state kept./calendar_donewhentokenStore.writethrows → "got a token but failed to save" message, state cleared.
Extensions to src/tools/calendar.test.ts:
- 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). - When the refresh token in the store changes between calls, the cached access token is dropped and a new one is minted.
invalid_grantfrom Google produces the updated error message that references/connect_calendar.
Config test additions:
- Setting only
GOOGLE_CLIENT_IDthrows (still requires both id and secret). - Setting id + secret without refresh token no longer throws;
google.refreshTokenisundefined. - Setting id + secret + refresh token still parses;
google.refreshTokenis 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_IDandGOOGLE_CLIENT_SECRETin.env. Then in Telegram, run/connect_calendar— the bot will reply with ahttps://google.com/devicelink and a short code. Approve on that page, send/calendar_doneback to the bot, and you're linked. The refresh token lives inworkspace/.secretary/google-token.json.
Add commands to the /status / commands list line: /connect_calendar, /calendar_done.
Add a manual E2E step:
/connect_calendarfrom a fresh workspace (nogoogle-token.json) → bot replies with ahttps://google.com/deviceURL and a code. Approve on that page, then/calendar_done→ bot replies "✅ Calendar connected" andworkspace/.secretary/google-token.jsonnow 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_donereports "no pending connection". Documented as expected. - Race between two
/connect_calendarcalls: 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_doneis 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.mdor.envat runtime. Rejected:MEMORY.mdis model-visible and the token doesn't belong there; rewriting.envat 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
/calendarcommand 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.