import { google, type Auth } from 'googleapis'; import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; import type { GdriveConfig } 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 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.gdrive.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/gdrive-token.json'); if (!existsSync(tokenPath)) { throw new Error(`Token file not found: ${tokenPath}. Run "flynn gdrive-auth" to authenticate.`); } const token = JSON.parse(readFileSync(tokenPath, 'utf-8')); oauth2Client.setCredentials(token); return oauth2Client; } interface FileSummary { id: string; name: string; mimeType: string; modifiedTime: string; size: string; owners: string[]; webViewLink: string; } /** Map Google Workspace MIME types to human-readable labels. */ function friendlyMimeType(mimeType: string): string { const map: Record = { 'application/vnd.google-apps.document': 'Google Doc', 'application/vnd.google-apps.spreadsheet': 'Google Sheet', 'application/vnd.google-apps.presentation': 'Google Slides', 'application/vnd.google-apps.form': 'Google Form', 'application/vnd.google-apps.drawing': 'Google Drawing', 'application/vnd.google-apps.folder': 'Folder', 'application/pdf': 'PDF', }; return map[mimeType] ?? mimeType; } /** Format file size in human-readable form. */ function formatSize(bytes: string | undefined): string { if (!bytes) {return '';} const n = parseInt(bytes, 10); if (isNaN(n)) {return '';} if (n < 1024) {return `${n} B`;} if (n < 1024 * 1024) {return `${(n / 1024).toFixed(1)} KB`;} return `${(n / (1024 * 1024)).toFixed(1)} MB`; } /** Format a list of file summaries for tool output. */ function formatFiles(files: FileSummary[]): string { if (files.length === 0) { return 'No files found.'; } return files .map(f => { const parts = [`[${f.id}] ${f.name}`]; parts.push(` Type: ${friendlyMimeType(f.mimeType)}`); parts.push(` Modified: ${f.modifiedTime}`); const size = formatSize(f.size); if (size) {parts.push(` Size: ${size}`);} if (f.owners.length > 0) {parts.push(` Owners: ${f.owners.join(', ')}`);} if (f.webViewLink) {parts.push(` Link: ${f.webViewLink}`);} return parts.join('\n'); }) .join('\n\n'); } /** Google Workspace MIME types that can be exported as plain text. */ const EXPORTABLE_TYPES: Record = { 'application/vnd.google-apps.document': { exportMime: 'text/plain', label: 'Google Doc' }, 'application/vnd.google-apps.spreadsheet': { exportMime: 'text/csv', label: 'Google Sheet' }, 'application/vnd.google-apps.presentation': { exportMime: 'text/plain', label: 'Google Slides' }, }; /** Text MIME types that can be read directly. */ function isTextMime(mimeType: string): boolean { return mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml' || mimeType === 'application/javascript' || mimeType === 'application/x-yaml' || mimeType.endsWith('+json') || mimeType.endsWith('+xml'); } /** * Creates Google Drive read-only tools. Shares config/token with gdocs. * Provides list, search, and read (with export for Workspace files). */ export function createGdriveTools(config: NonNullable): Tool[] { const driveList: Tool = { name: 'drive.list', description: 'List recent files from Google Drive. Returns id, name, type, modified time, size, owners, and link.', requiredSecretScopes: ['gdrive'], inputSchema: { type: 'object', properties: { maxResults: { type: 'number', description: 'Maximum number of files to return (default: 10)', }, mimeType: { type: 'string', description: 'Filter by MIME type (e.g. "application/pdf", "application/vnd.google-apps.spreadsheet")', }, folderId: { type: 'string', description: 'List files within a specific folder ID', }, }, }, execute: async (rawArgs: unknown): Promise => { const args = rawArgs as { maxResults?: number; mimeType?: string; folderId?: string }; const maxResults = args.maxResults ?? 10; try { const auth = createOAuth2Client(config); const drive = google.drive({ version: 'v3', auth }); const queryParts = ['trashed=false']; if (args.mimeType) { queryParts.push(`mimeType='${args.mimeType}'`); } if (args.folderId) { queryParts.push(`'${args.folderId}' in parents`); } const response = await drive.files.list({ q: queryParts.join(' and '), pageSize: maxResults, orderBy: 'modifiedTime desc', fields: 'files(id,name,mimeType,modifiedTime,size,owners,webViewLink)', }); const files = response.data.files ?? []; const summaries: FileSummary[] = files.map(f => ({ id: f.id ?? '', name: f.name ?? '(untitled)', mimeType: f.mimeType ?? '', modifiedTime: f.modifiedTime ?? '', size: f.size ?? '', owners: (f.owners ?? []).map(o => o.displayName ?? o.emailAddress ?? '').filter(Boolean), webViewLink: f.webViewLink ?? '', })); return { success: true, output: formatFiles(summaries), }; } catch (error) { return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; const driveSearch: Tool = { name: 'drive.search', description: 'Search Google Drive files by name or content. Returns id, name, type, modified time, size, owners, and link.', requiredSecretScopes: ['gdrive'], inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query to match against file names and content', }, maxResults: { type: 'number', description: 'Maximum number of results to return (default: 10)', }, mimeType: { type: 'string', description: 'Filter by MIME type (e.g. "application/pdf")', }, }, required: ['query'], }, execute: async (rawArgs: unknown): Promise => { const args = rawArgs as { query: string; maxResults?: number; mimeType?: string }; const maxResults = args.maxResults ?? 10; try { const auth = createOAuth2Client(config); const drive = google.drive({ version: 'v3', auth }); const escapedQuery = args.query.replace(/'/g, "\\'"); const queryParts = [ 'trashed=false', `(name contains '${escapedQuery}' or fullText contains '${escapedQuery}')`, ]; if (args.mimeType) { queryParts.push(`mimeType='${args.mimeType}'`); } const response = await drive.files.list({ q: queryParts.join(' and '), pageSize: maxResults, orderBy: 'modifiedTime desc', fields: 'files(id,name,mimeType,modifiedTime,size,owners,webViewLink)', }); const files = response.data.files ?? []; const summaries: FileSummary[] = files.map(f => ({ id: f.id ?? '', name: f.name ?? '(untitled)', mimeType: f.mimeType ?? '', modifiedTime: f.modifiedTime ?? '', size: f.size ?? '', owners: (f.owners ?? []).map(o => o.displayName ?? o.emailAddress ?? '').filter(Boolean), webViewLink: f.webViewLink ?? '', })); return { success: true, output: formatFiles(summaries), }; } catch (error) { return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; const driveRead: Tool = { name: 'drive.read', description: 'Read the content of a Google Drive file. Exports Google Workspace files (Docs, Sheets, Slides) as plain text/CSV. Downloads regular text files directly. Use drive.list or drive.search to get file IDs.', requiredSecretScopes: ['gdrive'], inputSchema: { type: 'object', properties: { fileId: { type: 'string', description: 'The Google Drive file ID (from drive.list or drive.search results)', }, }, required: ['fileId'], }, execute: async (rawArgs: unknown): Promise => { const args = rawArgs as { fileId: string }; try { const auth = createOAuth2Client(config); const drive = google.drive({ version: 'v3', auth }); // First get file metadata to determine type const meta = await drive.files.get({ fileId: args.fileId, fields: 'id,name,mimeType,size', }); const name = meta.data.name ?? '(untitled)'; const mimeType = meta.data.mimeType ?? ''; // Google Workspace files: export const exportable = EXPORTABLE_TYPES[mimeType]; if (exportable) { const exported = await drive.files.export({ fileId: args.fileId, mimeType: exportable.exportMime, }, { responseType: 'text' }); const content = typeof exported.data === 'string' ? exported.data : String(exported.data); return { success: true, output: `Name: ${name}\nType: ${exportable.label}\n\n${content || '(empty)'}`, }; } // Regular text files: download if (isTextMime(mimeType)) { const downloaded = await drive.files.get({ fileId: args.fileId, alt: 'media', }, { responseType: 'text' }); const content = typeof downloaded.data === 'string' ? downloaded.data : String(downloaded.data); return { success: true, output: `Name: ${name}\nType: ${mimeType}\n\n${content || '(empty)'}`, }; } // Binary / unsupported types return { success: true, output: `Name: ${name}\nType: ${friendlyMimeType(mimeType)}\n\nBinary file — cannot display content. Use the web link to view this file.`, }; } catch (error) { return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; return [driveList, driveSearch, driveRead]; }