Add Google Calendar as a native Pi custom tool
pi-claude-code-provider fences off Claude Code's MCP servers by design, so managed MCPs like Google Calendar can't reach the bot through that provider. Implement calendar access as a first-class Pi tool instead — it lives alongside `schedule` and calls Google Calendar API v3 directly with a subscription-free refresh-token grant. Config: three new optional env vars (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN) — set all three to enable the tool, leave all three blank to disable. Partial config raises a ConfigError at startup so mis-set env is caught early. Tool: list / create / update / delete on any calendar (default 'primary'). Access tokens are cached until expiry and refreshed on demand from the long-lived refresh token. Invalid_grant surfaces a clear "re-run device flow" error.
This commit is contained in:
@@ -14,3 +14,10 @@ SECRETARY_TIMEZONE=
|
||||
SECRETARY_SHELL=
|
||||
LOG_LEVEL=info
|
||||
# PI_CODING_AGENT_DIR= (Pi's own override for ~/.pi/agent, mainly for Docker)
|
||||
|
||||
# Google Calendar tool: all three or none. Bootstrap the refresh token with an
|
||||
# OAuth device flow against a Google Cloud OAuth client (type: TVs and Limited
|
||||
# Input devices) — see docs/. Scope needed: https://www.googleapis.com/auth/calendar
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REFRESH_TOKEN=
|
||||
|
||||
@@ -14,6 +14,9 @@ services:
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- SECRETARY_TIMEZONE=${SECRETARY_TIMEZONE:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- GOOGLE_REFRESH_TOKEN=${GOOGLE_REFRESH_TOKEN:-}
|
||||
volumes:
|
||||
- /media/disk1/containers/volcanic_secretary/workspace:/workspace
|
||||
- /media/disk1/containers/volcanic_secretary/pi-agent:/pi-agent
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface Config {
|
||||
timezone: string;
|
||||
shellPath?: string;
|
||||
logLevel: LogLevel;
|
||||
google?: GoogleConfig;
|
||||
}
|
||||
|
||||
export interface GoogleConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class ConfigError extends Error {}
|
||||
@@ -37,6 +44,20 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env, cwd: string = p
|
||||
throw new ConfigError(`SECRETARY_TIMEZONE is not a valid IANA timezone: "${timezone}"`);
|
||||
}
|
||||
|
||||
const googleClientId = blankToUndefined(env.GOOGLE_CLIENT_ID);
|
||||
const googleClientSecret = blankToUndefined(env.GOOGLE_CLIENT_SECRET);
|
||||
const googleRefreshToken = blankToUndefined(env.GOOGLE_REFRESH_TOKEN);
|
||||
const googleParts = [googleClientId, googleClientSecret, googleRefreshToken];
|
||||
if (googleParts.some(Boolean) && !googleParts.every(Boolean)) {
|
||||
throw new ConfigError(
|
||||
"Google Calendar needs all three of GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN, or none.",
|
||||
);
|
||||
}
|
||||
const google: GoogleConfig | undefined =
|
||||
googleClientId && googleClientSecret && googleRefreshToken
|
||||
? { clientId: googleClientId, clientSecret: googleClientSecret, refreshToken: googleRefreshToken }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
telegramBotToken,
|
||||
telegramOwnerId,
|
||||
@@ -47,6 +68,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env, cwd: string = p
|
||||
timezone,
|
||||
shellPath: blankToUndefined(env.SECRETARY_SHELL),
|
||||
logLevel,
|
||||
google,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
12
src/main.ts
12
src/main.ts
@@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { autoRetry } from "@grammyjs/auto-retry";
|
||||
import { Bot } from "grammy";
|
||||
import { AgentHost } from "./agent/host.js";
|
||||
import { createPiBackend } from "./agent/pi-backend.js";
|
||||
import { createPiBackend, type CustomTools } from "./agent/pi-backend.js";
|
||||
import { ConfigError, loadConfig } from "./config.js";
|
||||
import { createLogger } from "./log.js";
|
||||
import { Scheduler } from "./scheduler/scheduler.js";
|
||||
@@ -10,6 +10,7 @@ import { JobStore } from "./scheduler/store.js";
|
||||
import { createOpenAiTranscriber } from "./stt/openai.js";
|
||||
import { BOT_COMMANDS, formatStatus, registerBridge } from "./telegram/bridge.js";
|
||||
import { Outbound } from "./telegram/outbound.js";
|
||||
import { createCalendarTool } from "./tools/calendar.js";
|
||||
import { createScheduleTool } from "./tools/schedule.js";
|
||||
import { ensureWorkspace } from "./workspace.js";
|
||||
|
||||
@@ -73,11 +74,18 @@ async function main(): Promise<void> {
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const customTools: CustomTools = [createScheduleTool(scheduler, config.timezone)];
|
||||
if (config.google) {
|
||||
customTools.push(createCalendarTool(config.google, config.timezone));
|
||||
logger.info("calendar tool enabled");
|
||||
} else {
|
||||
logger.info("calendar tool disabled (set GOOGLE_CLIENT_ID/SECRET/REFRESH_TOKEN to enable)");
|
||||
}
|
||||
const backend = createPiBackend({
|
||||
workspaceDir: config.workspaceDir,
|
||||
piModel: config.piModel,
|
||||
timezone: config.timezone,
|
||||
customTools: [createScheduleTool(scheduler, config.timezone)],
|
||||
customTools,
|
||||
logger,
|
||||
onShutdownRequest: () => void shutdown("extension request"),
|
||||
});
|
||||
|
||||
251
src/tools/calendar.ts
Normal file
251
src/tools/calendar.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { GoogleConfig } from "../config.js";
|
||||
|
||||
export const calendarParams = Type.Object({
|
||||
action: Type.Union(
|
||||
[Type.Literal("list"), Type.Literal("create"), Type.Literal("update"), Type.Literal("delete")],
|
||||
{ description: "list events, create/update/delete a single event" },
|
||||
),
|
||||
calendar_id: Type.Optional(
|
||||
Type.String({ description: "Calendar id. Default 'primary' (the owner's main calendar)." }),
|
||||
),
|
||||
time_min: Type.Optional(
|
||||
Type.String({
|
||||
description: "list: ISO 8601 lower bound for events (inclusive), e.g. 2026-09-07T00:00:00+02:00. Default: now.",
|
||||
}),
|
||||
),
|
||||
time_max: Type.Optional(
|
||||
Type.String({ description: "list: ISO 8601 upper bound (exclusive). Optional." }),
|
||||
),
|
||||
q: Type.Optional(Type.String({ description: "list: free-text search over summaries/descriptions." })),
|
||||
max_results: Type.Optional(
|
||||
Type.Number({ description: "list: max events returned. Default 25, capped at 250." }),
|
||||
),
|
||||
event_id: Type.Optional(Type.String({ description: "Event id. Required for update and delete." })),
|
||||
summary: Type.Optional(Type.String({ description: "create/update: event title." })),
|
||||
description: Type.Optional(Type.String({ description: "create/update: event description." })),
|
||||
location: Type.Optional(Type.String({ description: "create/update: location string." })),
|
||||
start: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"create/update: start. ISO 8601 with offset for timed events (2026-09-10T10:00:00+02:00), or YYYY-MM-DD for all-day.",
|
||||
}),
|
||||
),
|
||||
end: Type.Optional(
|
||||
Type.String({ description: "create/update: end. Same format as start." }),
|
||||
),
|
||||
});
|
||||
|
||||
export type CalendarParams = Static<typeof calendarParams>;
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
end?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
htmlLink?: string;
|
||||
}
|
||||
|
||||
const CALENDAR_API = "https://www.googleapis.com/calendar/v3";
|
||||
|
||||
export function createCalendarTool(google: GoogleConfig, timezone: string) {
|
||||
let cachedToken: { access: string; expiresAt: number } | null = null;
|
||||
|
||||
const getAccessToken = async (): Promise<string> => {
|
||||
const now = Date.now();
|
||||
if (cachedToken && 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,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
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.",
|
||||
);
|
||||
}
|
||||
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 };
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
return defineTool({
|
||||
name: "calendar",
|
||||
label: "Calendar",
|
||||
description:
|
||||
"Read and modify the owner's Google Calendar: list events, or create, update, or delete an event.",
|
||||
promptSnippet: "Read and modify the owner's Google Calendar",
|
||||
parameters: calendarParams,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = await handleCalendar(params, timezone, getAccessToken);
|
||||
return { content: [{ type: "text", text }], details: {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCalendar(
|
||||
params: CalendarParams,
|
||||
timezone: string,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
const calId = params.calendar_id?.trim() || "primary";
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
return listEvents(calId, params, timezone, getAccessToken);
|
||||
case "create":
|
||||
return createEvent(calId, params, timezone, getAccessToken);
|
||||
case "update":
|
||||
return updateEvent(calId, params, timezone, getAccessToken);
|
||||
case "delete":
|
||||
return deleteEvent(calId, params, getAccessToken);
|
||||
}
|
||||
}
|
||||
|
||||
async function listEvents(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
timezone: string,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
const url = new URL(`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events`);
|
||||
url.searchParams.set("timeMin", params.time_min ?? new Date().toISOString());
|
||||
if (params.time_max) url.searchParams.set("timeMax", params.time_max);
|
||||
if (params.q) url.searchParams.set("q", params.q);
|
||||
url.searchParams.set("maxResults", String(Math.min(params.max_results ?? 25, 250)));
|
||||
url.searchParams.set("singleEvents", "true");
|
||||
url.searchParams.set("orderBy", "startTime");
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${await getAccessToken()}` },
|
||||
});
|
||||
await ensureOk(resp, "list events");
|
||||
const data = (await resp.json()) as { items?: CalendarEvent[] };
|
||||
const items = data.items ?? [];
|
||||
if (!items.length) return "No events.";
|
||||
return items.map((event) => describeEvent(event, timezone)).join("\n");
|
||||
}
|
||||
|
||||
async function createEvent(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
timezone: string,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
if (!params.summary) throw new Error("create requires summary");
|
||||
if (!params.start || !params.end) throw new Error("create requires start and end");
|
||||
const body = eventBody(params, timezone);
|
||||
const resp = await fetch(`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAccessToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await ensureOk(resp, "create event");
|
||||
const event = (await resp.json()) as CalendarEvent;
|
||||
return `Created: ${describeEvent(event, timezone)}`;
|
||||
}
|
||||
|
||||
async function updateEvent(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
timezone: string,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
if (!params.event_id) throw new Error("update requires event_id");
|
||||
const body = eventBody(params, timezone);
|
||||
if (Object.keys(body).length === 0) throw new Error("update requires at least one field to change");
|
||||
const resp = await fetch(
|
||||
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAccessToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
await ensureOk(resp, "update event");
|
||||
const event = (await resp.json()) as CalendarEvent;
|
||||
return `Updated: ${describeEvent(event, timezone)}`;
|
||||
}
|
||||
|
||||
async function deleteEvent(
|
||||
calId: string,
|
||||
params: CalendarParams,
|
||||
getAccessToken: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
if (!params.event_id) throw new Error("delete requires event_id");
|
||||
const resp = await fetch(
|
||||
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${await getAccessToken()}` },
|
||||
},
|
||||
);
|
||||
await ensureOk(resp, "delete event");
|
||||
return `Deleted event ${params.event_id}.`;
|
||||
}
|
||||
|
||||
function eventBody(params: CalendarParams, timezone: string): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (params.summary !== undefined) body.summary = params.summary;
|
||||
if (params.description !== undefined) body.description = params.description;
|
||||
if (params.location !== undefined) body.location = params.location;
|
||||
if (params.start !== undefined) body.start = toTimeField(params.start, timezone);
|
||||
if (params.end !== undefined) body.end = toTimeField(params.end, timezone);
|
||||
return body;
|
||||
}
|
||||
|
||||
function toTimeField(input: string, timezone: string): Record<string, string> {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(input.trim())
|
||||
? { date: input.trim() }
|
||||
: { dateTime: input, timeZone: timezone };
|
||||
}
|
||||
|
||||
async function ensureOk(resp: Response, action: string): Promise<void> {
|
||||
if (resp.ok) return;
|
||||
const text = await resp.text();
|
||||
throw new Error(`Google Calendar ${action} failed: ${resp.status} ${text}`);
|
||||
}
|
||||
|
||||
function describeEvent(event: CalendarEvent, timezone: string): string {
|
||||
const summary = event.summary ?? "(no title)";
|
||||
const when = describeWhen(event, timezone);
|
||||
const location = event.location ? ` @ ${event.location}` : "";
|
||||
return `${event.id} · ${summary} · ${when}${location}`;
|
||||
}
|
||||
|
||||
function describeWhen(event: CalendarEvent, timezone: string): string {
|
||||
const start = event.start ?? {};
|
||||
const end = event.end ?? {};
|
||||
if (start.date) {
|
||||
return start.date === end.date || !end.date
|
||||
? `all-day ${start.date}`
|
||||
: `all-day ${start.date} → ${end.date}`;
|
||||
}
|
||||
const s = start.dateTime ? formatTime(start.dateTime, timezone) : "?";
|
||||
const e = end.dateTime ? formatTime(end.dateTime, timezone) : "?";
|
||||
return `${s} → ${e}`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string, timezone: string): string {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: timezone,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
Reference in New Issue
Block a user