From 94264e848cba5a69149bb7409b3286fdd742ff14 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 11:40:53 -0800 Subject: [PATCH] feat(tools): add Google Calendar tools and register Gmail/GCal in daemon Add calendar.today, calendar.list, calendar.search tools mirroring the Gmail tool pattern. Includes gcal-auth CLI command, config schema, tool policy entries (messaging/coding profiles + group:gcal), and 17 tests. Also wires up gmail and gcal tool registration in the daemon and TUI. Co-Authored-By: Claude Opus 4.6 --- src/cli/gcal-auth.ts | 245 ++++++++++++++++++++++ src/cli/index.ts | 2 + src/config/schema.ts | 9 + src/daemon/index.ts | 8 +- src/tools/builtin/gcal.test.ts | 355 ++++++++++++++++++++++++++++++++ src/tools/builtin/gcal.ts | 278 +++++++++++++++++++++++++ src/tools/builtin/gmail.test.ts | 259 +++++++++++++++++++++++ src/tools/builtin/gmail.ts | 216 +++++++++++++++++++ src/tools/builtin/index.ts | 2 + src/tools/index.ts | 2 +- src/tools/policy.ts | 12 ++ 11 files changed, 1386 insertions(+), 2 deletions(-) create mode 100644 src/cli/gcal-auth.ts create mode 100644 src/tools/builtin/gcal.test.ts create mode 100644 src/tools/builtin/gcal.ts create mode 100644 src/tools/builtin/gmail.test.ts create mode 100644 src/tools/builtin/gmail.ts diff --git a/src/cli/gcal-auth.ts b/src/cli/gcal-auth.ts new file mode 100644 index 0000000..49d7bca --- /dev/null +++ b/src/cli/gcal-auth.ts @@ -0,0 +1,245 @@ +import type { Command } from 'commander'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { createServer, type Server } from 'http'; +import { URL } from 'url'; +import { loadConfigSafe } from './shared.js'; + +const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']; +const REDIRECT_PORT = 3000; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`; + +/** Expand ~ to the user's home directory. */ +function expandPath(p: string): string { + if (p.startsWith('~/') || p === '~') { + return resolve(homedir(), p.slice(2)); + } + return resolve(p); +} + +/** Read and parse the OAuth2 credentials file. */ +function readCredentials(credentialsPath: string): { + client_id: string; + client_secret: string; + redirect_uris?: string[]; +} { + if (!existsSync(credentialsPath)) { + throw new Error(`Credentials file not found: ${credentialsPath}`); + } + + const credentials = JSON.parse(readFileSync(credentialsPath, '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'); + } + + return { client_id, client_secret, redirect_uris }; +} + +/** Generate the OAuth2 authorization URL. */ +function generateAuthUrl(clientId: string, clientSecret: string, redirectUri: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +} + +/** Exchange authorization code for tokens using Google's token endpoint. */ +async function exchangeCodeForTokens( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise> { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Token exchange failed (${response.status}): ${body}`); + } + + return response.json() as Promise>; +} + +/** Save token to disk with restrictive permissions (0o600). */ +function saveToken(tokenPath: string, token: unknown): void { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf-8'); + try { + chmodSync(tokenPath, 0o600); + } catch { + // chmod may fail on some filesystems — not critical + } +} + +/** Start a temporary HTTP server to receive the OAuth callback. */ +function waitForCallback(port: number): Promise<{ code: string; server: Server }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`

Authorization failed

${error}

`); + reject(new Error(`OAuth error: ${error}`)); + server.close(); + return; + } + + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab and return to the terminal.

'); + resolve({ code, server }); + return; + } + + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Missing authorization code

'); + }); + + server.listen(port, () => {}); + server.on('error', reject); + }); +} + +/** Try to open a URL in the user's browser. */ +async function openBrowser(url: string): Promise { + const { exec } = await import('child_process'); + const command = process.platform === 'darwin' ? 'open' : 'xdg-open'; + return new Promise((resolve) => { + exec(`${command} ${JSON.stringify(url)}`, (error) => { + resolve(!error); + }); + }); +} + +/** Manual code entry via stdin. */ +async function promptForCode(): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question('Enter the authorization code: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export function registerGcalAuthCommand(program: Command): void { + program + .command('gcal-auth') + .description('Authenticate with Google Calendar via OAuth2') + .option('-c, --config ', 'Config file path') + .option('--manual', 'Manually paste the authorization code instead of using a local server') + .action(async (opts: { config?: string; manual?: boolean }) => { + // 1. Load config + const { config, error } = loadConfigSafe(opts.config); + if (error || !config) { + console.error(`Error: ${error ?? 'Could not load config'}`); + process.exit(1); + } + + const gcalConfig = config.automation.gcal; + if (!gcalConfig) { + console.error('Error: automation.gcal is not configured in config.yaml'); + process.exit(1); + } + + // 2. Read credentials + const credentialsPath = expandPath(gcalConfig.credentials_file ?? '~/.config/flynn/gcal-credentials.json'); + let creds: ReturnType; + try { + creds = readCredentials(credentialsPath); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + const tokenPath = expandPath(gcalConfig.token_file ?? '~/.config/flynn/gcal-token.json'); + + // 3. Check if already authenticated + if (existsSync(tokenPath)) { + console.log(`Token already exists at ${tokenPath}`); + console.log('Delete it first if you want to re-authenticate.'); + process.exit(0); + } + + const redirectUri = opts.manual + ? (creds.redirect_uris?.[0] ?? 'urn:ietf:wg:oauth:2.0:oob') + : REDIRECT_URI; + + // 4. Generate auth URL + const authUrl = generateAuthUrl(creds.client_id, creds.client_secret, redirectUri); + + if (opts.manual) { + // Manual flow + console.log('\nOpen this URL in your browser:\n'); + console.log(authUrl); + console.log(''); + const code = await promptForCode(); + const token = await exchangeCodeForTokens(code, creds.client_id, creds.client_secret, redirectUri); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } else { + // Local server flow + console.log('Starting local server for OAuth callback...'); + + let callbackResult: { code: string; server: Server }; + try { + const callbackPromise = waitForCallback(REDIRECT_PORT); + + const opened = await openBrowser(authUrl); + if (!opened) { + console.log('\nCould not open browser. Open this URL manually:\n'); + console.log(authUrl); + } else { + console.log('\nBrowser opened. Complete the authorization flow...'); + } + + console.log(`\nWaiting for callback on http://localhost:${REDIRECT_PORT}...`); + callbackResult = await callbackPromise; + } catch (err) { + console.error(`\nError: ${err instanceof Error ? err.message : err}`); + console.log('\nTry again with --manual flag: flynn gcal-auth --manual'); + process.exit(1); + } + + try { + const token = await exchangeCodeForTokens( + callbackResult.code, + creds.client_id, + creds.client_secret, + redirectUri, + ); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } finally { + callbackResult.server.close(); + } + } + + console.log('Google Calendar authentication complete!'); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 6d3ced9..ed90261 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,7 @@ import { registerTuiCommand } from './tui.js'; import { registerCompletionCommand } from './completion.js'; import { registerSetupCommand } from './setup.js'; import { registerGmailAuthCommand } from './gmail-auth.js'; +import { registerGcalAuthCommand } from './gcal-auth.js'; export function createProgram(): Command { const program = new Command(); @@ -27,6 +28,7 @@ export function createProgram(): Command { registerCompletionCommand(program); registerSetupCommand(program); registerGmailAuthCommand(program); + registerGcalAuthCommand(program); return program; } diff --git a/src/config/schema.ts b/src/config/schema.ts index cb41148..1916c13 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -167,10 +167,18 @@ const heartbeatSchema = z.object({ disk_threshold_mb: z.number().min(10).default(100), }).default({}); +const gcalSchema = z.object({ + enabled: z.boolean().default(false), + credentials_file: z.string().optional(), + token_file: z.string().default('~/.config/flynn/gcal-token.json'), + calendar_ids: z.array(z.string()).default(['primary']), +}).optional(); + const automationSchema = z.object({ cron: z.array(cronJobSchema).default([]), webhooks: z.array(webhookSchema).default([]), gmail: gmailSchema, + gcal: gcalSchema, heartbeat: heartbeatSchema, }).default({}); @@ -408,5 +416,6 @@ export type HeartbeatConfig = z.infer; export type HeartbeatCheck = z.infer; export type EmbeddingConfig = z.infer; export type EmbeddingProvider = z.infer; +export type GcalConfig = z.infer; export type PairingCodeConfig = z.infer; export type LogLevel = z.infer; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index bccdff7..f0594d9 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -25,7 +25,7 @@ import { initSkills, initMcp, loadSystemPrompt, initPairingManager, createGatewa import type { ModelRouter } from '../models/index.js'; import { SessionStore, SessionManager, parseDuration } from '../session/index.js'; import { HookEngine } from '../hooks/index.js'; -import { createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools } from '../tools/index.js'; +import { createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools } from '../tools/index.js'; import { ChannelRegistry } from '../channels/index.js'; import type { McpManager } from '../mcp/index.js'; import type { SkillRegistry, SkillInstaller } from '../skills/index.js'; @@ -128,6 +128,12 @@ export async function startDaemon(config: Config): Promise { if (cronScheduler) { for (const tool of createCronTools(cronScheduler)) { toolRegistry.register(tool); } } + if (config.automation.gmail?.enabled) { + for (const tool of createGmailTools(config.automation.gmail)) { toolRegistry.register(tool); } + } + if (config.automation.gcal?.enabled) { + for (const tool of createGcalTools(config.automation.gcal)) { toolRegistry.register(tool); } + } // ── Lifecycle ── await startServices({ config, lifecycle, channelRegistry, gateway, modelRouter, memoryDir, dataDir }); diff --git a/src/tools/builtin/gcal.test.ts b/src/tools/builtin/gcal.test.ts new file mode 100644 index 0000000..2714432 --- /dev/null +++ b/src/tools/builtin/gcal.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { GcalConfig } from '../../config/schema.js'; + +// Hoisted mocks so vi.mock factories can reference them +const { mockEventsList, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({ + mockEventsList: vi.fn(), + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), +})); + +vi.mock('googleapis', () => ({ + google: { + auth: { + OAuth2: vi.fn().mockImplementation(() => ({ + setCredentials: vi.fn(), + })), + }, + calendar: vi.fn().mockReturnValue({ + events: { + list: mockEventsList, + }, + }), + }, +})); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }; +}); + +import { createGcalTools } from './gcal.js'; + +// ── Test config ───────────────────────────────────────────────────────────── + +const testConfig: NonNullable = { + enabled: true, + credentials_file: '/tmp/test-creds.json', + token_file: '/tmp/test-token.json', + calendar_ids: ['primary'], +}; + +const fakeCredentials = { + installed: { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + redirect_uris: ['http://localhost'], + }, +}; + +const fakeToken = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function setupValidAuth() { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((path: unknown) => { + const p = String(path); + if (p.includes('creds')) return JSON.stringify(fakeCredentials); + if (p.includes('token')) return JSON.stringify(fakeToken); + return ''; + }); +} + +function mockCalendarEvent( + id: string, + summary: string, + startTime: string, + endTime: string, + opts?: { location?: string; attendees?: string[]; htmlLink?: string }, +) { + return { + id, + summary, + start: { dateTime: startTime }, + end: { dateTime: endTime }, + location: opts?.location, + attendees: opts?.attendees?.map(email => ({ email })), + htmlLink: opts?.htmlLink ?? `https://calendar.google.com/event/${id}`, + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createGcalTools', () => { + it('returns 3 tools with correct names', () => { + const tools = createGcalTools(testConfig); + expect(tools).toHaveLength(3); + expect(tools.map(t => t.name)).toEqual(['calendar.today', 'calendar.list', 'calendar.search']); + }); + + it('tools have descriptions and input schemas', () => { + const tools = createGcalTools(testConfig); + for (const tool of tools) { + expect(tool.description).toBeTruthy(); + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema.type).toBe('object'); + } + }); +}); + +describe('calendar.today', () => { + it('returns error when credentials file missing', async () => { + mockExistsSync.mockReturnValue(false); + const [todayTool] = createGcalTools(testConfig); + + const result = await todayTool.execute({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Credentials file not found'); + }); + + it('returns error when token file missing', async () => { + mockExistsSync.mockImplementation((path: unknown) => { + return String(path).includes('creds'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials)); + const [todayTool] = createGcalTools(testConfig); + + const result = await todayTool.execute({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Token file not found'); + }); + + it('lists today\'s events', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ + data: { + items: [ + mockCalendarEvent('ev1', 'Team standup', '2026-02-10T09:00:00Z', '2026-02-10T09:30:00Z', { + location: 'Room A', + attendees: ['alice@test.com', 'bob@test.com'], + }), + mockCalendarEvent('ev2', 'Lunch', '2026-02-10T12:00:00Z', '2026-02-10T13:00:00Z'), + ], + }, + }); + + const [todayTool] = createGcalTools(testConfig); + const result = await todayTool.execute({}); + + expect(result.success).toBe(true); + expect(result.output).toContain('Team standup'); + expect(result.output).toContain('Room A'); + expect(result.output).toContain('alice@test.com'); + expect(result.output).toContain('Lunch'); + + expect(mockEventsList).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'primary', + singleEvents: true, + orderBy: 'startTime', + }), + ); + }); + + it('handles empty results', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ data: { items: [] } }); + + const [todayTool] = createGcalTools(testConfig); + const result = await todayTool.execute({}); + + expect(result.success).toBe(true); + expect(result.output).toBe('No events found.'); + }); + + it('handles API errors gracefully', async () => { + setupValidAuth(); + mockEventsList.mockRejectedValue(new Error('API quota exceeded')); + + const [todayTool] = createGcalTools(testConfig); + const result = await todayTool.execute({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('API quota exceeded'); + }); + + it('respects calendarId parameter', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ data: { items: [] } }); + + const [todayTool] = createGcalTools(testConfig); + await todayTool.execute({ calendarId: 'work@group.calendar.google.com' }); + + expect(mockEventsList).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'work@group.calendar.google.com', + }), + ); + }); +}); + +describe('calendar.list', () => { + it('queries events in date range', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ + data: { + items: [ + mockCalendarEvent('ev1', 'Conference', '2026-02-15T10:00:00Z', '2026-02-15T17:00:00Z'), + ], + }, + }); + + const [, listTool] = createGcalTools(testConfig); + const result = await listTool.execute({ startDate: '2026-02-15', endDate: '2026-02-16' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('Conference'); + + expect(mockEventsList).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'primary', + singleEvents: true, + orderBy: 'startTime', + }), + ); + }); + + it('returns error for invalid dates', async () => { + setupValidAuth(); + const [, listTool] = createGcalTools(testConfig); + const result = await listTool.execute({ startDate: 'not-a-date', endDate: '2026-02-10' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid date format'); + }); + + it('respects maxResults param', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ data: { items: [] } }); + + const [, listTool] = createGcalTools(testConfig); + await listTool.execute({ startDate: '2026-02-10', endDate: '2026-02-11', maxResults: 5 }); + + expect(mockEventsList).toHaveBeenCalledWith( + expect.objectContaining({ + maxResults: 5, + }), + ); + }); + + it('respects calendarId param', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ data: { items: [] } }); + + const [, listTool] = createGcalTools(testConfig); + await listTool.execute({ startDate: '2026-02-10', endDate: '2026-02-11', calendarId: 'work@group.calendar.google.com' }); + + expect(mockEventsList).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'work@group.calendar.google.com', + }), + ); + }); +}); + +describe('calendar.search', () => { + it('searches with query parameter', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ + data: { + items: [ + mockCalendarEvent('ev1', 'Team planning', '2026-02-10T14:00:00Z', '2026-02-10T15:00:00Z'), + ], + }, + }); + + const [, , searchTool] = createGcalTools(testConfig); + const result = await searchTool.execute({ query: 'planning' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('Team planning'); + + expect(mockEventsList).toHaveBeenCalledWith( + expect.objectContaining({ + q: 'planning', + singleEvents: true, + orderBy: 'startTime', + }), + ); + }); + + it('respects maxResults param', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ data: { items: [] } }); + + const [, , searchTool] = createGcalTools(testConfig); + await searchTool.execute({ query: 'meeting', maxResults: 3 }); + + expect(mockEventsList).toHaveBeenCalledWith( + expect.objectContaining({ + q: 'meeting', + maxResults: 3, + }), + ); + }); + + it('returns error when credentials missing', async () => { + mockExistsSync.mockReturnValue(false); + const [, , searchTool] = createGcalTools(testConfig); + + const result = await searchTool.execute({ query: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Credentials file not found'); + }); + + it('handles API errors gracefully', async () => { + setupValidAuth(); + mockEventsList.mockRejectedValue(new Error('API quota exceeded')); + + const [, , searchTool] = createGcalTools(testConfig); + const result = await searchTool.execute({ query: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('API quota exceeded'); + }); + + it('formats events with all fields', async () => { + setupValidAuth(); + mockEventsList.mockResolvedValue({ + data: { + items: [ + mockCalendarEvent('ev1', 'Board meeting', '2026-03-01T10:00:00Z', '2026-03-01T12:00:00Z', { + location: 'HQ Conference Room', + attendees: ['ceo@company.com', 'cfo@company.com'], + htmlLink: 'https://calendar.google.com/event/ev1', + }), + ], + }, + }); + + const [, , searchTool] = createGcalTools(testConfig); + const result = await searchTool.execute({ query: 'board' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('Board meeting'); + expect(result.output).toContain('HQ Conference Room'); + expect(result.output).toContain('ceo@company.com'); + expect(result.output).toContain('cfo@company.com'); + expect(result.output).toContain('https://calendar.google.com/event/ev1'); + }); +}); diff --git a/src/tools/builtin/gcal.ts b/src/tools/builtin/gcal.ts new file mode 100644 index 0000000..329a43f --- /dev/null +++ b/src/tools/builtin/gcal.ts @@ -0,0 +1,278 @@ +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.", + 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.', + 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.', + 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]; +} diff --git a/src/tools/builtin/gmail.test.ts b/src/tools/builtin/gmail.test.ts new file mode 100644 index 0000000..9886d30 --- /dev/null +++ b/src/tools/builtin/gmail.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { GmailConfig } from '../../config/schema.js'; + +// Hoisted mocks so vi.mock factories can reference them +const { mockMessagesList, mockMessagesGet, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({ + mockMessagesList: vi.fn(), + mockMessagesGet: vi.fn(), + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), +})); + +vi.mock('googleapis', () => ({ + google: { + auth: { + OAuth2: vi.fn().mockImplementation(() => ({ + setCredentials: vi.fn(), + })), + }, + gmail: vi.fn().mockReturnValue({ + users: { + messages: { + list: mockMessagesList, + get: mockMessagesGet, + }, + }, + }), + }, +})); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }; +}); + +import { createGmailTools } from './gmail.js'; + +// ── Test config ───────────────────────────────────────────────────────────── + +const testConfig: NonNullable = { + enabled: true, + credentials_file: '/tmp/test-creds.json', + token_file: '/tmp/test-token.json', + watch_labels: ['INBOX'], + poll_interval: '60s', + output: { channel: 'discord', peer: '123' }, + message: '{{from}}: {{subject}}', +}; + +const fakeCredentials = { + installed: { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + redirect_uris: ['http://localhost'], + }, +}; + +const fakeToken = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function setupValidAuth() { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((path: unknown) => { + const p = String(path); + if (p.includes('creds')) return JSON.stringify(fakeCredentials); + if (p.includes('token')) return JSON.stringify(fakeToken); + return ''; + }); +} + +function mockMessageDetails(id: string, from: string, subject: string, date: string, snippet: string) { + return { + data: { + payload: { + headers: [ + { name: 'From', value: from }, + { name: 'Subject', value: subject }, + { name: 'Date', value: date }, + ], + }, + snippet, + }, + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createGmailTools', () => { + it('returns 2 tools with correct names', () => { + const tools = createGmailTools(testConfig); + expect(tools).toHaveLength(2); + expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search']); + }); + + it('tools have descriptions and input schemas', () => { + const tools = createGmailTools(testConfig); + for (const tool of tools) { + expect(tool.description).toBeTruthy(); + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema.type).toBe('object'); + } + }); +}); + +describe('gmail.list', () => { + it('returns error when credentials file missing', async () => { + mockExistsSync.mockReturnValue(false); + const [listTool] = createGmailTools(testConfig); + + const result = await listTool.execute({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Credentials file not found'); + }); + + it('returns error when token file missing', async () => { + mockExistsSync.mockImplementation((path: unknown) => { + return String(path).includes('creds'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials)); + const [listTool] = createGmailTools(testConfig); + + const result = await listTool.execute({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Token file not found'); + }); + + it('lists recent emails with default params', async () => { + setupValidAuth(); + mockMessagesList.mockResolvedValue({ + data: { + messages: [{ id: 'msg1' }, { id: 'msg2' }], + }, + }); + mockMessagesGet + .mockResolvedValueOnce(mockMessageDetails('msg1', 'alice@test.com', 'Hello', 'Mon, 10 Feb 2026', 'Hi there')) + .mockResolvedValueOnce(mockMessageDetails('msg2', 'bob@test.com', 'Meeting', 'Mon, 10 Feb 2026', 'At 3pm')); + + const [listTool] = createGmailTools(testConfig); + const result = await listTool.execute({}); + + expect(result.success).toBe(true); + expect(result.output).toContain('alice@test.com'); + expect(result.output).toContain('Hello'); + expect(result.output).toContain('bob@test.com'); + expect(result.output).toContain('Meeting'); + + expect(mockMessagesList).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + labelIds: ['INBOX'], + maxResults: 10, + }), + ); + }); + + it('respects maxResults and label params', async () => { + setupValidAuth(); + mockMessagesList.mockResolvedValue({ data: { messages: [] } }); + + const [listTool] = createGmailTools(testConfig); + await listTool.execute({ maxResults: 5, label: 'SENT' }); + + expect(mockMessagesList).toHaveBeenCalledWith( + expect.objectContaining({ + labelIds: ['SENT'], + maxResults: 5, + }), + ); + }); + + it('handles empty results', async () => { + setupValidAuth(); + mockMessagesList.mockResolvedValue({ data: { messages: [] } }); + + const [listTool] = createGmailTools(testConfig); + const result = await listTool.execute({}); + + expect(result.success).toBe(true); + expect(result.output).toBe('No messages found.'); + }); +}); + +describe('gmail.search', () => { + it('searches with query parameter', async () => { + setupValidAuth(); + mockMessagesList.mockResolvedValue({ + data: { + messages: [{ id: 'msg1' }], + }, + }); + mockMessagesGet.mockResolvedValueOnce( + mockMessageDetails('msg1', 'alice@test.com', 'Invoice', 'Mon, 10 Feb 2026', 'Your invoice'), + ); + + const [, searchTool] = createGmailTools(testConfig); + const result = await searchTool.execute({ query: 'from:alice subject:invoice' }); + + expect(result.success).toBe(true); + expect(result.output).toContain('Invoice'); + expect(result.output).toContain('alice@test.com'); + + expect(mockMessagesList).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + q: 'from:alice subject:invoice', + maxResults: 10, + }), + ); + }); + + it('respects maxResults param', async () => { + setupValidAuth(); + mockMessagesList.mockResolvedValue({ data: { messages: [] } }); + + const [, searchTool] = createGmailTools(testConfig); + await searchTool.execute({ query: 'is:unread', maxResults: 3 }); + + expect(mockMessagesList).toHaveBeenCalledWith( + expect.objectContaining({ + q: 'is:unread', + maxResults: 3, + }), + ); + }); + + it('returns error when credentials missing', async () => { + mockExistsSync.mockReturnValue(false); + const [, searchTool] = createGmailTools(testConfig); + + const result = await searchTool.execute({ query: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Credentials file not found'); + }); + + it('handles API errors gracefully', async () => { + setupValidAuth(); + mockMessagesList.mockRejectedValue(new Error('API quota exceeded')); + + const [, searchTool] = createGmailTools(testConfig); + const result = await searchTool.execute({ query: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('API quota exceeded'); + }); +}); diff --git a/src/tools/builtin/gmail.ts b/src/tools/builtin/gmail.ts new file mode 100644 index 0000000..be77b81 --- /dev/null +++ b/src/tools/builtin/gmail.ts @@ -0,0 +1,216 @@ +import { google, type Auth } from 'googleapis'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import type { GmailConfig } 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 Gmail 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.gmail.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/gmail-token.json'); + if (!existsSync(tokenPath)) { + throw new Error(`Token file not found: ${tokenPath}. Run "flynn gmail-auth" to authenticate.`); + } + + const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); + oauth2Client.setCredentials(token); + + return oauth2Client; +} + +interface EmailSummary { + id: string; + from: string; + subject: string; + date: string; + snippet: string; +} + +/** Fetch metadata for a single message. */ +async function fetchMessageDetails( + gmail: ReturnType, + messageId: string, +): Promise { + try { + const msg = await gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'metadata', + metadataHeaders: ['From', 'Subject', 'Date'], + }); + + const headers = msg.data.payload?.headers ?? []; + const getHeader = (name: string): string => + headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value ?? ''; + + return { + id: messageId, + from: getHeader('From'), + subject: getHeader('Subject'), + date: getHeader('Date'), + snippet: msg.data.snippet ?? '', + }; + } catch { + return null; + } +} + +/** Format a list of email summaries for tool output. */ +function formatEmails(emails: EmailSummary[]): string { + if (emails.length === 0) { + return 'No messages found.'; + } + + return emails + .map(e => `[${e.id}] ${e.date}\n From: ${e.from}\n Subject: ${e.subject}\n ${e.snippet}`) + .join('\n\n'); +} + +/** + * Creates Gmail query tools bound to the given GmailConfig. + * Tools create their own OAuth2 client, independent of GmailWatcher. + */ +export function createGmailTools(config: NonNullable): Tool[] { + const gmailList: Tool = { + name: 'gmail.list', + description: + 'List recent emails from Gmail. Returns id, from, subject, date, and snippet for each message.', + inputSchema: { + type: 'object', + properties: { + maxResults: { + type: 'number', + description: 'Maximum number of emails to return (default: 10)', + }, + label: { + type: 'string', + description: 'Gmail label to filter by (default: INBOX)', + }, + }, + }, + execute: async (rawArgs: unknown): Promise => { + const args = rawArgs as { maxResults?: number; label?: string }; + const maxResults = args.maxResults ?? 10; + const label = args.label ?? 'INBOX'; + + try { + const auth = createOAuth2Client(config); + const gmail = google.gmail({ version: 'v1', auth }); + + const listResponse = await gmail.users.messages.list({ + userId: 'me', + labelIds: [label], + maxResults, + }); + + const messages = listResponse.data.messages ?? []; + const emails: EmailSummary[] = []; + + for (const msg of messages) { + if (!msg.id) continue; + const details = await fetchMessageDetails(gmail, msg.id); + if (details) emails.push(details); + } + + return { + success: true, + output: formatEmails(emails), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + const gmailSearch: Tool = { + name: 'gmail.search', + description: + 'Search Gmail using Gmail query syntax (e.g. "from:user@example.com", "is:unread", "subject:hello"). Returns id, from, subject, date, and snippet for each match.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Gmail search query (same syntax as Gmail search bar)', + }, + maxResults: { + type: 'number', + description: 'Maximum number of results to return (default: 10)', + }, + }, + required: ['query'], + }, + execute: async (rawArgs: unknown): Promise => { + const args = rawArgs as { query: string; maxResults?: number }; + const maxResults = args.maxResults ?? 10; + + try { + const auth = createOAuth2Client(config); + const gmail = google.gmail({ version: 'v1', auth }); + + const listResponse = await gmail.users.messages.list({ + userId: 'me', + q: args.query, + maxResults, + }); + + const messages = listResponse.data.messages ?? []; + const emails: EmailSummary[] = []; + + for (const msg of messages) { + if (!msg.id) continue; + const details = await fetchMessageDetails(gmail, msg.id); + if (details) emails.push(details); + } + + return { + success: true, + output: formatEmails(emails), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + return [gmailList, gmailSearch]; +} diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index a42b1a6..8658d28 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -21,6 +21,8 @@ export { createSessionTools } from './sessions.js'; export { createAgentsListTool } from './agents-list.js'; export { createMessageSendTool } from './message-send.js'; export { createCronTools } from './cron.js'; +export { createGmailTools } from './gmail.js'; +export { createGcalTools } from './gcal.js'; import type { Tool } from '../types.js'; import type { MemoryStore } from '../../memory/store.js'; diff --git a/src/tools/index.ts b/src/tools/index.ts index 261ba40..274a74c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js'; export type { ToolExecutorConfig } from './executor.js'; export { ToolPolicy } from './policy.js'; export type { ToolPolicyContext } from './policy.js'; -export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools } from './builtin/index.js'; +export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools } from './builtin/index.js'; export type { WebSearchConfig } from './builtin/web-search.js'; export type { ProcessManagerConfig } from './builtin/process/index.js'; export type { BrowserManagerConfig } from './builtin/browser/index.js'; diff --git a/src/tools/policy.ts b/src/tools/policy.ts index e12c5be..bf10314 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -20,6 +20,11 @@ const PROFILE_TOOLS: Record> = { 'memory.write', 'memory.search', 'web.search', + 'gmail.list', + 'gmail.search', + 'calendar.today', + 'calendar.list', + 'calendar.search', ]), coding: new Set([ 'file.read', @@ -30,6 +35,11 @@ const PROFILE_TOOLS: Record> = { 'memory.write', 'memory.search', 'web.search', + 'gmail.list', + 'gmail.search', + 'calendar.today', + 'calendar.list', + 'calendar.search', 'file.write', 'file.edit', 'file.patch', @@ -57,6 +67,8 @@ export const TOOL_GROUPS: Record = { 'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'], 'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'], 'group:memory': ['memory.read', 'memory.write', 'memory.search'], + 'group:gmail': ['gmail.list', 'gmail.search'], + 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'], }; /** Expand group references in a list of tool names/patterns. */