282 lines
8.7 KiB
TypeScript
282 lines
8.7 KiB
TypeScript
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<GcalConfig>): 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<typeof google.calendar>,
|
|
params: {
|
|
calendarId?: string;
|
|
timeMin?: string;
|
|
timeMax?: string;
|
|
q?: string;
|
|
maxResults?: number;
|
|
},
|
|
): Promise<EventSummary[]> {
|
|
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<GcalConfig>): 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<ToolResult> => {
|
|
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<ToolResult> => {
|
|
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<ToolResult> => {
|
|
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];
|
|
}
|