From ad847b1cf420b93234951ef10c34734f99fd6b61 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Sun, 22 Mar 2026 18:50:06 +0300 Subject: [PATCH] ozellik: google oauth, gmail-drive araclari ve admin durum kartlarini ekle --- .env.example | 6 +- .gitignore | 1 + README.md | 89 ++++++++++++ backend/app/admin/routes.py | 163 ++++++++++++++++++++- backend/app/admin/services.py | 6 +- backend/app/config.py | 6 +- backend/app/db.py | 2 + backend/app/google/auth.py | 164 +++++++++++++++++++++ backend/app/llm/planner.py | 2 + backend/app/models.py | 19 ++- backend/app/tools/gmail.py | 110 ++++++++++++++ backend/app/tools/google_drive.py | 167 ++++++++++++++++++++++ backend/app/tools/registry.py | 8 ++ backend/app/tools/second_brain.py | 73 +++++++++- backend/pyproject.toml | 3 + backend/scripts/google_oauth_bootstrap.py | 29 ++++ frontend/src/App.tsx | 97 ++++++++++++- frontend/src/api.ts | 9 ++ frontend/src/styles.css | 15 +- frontend/src/types.ts | 15 ++ 20 files changed, 970 insertions(+), 14 deletions(-) create mode 100644 backend/app/google/auth.py create mode 100644 backend/app/tools/gmail.py create mode 100644 backend/app/tools/google_drive.py create mode 100644 backend/scripts/google_oauth_bootstrap.py diff --git a/.env.example b/.env.example index cd5a0d0..9da3ab5 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,11 @@ WISECLAW_LOCAL_MODEL=qwen3-vl-8b-instruct-mlx@5bit WISECLAW_ZAI_BASE_URL=https://api.z.ai/api/anthropic WISECLAW_ZAI_MODEL=glm-5 WISECLAW_ANYTHINGLLM_BASE_URL=http://127.0.0.1:3001 -WISECLAW_ANYTHINGLLM_WORKSPACE_SLUG=wiseclaw +WISECLAW_ANYTHINGLLM_WORKSPACE_SLUG=benim-calisma-alanim +WISECLAW_GOOGLE_CLIENT_SECRETS_FILE=.google/client_secret.json +WISECLAW_GOOGLE_TOKEN_FILE=.google/token.json +WISECLAW_GOOGLE_CLIENT_ID= +WISECLAW_GOOGLE_CLIENT_SECRET= WISECLAW_SEARCH_PROVIDER=brave WISECLAW_TELEGRAM_BOT_TOKEN= WISECLAW_BRAVE_API_KEY= diff --git a/.gitignore b/.gitignore index e39a4bd..183453d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ build/ .DS_Store .env +.google/ wiseclaw.db .codex/ .playwright-cli/ diff --git a/README.md b/README.md index ca0f3ee..d24a529 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ - 🧠 `/tanisalim` ile kalıcı kullanıcı profili ve iletişim tercihleri - 🗂️ AnythingLLM tabanlı ikinci beyin sorguları - 📝 `/notlarima_ekle` ile second brain notu ekleme ve otomatik senkron +- 📬 Google OAuth ile Gmail bağlantısı kurup gelen mailleri listeleme, arama ve özetleme +- ☁️ Google Drive dosyalarını listeleme, arama ve Telegram'dan gelen dosyaları Drive root'a yükleme - ⚙️ `/otomasyon_ekle` ile zamanlanmış görev oluşturma - 🌐 Brave Search ile web ve görsel arama - 🧭 `browser_use` ile gerçek tarayıcıda gezinme @@ -18,6 +20,7 @@ - `Local (LM Studio)` - `Z.AI` - 📊 Admin panelden ayarlar, loglar, memory, profiller ve otomasyonları yönetme +- 🔗 Admin panelden Google OAuth, AnythingLLM ve LLM bağlantı durumlarını canlı izleme ## 🏗️ Mimari @@ -49,6 +52,10 @@ Admin panelden aktif sağlayıcı değiştirildiğinde yeni istekler seçili sa Gerçek browser otomasyonu - `apple_notes` Apple Notes not oluşturma +- `gmail` + Gmail mesajlarını listeleme ve arama +- `google_drive` + Google Drive dosyalarını listeleme, arama ve yükleme - `files` Dosya/dizin erişimi - `terminal` @@ -56,6 +63,75 @@ Admin panelden aktif sağlayıcı değiştirildiğinde yeni istekler seçili sa - `second_brain` AnythingLLM workspace context sorgulama +## 📬 Google Entegrasyonları + +WiseClaw artık Google OAuth üzerinden tek bir Google hesabına bağlanabilir. + +### Admin'den Bağlama + +Admin panelde yeni `Google OAuth` kartı bulunur: + +- `Google OAuth client ID` +- `Google OAuth client secret` +- `Connect Google` / `Reconnect Google` + +Bu bilgiler kaydedildiğinde WiseClaw otomatik olarak şu dosyayı üretir: + +- [backend/.google/client_secret.json](/Users/wisecolt-macmini/Project/wiseclaw/backend/.google/client_secret.json) + +Başarılı bağlantıdan sonra token burada tutulur: + +- [backend/.google/token.json](/Users/wisecolt-macmini/Project/wiseclaw/backend/.google/token.json) + +### Gmail + +Desteklenen ilk sürüm işlemleri: + +- gelen kutusundaki son mailleri listeleme +- Gmail arama sorgusuyla filtreleme +- kısa özet üretme + +Örnek komutlar: + +```text +Gmail'de gelen ilk 10 maili özetle +Son 5 maili listele +OpenAI geçen mailleri bul +``` + +### Google Drive + +Desteklenen ilk sürüm işlemleri: + +- Drive'daki dosyaları listeleme +- dosya adıyla arama +- Telegram'dan gelen dosyayı Drive root'a yükleme + +Örnek komutlar: + +```text +Drive'da invoice geçen dosyaları ara +Google Drive'daki son 10 dosyayı listele +``` + +Telegram upload akışı: + +1. Telegram'da bir `document` veya `photo` gönder +2. O mesaja reply yap +3. Şunu yaz: + +```text +Bunu Google Drive'a yukle +``` + +WiseClaw dosyayı geçici alana indirir, Drive root'a yükler ve sana: + +- dosya adı +- Drive linki +- dosya ID'si + +döner. + ## 🧠 İkinci Beyin Akışı WiseClaw, AnythingLLM'yi ikinci beyin olarak kullanabilir. @@ -124,7 +200,10 @@ Admin panelde şunları yönetebilirsin: - Runtime settings - Model provider - Search provider +- AnythingLLM canlı durum kartı (`Active / Off`) +- Google auth durumu (`Connected / Not connected`) - Brave / Z.AI / AnythingLLM secret'ları +- Google OAuth client bilgileri - Telegram whitelist - User Profiles - Automations @@ -141,6 +220,8 @@ Admin panelde şunları yönetebilirsin: - `/admin/memory` - `/admin/integrations/llm` - `/admin/integrations/telegram` +- `/admin/integrations/anythingllm` +- `/admin/integrations/google` ## 🚀 Kurulum @@ -175,6 +256,10 @@ npm run dev - `WISECLAW_ZAI_MODEL` - `WISECLAW_ANYTHINGLLM_BASE_URL` - `WISECLAW_ANYTHINGLLM_WORKSPACE_SLUG` +- `WISECLAW_GOOGLE_CLIENT_SECRETS_FILE` +- `WISECLAW_GOOGLE_TOKEN_FILE` +- `WISECLAW_GOOGLE_CLIENT_ID` +- `WISECLAW_GOOGLE_CLIENT_SECRET` - `WISECLAW_TELEGRAM_BOT_TOKEN` - `WISECLAW_BRAVE_API_KEY` - `WISECLAW_ZAI_API_KEY` @@ -191,6 +276,8 @@ curl http://127.0.0.1:8000/health curl http://127.0.0.1:8000/bootstrap curl http://127.0.0.1:8000/admin/integrations/llm curl http://127.0.0.1:8000/admin/integrations/telegram +curl http://127.0.0.1:8000/admin/integrations/anythingllm +curl http://127.0.0.1:8000/admin/integrations/google ``` ## 🔁 Restart @@ -215,6 +302,8 @@ Bu script: - AnythingLLM tarafında görünen workspace adı ile gerçek `slug` farklı olabilir. - Brave image search sonuçları Telegram'da medya grubu olarak gönderilebilir. - Bazı browser görevleri captcha/anti-bot nedeniyle manuel müdahale isteyebilir. +- Google Drive upload için OAuth yetkileri değiştiyse Google hesabını yeniden bağlamak gerekebilir. +- `/clean_chat` Telegram ekranını temizler ama SQLite kayıtlarını silmez. ## 🧭 Geliştirme Notu diff --git a/backend/app/admin/routes.py b/backend/app/admin/routes.py index 2609aa4..a31e2e0 100644 --- a/backend/app/admin/routes.py +++ b/backend/app/admin/routes.py @@ -1,12 +1,27 @@ -from fastapi import APIRouter, Depends +from pathlib import Path + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse from pydantic import BaseModel from sqlalchemy.orm import Session from app.admin.services import AdminService from app.config import get_settings as get_app_settings from app.db import SecretORM, get_session +from app.google.auth import GoogleAuthError, GoogleAuthManager from app.llm.ollama_client import OllamaClient -from app.models import AutomationRecord, MemoryRecord, OllamaStatus, RuntimeSettings, TelegramStatus, UserProfileRecord, UserRecord +from app.models import ( + AnythingLLMStatus, + AutomationRecord, + GoogleIntegrationStatus, + MemoryRecord, + OllamaStatus, + RuntimeSettings, + TelegramStatus, + UserProfileRecord, + UserRecord, +) +from app.tools.second_brain import SecondBrainTool router = APIRouter(prefix="/admin", tags=["admin"]) @@ -16,10 +31,29 @@ class SecretPayload(BaseModel): value: str +class GoogleClientPayload(BaseModel): + client_id: str + client_secret: str + + def get_admin_service(session: Session = Depends(get_session)) -> AdminService: return AdminService(session) +def get_google_auth_manager() -> GoogleAuthManager: + return GoogleAuthManager(get_app_settings(), Path(__file__).resolve().parents[2]) + + +def sync_google_client_file(service: AdminService, manager: GoogleAuthManager) -> None: + settings = get_app_settings() + client_id_record = service.session.get(SecretORM, "google_client_id") + client_secret_record = service.session.get(SecretORM, "google_client_secret") + client_id = (client_id_record.value if client_id_record else settings.google_client_id).strip() + client_secret = (client_secret_record.value if client_secret_record else settings.google_client_secret).strip() + if client_id and client_secret: + manager.write_client_secrets_file(client_id, client_secret) + + @router.get("/dashboard") def get_dashboard(service: AdminService = Depends(get_admin_service)): return service.dashboard() @@ -77,6 +111,18 @@ def post_secret(payload: SecretPayload, service: AdminService = Depends(get_admi return {"status": "ok"} +@router.post("/integrations/google/client") +def post_google_client( + payload: GoogleClientPayload, + service: AdminService = Depends(get_admin_service), + manager: GoogleAuthManager = Depends(get_google_auth_manager), +): + service.save_secret("google_client_id", payload.client_id.strip()) + service.save_secret("google_client_secret", payload.client_secret.strip()) + sync_google_client_file(service, manager) + return {"status": "ok"} + + @router.get("/integrations/llm", response_model=OllamaStatus) @router.get("/integrations/ollama", response_model=OllamaStatus) async def get_llm_status(service: AdminService = Depends(get_admin_service)): @@ -94,3 +140,116 @@ async def get_llm_status(service: AdminService = Depends(get_admin_service)): @router.get("/integrations/telegram", response_model=TelegramStatus) def get_telegram_status(service: AdminService = Depends(get_admin_service)): return service.telegram_status() + + +@router.get("/integrations/anythingllm", response_model=AnythingLLMStatus) +async def get_anythingllm_status(service: AdminService = Depends(get_admin_service)): + runtime = service.get_runtime_settings() + settings = get_app_settings() + secret = service.session.get(SecretORM, "anythingllm_api_key") + tool = SecondBrainTool( + base_url=runtime.anythingllm_base_url, + workspace_slug=runtime.anythingllm_workspace_slug, + api_key=secret.value if secret else settings.anythingllm_api_key, + ) + status = await tool.status() + return AnythingLLMStatus( + reachable=bool(status.get("reachable")), + workspace_found=bool(status.get("workspace_found")), + base_url=runtime.anythingllm_base_url, + workspace_slug=runtime.anythingllm_workspace_slug, + message=str(status.get("message", "")), + ) + + +@router.get("/integrations/google", response_model=GoogleIntegrationStatus) +def get_google_status( + request: Request, + service: AdminService = Depends(get_admin_service), + manager: GoogleAuthManager = Depends(get_google_auth_manager), +): + sync_google_client_file(service, manager) + client_configured, connected, message = manager.oauth_status() + connect_url = str(request.url_for("google_oauth_connect")) + return GoogleIntegrationStatus( + client_configured=client_configured, + connected=connected, + connect_url=connect_url, + message=message, + ) + + +@router.get("/integrations/google/connect", name="google_oauth_connect") +def google_oauth_connect( + request: Request, + service: AdminService = Depends(get_admin_service), + manager: GoogleAuthManager = Depends(get_google_auth_manager), +): + redirect_uri = str(request.url_for("google_oauth_callback")) + try: + sync_google_client_file(service, manager) + authorization_url = manager.begin_web_oauth(redirect_uri) + except GoogleAuthError as exc: + return HTMLResponse( + ( + "" + f"

