From 26bcdd7b34d192e08ab2d236309303f02be63db1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Mar 2025 23:51:40 -0800 Subject: [PATCH] feat(oauth): added google drive credentials, fixed some oauth bugs --- app/api/auth/oauth/credentials/route.ts | 4 +- .../components/credential-selector.tsx | 9 +- blocks/blocks/drive.ts | 165 ++++++++++++++++++ blocks/index.ts | 3 + components/ui/oauth-required-modal.tsx | 11 +- tools/drive/download.ts | 65 +++++++ tools/drive/export.ts | 113 ++++++++++++ tools/drive/list.ts | 79 +++++++++ tools/drive/types.ts | 45 +++++ tools/drive/upload.ts | 77 ++++++++ tools/index.ts | 6 + tools/types.ts | 2 +- 12 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 blocks/blocks/drive.ts create mode 100644 tools/drive/download.ts create mode 100644 tools/drive/export.ts create mode 100644 tools/drive/list.ts create mode 100644 tools/drive/types.ts create mode 100644 tools/drive/upload.ts diff --git a/app/api/auth/oauth/credentials/route.ts b/app/api/auth/oauth/credentials/route.ts index 7535e8e1f7..c8e8cc3fe6 100644 --- a/app/api/auth/oauth/credentials/route.ts +++ b/app/api/auth/oauth/credentials/route.ts @@ -40,9 +40,7 @@ export async function GET(request: NextRequest) { const accounts = await db .select() .from(account) - .where( - and(eq(account.userId, session.user.id), like(account.providerId, `${baseProvider}-%`)) - ) + .where(and(eq(account.userId, session.user.id), eq(account.providerId, provider))) // Transform accounts into credentials const credentials = await Promise.all( diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx index 27d1e0f8e4..3f39015375 100644 --- a/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx @@ -55,7 +55,9 @@ export function CredentialSelector({ if (!open) return setIsLoading(true) try { - const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`) + const providerId = getProviderId() + + const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) if (response.ok) { const data = await response.json() setCredentials(data.credentials) @@ -173,11 +175,10 @@ export function CredentialSelector({ // Get provider icon const getProviderIcon = (provider: OAuthProvider) => { - switch (provider) { + const baseProvider = provider.split('-')[0] as OAuthProvider + switch (baseProvider) { case 'google': return - case 'google-email': - return case 'github': return case 'twitter': diff --git a/blocks/blocks/drive.ts b/blocks/blocks/drive.ts new file mode 100644 index 0000000000..cde978b9ff --- /dev/null +++ b/blocks/blocks/drive.ts @@ -0,0 +1,165 @@ +import { GoogleDriveIcon } from '@/components/icons' +import { + GoogleDriveDownloadResponse, + GoogleDriveListResponse, + GoogleDriveUploadResponse, +} from '@/tools/drive/types' +import { BlockConfig } from '../types' + +type GoogleDriveResponse = + | GoogleDriveUploadResponse + | GoogleDriveDownloadResponse + | GoogleDriveListResponse + +export const GoogleDriveBlock: BlockConfig = { + type: 'google_drive', + name: 'Google Drive', + description: 'Upload, download, and list files in Google Drive', + longDescription: + 'Integrate Google Drive functionality to manage files and folders. Upload new files, download existing ones, and list contents of folders using OAuth authentication. Supports file operations with custom MIME types and folder organization.', + category: 'tools', + bgColor: '#1EA362', + icon: GoogleDriveIcon, + subBlocks: [ + // Operation selector + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + // { label: 'Upload File', id: 'upload' }, + // { label: 'Download File', id: 'download' }, + { label: 'List Files', id: 'list' }, + ], + }, + // Google Drive Credentials + { + id: 'credential', + title: 'Google Drive Account', + type: 'oauth-input', + layout: 'full', + provider: 'google-drive', + serviceId: 'google-drive', + requiredScopes: ['https://www.googleapis.com/auth/drive'], + placeholder: 'Select Google Drive account', + }, + // Upload Fields + { + id: 'fileName', + title: 'File Name', + type: 'short-input', + layout: 'full', + placeholder: 'Name for the uploaded file (e.g., document.txt)', + condition: { field: 'operation', value: 'upload' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input', + layout: 'full', + placeholder: 'Content to upload to the file', + condition: { field: 'operation', value: 'upload' }, + }, + { + id: 'mimeType', + title: 'MIME Type', + type: 'short-input', + layout: 'full', + placeholder: + 'File MIME type (default: text/plain, e.g., text/plain, application/json, text/csv)', + condition: { field: 'operation', value: 'upload' }, + }, + { + id: 'folderId', + title: 'Parent Folder ID', + type: 'short-input', + layout: 'full', + placeholder: 'ID of the parent folder (optional, leave empty for root folder)', + condition: { field: 'operation', value: 'upload' }, + }, + // Download Fields + { + id: 'fileId', + title: 'File ID', + type: 'short-input', + layout: 'full', + placeholder: 'ID of the file to download (find in file URL or by listing files)', + condition: { field: 'operation', value: 'download' }, + }, + // List Fields + { + id: 'folderId', + title: 'Folder ID', + type: 'short-input', + layout: 'full', + placeholder: 'ID of the folder to list (optional, leave empty for root folder)', + condition: { field: 'operation', value: 'list' }, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + layout: 'full', + placeholder: 'Search for specific files (e.g., name contains "report")', + condition: { field: 'operation', value: 'list' }, + }, + { + id: 'pageSize', + title: 'Results Per Page', + type: 'short-input', + layout: 'full', + placeholder: 'Number of results (default: 100, max: 1000)', + condition: { field: 'operation', value: 'list' }, + }, + ], + tools: { + access: ['google_drive_upload', 'google_drive_download', 'google_drive_list'], + config: { + tool: (params) => { + switch (params.operation) { + case 'upload': + return 'google_drive_upload' + case 'download': + return 'google_drive_download' + case 'list': + return 'google_drive_list' + default: + throw new Error(`Invalid Google Drive operation: ${params.operation}`) + } + }, + params: (params) => { + const { credential, ...rest } = params + // Convert pageSize to number if it exists + const pageSize = rest.pageSize ? parseInt(rest.pageSize as string, 10) : undefined + return { + ...rest, + pageSize, + credential, + } + }, + }, + }, + inputs: { + operation: { type: 'string', required: true }, + credential: { type: 'string', required: true }, + // Upload operation inputs + fileName: { type: 'string', required: false }, + content: { type: 'string', required: false }, + mimeType: { type: 'string', required: false }, + // Download operation inputs + fileId: { type: 'string', required: false }, + // List operation inputs + folderId: { type: 'string', required: false }, + query: { type: 'string', required: false }, + pageSize: { type: 'number', required: false }, + }, + outputs: { + response: { + type: { + content: 'string', + metadata: 'json', + }, + }, + }, +} diff --git a/blocks/index.ts b/blocks/index.ts index 40ecb37626..1178c4bcd8 100644 --- a/blocks/index.ts +++ b/blocks/index.ts @@ -3,6 +3,7 @@ import { AgentBlock } from './blocks/agent' import { ApiBlock } from './blocks/api' import { ConditionBlock } from './blocks/condition' import { CrewAIVisionBlock } from './blocks/crewai' +import { GoogleDriveBlock } from './blocks/drive' import { EvaluatorBlock } from './blocks/evaluator' import { ExaBlock } from './blocks/exa' import { FirecrawlBlock } from './blocks/firecrawl' @@ -49,6 +50,7 @@ export { OpenAIBlock, ExaBlock, RedditBlock, + GoogleDriveBlock, } // Registry of all block configurations, alphabetically sorted @@ -57,6 +59,7 @@ const blocks: Record = { api: ApiBlock, condition: ConditionBlock, crewai_vision: CrewAIVisionBlock, + google_drive: GoogleDriveBlock, evaluator: EvaluatorBlock, exa: ExaBlock, firecrawl: FirecrawlBlock, diff --git a/components/ui/oauth-required-modal.tsx b/components/ui/oauth-required-modal.tsx index 2638ffce4e..d920305300 100644 --- a/components/ui/oauth-required-modal.tsx +++ b/components/ui/oauth-required-modal.tsx @@ -1,7 +1,13 @@ 'use client' import { Check } from 'lucide-react' -import { GithubIcon, GmailIcon, GoogleIcon, xIcon as XIcon } from '@/components/icons' +import { + GithubIcon, + GmailIcon, + GoogleDriveIcon, + GoogleIcon, + xIcon as XIcon, +} from '@/components/icons' import { Button } from '@/components/ui/button' import { Dialog, @@ -28,6 +34,7 @@ const PROVIDER_NAMES: Record = { github: 'GitHub', google: 'Google', 'google-email': 'Gmail', + 'google-drive': 'Google Drive', twitter: 'X (Twitter)', } @@ -36,6 +43,7 @@ const PROVIDER_ICONS: Record = { 'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf', 'https://www.googleapis.com/auth/gmail.readonly': 'View and read your email messages', 'https://www.googleapis.com/auth/drive': 'View and manage your Google Drive files', + 'https://www.googleapis.com/auth/drive.file': 'View and manage your Google Drive files', 'https://www.googleapis.com/auth/calendar': 'View and manage your calendar', 'https://www.googleapis.com/auth/userinfo.email': 'View your email address', 'https://www.googleapis.com/auth/userinfo.profile': 'View your basic profile info', diff --git a/tools/drive/download.ts b/tools/drive/download.ts new file mode 100644 index 0000000000..d513ddec81 --- /dev/null +++ b/tools/drive/download.ts @@ -0,0 +1,65 @@ +import { ToolConfig } from '../types' +import { GoogleDriveDownloadResponse, GoogleDriveToolParams } from './types' + +export const downloadTool: ToolConfig = { + id: 'google_drive_download', + name: 'Download from Google Drive', + description: 'Download a file from Google Drive', + version: '1.0', + oauth: { + required: true, + provider: 'google-drive', + additionalScopes: ['https://www.googleapis.com/auth/drive'], + }, + params: { + accessToken: { type: 'string', required: true }, + fileId: { type: 'string', required: true }, + }, + request: { + url: (params) => `https://www.googleapis.com/drive/v3/files/${params.fileId}?alt=media`, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json() + throw new Error(error.error?.message || 'Failed to download file from Google Drive') + } + + // Get file metadata + const metadataResponse = await fetch( + `https://www.googleapis.com/drive/v3/files/${response.url.split('files/')[1].split('?')[0]}`, + { + headers: { + Authorization: response.headers.get('Authorization') || '', + }, + } + ) + + const metadata = await metadataResponse.json() + const content = await response.text() + + return { + success: true, + output: { + content, + metadata: { + id: metadata.id, + name: metadata.name, + mimeType: metadata.mimeType, + webViewLink: metadata.webViewLink, + webContentLink: metadata.webContentLink, + size: metadata.size, + createdTime: metadata.createdTime, + modifiedTime: metadata.modifiedTime, + parents: metadata.parents, + }, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while downloading from Google Drive' + }, +} diff --git a/tools/drive/export.ts b/tools/drive/export.ts new file mode 100644 index 0000000000..326269fa21 --- /dev/null +++ b/tools/drive/export.ts @@ -0,0 +1,113 @@ +import { ToolConfig } from '../types' +import { GoogleDriveDownloadResponse } from './types' +import { GoogleDriveToolParams } from './types' + +export const exportTool: ToolConfig< + GoogleDriveToolParams & { mimeType?: string }, + GoogleDriveDownloadResponse +> = { + id: 'google_drive_export', + name: 'Export from Google Drive', + description: 'Export a Google Workspace file (Docs, Sheets, Slides) from Google Drive', + version: '1.0', + oauth: { + required: true, + provider: 'google-drive', + additionalScopes: ['https://www.googleapis.com/auth/drive'], + }, + params: { + accessToken: { type: 'string', required: true }, + fileId: { type: 'string', required: true }, + mimeType: { type: 'string', required: false }, + }, + request: { + url: (params) => { + const exportMimeType = params.mimeType || 'application/pdf' + return `https://www.googleapis.com/drive/v3/files/${params.fileId}/export?mimeType=${encodeURIComponent(exportMimeType)}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json() + console.error('Google Drive export error:', { + status: response.status, + statusText: response.statusText, + error, + fileId: response.url.split('files/')[1]?.split('?')[0], + }) + throw new Error( + `Failed to export file from Google Drive: ${response.status} ${response.statusText} - ${error.error?.message || 'Unknown error'}` + ) + } + + // Get file metadata + const fileId = response.url.split('files/')[1]?.split('?')[0] + const metadataResponse = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}`, { + headers: { + Authorization: response.headers.get('Authorization') || '', + }, + }) + + if (!metadataResponse.ok) { + const metadataError = await metadataResponse.json() + console.error('Google Drive metadata error:', { + status: metadataResponse.status, + statusText: metadataResponse.statusText, + error: metadataError, + }) + throw new Error( + `Failed to get file metadata: ${metadataResponse.status} ${metadataResponse.statusText} - ${metadataError.error?.message || 'Unknown error'}` + ) + } + + const metadata = await metadataResponse.json() + let content + try { + content = await response.text() + } catch (error: any) { + console.error('Error reading response content:', { + message: error.message, + stack: error.stack, + error: JSON.stringify(error), + }) + throw new Error(`Failed to read file content: ${error?.message || 'Unknown error'}`) + } + + return { + success: true, + output: { + content, + metadata: { + id: metadata.id, + name: metadata.name, + mimeType: metadata.mimeType, + webViewLink: metadata.webViewLink, + webContentLink: metadata.webContentLink, + size: metadata.size, + createdTime: metadata.createdTime, + modifiedTime: metadata.modifiedTime, + parents: metadata.parents, + }, + }, + } + }, + transformError: (error: any) => { + console.error('Export tool error:', { + message: error.message, + stack: error.stack, + error: JSON.stringify(error, null, 2), + }) + if (typeof error === 'string') { + return error + } + return ( + error.message || + JSON.stringify(error, null, 2) || + 'An error occurred while exporting from Google Drive' + ) + }, +} diff --git a/tools/drive/list.ts b/tools/drive/list.ts new file mode 100644 index 0000000000..637ea9af42 --- /dev/null +++ b/tools/drive/list.ts @@ -0,0 +1,79 @@ +import { ToolConfig } from '../types' +import { GoogleDriveListResponse, GoogleDriveToolParams } from './types' + +export const listTool: ToolConfig = { + id: 'google_drive_list', + name: 'List Google Drive Files', + description: 'List files and folders in Google Drive', + version: '1.0', + oauth: { + required: true, + provider: 'google-drive', + additionalScopes: ['https://www.googleapis.com/auth/drive'], + }, + params: { + accessToken: { type: 'string', required: true }, + folderId: { type: 'string', required: false }, + query: { type: 'string', required: false }, + pageSize: { type: 'number', required: false }, + pageToken: { type: 'string', required: false }, + }, + request: { + url: (params) => { + const url = new URL('https://www.googleapis.com/drive/v3/files') + url.searchParams.append( + 'fields', + 'files(id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents),nextPageToken' + ) + + if (params.folderId) { + url.searchParams.append('q', `'${params.folderId}' in parents`) + } + if (params.query) { + const existingQ = url.searchParams.get('q') + const queryPart = `name contains '${params.query}'` + url.searchParams.set('q', existingQ ? `${existingQ} and ${queryPart}` : queryPart) + } + if (params.pageSize) { + url.searchParams.append('pageSize', params.pageSize.toString()) + } + if (params.pageToken) { + url.searchParams.append('pageToken', params.pageToken) + } + + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Failed to list Google Drive files') + } + + return { + success: true, + output: { + files: data.files.map((file: any) => ({ + id: file.id, + name: file.name, + mimeType: file.mimeType, + webViewLink: file.webViewLink, + webContentLink: file.webContentLink, + size: file.size, + createdTime: file.createdTime, + modifiedTime: file.modifiedTime, + parents: file.parents, + })), + nextPageToken: data.nextPageToken, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while listing Google Drive files' + }, +} diff --git a/tools/drive/types.ts b/tools/drive/types.ts new file mode 100644 index 0000000000..f783266eb1 --- /dev/null +++ b/tools/drive/types.ts @@ -0,0 +1,45 @@ +import { ToolResponse } from '../types' + +export interface GoogleDriveFile { + id: string + name: string + mimeType: string + webViewLink?: string + webContentLink?: string + size?: string + createdTime?: string + modifiedTime?: string + parents?: string[] +} + +export interface GoogleDriveListResponse extends ToolResponse { + output: { + files: GoogleDriveFile[] + nextPageToken?: string + } +} + +export interface GoogleDriveUploadResponse extends ToolResponse { + output: { + file: GoogleDriveFile + } +} + +export interface GoogleDriveDownloadResponse extends ToolResponse { + output: { + content: string + metadata: GoogleDriveFile + } +} + +export interface GoogleDriveToolParams { + accessToken: string + folderId?: string + fileId?: string + fileName?: string + content?: string + mimeType?: string + query?: string + pageSize?: number + pageToken?: string +} diff --git a/tools/drive/upload.ts b/tools/drive/upload.ts new file mode 100644 index 0000000000..92e74af2bb --- /dev/null +++ b/tools/drive/upload.ts @@ -0,0 +1,77 @@ +import { ToolConfig } from '../types' +import { GoogleDriveToolParams, GoogleDriveUploadResponse } from './types' + +export const uploadTool: ToolConfig = { + id: 'google_drive_upload', + name: 'Upload to Google Drive', + description: 'Upload a file to Google Drive', + version: '1.0', + oauth: { + required: true, + provider: 'google-drive', + additionalScopes: ['https://www.googleapis.com/auth/drive.file'], + }, + params: { + accessToken: { type: 'string', required: true }, + fileName: { type: 'string', required: true }, + content: { type: 'string', required: true }, + mimeType: { type: 'string', required: false }, + folderId: { type: 'string', required: false }, + }, + request: { + url: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'multipart/related; boundary=boundary', + }), + body: (params) => { + const metadata = { + name: params.fileName, + ...(params.folderId ? { parents: [params.folderId] } : {}), + } + + const mimeType = params.mimeType || 'text/plain' + + const body = `--boundary +Content-Type: application/json; charset=UTF-8 + +${JSON.stringify(metadata)} + +--boundary +Content-Type: ${mimeType} + +${params.content} +--boundary--` + + return { body } + }, + }, + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Failed to upload file to Google Drive') + } + + return { + success: true, + output: { + file: { + id: data.id, + name: data.name, + mimeType: data.mimeType, + webViewLink: data.webViewLink, + webContentLink: data.webContentLink, + size: data.size, + createdTime: data.createdTime, + modifiedTime: data.modifiedTime, + parents: data.parents, + }, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while uploading to Google Drive' + }, +} diff --git a/tools/index.ts b/tools/index.ts index f436bac2db..6f1272b87d 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -1,6 +1,9 @@ import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' import { visionTool as crewAIVision } from './crewai/vision' +import { downloadTool as driveDownloadTool } from './drive/download' +import { listTool as driveListTool } from './drive/list' +import { uploadTool as driveUploadTool } from './drive/upload' import { answerTool as exaAnswer } from './exa/answer' import { findSimilarLinksTool as exaFindSimilarLinks } from './exa/findSimilarLinks' import { getContentsTool as exaGetContents } from './exa/getContents' @@ -77,6 +80,9 @@ export const tools: Record = { exa_find_similar_links: exaFindSimilarLinks, exa_answer: exaAnswer, reddit_hot_posts: redditHotPosts, + google_drive_download: driveDownloadTool, + google_drive_list: driveListTool, + google_drive_upload: driveUploadTool, } // Get a tool by its ID diff --git a/tools/types.ts b/tools/types.ts index a8c96d7578..8edfa0eed0 100644 --- a/tools/types.ts +++ b/tools/types.ts @@ -1,5 +1,5 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' -export type OAuthProvider = 'google' | 'google-email' | 'github' | 'twitter' +export type OAuthProvider = 'google' | 'google-email' | 'google-drive' | 'github' | 'twitter' export interface ToolResponse { success: boolean // Whether the tool execution was successful