diff --git a/CLAUDE.md b/CLAUDE.md index c8bf7e8..cde9db4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,7 @@ Import from `modules.core`: `EventBus`, `BaseEvent`, `TaskRegistry`, `TaskInfo`, | `modules/crm/` | `service.py` | `AmoCRMService` | | `modules/ecommerce/` | `service.py` | `WooCommerceService` | | `modules/telephony/` | `service.py` | `GSMService` | +| `modules/google/` | `service.py`, `models.py` | `GoogleOAuthService` | **Import pattern**: `from modules.chat.service import chat_service` (direct, preferred) or `from db.integration import async_chat_manager` (backward-compatible alias). Domain `__init__.py` files do NOT re-export services (see Known Issues #9). @@ -204,6 +205,7 @@ Phase 3 migration complete: all 28 routers moved from `app/routers/` to domain m | `modules/monitoring/` | `router_audit.py`, `router_usage.py`, `router_monitor.py` | `app/routers/audit.py`, `usage.py`, `monitor.py` | | `modules/chat/` | `router.py` | `app/routers/chat.py` | | `modules/llm/` | `router.py` | `app/routers/llm.py` | +| `modules/google/` | `router.py` (+ `callback_router`) | `app/routers/google.py` | **Phase 4 routers** (extracted from `orchestrator.py`, not from `app/routers/`): @@ -336,6 +338,9 @@ REDIS_URL=redis://localhost:6379/0 # Optional, graceful fallback DEV_MODE=1 # Backend proxies to Vite dev server (:5173) VECTOR_SEARCH_URL=http://localhost:8003 # Optional, Vector Search microservice VECTOR_SEARCH_TOKEN= # Bearer token for Vector Search API +GOOGLE_CLIENT_ID= # Google OAuth 2.0 (Drive, Docs, Sheets, Gmail) +GOOGLE_CLIENT_SECRET= # Google OAuth 2.0 client secret +GOOGLE_REDIRECT_URI= # OAuth callback URL (default: {BASE_URL}/admin/oauth/google/callback) ``` ## Deployment diff --git a/admin/src/api/google.ts b/admin/src/api/google.ts index 1edbb89..5fbb415 100644 --- a/admin/src/api/google.ts +++ b/admin/src/api/google.ts @@ -6,7 +6,37 @@ export interface GoogleOAuthStatus { scopes: string[] } +export interface GoogleDriveFile { + id: string + name: string + mimeType: string + modifiedTime: string | null + size: string | null + isFolder: boolean +} + +export interface GoogleDriveListResponse { + files: GoogleDriveFile[] + nextPageToken?: string +} + +export interface GoogleDocContent { + title: string + text: string + id: string +} + +export interface GoogleSheetContent { + title: string + sheet: string + sheets: string[] + markdown: string + rows: number + id: string +} + export const googleApi = { + // OAuth getAuthUrl: () => api.get<{ auth_url: string }>('/admin/google/auth-url'), @@ -15,4 +45,24 @@ export const googleApi = { disconnect: () => api.post<{ status: string }>('/admin/google/disconnect'), + + // Drive + driveList: (folderId = 'root', query?: string, pageToken?: string) => { + const params = new URLSearchParams({ folder_id: folderId }) + if (query) params.set('query', query) + if (pageToken) params.set('page_token', pageToken) + return api.get(`/admin/google/drive/files?${params}`) + }, + + driveSearch: (query: string) => + api.get(`/admin/google/drive/search?query=${encodeURIComponent(query)}`), + + // File content + getFileContent: (fileId: string, mimeType: string, sheetName?: string) => { + const params = new URLSearchParams({ mime_type: mimeType }) + if (sheetName) params.set('sheet_name', sheetName) + return api.get( + `/admin/google/drive/file/${fileId}/content?${params}` + ) + }, } diff --git a/admin/src/plugins/i18n.ts b/admin/src/plugins/i18n.ts index 13f55cd..9eeab45 100644 --- a/admin/src/plugins/i18n.ts +++ b/admin/src/plugins/i18n.ts @@ -1353,6 +1353,8 @@ const messages = { connected: "Google аккаунт подключён", disconnected: "Google аккаунт отключён", connectionFailed: "Не удалось подключить Google", + searchFiles: "Поиск файлов...", + noFiles: "Нет файлов", }, }, en: { @@ -2705,6 +2707,8 @@ const messages = { connected: "Google account connected", disconnected: "Google account disconnected", connectionFailed: "Failed to connect Google", + searchFiles: "Search files...", + noFiles: "No files", }, }, kk: { @@ -4057,6 +4061,8 @@ const messages = { connected: "Google аккаунт қосылды", disconnected: "Google аккаунт ажыратылды", connectionFailed: "Google қосу сәтсіз аяқталды", + searchFiles: "Файлдарды іздеу...", + noFiles: "Файлдар жоқ", }, }, }; diff --git a/admin/src/views/ChatView.vue b/admin/src/views/ChatView.vue index 2a3d0ff..a16dcbd 100644 --- a/admin/src/views/ChatView.vue +++ b/admin/src/views/ChatView.vue @@ -1501,6 +1501,119 @@ function addEmptyContextFile() { editingFileContent.value = '' } +// ── Google Drive picker ────────────────────────────────────── +const googleConnected = ref(false) +const showGoogleDrivePicker = ref(false) +const gdriveFiles = ref([]) +const gdriveLoading = ref(false) +const gdriveSearch = ref('') +const gdrivePath = ref<{ id: string; name: string }[]>([]) +const gdriveCurrentFolder = ref('root') + +async function checkGoogleConnection() { + try { + const { googleApi } = await import('@/api/google') + const status = await googleApi.getStatus() + googleConnected.value = status.connected + } catch { + googleConnected.value = false + } +} + +async function loadGdriveFolder(folderId = 'root') { + gdriveLoading.value = true + gdriveCurrentFolder.value = folderId + try { + const { googleApi } = await import('@/api/google') + const result = await googleApi.driveList(folderId) + gdriveFiles.value = result.files + } catch { + gdriveFiles.value = [] + } finally { + gdriveLoading.value = false + } +} + +async function searchGoogleDrive() { + if (!gdriveSearch.value.trim()) { + loadGdriveFolder(gdriveCurrentFolder.value) + return + } + gdriveLoading.value = true + try { + const { googleApi } = await import('@/api/google') + const result = await googleApi.driveSearch(gdriveSearch.value) + gdriveFiles.value = result.files + gdrivePath.value = [] + } catch { + gdriveFiles.value = [] + } finally { + gdriveLoading.value = false + } +} + +function navigateGdrive(folderId: string, breadcrumbIndex?: number) { + if (folderId === 'root') { + gdrivePath.value = [] + } else if (breadcrumbIndex !== undefined) { + gdrivePath.value = gdrivePath.value.slice(0, breadcrumbIndex + 1) + } + gdriveSearch.value = '' + loadGdriveFolder(folderId) +} + +function navigateGdriveInto(folder: import('@/api/google').GoogleDriveFile) { + gdrivePath.value.push({ id: folder.id, name: folder.name }) + gdriveSearch.value = '' + loadGdriveFolder(folder.id) +} + +async function attachGoogleFile(file: import('@/api/google').GoogleDriveFile) { + gdriveLoading.value = true + try { + const { googleApi } = await import('@/api/google') + const content = await googleApi.getFileContent(file.id, file.mimeType) + const text = 'markdown' in content ? content.markdown : ('text' in content ? content.text : '') + const title = content.title || file.name + contextFiles.value.push({ name: title, content: text }) + autoSaveContextFiles() + showGoogleDrivePicker.value = false + toastStore.success(`${title} добавлен в контекст`) + } catch (e) { + toastStore.error('Не удалось загрузить файл из Google Drive') + } finally { + gdriveLoading.value = false + } +} + +function gdriveIcon(file: import('@/api/google').GoogleDriveFile): string { + if (file.isFolder) return '\ud83d\udcc1' + const mt = file.mimeType + if (mt.includes('document')) return '\ud83d\udcd4' + if (mt.includes('spreadsheet')) return '\ud83d\udcca' + if (mt.includes('presentation')) return '\ud83d\udcfd\ufe0f' + if (mt.includes('pdf')) return '\ud83d\udcc4' + if (mt.includes('image')) return '\ud83d\uddbc\ufe0f' + return '\ud83d\udcc3' +} + +function gdriveTypeLabel(mimeType: string): string { + if (mimeType.includes('document')) return 'Doc' + if (mimeType.includes('spreadsheet')) return 'Sheet' + if (mimeType.includes('presentation')) return 'Slides' + if (mimeType.includes('pdf')) return 'PDF' + return '' +} + +// Load Google Drive files when picker opens +watch(showGoogleDrivePicker, (v) => { + if (v) { + gdrivePath.value = [] + gdriveSearch.value = '' + loadGdriveFolder('root') + } +}) + function editContextFile(index: number) { editingFileIndex.value = index editingFileName.value = contextFiles.value[index].name @@ -1813,6 +1926,7 @@ onMounted(() => { currentSessionId.value = sessions.value[0].id } fetchAllCcSessions() + checkGoogleConnection() }) watch(sessions, (newSessions) => { @@ -3232,7 +3346,7 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0" v-if="currentSessionId && !isReadOnly" :class="[ 'p-3 rounded-lg transition-colors shrink-0', - webSearchEnabled ? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30' : 'bg-secondary text-muted-foreground hover:bg-secondary/80' + webSearchEnabled ? 'bg-orange-500/20 text-orange-400 hover:bg-orange-500/30' : 'bg-secondary text-muted-foreground hover:bg-secondary/80' ]" :title="webSearchEnabled ? t('chatView.webSearchOn') : t('chatView.webSearchOff')" @click="webSearchEnabled = !webSearchEnabled" @@ -3916,7 +4030,7 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0" -
+
+ +
+ + +
+
+

Google Drive

+ +
+ +
+ +
+ +
+ + +
+ +
+ + {{ t('common.loading') }} +
+
+ {{ t('google.noFiles') }} +
+
+ +
diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index c023e50..34f7728 100644 Binary files a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 2127973..8598610 100644 Binary files a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index b441f37..1fe4228 100644 Binary files a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 72905b8..9b387b6 100644 Binary files a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 8ed0605..19ae5a8 100644 Binary files a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 9502e47..1d1f157 100644 Binary files a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 4d1e077..1f05e14 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index df0f158..c1c8d46 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 853db04..093b0cd 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 6cdf97c..acb7ed1 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 2960cbb..d9f5036 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 8e3093a..fd07d62 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 46de6e2..83934f6 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index d2ea9ab..2ce5cac 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index a40d73e..6e09f19 100644 Binary files a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/mobile/android/app/src/main/res/values/ic_launcher_background.xml index f42ada6..ea90e8a 100644 --- a/mobile/android/app/src/main/res/values/ic_launcher_background.xml +++ b/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #FFFFFF + #1A1308 diff --git a/modules/google/router.py b/modules/google/router.py index d4a4c87..6553e1a 100644 --- a/modules/google/router.py +++ b/modules/google/router.py @@ -1,4 +1,4 @@ -"""Google OAuth router — auth flow, status, disconnect.""" +"""Google OAuth + Drive/Docs/Sheets router.""" import logging @@ -63,3 +63,61 @@ async def google_oauth_callback( except Exception as e: logger.error(f"Google OAuth callback error: {e}") return RedirectResponse(url="/admin/#/settings?google=error") + + +# ── Drive / Docs / Sheets ──────────────────────────────────── + + +@router.get("/drive/files") +async def drive_list_files( + folder_id: str = Query("root"), + query: str = Query(None), + page_token: str = Query(None), + page_size: int = Query(50, ge=1, le=100), + user: User = Depends(get_current_user), +): + """List files in a Google Drive folder.""" + try: + return await google_oauth_service.drive_list( + user.id, folder_id, query, page_token, page_size + ) + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + logger.error(f"Drive list error: {e}") + raise HTTPException(status_code=502, detail="Google Drive error") + + +@router.get("/drive/search") +async def drive_search( + query: str = Query(..., min_length=1), + page_size: int = Query(20, ge=1, le=50), + user: User = Depends(get_current_user), +): + """Search files across Google Drive.""" + try: + return await google_oauth_service.drive_search(user.id, query, page_size) + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + logger.error(f"Drive search error: {e}") + raise HTTPException(status_code=502, detail="Google Drive error") + + +@router.get("/drive/file/{file_id}/content") +async def drive_get_content( + file_id: str, + mime_type: str = Query(...), + sheet_name: str = Query(None), + user: User = Depends(get_current_user), +): + """Get file content (auto-detects Docs/Sheets/text files).""" + try: + if mime_type == "application/vnd.google-apps.spreadsheet" and sheet_name: + return await google_oauth_service.sheets_get_data(user.id, file_id, sheet_name) + return await google_oauth_service.drive_get_file_content(user.id, file_id, mime_type) + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + logger.error(f"File content error: {e}") + raise HTTPException(status_code=502, detail="Google API error") diff --git a/modules/google/service.py b/modules/google/service.py index 5b4f438..4dfb032 100644 --- a/modules/google/service.py +++ b/modules/google/service.py @@ -243,5 +243,244 @@ async def disconnect(self, user_id: int) -> bool: logger.info(f"Google OAuth disconnected for user {user_id}") return True + # ── Google Drive ────────────────────────────────────────────── + + async def drive_list( + self, + user_id: int, + folder_id: str = "root", + query: str | None = None, + page_token: str | None = None, + page_size: int = 50, + ) -> dict: + """List files in a Drive folder. Returns {files, nextPageToken}.""" + creds = await self.get_valid_credentials(user_id) + if not creds: + raise ValueError("Google not connected") + + fields = "nextPageToken, files(id, name, mimeType, modifiedTime, size, iconLink)" + q_parts = [f"'{folder_id}' in parents", "trashed = false"] + if query: + q_parts.append(f"name contains '{query}'") + q = " and ".join(q_parts) + + params: dict = { + "q": q, + "fields": fields, + "pageSize": page_size, + "orderBy": "folder,name", + } + if page_token: + params["pageToken"] = page_token + + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://www.googleapis.com/drive/v3/files", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + params=params, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + + return { + "files": [ + { + "id": f["id"], + "name": f["name"], + "mimeType": f["mimeType"], + "modifiedTime": f.get("modifiedTime"), + "size": f.get("size"), + "isFolder": f["mimeType"] == "application/vnd.google-apps.folder", + } + for f in data.get("files", []) + ], + "nextPageToken": data.get("nextPageToken"), + } + + async def drive_search(self, user_id: int, query: str, page_size: int = 20) -> dict: + """Search files across entire Drive.""" + creds = await self.get_valid_credentials(user_id) + if not creds: + raise ValueError("Google not connected") + + fields = "files(id, name, mimeType, modifiedTime, size)" + q = f"name contains '{query}' and trashed = false" + + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://www.googleapis.com/drive/v3/files", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + params={"q": q, "fields": fields, "pageSize": page_size}, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + + return { + "files": [ + { + "id": f["id"], + "name": f["name"], + "mimeType": f["mimeType"], + "modifiedTime": f.get("modifiedTime"), + "size": f.get("size"), + "isFolder": f["mimeType"] == "application/vnd.google-apps.folder", + } + for f in data.get("files", []) + ] + } + + # ── Google Docs ────────────────────────────────────────────── + + async def docs_get_text(self, user_id: int, document_id: str) -> dict: + """Get Google Doc content as plain text. Returns {title, text}.""" + creds = await self.get_valid_credentials(user_id) + if not creds: + raise ValueError("Google not connected") + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://docs.googleapis.com/v1/documents/{document_id}", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + timeout=30, + ) + resp.raise_for_status() + doc = resp.json() + + title = doc.get("title", "Untitled") + # Extract text from document body + text_parts: list[str] = [] + for element in doc.get("body", {}).get("content", []): + paragraph = element.get("paragraph") + if not paragraph: + continue + for pe in paragraph.get("elements", []): + text_run = pe.get("textRun") + if text_run: + text_parts.append(text_run.get("content", "")) + + return {"title": title, "text": "".join(text_parts), "id": document_id} + + # ── Google Sheets ──────────────────────────────────────────── + + async def sheets_get_data( + self, + user_id: int, + spreadsheet_id: str, + sheet_name: str | None = None, + ) -> dict: + """Get Google Sheet as markdown table. Returns {title, sheets, markdown}.""" + creds = await self.get_valid_credentials(user_id) + if not creds: + raise ValueError("Google not connected") + + async with httpx.AsyncClient() as client: + # Get spreadsheet metadata + resp = await client.get( + f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + params={"fields": "properties.title,sheets.properties.title"}, + timeout=15, + ) + resp.raise_for_status() + meta = resp.json() + title = meta.get("properties", {}).get("title", "Untitled") + sheet_names = [s["properties"]["title"] for s in meta.get("sheets", [])] + + # Determine which sheet to read + target = sheet_name if sheet_name and sheet_name in sheet_names else sheet_names[0] + + # Read values + resp = await client.get( + f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/{target}", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + timeout=30, + ) + resp.raise_for_status() + values = resp.json().get("values", []) + + # Convert to markdown table + md = self._values_to_markdown(values) + return { + "title": title, + "sheet": target, + "sheets": sheet_names, + "markdown": md, + "rows": len(values), + "id": spreadsheet_id, + } + + @staticmethod + def _values_to_markdown(values: list[list[str]]) -> str: + """Convert sheet values to markdown table.""" + if not values: + return "(empty sheet)" + # Header + header = values[0] + col_count = len(header) + lines = ["| " + " | ".join(str(c) for c in header) + " |"] + lines.append("| " + " | ".join("---" for _ in range(col_count)) + " |") + # Data rows (limit to 500 rows for context) + for row in values[1:500]: + padded = list(row) + [""] * (col_count - len(row)) + lines.append("| " + " | ".join(str(c) for c in padded[:col_count]) + " |") + if len(values) > 501: + lines.append(f"... ({len(values) - 501} more rows)") + return "\n".join(lines) + + # ── Generic file download ──────────────────────────────────── + + async def drive_get_file_content(self, user_id: int, file_id: str, mime_type: str) -> dict: + """Get file content based on type. Routes to appropriate method.""" + if mime_type == "application/vnd.google-apps.document": + return await self.docs_get_text(user_id, file_id) + elif mime_type == "application/vnd.google-apps.spreadsheet": + return await self.sheets_get_data(user_id, file_id) + else: + # Regular file — download as text + return await self._download_file_as_text(user_id, file_id) + + async def _download_file_as_text(self, user_id: int, file_id: str) -> dict: + """Download a regular Drive file as text (for txt, csv, md, etc.).""" + creds = await self.get_valid_credentials(user_id) + if not creds: + raise ValueError("Google not connected") + + async with httpx.AsyncClient() as client: + # Get file metadata + meta_resp = await client.get( + f"https://www.googleapis.com/drive/v3/files/{file_id}", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + params={"fields": "name,mimeType,size"}, + timeout=10, + ) + meta_resp.raise_for_status() + meta = meta_resp.json() + + # Download content (limit 1MB) + size = int(meta.get("size", 0)) + if size > 1_048_576: + return { + "title": meta["name"], + "text": f"(file too large: {size / 1048576:.1f} MB, max 1 MB)", + "id": file_id, + } + + resp = await client.get( + f"https://www.googleapis.com/drive/v3/files/{file_id}", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + params={"alt": "media"}, + timeout=30, + ) + resp.raise_for_status() + + try: + text = resp.text + except Exception: + text = "(binary file — cannot display as text)" + + return {"title": meta["name"], "text": text, "id": file_id} + google_oauth_service = GoogleOAuthService()