350 lines
12 KiB
TypeScript
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];
|
|
}
|