Files
flynn/src/tools/builtin/gdrive.ts
T
2026-02-15 10:16:51 -08:00

350 lines
12 KiB
TypeScript

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<GdriveConfig>): 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<string, string> = {
'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<string, { exportMime: string; label: string }> = {
'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<GdriveConfig>): 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<ToolResult> => {
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<ToolResult> => {
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<ToolResult> => {
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];
}