From 550b02445b5d5e007e583fc3fc14a1284dcf11e1 Mon Sep 17 00:00:00 2001 From: Artur Do Lago Date: Thu, 8 Jan 2026 23:40:20 +0100 Subject: [PATCH] feat(zee): integrate Google Calendar with canvas sidecar - Add Google Calendar client using OAuth2 tokens from ~/.zee/credentials/google/ - Update zee:calendar tool with real Google Calendar API integration - Support today, week, month, and custom date range queries - Auto-display calendar events in canvas sidecar - Handle token refresh automatically Co-Authored-By: Claude Opus 4.5 --- src/domain/zee/google/calendar.ts | 275 ++++++++++++++++++++++++++++++ src/domain/zee/google/index.ts | 5 + src/domain/zee/tools.ts | 188 +++++++++++++++----- 3 files changed, 429 insertions(+), 39 deletions(-) create mode 100644 src/domain/zee/google/calendar.ts create mode 100644 src/domain/zee/google/index.ts diff --git a/src/domain/zee/google/calendar.ts b/src/domain/zee/google/calendar.ts new file mode 100644 index 00000000000..4179a3bae2f --- /dev/null +++ b/src/domain/zee/google/calendar.ts @@ -0,0 +1,275 @@ +/** + * Google Calendar Client for Zee + * + * Uses OAuth2 tokens from ~/.zee/credentials/google/ to fetch calendar events. + */ + +import { readFile, writeFile } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; + +const CREDENTIALS_DIR = join(homedir(), ".zee", "credentials", "google"); +const OAUTH_CLIENT_PATH = join(CREDENTIALS_DIR, "oauth-client.json"); +const TOKENS_PATH = join(CREDENTIALS_DIR, "tokens.json"); + +const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"; + +interface OAuthClient { + installed: { + client_id: string; + client_secret: string; + token_uri: string; + }; +} + +interface Tokens { + access_token: string; + refresh_token: string; + expiry_date: number; + token_type: string; + scope: string; +} + +interface CalendarEvent { + id: string; + summary: string; + description?: string; + start: { dateTime?: string; date?: string; timeZone?: string }; + end: { dateTime?: string; date?: string; timeZone?: string }; + location?: string; + attendees?: Array<{ email: string; displayName?: string; responseStatus?: string }>; + status: string; + htmlLink?: string; +} + +interface CalendarList { + items: Array<{ + id: string; + summary: string; + primary?: boolean; + backgroundColor?: string; + }>; +} + +interface EventsResponse { + items: CalendarEvent[]; + nextPageToken?: string; + summary?: string; +} + +export interface FormattedEvent { + id: string; + title: string; + date: string; + startTime?: string; + endTime?: string; + location?: string; + description?: string; + isAllDay: boolean; +} + +async function loadCredentials(): Promise<{ client: OAuthClient; tokens: Tokens }> { + const [clientJson, tokensJson] = await Promise.all([ + readFile(OAUTH_CLIENT_PATH, "utf-8"), + readFile(TOKENS_PATH, "utf-8"), + ]); + return { + client: JSON.parse(clientJson) as OAuthClient, + tokens: JSON.parse(tokensJson) as Tokens, + }; +} + +async function saveTokens(tokens: Tokens): Promise { + await writeFile(TOKENS_PATH, JSON.stringify(tokens, null, 2)); +} + +async function refreshAccessToken(client: OAuthClient, tokens: Tokens): Promise { + const response = await fetch(client.installed.token_uri, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.installed.client_id, + client_secret: client.installed.client_secret, + refresh_token: tokens.refresh_token, + grant_type: "refresh_token", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to refresh token: ${error}`); + } + + const newTokenData = await response.json() as Record; + const updatedTokens: Tokens = { + ...tokens, + access_token: newTokenData.access_token as string, + expiry_date: Date.now() + ((newTokenData.expires_in as number) || 3600) * 1000, + }; + + await saveTokens(updatedTokens); + return updatedTokens; +} + +async function getValidToken(): Promise { + const { client, tokens } = await loadCredentials(); + + // Refresh if expired or expiring within 5 minutes + if (Date.now() > tokens.expiry_date - 5 * 60 * 1000) { + const refreshed = await refreshAccessToken(client, tokens); + return refreshed.access_token; + } + + return tokens.access_token; +} + +async function calendarRequest(endpoint: string, params?: Record): Promise { + const token = await getValidToken(); + const url = new URL(`${CALENDAR_API_BASE}${endpoint}`); + + if (params) { + Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); + } + + const response = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Calendar API error (${response.status}): ${error}`); + } + + return response.json() as T; +} + +export async function listCalendars(): Promise { + const result = await calendarRequest("/users/me/calendarList"); + return result.items; +} + +export async function listEvents( + calendarId: string = "primary", + options: { + timeMin?: string; + timeMax?: string; + maxResults?: number; + singleEvents?: boolean; + orderBy?: "startTime" | "updated"; + } = {} +): Promise { + const params: Record = { + singleEvents: String(options.singleEvents ?? true), + orderBy: options.orderBy ?? "startTime", + maxResults: String(options.maxResults ?? 50), + }; + + if (options.timeMin) params.timeMin = options.timeMin; + if (options.timeMax) params.timeMax = options.timeMax; + + const result = await calendarRequest( + `/calendars/${encodeURIComponent(calendarId)}/events`, + params + ); + return result.items || []; +} + +export async function getTodayEvents(calendarId: string = "primary"): Promise { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + + return listEvents(calendarId, { + timeMin: startOfDay.toISOString(), + timeMax: endOfDay.toISOString(), + }); +} + +export async function getWeekEvents(calendarId: string = "primary"): Promise { + const now = new Date(); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - now.getDay()); // Start from Sunday + startOfWeek.setHours(0, 0, 0, 0); + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 7); + + return listEvents(calendarId, { + timeMin: startOfWeek.toISOString(), + timeMax: endOfWeek.toISOString(), + }); +} + +export async function getMonthEvents( + calendarId: string = "primary", + year?: number, + month?: number +): Promise { + const now = new Date(); + const targetYear = year ?? now.getFullYear(); + const targetMonth = month ?? now.getMonth(); + + const startOfMonth = new Date(targetYear, targetMonth, 1); + const endOfMonth = new Date(targetYear, targetMonth + 1, 0, 23, 59, 59); + + return listEvents(calendarId, { + timeMin: startOfMonth.toISOString(), + timeMax: endOfMonth.toISOString(), + }); +} + +export function formatEventsForCanvas(events: CalendarEvent[]): FormattedEvent[] { + return events.map((event) => { + const startDateTime = event.start.dateTime || event.start.date; + const endDateTime = event.end.dateTime || event.end.date; + const isAllDay = !event.start.dateTime; + + let date = ""; + let startTime: string | undefined; + let endTime: string | undefined; + + if (startDateTime) { + const startDate = new Date(startDateTime); + date = startDate.toISOString().split("T")[0]; + + if (!isAllDay) { + startTime = startDate.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + if (endDateTime) { + const endDate = new Date(endDateTime); + endTime = endDate.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + } + } + } + + return { + id: event.id, + title: event.summary || "(No title)", + date, + startTime, + endTime, + location: event.location, + description: event.description, + isAllDay, + }; + }); +} + +export async function checkCredentialsExist(): Promise { + try { + await Promise.all([ + readFile(OAUTH_CLIENT_PATH), + readFile(TOKENS_PATH), + ]); + return true; + } catch { + return false; + } +} diff --git a/src/domain/zee/google/index.ts b/src/domain/zee/google/index.ts new file mode 100644 index 00000000000..90343a27029 --- /dev/null +++ b/src/domain/zee/google/index.ts @@ -0,0 +1,5 @@ +/** + * Google API integrations for Zee + */ + +export * from "./calendar.js"; diff --git a/src/domain/zee/tools.ts b/src/domain/zee/tools.ts index a75244f6bef..1bb5475dcb8 100644 --- a/src/domain/zee/tools.ts +++ b/src/domain/zee/tools.ts @@ -10,6 +10,16 @@ import { z } from "zod"; import type { ToolDefinition, ToolRuntime, ToolExecutionContext, ToolExecutionResult } from "../../mcp/types"; +import { + getTodayEvents, + getWeekEvents, + getMonthEvents, + listEvents, + formatEventsForCanvas, + checkCredentialsExist, + type FormattedEvent, +} from "./google/calendar.js"; +import { requestDaemon } from "../../daemon/ipc-client.js"; // ============================================================================= // Memory Store Tool @@ -255,64 +265,164 @@ The notification system will: // ============================================================================= const CalendarParams = z.object({ - action: z.enum(["list", "create", "update", "delete", "find_time"]) - .describe("Calendar action"), - event: z.object({ - title: z.string().optional(), - start: z.string().optional(), - end: z.string().optional(), - description: z.string().optional(), - attendees: z.array(z.string()).optional(), - }).optional().describe("Event details"), + action: z.enum(["list", "today", "week", "month", "show"]) + .describe("Calendar action: list (date range), today, week, month, or show (display in canvas)"), dateRange: z.object({ start: z.string(), end: z.string(), - }).optional().describe("Date range for queries"), - duration: z.number().optional() - .describe("Duration in minutes (for find_time)"), + }).optional().describe("Date range for 'list' action (ISO dates)"), + showInCanvas: z.boolean().default(true) + .describe("Display results in canvas sidecar"), + year: z.number().optional().describe("Year for 'month' action"), + month: z.number().min(0).max(11).optional().describe("Month (0-11) for 'month' action"), }); export const calendarTool: ToolDefinition = { id: "zee:calendar", category: "domain", init: async () => ({ - description: `Manage calendar events and find available time. + description: `View Google Calendar events and display them in canvas sidecar. + Actions: -- list: Show events in a date range -- create: Create new event -- update: Update existing event -- delete: Remove event -- find_time: Find available slots +- today: Show today's events +- week: Show this week's events +- month: Show this month's events (or specify year/month) +- list: Show events in a custom date range +- show: Just display calendar canvas without fetching new events Examples: -- List today's events: { action: "list", dateRange: { start: "2024-01-15", end: "2024-01-15" } } -- Find 30min slot: { action: "find_time", duration: 30 }`, +- Today's events: { action: "today" } +- This week: { action: "week" } +- January 2026: { action: "month", year: 2026, month: 0 } +- Custom range: { action: "list", dateRange: { start: "2026-01-01", end: "2026-01-15" } } +- Display only: { action: "show" }`, parameters: CalendarParams, execute: async (args, ctx): Promise => { - const { action, event, dateRange, duration } = args; + const { action, dateRange, showInCanvas, year, month } = args; ctx.metadata({ title: `Calendar: ${action}` }); - return { - title: `Calendar: ${action}`, - metadata: { - action, - hasEvent: !!event, - hasDateRange: !!dateRange, - }, - output: `[Zee would ${action} calendar] + // Check credentials first + const hasCredentials = await checkCredentialsExist(); + if (!hasCredentials) { + return { + title: `Calendar Error`, + metadata: { action }, + output: `Google Calendar credentials not found. -Action: ${action} -${event ? `Event: ${JSON.stringify(event, null, 2)}` : ""} -${dateRange ? `Date range: ${dateRange.start} to ${dateRange.end}` : ""} -${duration ? `Duration: ${duration} minutes` : ""} +Please set up OAuth credentials at: + ~/.zee/credentials/google/oauth-client.json + ~/.zee/credentials/google/tokens.json -Calendar integration: -- Google Calendar API -- Apple Calendar -- Outlook Calendar -- CalDAV support`, - }; +You can create credentials at: + https://console.cloud.google.com/apis/credentials + +Required scopes: + - https://www.googleapis.com/auth/calendar + - https://www.googleapis.com/auth/calendar.events`, + }; + } + + try { + let events: FormattedEvent[] = []; + let periodLabel = ""; + + if (action === "show") { + // Just show the canvas with current date + periodLabel = "Calendar"; + } else if (action === "today") { + const raw = await getTodayEvents(); + events = formatEventsForCanvas(raw); + periodLabel = "Today"; + } else if (action === "week") { + const raw = await getWeekEvents(); + events = formatEventsForCanvas(raw); + periodLabel = "This Week"; + } else if (action === "month") { + const raw = await getMonthEvents("primary", year, month); + events = formatEventsForCanvas(raw); + const targetDate = new Date(year ?? new Date().getFullYear(), month ?? new Date().getMonth(), 1); + periodLabel = targetDate.toLocaleDateString("en-US", { month: "long", year: "numeric" }); + } else if (action === "list" && dateRange) { + const raw = await listEvents("primary", { + timeMin: new Date(dateRange.start).toISOString(), + timeMax: new Date(dateRange.end).toISOString(), + }); + events = formatEventsForCanvas(raw); + periodLabel = `${dateRange.start} to ${dateRange.end}`; + } + + // Format events for display + const eventsList = events.length > 0 + ? events.map((e) => { + const time = e.isAllDay ? "All day" : `${e.startTime}${e.endTime ? ` - ${e.endTime}` : ""}`; + const loc = e.location ? ` @ ${e.location}` : ""; + return `• ${e.date} ${time}: ${e.title}${loc}`; + }).join("\n") + : "No events found."; + + // Show in canvas sidecar if requested + if (showInCanvas) { + try { + // Convert events to canvas calendar format + const canvasEvents = events.map((e) => ({ + date: e.date, + title: e.title, + })); + + const targetDate = action === "month" && year && month !== undefined + ? `${year}-${String(month + 1).padStart(2, "0")}-15` + : new Date().toISOString().split("T")[0]; + + await requestDaemon("canvas:spawn", { + kind: "calendar", + id: "zee-calendar", + config: { + title: `Calendar: ${periodLabel}`, + date: targetDate, + events: canvasEvents, + }, + }); + } catch (canvasError) { + // Canvas might not be available, continue without it + } + } + + return { + title: `Calendar: ${periodLabel}`, + metadata: { + action, + eventCount: events.length, + period: periodLabel, + }, + output: `${periodLabel} - ${events.length} event(s) + +${eventsList}${showInCanvas ? "\n\nšŸ“… Calendar displayed in canvas sidecar." : ""}`, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if it's an auth error + if (errorMessage.includes("401") || errorMessage.includes("invalid_grant")) { + return { + title: `Calendar Auth Error`, + metadata: { action }, + output: `Google Calendar authentication failed. + +Your access token may have expired. Please re-authenticate: +1. Delete ~/.zee/credentials/google/tokens.json +2. Run the OAuth flow again to get new tokens + +Error: ${errorMessage}`, + }; + } + + return { + title: `Calendar Error`, + metadata: { action }, + output: `Failed to fetch calendar: ${errorMessage}`, + }; + } }, }), };