mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 18:55:37 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
await writeFile(TOKENS_PATH, JSON.stringify(tokens, null, 2));
|
||||
}
|
||||
|
||||
async function refreshAccessToken(client: OAuthClient, tokens: Tokens): Promise<Tokens> {
|
||||
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<string, unknown>;
|
||||
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<string> {
|
||||
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<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
|
||||
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<CalendarList["items"]> {
|
||||
const result = await calendarRequest<CalendarList>("/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<CalendarEvent[]> {
|
||||
const params: Record<string, string> = {
|
||||
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<EventsResponse>(
|
||||
`/calendars/${encodeURIComponent(calendarId)}/events`,
|
||||
params
|
||||
);
|
||||
return result.items || [];
|
||||
}
|
||||
|
||||
export async function getTodayEvents(calendarId: string = "primary"): Promise<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await Promise.all([
|
||||
readFile(OAUTH_CLIENT_PATH),
|
||||
readFile(TOKENS_PATH),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Google API integrations for Zee
|
||||
*/
|
||||
|
||||
export * from "./calendar.js";
|
||||
+149
-39
@@ -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<ToolExecutionResult> => {
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user