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 { 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 };
|
type FetchCall = { url: string; init?: RequestInit };
|
||||||
|
|
||||||
@@ -338,3 +339,106 @@ describe("calendar tool", () => {
|
|||||||
).rejects.toThrow(/list_instances requires event_id/);
|
).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 { defineTool } from "@earendil-works/pi-coding-agent";
|
||||||
import { type Static, Type } from "typebox";
|
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({
|
export const calendarParams = Type.Object({
|
||||||
action: Type.Union(
|
action: Type.Union(
|
||||||
@@ -87,19 +87,36 @@ function eventUrl(calId: string, eventId: string): string {
|
|||||||
return `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(eventId)}`;
|
return `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(eventId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCalendarTool(google: GoogleConfig, timezone: string) {
|
export interface CalendarDeps {
|
||||||
let cachedToken: { access: string; expiresAt: number } | null = null;
|
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 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();
|
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", {
|
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
client_id: google.clientId,
|
client_id: deps.clientId,
|
||||||
client_secret: google.clientSecret,
|
client_secret: deps.clientSecret,
|
||||||
refresh_token: google.refreshToken,
|
refresh_token: refreshToken,
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -107,13 +124,17 @@ export function createCalendarTool(google: GoogleConfig, timezone: string) {
|
|||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
if (text.includes("invalid_grant")) {
|
if (text.includes("invalid_grant")) {
|
||||||
throw new Error(
|
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}`);
|
throw new Error(`Google token refresh failed: ${resp.status} ${text}`);
|
||||||
}
|
}
|
||||||
const data = (await resp.json()) as { access_token: string; expires_in: number };
|
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;
|
return data.access_token;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +146,7 @@ export function createCalendarTool(google: GoogleConfig, timezone: string) {
|
|||||||
promptSnippet: "Read and modify the owner's Google Calendar",
|
promptSnippet: "Read and modify the owner's Google Calendar",
|
||||||
parameters: calendarParams,
|
parameters: calendarParams,
|
||||||
execute: async (_toolCallId, params) => {
|
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: {} };
|
return { content: [{ type: "text", text }], details: {} };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user