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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** 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(`<h1>Authorization failed</h1><p>${error}</p>`);
|
||||
reject(new Error(`OAuth error: ${error}`));
|
||||
server.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authorization successful!</h1><p>You can close this tab and return to the terminal.</p>');
|
||||
resolve({ code, server });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Missing authorization code</h1>');
|
||||
});
|
||||
|
||||
server.listen(port, () => {});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Try to open a URL in the user's browser. */
|
||||
async function openBrowser(url: string): Promise<boolean> {
|
||||
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<string> {
|
||||
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 <path>', '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<typeof readCredentials>;
|
||||
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!');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof heartbeatSchema>;
|
||||
export type HeartbeatCheck = z.infer<typeof heartbeatCheckSchema>;
|
||||
export type EmbeddingConfig = z.infer<typeof embeddingSchema>;
|
||||
export type EmbeddingProvider = z.infer<typeof embeddingProviderSchema>;
|
||||
export type GcalConfig = z.infer<typeof gcalSchema>;
|
||||
export type PairingCodeConfig = z.infer<typeof pairingSchema>;
|
||||
export type LogLevel = z.infer<typeof logLevelSchema>;
|
||||
|
||||
+7
-1
@@ -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<DaemonContext> {
|
||||
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 });
|
||||
|
||||
@@ -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<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
};
|
||||
});
|
||||
|
||||
import { createGcalTools } from './gcal.js';
|
||||
|
||||
// ── Test config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testConfig: NonNullable<GcalConfig> = {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<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.",
|
||||
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.',
|
||||
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.',
|
||||
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];
|
||||
}
|
||||
@@ -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<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
};
|
||||
});
|
||||
|
||||
import { createGmailTools } from './gmail.js';
|
||||
|
||||
// ── Test config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testConfig: NonNullable<GmailConfig> = {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<GmailConfig>): 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<typeof google.gmail>,
|
||||
messageId: string,
|
||||
): Promise<EmailSummary | null> {
|
||||
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<GmailConfig>): 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<ToolResult> => {
|
||||
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<ToolResult> => {
|
||||
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];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -20,6 +20,11 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
||||
'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<ToolProfile, Set<string>> = {
|
||||
'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<string, string[]> = {
|
||||
'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. */
|
||||
|
||||
Reference in New Issue
Block a user