From ecf61c34e1decaace7d814a7e808b5adfeec9498 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 23 Sep 2026 10:07:50 +0000 Subject: [PATCH] Calendar tool reads refresh token from GoogleTokenStore per call --- src/tools/calendar.test.ts | 106 ++++++++++++++++++++++++++++++++++++- src/tools/calendar.ts | 41 ++++++++++---- 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/src/tools/calendar.test.ts b/src/tools/calendar.test.ts index 81adc35..5827d4a 100644 --- a/src/tools/calendar.test.ts +++ b/src/tools/calendar.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { handleCalendar } from "./calendar.js"; +import { createCalendarTool, handleCalendar } from "./calendar.js"; +import type { GoogleTokenStore } from "./google-token-store.js"; type FetchCall = { url: string; init?: RequestInit }; @@ -338,3 +339,106 @@ describe("calendar tool", () => { ).rejects.toThrow(/list_instances requires event_id/); }); }); + +function memoryStore(initial: string | null): GoogleTokenStore & { current: string | null } { + const state = { current: initial }; + return { + read: () => state.current, + write: (t: string) => { + state.current = t; + }, + get current() { + return state.current; + }, + set current(v: string | null) { + state.current = v; + }, + }; +} + +async function runTool(tool: ReturnType, params: unknown): Promise { + const res = await tool.execute("tc-1", params as never); + return (res.content[0] as { text: string }).text; +} + +describe("createCalendarTool store integration", () => { + let originalFetch: typeof globalThis.fetch; + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns a 'not connected' message when the store has no refresh token", async () => { + globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: memoryStore(null), + timezone: "UTC", + }); + await expect(runTool(tool, { action: "list" })).rejects.toThrow(/not connected/i); + await expect(runTool(tool, { action: "list" })).rejects.toThrow(/\/connect_calendar/); + }); + + it("mints an access token using the store's refresh token, then reuses the cached one", async () => { + const { fn, calls } = stubFetch([ + { body: { access_token: "at-1", expires_in: 3600 } }, + { body: { items: [] } }, + { body: { items: [] } }, + ]); + globalThis.fetch = fn as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: memoryStore("rt-1"), + timezone: "UTC", + }); + await runTool(tool, { action: "list" }); + await runTool(tool, { action: "list" }); + // First call = token refresh, second and third = list events (cached token). + expect(calls[0].url).toBe("https://oauth2.googleapis.com/token"); + expect(calls[1].url).toContain("/calendars/primary/events"); + expect(calls[2].url).toContain("/calendars/primary/events"); + const tokenBody = new URLSearchParams(calls[0].init!.body as string); + expect(tokenBody.get("refresh_token")).toBe("rt-1"); + }); + + it("drops the cached access token when the stored refresh token changes", async () => { + const store = memoryStore("rt-1"); + const { fn, calls } = stubFetch([ + { body: { access_token: "at-1", expires_in: 3600 } }, + { body: { items: [] } }, + { body: { access_token: "at-2", expires_in: 3600 } }, + { body: { items: [] } }, + ]); + globalThis.fetch = fn as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: store, + timezone: "UTC", + }); + await runTool(tool, { action: "list" }); + store.current = "rt-2"; + await runTool(tool, { action: "list" }); + // Two refresh calls, two list calls, interleaved. + expect(calls[0].url).toBe("https://oauth2.googleapis.com/token"); + expect(calls[2].url).toBe("https://oauth2.googleapis.com/token"); + const secondRefresh = new URLSearchParams(calls[2].init!.body as string); + expect(secondRefresh.get("refresh_token")).toBe("rt-2"); + }); + + it("invalid_grant returns the /connect_calendar message", async () => { + const { fn } = stubFetch([{ status: 400, body: { error: "invalid_grant" } }]); + globalThis.fetch = fn as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: memoryStore("rt-stale"), + timezone: "UTC", + }); + await expect(runTool(tool, { action: "list" })).rejects.toThrow(/\/connect_calendar/); + }); +}); diff --git a/src/tools/calendar.ts b/src/tools/calendar.ts index 29b3d50..77d87d5 100644 --- a/src/tools/calendar.ts +++ b/src/tools/calendar.ts @@ -1,6 +1,6 @@ import { defineTool } from "@earendil-works/pi-coding-agent"; import { type Static, Type } from "typebox"; -import type { GoogleConfig } from "../config.js"; +import type { GoogleTokenStore } from "./google-token-store.js"; export const calendarParams = Type.Object({ action: Type.Union( @@ -87,19 +87,36 @@ function eventUrl(calId: string, eventId: string): string { return `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(eventId)}`; } -export function createCalendarTool(google: GoogleConfig, timezone: string) { - let cachedToken: { access: string; expiresAt: number } | null = null; +export interface CalendarDeps { + clientId: string; + clientSecret: string; + tokenStore: GoogleTokenStore; + timezone: string; +} + +export function createCalendarTool(deps: CalendarDeps) { + let cachedToken: { access: string; expiresAt: number; refreshToken: string } | null = null; const getAccessToken = async (): Promise => { + const refreshToken = deps.tokenStore.read(); + if (!refreshToken) { + throw new Error("Google Calendar is not connected. Run /connect_calendar in the bot to link your Google account."); + } const now = Date.now(); - if (cachedToken && cachedToken.expiresAt > now + 60_000) return cachedToken.access; + if ( + cachedToken && + cachedToken.refreshToken === refreshToken && + cachedToken.expiresAt > now + 60_000 + ) { + return cachedToken.access; + } const resp = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - client_id: google.clientId, - client_secret: google.clientSecret, - refresh_token: google.refreshToken, + client_id: deps.clientId, + client_secret: deps.clientSecret, + refresh_token: refreshToken, grant_type: "refresh_token", }), }); @@ -107,13 +124,17 @@ export function createCalendarTool(google: GoogleConfig, timezone: string) { const text = await resp.text(); if (text.includes("invalid_grant")) { throw new Error( - "Google refresh token was rejected (invalid_grant). Re-run the device-flow bootstrap and update GOOGLE_REFRESH_TOKEN.", + "Google refresh token was rejected (invalid_grant). Run /connect_calendar in the bot to relink.", ); } throw new Error(`Google token refresh failed: ${resp.status} ${text}`); } const data = (await resp.json()) as { access_token: string; expires_in: number }; - cachedToken = { access: data.access_token, expiresAt: now + data.expires_in * 1000 }; + cachedToken = { + access: data.access_token, + expiresAt: now + data.expires_in * 1000, + refreshToken, + }; return data.access_token; }; @@ -125,7 +146,7 @@ export function createCalendarTool(google: GoogleConfig, timezone: string) { promptSnippet: "Read and modify the owner's Google Calendar", parameters: calendarParams, execute: async (_toolCallId, params) => { - const text = await handleCalendar(params, timezone, getAccessToken); + const text = await handleCalendar(params, deps.timezone, getAccessToken); return { content: [{ type: "text", text }], details: {} }; }, });