Google connect failed

{exc}

" + "

Add your client_secret.json file, then try the connect button again.

" + "" + ), + status_code=400, + ) + return RedirectResponse(url=authorization_url) + + +@router.get("/integrations/google/callback", response_class=HTMLResponse, name="google_oauth_callback") +def google_oauth_callback( + request: Request, + state: str | None = None, + error: str | None = None, + manager: GoogleAuthManager = Depends(get_google_auth_manager), +): + if error: + return HTMLResponse( + ( + "" + f"

Google connect failed

{error}

" + "

You can close this tab and try again from the WiseClaw admin panel.

" + "" + ), + status_code=400, + ) + + if not state: + return HTMLResponse( + ( + "" + "

Google connect failed

Missing OAuth state.

" + "

You can close this tab and try again from the WiseClaw admin panel.

" + "" + ), + status_code=400, + ) + + try: + manager.complete_web_oauth(str(request.url_for("google_oauth_callback")), state, str(request.url)) + except Exception as exc: + return HTMLResponse( + ( + "" + f"

Google connect failed

{exc}

" + "

You can close this tab and try again from the WiseClaw admin panel.

" + "" + ), + status_code=400, + ) + + return HTMLResponse( + ( + "" + "

Google account connected

" + "

WiseClaw can now use your Gmail and Google Drive tools.

