import { google, type Auth } from 'googleapis'; import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; import type { GcalConfig } from '../../config/schema.js'; import type { Tool, ToolResult } from '../types.js'; /** Expand ~ to home directory. */ function expandPath(p: string): string { if (p.startsWith('~/') || p === '~') { return resolve(homedir(), p.slice(2)); } return resolve(p); } /** Create an OAuth2 client from Google Calendar config (credentials + token files). */ function createOAuth2Client(config: NonNullable): Auth.OAuth2Client { const credentialsPath = config.credentials_file; if (!credentialsPath) { throw new Error('No credentials_file configured. Set automation.gcal.credentials_file in config.'); } const expandedCredsPath = expandPath(credentialsPath); if (!existsSync(expandedCredsPath)) { throw new Error(`Credentials file not found: ${expandedCredsPath}`); } const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8')); const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {}; if (!client_id || !client_secret) { throw new Error('Invalid credentials file — missing client_id or client_secret'); } const oauth2Client = new google.auth.OAuth2( client_id, client_secret, redirect_uris?.[0] ?? 'http://localhost', ); const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gcal-token.json'); if (!existsSync(tokenPath)) { throw new Error(`Token file not found: ${tokenPath}. Run "flynn gcal-auth" to authenticate.`); } const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); oauth2Client.setCredentials(token); return oauth2Client; } interface EventSummary { id: string; summary: string; start: string; end: string; location: string; attendees: string[]; htmlLink: string; } /** Fetch events from Google Calendar. */ async function fetchEvents( calendar: ReturnType, params: { calendarId?: string; timeMin?: string; timeMax?: string; q?: string; maxResults?: number; }, ): Promise { const response = await calendar.events.list({ calendarId: params.calendarId ?? 'primary', timeMin: params.timeMin, timeMax: params.timeMax, q: params.q, maxResults: params.maxResults ?? 25, singleEvents: true, orderBy: 'startTime', }); const items = response.data.items ?? []; return items.map(event => ({ id: event.id ?? '', summary: event.summary ?? '(no title)', start: event.start?.dateTime ?? event.start?.date ?? '', end: event.end?.dateTime ?? event.end?.date ?? '', location: event.location ?? '', attendees: (event.attendees ?? []).map(a => a.email ?? '').filter(Boolean), htmlLink: event.htmlLink ?? '', })); } /** Format a list of event summaries for tool output. */ function formatEvents(events: EventSummary[]): string { if (events.length === 0) { return 'No events found.'; } return events .map(e => { const parts = [`[${e.id}] ${e.summary}`, ` Time: ${e.start} — ${e.end}`]; if (e.location) {parts.push(` Location: ${e.location}`);} if (e.attendees.length > 0) {parts.push(` Attendees: ${e.attendees.join(', ')}`);} if (e.htmlLink) {parts.push(` Link: ${e.htmlLink}`);} return parts.join('\n'); }) .join('\n\n'); } /** * Creates Google Calendar query tools bound to the given GcalConfig. * Tools create their own OAuth2 client per invocation. */ export function createGcalTools(config: NonNullable): Tool[] { const calendarToday: Tool = { name: 'calendar.today', description: "List today's events from Google Calendar. Returns summary, time, location, attendees, and link for each event.", requiredSecretScopes: ['gcal'], inputSchema: { type: 'object', properties: { calendarId: { type: 'string', description: 'Calendar ID to query (default: primary)', }, }, }, execute: async (rawArgs: unknown): Promise => { const args = rawArgs as { calendarId?: string }; try { const auth = createOAuth2Client(config); const calendar = google.calendar({ version: 'v3', auth }); 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); const events = await fetchEvents(calendar, { calendarId: args.calendarId, timeMin: startOfDay.toISOString(), timeMax: endOfDay.toISOString(), }); return { success: true, output: formatEvents(events), }; } catch (error) { return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; const calendarList: Tool = { name: 'calendar.list', description: 'List events from Google Calendar in a date range. Returns summary, time, location, attendees, and link for each event.', requiredSecretScopes: ['gcal'], inputSchema: { type: 'object', properties: { startDate: { type: 'string', description: 'Start date in ISO format (e.g. 2026-02-10)', }, endDate: { type: 'string', description: 'End date in ISO format (e.g. 2026-02-14)', }, calendarId: { type: 'string', description: 'Calendar ID to query (default: primary)', }, maxResults: { type: 'number', description: 'Maximum number of events to return (default: 25)', }, }, required: ['startDate', 'endDate'], }, execute: async (rawArgs: unknown): Promise => { const args = rawArgs as { startDate: string; endDate: string; calendarId?: string; maxResults?: number }; try { const startMs = Date.parse(args.startDate); const endMs = Date.parse(args.endDate); if (isNaN(startMs) || isNaN(endMs)) { return { success: false, output: '', error: 'Invalid date format. Use ISO format (e.g. 2026-02-10).' }; } const auth = createOAuth2Client(config); const calendar = google.calendar({ version: 'v3', auth }); const events = await fetchEvents(calendar, { calendarId: args.calendarId, timeMin: new Date(startMs).toISOString(), timeMax: new Date(endMs).toISOString(), maxResults: args.maxResults, }); return { success: true, output: formatEvents(events), }; } catch (error) { return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; const calendarSearch: Tool = { name: 'calendar.search', description: 'Search Google Calendar events by text query. Returns summary, time, location, attendees, and link for each match.', requiredSecretScopes: ['gcal'], inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Full-text search query', }, calendarId: { type: 'string', description: 'Calendar ID to query (default: primary)', }, maxResults: { type: 'number', description: 'Maximum number of results to return (default: 25)', }, }, required: ['query'], }, execute: async (rawArgs: unknown): Promise => { const args = rawArgs as { query: string; calendarId?: string; maxResults?: number }; try { const auth = createOAuth2Client(config); const calendar = google.calendar({ version: 'v3', auth }); // For search, use a wide time window (1 year back to 1 year ahead) const now = new Date(); const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); const yearAhead = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()); const events = await fetchEvents(calendar, { calendarId: args.calendarId, timeMin: yearAgo.toISOString(), timeMax: yearAhead.toISOString(), q: args.query, maxResults: args.maxResults, }); return { success: true, output: formatEvents(events), }; } catch (error) { return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; return [calendarToday, calendarList, calendarSearch]; }