Calendar tool reads refresh token from GoogleTokenStore per call
This commit is contained in:
@@ -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<typeof createCalendarTool>, params: unknown): Promise<string> {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> => {
|
||||
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: {} };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user