" + "

You can close this tab and refresh the admin panel.

" + "" + ) + ) diff --git a/backend/app/admin/services.py b/backend/app/admin/services.py index 09fcd1a..f583297 100644 --- a/backend/app/admin/services.py +++ b/backend/app/admin/services.py @@ -169,7 +169,11 @@ class AdminService: def get_secret_mask(self, key: str) -> str: record = self.session.get(SecretORM, key) - value = record.value if record else "" + if record is not None: + value = record.value + else: + settings = get_settings() + value = str(getattr(settings, key, "") or "") if len(value) < 4: return "" return f"{value[:2]}***{value[-2:]}" diff --git a/backend/app/config.py b/backend/app/config.py index 57af586..48f8430 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,7 +21,11 @@ class Settings(BaseSettings): zai_base_url: str = "https://api.z.ai/api/anthropic" zai_model: str = "glm-5" anythingllm_base_url: str = "http://127.0.0.1:3001" - anythingllm_workspace_slug: str = "wiseclaw" + anythingllm_workspace_slug: str = "benim-calisma-alanim" + google_client_secrets_file: str = ".google/client_secret.json" + google_token_file: str = ".google/token.json" + google_client_id: str = Field(default="", repr=False) + google_client_secret: str = Field(default="", repr=False) search_provider: str = "brave" telegram_bot_token: str = Field(default="", repr=False) brave_api_key: str = Field(default="", repr=False) diff --git a/backend/app/db.py b/backend/app/db.py index f0ff4d0..5976766 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -12,6 +12,8 @@ DEFAULT_TOOLS = { "brave_search": True, "second_brain": True, "browser_use": True, + "gmail": True, + "google_drive": True, "searxng_search": False, "web_fetch": True, "apple_notes": True, diff --git a/backend/app/google/auth.py b/backend/app/google/auth.py new file mode 100644 index 0000000..205b070 --- /dev/null +++ b/backend/app/google/auth.py @@ -0,0 +1,164 @@ +import asyncio +import json +import os +import time +from pathlib import Path + +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import Flow + +from app.config import Settings + + +GOOGLE_SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/drive.file", +] + + +class GoogleAuthError(RuntimeError): + pass + + +_OAUTH_STATES: dict[str, float] = {} +_OAUTH_STATE_TTL_SECONDS = 900 + + +class GoogleAuthManager: + def __init__(self, settings: Settings, workspace_root: Path) -> None: + self.settings = settings + self.workspace_root = workspace_root.resolve() + + async def get_credentials(self) -> Credentials: + return await asyncio.to_thread(self._load_credentials) + + def _load_credentials(self) -> Credentials: + token_path = self.token_path + client_path = self.client_path + + if not client_path.exists(): + raise GoogleAuthError( + f"Google client secrets file is missing: {client_path}. " + "Create a Google OAuth Web Application and place its JSON here." + ) + if not token_path.exists(): + raise GoogleAuthError( + f"Google token file is missing: {token_path}. " + "Run the Google OAuth bootstrap step first." + ) + + credentials = Credentials.from_authorized_user_file(str(token_path), GOOGLE_SCOPES) + if credentials.expired and credentials.refresh_token: + credentials.refresh(Request()) + token_path.write_text(credentials.to_json(), encoding="utf-8") + + if not credentials.valid: + raise GoogleAuthError( + "Google credentials are invalid. Re-run the Google OAuth bootstrap step." + ) + return credentials + + @property + def client_path(self) -> Path: + return self._resolve_path(self.settings.google_client_secrets_file) + + @property + def token_path(self) -> Path: + return self._resolve_path(self.settings.google_token_file) + + def write_client_secrets_file(self, client_id: str, client_secret: str) -> Path: + client_id = client_id.strip() + client_secret = client_secret.strip() + if not client_id or not client_secret: + raise GoogleAuthError("Google client ID and client secret are required.") + + redirect_uris = [ + f"http://127.0.0.1:{self.settings.admin_port}/admin/integrations/google/callback", + f"http://localhost:{self.settings.admin_port}/admin/integrations/google/callback", + ] + payload = { + "web": { + "client_id": client_id, + "project_id": "wiseclaw-local", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": client_secret, + "redirect_uris": redirect_uris, + } + } + self.client_path.parent.mkdir(parents=True, exist_ok=True) + self.client_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return self.client_path + + def begin_web_oauth(self, redirect_uri: str) -> str: + if not self.client_path.exists(): + raise GoogleAuthError( + f"Google client secrets file is missing: {self.client_path}. " + "Place your Google OAuth client JSON there first." + ) + + self._configure_local_oauth_transport(redirect_uri) + flow = Flow.from_client_secrets_file(str(self.client_path), GOOGLE_SCOPES) + flow.redirect_uri = redirect_uri + authorization_url, state = flow.authorization_url( + access_type="offline", + include_granted_scopes="true", + prompt="consent", + ) + self._store_state(state) + return authorization_url + + def complete_web_oauth(self, redirect_uri: str, state: str, authorization_response: str) -> Credentials: + if not self._consume_state(state): + raise GoogleAuthError("Google OAuth state is missing or expired. Start the connect flow again.") + + self._configure_local_oauth_transport(redirect_uri) + flow = Flow.from_client_secrets_file(str(self.client_path), GOOGLE_SCOPES, state=state) + flow.redirect_uri = redirect_uri + flow.fetch_token(authorization_response=authorization_response) + credentials = flow.credentials + self.token_path.parent.mkdir(parents=True, exist_ok=True) + self.token_path.write_text(credentials.to_json(), encoding="utf-8") + return credentials + + def oauth_status(self) -> tuple[bool, bool, str]: + if not self.client_path.exists(): + return False, False, ( + f"Missing Google OAuth client file at {self.client_path}. " + "Add client_secret.json first." + ) + if not self.token_path.exists(): + return False, False, "Google account is not connected yet." + try: + self._load_credentials() + except GoogleAuthError as exc: + return True, False, str(exc) + return True, True, "Google account is connected." + + def _resolve_path(self, raw_path: str) -> Path: + path = Path(raw_path).expanduser() + if not path.is_absolute(): + path = (self.workspace_root / path).resolve() + else: + path = path.resolve() + return path + + def _store_state(self, state: str) -> None: + now = time.time() + expired = [item for item, created_at in _OAUTH_STATES.items() if now - created_at > _OAUTH_STATE_TTL_SECONDS] + for item in expired: + _OAUTH_STATES.pop(item, None) + _OAUTH_STATES[state] = now + + def _consume_state(self, state: str) -> bool: + created_at = _OAUTH_STATES.pop(state, None) + if created_at is None: + return False + return time.time() - created_at <= _OAUTH_STATE_TTL_SECONDS + + def _configure_local_oauth_transport(self, redirect_uri: str) -> None: + if redirect_uri.startswith("http://127.0.0.1") or redirect_uri.startswith("http://localhost"): + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" diff --git a/backend/app/llm/planner.py b/backend/app/llm/planner.py index 1f5c1b0..b45a0db 100644 --- a/backend/app/llm/planner.py +++ b/backend/app/llm/planner.py @@ -30,6 +30,8 @@ def build_prompt_context( "If the user asks you to create or update files, use the files tool with action `write`.\n" "If the user asks you to create a note in Apple Notes, use apple_notes with action `create_note`.\n" "If the user asks about their saved notes, documents, archive, workspace knowledge, or second brain, use second_brain or the injected second-brain context before answering.\n" + "If the user asks about Gmail or email inbox contents, use the gmail tool before answering.\n" + "If the user asks about Google Drive files or documents, use the google_drive tool before answering.\n" "For a static HTML/CSS/JS app, write the files first, then use the terminal tool to run a local server in the background with a command like `python3 -m http.server 9990 -d `.\n" "If the user asks you to open, inspect, interact with, or extract information from a website in a real browser, use browser_use.\n" "If the user asks you to inspect files, browse the web, or run terminal commands, use the matching tool instead of guessing. " diff --git a/backend/app/models.py b/backend/app/models.py index ba416cc..523146d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -61,12 +61,14 @@ class RuntimeSettings(BaseModel): local_model: str = "qwen3-vl-8b-instruct-mlx@5bit" zai_model: Literal["glm-4.7", "glm-5"] = "glm-5" anythingllm_base_url: str = "http://127.0.0.1:3001" - anythingllm_workspace_slug: str = "wiseclaw" + anythingllm_workspace_slug: str = "benim-calisma-alanim" tools: list[ToolToggle] = Field( default_factory=lambda: [ ToolToggle(name="brave_search", enabled=True), ToolToggle(name="second_brain", enabled=True), ToolToggle(name="browser_use", enabled=True), + ToolToggle(name="gmail", enabled=True), + ToolToggle(name="google_drive", enabled=True), ToolToggle(name="searxng_search", enabled=False), ToolToggle(name="web_fetch", enabled=True), ToolToggle(name="apple_notes", enabled=True), @@ -105,6 +107,21 @@ class TelegramStatus(BaseModel): message: str +class GoogleIntegrationStatus(BaseModel): + client_configured: bool + connected: bool + connect_url: str + message: str + + +class AnythingLLMStatus(BaseModel): + reachable: bool + workspace_found: bool + base_url: str + workspace_slug: str + message: str + + class AutomationRecord(BaseModel): id: int telegram_user_id: int diff --git a/backend/app/tools/gmail.py b/backend/app/tools/gmail.py new file mode 100644 index 0000000..b55ac15 --- /dev/null +++ b/backend/app/tools/gmail.py @@ -0,0 +1,110 @@ +import asyncio +from typing import Any + +from googleapiclient.discovery import build + +from app.google.auth import GoogleAuthError, GoogleAuthManager +from app.tools.base import Tool + + +class GmailTool(Tool): + name = "gmail" + description = "List and search Gmail messages for the connected Google account." + + def __init__(self, auth_manager: GoogleAuthManager) -> None: + self.auth_manager = auth_manager + + def parameters_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional Gmail search query such as from:someone newer_than:7d.", + }, + "max_results": { + "type": "integer", + "description": "Maximum number of messages to return, from 1 to 20.", + "minimum": 1, + "maximum": 20, + }, + "label_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional Gmail label filters. Defaults to INBOX.", + }, + }, + "additionalProperties": False, + } + + async def run(self, payload: dict[str, Any]) -> dict[str, Any]: + query = str(payload.get("query", "")).strip() + max_results = max(1, min(20, int(payload.get("max_results", 10) or 10))) + label_ids = payload.get("label_ids") + if not isinstance(label_ids, list) or not label_ids: + label_ids = ["INBOX"] + + try: + creds = await self.auth_manager.get_credentials() + except GoogleAuthError as exc: + return {"tool": self.name, "status": "error", "message": str(exc)} + + return await asyncio.to_thread( + self._list_messages, + creds, + query, + max_results, + [str(label) for label in label_ids], + ) + + def _list_messages( + self, + credentials: Any, + query: str, + max_results: int, + label_ids: list[str], + ) -> dict[str, Any]: + service = build("gmail", "v1", credentials=credentials, cache_discovery=False) + response = ( + service.users() + .messages() + .list(userId="me", q=query or None, labelIds=label_ids, maxResults=max_results) + .execute() + ) + message_refs = response.get("messages", []) + messages = [] + for item in message_refs: + detail = ( + service.users() + .messages() + .get( + userId="me", + id=item["id"], + format="metadata", + metadataHeaders=["From", "Subject", "Date"], + ) + .execute() + ) + headers = { + header.get("name", "").lower(): header.get("value", "") + for header in detail.get("payload", {}).get("headers", []) + } + messages.append( + { + "id": detail.get("id", ""), + "thread_id": detail.get("threadId", ""), + "from": headers.get("from", ""), + "subject": headers.get("subject", "(no subject)"), + "date": headers.get("date", ""), + "snippet": detail.get("snippet", ""), + "label_ids": detail.get("labelIds", []), + } + ) + + return { + "tool": self.name, + "status": "ok", + "query": query, + "count": len(messages), + "messages": messages, + } diff --git a/backend/app/tools/google_drive.py b/backend/app/tools/google_drive.py new file mode 100644 index 0000000..213754a --- /dev/null +++ b/backend/app/tools/google_drive.py @@ -0,0 +1,167 @@ +import asyncio +from pathlib import Path +from typing import Any + +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import MediaFileUpload + +from app.google.auth import GoogleAuthError, GoogleAuthManager +from app.tools.base import Tool + + +class GoogleDriveTool(Tool): + name = "google_drive" + description = "List, search, and upload files to the connected Google Drive account." + + def __init__(self, auth_manager: GoogleAuthManager) -> None: + self.auth_manager = auth_manager + + def parameters_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "upload"], + "description": "Drive action to perform. Defaults to list.", + }, + "query": { + "type": "string", + "description": "Optional Drive API query or free-text filename search.", + }, + "max_results": { + "type": "integer", + "description": "Maximum number of files to return, from 1 to 20.", + "minimum": 1, + "maximum": 20, + }, + "local_path": { + "type": "string", + "description": "Absolute local file path to upload when action is upload.", + }, + "filename": { + "type": "string", + "description": "Optional destination filename for uploads.", + }, + "mime_type": { + "type": "string", + "description": "Optional MIME type for uploads.", + }, + }, + "additionalProperties": False, + } + + async def run(self, payload: dict[str, Any]) -> dict[str, Any]: + action = str(payload.get("action", "list") or "list").strip().lower() + query = str(payload.get("query", "")).strip() + max_results = max(1, min(20, int(payload.get("max_results", 10) or 10))) + local_path = str(payload.get("local_path", "")).strip() + filename = str(payload.get("filename", "")).strip() + mime_type = str(payload.get("mime_type", "")).strip() + + try: + creds = await self.auth_manager.get_credentials() + except GoogleAuthError as exc: + return {"tool": self.name, "status": "error", "message": str(exc)} + + if action == "upload": + if not local_path: + return {"tool": self.name, "status": "error", "message": "local_path is required for uploads."} + try: + return await asyncio.to_thread(self._upload_file, creds, local_path, filename, mime_type) + except HttpError as exc: + return {"tool": self.name, "status": "error", "message": self._format_http_error(exc)} + + return await asyncio.to_thread(self._list_files, creds, query, max_results) + + def _list_files(self, credentials: Any, query: str, max_results: int) -> dict[str, Any]: + service = build("drive", "v3", credentials=credentials, cache_discovery=False) + api_query = "" + if query: + if any(token in query for token in ("name contains", "mimeType", "trashed", "modifiedTime")): + api_query = query + else: + escaped = query.replace("'", "\\'") + api_query = f"name contains '{escaped}' and trashed = false" + else: + api_query = "trashed = false" + + response = ( + service.files() + .list( + q=api_query, + pageSize=max_results, + orderBy="modifiedTime desc", + fields="files(id,name,mimeType,modifiedTime,webViewLink,owners(displayName))", + ) + .execute() + ) + files = [] + for item in response.get("files", []): + owners = item.get("owners", []) + files.append( + { + "id": item.get("id", ""), + "name": item.get("name", ""), + "mime_type": item.get("mimeType", ""), + "modified_time": item.get("modifiedTime", ""), + "web_view_link": item.get("webViewLink", ""), + "owners": [owner.get("displayName", "") for owner in owners], + } + ) + + return { + "tool": self.name, + "status": "ok", + "query": query, + "count": len(files), + "files": files, + } + + def _upload_file(self, credentials: Any, local_path: str, filename: str, mime_type: str) -> dict[str, Any]: + path = Path(local_path) + if not path.exists() or not path.is_file(): + return { + "tool": self.name, + "status": "error", + "message": f"Upload file was not found: {path}", + } + + service = build("drive", "v3", credentials=credentials, cache_discovery=False) + final_name = filename or path.name + media = MediaFileUpload(str(path), mimetype=mime_type or None, resumable=False) + created = ( + service.files() + .create( + body={"name": final_name}, + media_body=media, + fields="id,name,mimeType,webViewLink,webContentLink", + ) + .execute() + ) + + return { + "tool": self.name, + "status": "ok", + "action": "upload", + "file": { + "id": created.get("id", ""), + "name": created.get("name", final_name), + "mime_type": created.get("mimeType", mime_type), + "web_view_link": created.get("webViewLink", ""), + "web_content_link": created.get("webContentLink", ""), + }, + } + + def _format_http_error(self, exc: HttpError) -> str: + content = getattr(exc, "content", b"") + if isinstance(content, bytes): + text = content.decode("utf-8", errors="ignore").strip() + else: + text = str(content).strip() + if "insufficientPermissions" in text or "Insufficient Permission" in text: + return ( + "Google Drive upload izni yok. Google'i yeniden baglayip Drive yukleme iznini onaylaman gerekiyor." + ) + return text or str(exc) diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 36953ac..cd43990 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -4,11 +4,14 @@ from sqlalchemy.orm import Session from app.config import get_settings from app.db import SecretORM +from app.google.auth import GoogleAuthManager from app.models import RuntimeSettings from app.tools.apple_notes import AppleNotesTool from app.tools.browser_use import BrowserUseTool from app.tools.brave_search import BraveSearchTool from app.tools.files import FilesTool +from app.tools.gmail import GmailTool +from app.tools.google_drive import GoogleDriveTool from app.tools.second_brain import SecondBrainTool from app.tools.terminal import TerminalTool from app.tools.web_fetch import WebFetchTool @@ -18,6 +21,7 @@ def build_tools(runtime: RuntimeSettings, workspace_root: Path, session: Session enabled = {tool.name for tool in runtime.tools if tool.enabled} tools: dict[str, object] = {} settings = get_settings() + google_auth = GoogleAuthManager(settings, Path(__file__).resolve().parents[2]) if "files" in enabled: tools["files"] = FilesTool(workspace_root) @@ -39,6 +43,10 @@ def build_tools(runtime: RuntimeSettings, workspace_root: Path, session: Session workspace_slug=runtime.anythingllm_workspace_slug, api_key=api_key, ) + if "gmail" in enabled: + tools["gmail"] = GmailTool(google_auth) + if "google_drive" in enabled: + tools["google_drive"] = GoogleDriveTool(google_auth) if "web_fetch" in enabled: tools["web_fetch"] = WebFetchTool() if "terminal" in enabled: diff --git a/backend/app/tools/second_brain.py b/backend/app/tools/second_brain.py index 1eb8009..e4fc165 100644 --- a/backend/app/tools/second_brain.py +++ b/backend/app/tools/second_brain.py @@ -113,6 +113,49 @@ class SecondBrainTool(Tool): "raw": data, } + async def status(self) -> dict[str, Any]: + if not self.base_url: + return {"reachable": False, "workspace_found": False, "message": "AnythingLLM base URL is not configured."} + if not self.workspace_slug: + return { + "reachable": False, + "workspace_found": False, + "message": "AnythingLLM workspace slug is not configured.", + } + if not self.api_key: + return {"reachable": False, "workspace_found": False, "message": "AnythingLLM API key is not configured."} + + endpoint = f"{self.base_url}/api/v1/workspaces" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get(endpoint, headers=headers) + response.raise_for_status() + except httpx.HTTPError as exc: + return {"reachable": False, "workspace_found": False, "message": str(exc)} + + try: + data = response.json() + except ValueError: + return {"reachable": False, "workspace_found": False, "message": "AnythingLLM returned invalid JSON."} + + workspaces = self._extract_workspaces(data) + slug_match = next((item for item in workspaces if item == self.workspace_slug), None) + if slug_match: + return { + "reachable": True, + "workspace_found": True, + "message": f"Workspace {self.workspace_slug} is reachable.", + } + return { + "reachable": True, + "workspace_found": False, + "message": f"Workspace {self.workspace_slug} was not found.", + } + def _build_query_prompt(self, query: str, mode: str) -> str: if mode == "query": return ( @@ -128,13 +171,18 @@ class SecondBrainTool(Tool): try: payload = response.json() except ValueError: - return f"HTTP {response.status_code}" + text = response.text.strip() + return text or f"HTTP {response.status_code}" if isinstance(payload, dict): for key in ("error", "message"): value = payload.get(key) if isinstance(value, str) and value.strip(): return value.strip() - return f"HTTP {response.status_code}" + detail = payload.get("detail") + if isinstance(detail, str) and detail.strip(): + return detail.strip() + text = response.text.strip() + return text or f"HTTP {response.status_code}" def _extract_text_response(self, data: Any) -> str: if isinstance(data, dict): @@ -162,3 +210,24 @@ class SecondBrainTool(Tool): } ) return sources + + def _extract_workspaces(self, data: Any) -> list[str]: + if isinstance(data, dict): + for key in ("workspaces", "data"): + value = data.get(key) + if isinstance(value, list): + return self._workspace_slugs_from_list(value) + if isinstance(data, list): + return self._workspace_slugs_from_list(data) + return [] + + def _workspace_slugs_from_list(self, items: list[Any]) -> list[str]: + slugs: list[str] = [] + for item in items: + if isinstance(item, dict): + for key in ("slug", "name"): + value = item.get(key) + if isinstance(value, str) and value.strip(): + slugs.append(value.strip()) + break + return slugs diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0d8b6a9..5f716f0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ "python-telegram-bot>=22.0,<23.0", "browser-use>=0.12.2,<1.0.0", "anthropic>=0.76.0,<1.0.0", + "google-api-python-client>=2.181.0,<3.0.0", + "google-auth>=2.40.0,<3.0.0", + "google-auth-oauthlib>=1.2.2,<2.0.0", ] [tool.setuptools.packages.find] diff --git a/backend/scripts/google_oauth_bootstrap.py b/backend/scripts/google_oauth_bootstrap.py new file mode 100644 index 0000000..e131b7d --- /dev/null +++ b/backend/scripts/google_oauth_bootstrap.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from google_auth_oauthlib.flow import InstalledAppFlow + +from app.config import get_settings +from app.google.auth import GOOGLE_SCOPES + + +def main() -> None: + settings = get_settings() + workspace_root = Path(__file__).resolve().parents[1] + client_path = (workspace_root / settings.google_client_secrets_file).resolve() + token_path = (workspace_root / settings.google_token_file).resolve() + token_path.parent.mkdir(parents=True, exist_ok=True) + + if not client_path.exists(): + raise SystemExit( + f"Missing Google OAuth client file: {client_path}\n" + "Create a Google OAuth Desktop App and place its JSON there first." + ) + + flow = InstalledAppFlow.from_client_secrets_file(str(client_path), GOOGLE_SCOPES) + creds = flow.run_local_server(port=0, open_browser=True) + token_path.write_text(creds.to_json(), encoding="utf-8") + print(f"Google token saved to {token_path}") + + +if __name__ == "__main__": + main() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19ad196..227a228 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,8 +2,10 @@ import { FormEvent, useEffect, useState } from "react"; import { api } from "./api"; import type { + AnythingLLMStatus, AutomationRecord, DashboardSnapshot, + GoogleIntegrationStatus, MemoryRecord, OllamaStatus, RuntimeSettings, @@ -20,11 +22,13 @@ const defaultSettings: RuntimeSettings = { local_model: "qwen3-vl-8b-instruct-mlx@5bit", zai_model: "glm-5", anythingllm_base_url: "http://127.0.0.1:3001", - anythingllm_workspace_slug: "wiseclaw", + anythingllm_workspace_slug: "benim-calisma-alanim", tools: [ { name: "brave_search", enabled: true }, { name: "second_brain", enabled: true }, { name: "browser_use", enabled: true }, + { name: "gmail", enabled: true }, + { name: "google_drive", enabled: true }, { name: "searxng_search", enabled: false }, { name: "web_fetch", enabled: true }, { name: "apple_notes", enabled: true }, @@ -46,8 +50,14 @@ export function App() { const [zaiSecretValue, setZaiSecretValue] = useState(""); const [anythingSecretMask, setAnythingSecretMask] = useState(""); const [anythingSecretValue, setAnythingSecretValue] = useState(""); + const [googleClientIdMask, setGoogleClientIdMask] = useState(""); + const [googleClientIdValue, setGoogleClientIdValue] = useState(""); + const [googleClientSecretMask, setGoogleClientSecretMask] = useState(""); + const [googleClientSecretValue, setGoogleClientSecretValue] = useState(""); const [ollamaStatus, setOllamaStatus] = useState(null); const [telegramStatus, setTelegramStatus] = useState(null); + const [googleStatus, setGoogleStatus] = useState(null); + const [anythingStatus, setAnythingStatus] = useState(null); const [status, setStatus] = useState("Loading WiseClaw admin..."); const providerLabel = settings.model_provider === "local" ? "Local (LM Studio)" : "Z.AI"; const searchProviderLabel = settings.search_provider === "brave" ? "Brave" : "SearXNG"; @@ -63,7 +73,23 @@ export function App() { async function load() { try { - const [dashboardData, settingsData, userData, profileData, automationData, memoryData, secretData, zaiSecretData, anythingSecretData, ollamaData, telegramData] = + const [ + dashboardData, + settingsData, + userData, + profileData, + automationData, + memoryData, + secretData, + zaiSecretData, + anythingSecretData, + googleClientIdData, + googleClientSecretData, + ollamaData, + telegramData, + anythingllmData, + googleData, + ] = await Promise.all([ api.getDashboard(), api.getSettings(), @@ -74,8 +100,12 @@ export function App() { api.getSecretMask("brave_api_key"), api.getSecretMask("zai_api_key"), api.getSecretMask("anythingllm_api_key"), + api.getSecretMask("google_client_id"), + api.getSecretMask("google_client_secret"), api.getOllamaStatus(), api.getTelegramStatus(), + api.getAnythingLLMStatus(), + api.getGoogleStatus(), ]); setDashboard(dashboardData); setSettings(settingsData); @@ -86,8 +116,12 @@ export function App() { setSecretMask(secretData.masked); setZaiSecretMask(zaiSecretData.masked); setAnythingSecretMask(anythingSecretData.masked); + setGoogleClientIdMask(googleClientIdData.masked); + setGoogleClientSecretMask(googleClientSecretData.masked); setOllamaStatus(ollamaData); setTelegramStatus(telegramData); + setAnythingStatus(anythingllmData); + setGoogleStatus(googleData); setStatus("WiseClaw admin ready."); } catch (error) { setStatus(error instanceof Error ? error.message : "Failed to load admin data."); @@ -135,6 +169,18 @@ export function App() { await load(); } + async function handleGoogleClientSubmit(event: FormEvent) { + event.preventDefault(); + if (!googleClientIdValue.trim() || !googleClientSecretValue.trim()) { + return; + } + await api.saveGoogleClient(googleClientIdValue.trim(), googleClientSecretValue.trim()); + setGoogleClientIdValue(""); + setGoogleClientSecretValue(""); + setStatus("Google OAuth client saved."); + await load(); + } + async function handleAddUser(event: FormEvent) { event.preventDefault(); const form = new FormData(event.currentTarget); @@ -197,8 +243,8 @@ export function App() {
- Terminal mode - {settings.terminal_mode} + AnythingLLM + {anythingStatus?.reachable && anythingStatus?.workspace_found ? "Active" : "Off"}
Search provider @@ -220,6 +266,21 @@ export function App() { {telegramStatus?.configured ? "Configured" : "Missing token"}

{telegramStatus?.message || "Checking..."}

+
+ Google auth: + {googleStatus?.connected ? "Connected" : "Not connected"} +

{googleStatus?.message || "Checking Google OAuth status..."}

+ {googleStatus?.connect_url ? ( + + {googleStatus.connected ? "Reconnect Google" : "Connect Google"} + + ) : null} +
@@ -293,7 +354,7 @@ export function App() { setSettings({ ...settings, anythingllm_workspace_slug: event.target.value })} - placeholder="wiseclaw" + placeholder="benim-calisma-alanim" /> @@ -406,6 +467,32 @@ export function App() { +
+
+

Google OAuth

+ +
+

Current client ID: {googleClientIdMask || "not configured"}

+

Current client secret: {googleClientSecretMask || "not configured"}

+ + +
+

Telegram Whitelist

diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fadc00e..2f9ce9e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,8 @@ import type { + AnythingLLMStatus, AutomationRecord, DashboardSnapshot, + GoogleIntegrationStatus, MemoryRecord, OllamaStatus, RuntimeSettings, @@ -53,6 +55,13 @@ export const api = { method: "POST", body: JSON.stringify({ key, value }), }), + saveGoogleClient: (client_id: string, client_secret: string) => + request<{ status: string }>("/admin/integrations/google/client", { + method: "POST", + body: JSON.stringify({ client_id, client_secret }), + }), getOllamaStatus: () => request("/admin/integrations/llm"), getTelegramStatus: () => request("/admin/integrations/telegram"), + getAnythingLLMStatus: () => request("/admin/integrations/anythingllm"), + getGoogleStatus: () => request("/admin/integrations/google"), }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 04e3994..267e2e4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -167,7 +167,7 @@ label { .integration-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0.9rem; } @@ -192,6 +192,19 @@ label { color: #4f5b57; } +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 0.6rem; + padding: 0.65rem 0.9rem; + border-radius: 999px; + background: #1f5c66; + color: #f5f1e8; + text-decoration: none; + font-weight: 600; +} + .grid.two-up { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2b3c105..57d570a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -85,3 +85,18 @@ export type TelegramStatus = { polling_active: boolean; message: string; }; + +export type GoogleIntegrationStatus = { + client_configured: boolean; + connected: boolean; + connect_url: string; + message: string; +}; + +export type AnythingLLMStatus = { + reachable: boolean; + workspace_found: boolean; + base_url: string; + workspace_slug: string; + message: string; +};