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..."}
+
@@ -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() {
+
+