Compare commits

..

3 Commits

21 changed files with 1314 additions and 41 deletions

View File

@@ -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_BASE_URL=https://api.z.ai/api/anthropic
WISECLAW_ZAI_MODEL=glm-5 WISECLAW_ZAI_MODEL=glm-5
WISECLAW_ANYTHINGLLM_BASE_URL=http://127.0.0.1:3001 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_SEARCH_PROVIDER=brave
WISECLAW_TELEGRAM_BOT_TOKEN= WISECLAW_TELEGRAM_BOT_TOKEN=
WISECLAW_BRAVE_API_KEY= WISECLAW_BRAVE_API_KEY=

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ dist/
build/ build/
.DS_Store .DS_Store
.env .env
.google/
wiseclaw.db wiseclaw.db
.codex/ .codex/
.playwright-cli/ .playwright-cli/

308
README.md
View File

@@ -1,28 +1,232 @@
# WiseClaw # WiseClaw
WiseClaw is a local-first personal assistant for macOS. It runs a FastAPI backend, supports either a local LM Studio endpoint or the hosted Z.AI API for LLM access, can augment replies with an AnythingLLM-backed second brain, exposes a Telegram bot, and includes a React admin panel for settings, logs, and memory management. 🦉 WiseClaw, macOS üzerinde çalışan yerel-öncelikli bir kişisel asistan altyapısıdır. FastAPI backend, Telegram botu, React admin paneli, çoklu LLM sağlayıcı desteği, tarayıcı otomasyonu, araç çağırma, otomasyonlar ve AnythingLLM tabanlı "ikinci beyin" entegrasyonunu aynı projede bir araya getirir.
## Planned capabilities ## ✨ Neler Yapabiliyor?
- Telegram chat with whitelist support - 🤖 Telegram üzerinden konuşma, komut ve araç kullanımı
- Global model provider switch for `Local (LM Studio)` or `Z.AI` - 🧠 `/tanisalim` ile kalıcı kullanıcı profili ve iletişim tercihleri
- Local LM Studio integration for `qwen3-vl-8b-instruct-mlx@5bit` - 🗂️ AnythingLLM tabanlı ikinci beyin sorguları
- Z.AI integration for `glm-4.7` and `glm-5` - 📝 `/notlarima_ekle` ile second brain notu ekleme ve otomatik senkron
- AnythingLLM second-brain context retrieval via workspace chat - 📬 Google OAuth ile Gmail bağlantısı kurup gelen mailleri listeleme, arama ve özetleme
- Brave or SearXNG-backed web search - ☁️ Google Drive dosyalarını listeleme, arama ve Telegram'dan gelen dosyaları Drive root'a yükleme
- Apple Notes integration via AppleScript - ⚙️ `/otomasyon_ekle` ile zamanlanmış görev oluşturma
- File read/write tools - 🌐 Brave Search ile web ve görsel arama
- Terminal execution with policy modes - 🧭 `browser_use` ile gerçek tarayıcıda gezinme
- SQLite-backed memory, settings, and audit logs - 🍎 Apple Notes üzerinde not oluşturma
- macOS Keychain for secrets - 📁 Dosya okuma/yazma
- 🖥️ Politika tabanlı terminal komut çalıştırma
- 🔀 Global model sağlayıcı seçimi:
- `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
## Repository layout ## 🏗️ Mimari
- `backend/` FastAPI app and WiseClaw core modules - `backend/`
- `frontend/` React admin panel FastAPI uygulaması, orchestrator, tool'lar, Telegram botu ve scheduler
- `docs/` architecture and rollout notes - `frontend/`
React tabanlı admin panel
- `docs/`
Mimari notlar ve brainstorm kayıtları
## Local development ## 🧩 LLM Sağlayıcıları
WiseClaw tek bir global sağlayıcı ile çalışır:
- 🏠 `Local (LM Studio)`
Yerel OpenAI-uyumlu endpoint üzerinden çalışır
- ☁️ `Z.AI`
Anthropic-uyumlu API üzerinden `glm-4.7` ve `glm-5` modellerini kullanır
Admin panelden aktif sağlayıcı değiştirildiğinde yeni istekler seçili sağlayıcıya gider.
## 🛠️ Başlıca Tool'lar
- `brave_search`
Web ve image search
- `web_fetch`
Tekil URL çekme ve içerik okuma
- `browser_use`
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`
Güvenlik politikasıyla komut çalıştırma
- `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.
### Sorgulama
Telegram'da örnek:
```text
Notlara bak, serkan ile ne zaman ve nerde buluştum?
```
WiseClaw bu isteği `second_brain` tool'una yönlendirir, AnythingLLM workspace'inden bağlam çeker ve kısa cevap üretir.
### Not Ekleme
Telegram akışı:
```text
/notlarima_ekle
```
Ardından gönderilen not:
1. SQLite veritabanına `second_brain` kaydı olarak yazılır
2. [backend/second_brain.md](/Users/wisecolt-macmini/Project/wiseclaw/backend/second_brain.md) dosyası yeniden üretilir
3. Eski `second_brain.md` AnythingLLM workspace'inden kaldırılır
4. Yeni dosya tekrar upload edilip workspace'e bağlanır
Bu yaklaşım belge tabanlı RAG akışına daha uygun olduğu için doğrudan DB -> AnythingLLM yazmaktan daha sağlamdır.
## 💬 Telegram Komutları
- `/start`
- `/tanisalim`
- `/profilim`
- `/tercihlerim`
- `/tanisalim_sifirla`
- `/otomasyon_ekle`
- `/otomasyonlar`
- `/otomasyon_durdur <id>`
- `/otomasyon_baslat <id>`
- `/otomasyon_sil <id>`
- `/notlarima_ekle`
- `/clean_chat`
`/clean_chat` yalnızca Telegram konuşma ekranını temizlemeye çalışır; veritabanındaki memory, audit log, profil veya second brain kayıtlarını silmez.
## ⏱️ Otomasyonlar
WiseClaw backend içinde çalışan scheduler ile zamanlanmış görevleri destekler.
Desteklenen ilk sürüm sıklıkları:
- günlük
- hafta içi
- haftalık
- saatlik
Otomasyon sonuçları:
- Telegram'a gönderilir
- audit log'a yazılır
## 🧪 Admin Panel
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
- Memory
- Recent Logs
Önemli endpointler:
- `/admin/dashboard`
- `/admin/settings`
- `/admin/users`
- `/admin/profiles`
- `/admin/automations`
- `/admin/memory`
- `/admin/integrations/llm`
- `/admin/integrations/telegram`
- `/admin/integrations/anythingllm`
- `/admin/integrations/google`
## 🚀 Kurulum
### Backend ### Backend
@@ -42,23 +246,73 @@ npm install
npm run dev npm run dev
``` ```
### Smoke checks ## 🔐 Ortam Değişkenleri
```bash [.env.example](/Users/wisecolt-macmini/Project/wiseclaw/.env.example) dosyasını `.env` olarak kopyalayabilirsin.
cd backend
source .venv312/bin/activate
uvicorn app.main:app --reload
```
Then in another shell: Öne çıkan alanlar:
- `WISECLAW_MODEL_PROVIDER`
- `WISECLAW_LOCAL_BASE_URL`
- `WISECLAW_LOCAL_MODEL`
- `WISECLAW_ZAI_BASE_URL`
- `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`
- `WISECLAW_ANYTHINGLLM_API_KEY`
Not: secret'lar admin panelden daha sonra da kaydedilebilir.
## ✅ Hızlı Kontrol
Backend ayağa kalktıktan sonra:
```bash ```bash
curl http://127.0.0.1:8000/health curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/bootstrap 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/llm
curl http://127.0.0.1:8000/admin/integrations/telegram 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
``` ```
## Environment bootstrap ## 🔁 Restart
Copy `.env.example` to `.env` and fill in only the values you need for the first boot. Secrets such as `WISECLAW_ZAI_API_KEY` and `WISECLAW_ANYTHINGLLM_API_KEY` can also be saved later from the admin panel. Projede tek komutluk restart script'i var:
```bash
cd /Users/wisecolt-macmini/Project/wiseclaw
zsh ./restart.sh
```
Bu script:
- eski backend sürecini kapatır
- yeni `uvicorn` sürecini başlatır
- log'u `.wiseclaw/logs/backend.log` içine yazar
- health check ile ayağa kalktığını doğrular
## 📌 Notlar
- `LM Studio status: Reachable` görünüp `model is not installed` uyarısı alıyorsan, endpoint açık ama seçili model adı yüklü modellerle birebir eşleşmiyor demektir.
- 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
Bu repo hızlı iterasyonla büyüdüğü için bazı alanlarda bilinçli teknik borçlar bulunur. Ana yön şu anda şudur:
- daha sağlam tool routing
- daha iyi approval akışları
- second brain retrieval kalitesini artırma
- admin panel kullanılabilirliğini geliştirme

View File

@@ -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 pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.admin.services import AdminService from app.admin.services import AdminService
from app.config import get_settings as get_app_settings from app.config import get_settings as get_app_settings
from app.db import SecretORM, get_session from app.db import SecretORM, get_session
from app.google.auth import GoogleAuthError, GoogleAuthManager
from app.llm.ollama_client import OllamaClient 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"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -16,10 +31,29 @@ class SecretPayload(BaseModel):
value: str value: str
class GoogleClientPayload(BaseModel):
client_id: str
client_secret: str
def get_admin_service(session: Session = Depends(get_session)) -> AdminService: def get_admin_service(session: Session = Depends(get_session)) -> AdminService:
return AdminService(session) 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") @router.get("/dashboard")
def get_dashboard(service: AdminService = Depends(get_admin_service)): def get_dashboard(service: AdminService = Depends(get_admin_service)):
return service.dashboard() return service.dashboard()
@@ -77,6 +111,18 @@ def post_secret(payload: SecretPayload, service: AdminService = Depends(get_admi
return {"status": "ok"} 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/llm", response_model=OllamaStatus)
@router.get("/integrations/ollama", response_model=OllamaStatus) @router.get("/integrations/ollama", response_model=OllamaStatus)
async def get_llm_status(service: AdminService = Depends(get_admin_service)): 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) @router.get("/integrations/telegram", response_model=TelegramStatus)
def get_telegram_status(service: AdminService = Depends(get_admin_service)): def get_telegram_status(service: AdminService = Depends(get_admin_service)):
return service.telegram_status() 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(
(
"<html><body style='font-family: sans-serif; padding: 24px;'>"
f"<h2>Google connect failed</h2><p>{exc}</p>"
"<p>Add your client_secret.json file, then try the connect button again.</p>"
"</body></html>"
),
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(
(
"<html><body style='font-family: sans-serif; padding: 24px;'>"
f"<h2>Google connect failed</h2><p>{error}</p>"
"<p>You can close this tab and try again from the WiseClaw admin panel.</p>"
"</body></html>"
),
status_code=400,
)
if not state:
return HTMLResponse(
(
"<html><body style='font-family: sans-serif; padding: 24px;'>"
"<h2>Google connect failed</h2><p>Missing OAuth state.</p>"
"<p>You can close this tab and try again from the WiseClaw admin panel.</p>"
"</body></html>"
),
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(
(
"<html><body style='font-family: sans-serif; padding: 24px;'>"
f"<h2>Google connect failed</h2><p>{exc}</p>"
"<p>You can close this tab and try again from the WiseClaw admin panel.</p>"
"</body></html>"
),
status_code=400,
)
return HTMLResponse(
(
"<html><body style='font-family: sans-serif; padding: 24px;'>"
"<h2>Google account connected</h2>"
"<p>WiseClaw can now use your Gmail and Google Drive tools.</p>"
"<p>You can close this tab and refresh the admin panel.</p>"
"</body></html>"
)
)

View File

@@ -169,7 +169,11 @@ class AdminService:
def get_secret_mask(self, key: str) -> str: def get_secret_mask(self, key: str) -> str:
record = self.session.get(SecretORM, key) 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: if len(value) < 4:
return "" return ""
return f"{value[:2]}***{value[-2:]}" return f"{value[:2]}***{value[-2:]}"

View File

@@ -21,7 +21,11 @@ class Settings(BaseSettings):
zai_base_url: str = "https://api.z.ai/api/anthropic" zai_base_url: str = "https://api.z.ai/api/anthropic"
zai_model: str = "glm-5" zai_model: str = "glm-5"
anythingllm_base_url: str = "http://127.0.0.1:3001" 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" search_provider: str = "brave"
telegram_bot_token: str = Field(default="", repr=False) telegram_bot_token: str = Field(default="", repr=False)
brave_api_key: str = Field(default="", repr=False) brave_api_key: str = Field(default="", repr=False)

View File

@@ -12,6 +12,8 @@ DEFAULT_TOOLS = {
"brave_search": True, "brave_search": True,
"second_brain": True, "second_brain": True,
"browser_use": True, "browser_use": True,
"gmail": True,
"google_drive": True,
"searxng_search": False, "searxng_search": False,
"web_fetch": True, "web_fetch": True,
"apple_notes": True, "apple_notes": True,

164
backend/app/google/auth.py Normal file
View File

@@ -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"

View File

@@ -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 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 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 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 <folder>`.\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 <folder>`.\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 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. " "If the user asks you to inspect files, browse the web, or run terminal commands, use the matching tool instead of guessing. "

View File

@@ -61,12 +61,14 @@ class RuntimeSettings(BaseModel):
local_model: str = "qwen3-vl-8b-instruct-mlx@5bit" local_model: str = "qwen3-vl-8b-instruct-mlx@5bit"
zai_model: Literal["glm-4.7", "glm-5"] = "glm-5" zai_model: Literal["glm-4.7", "glm-5"] = "glm-5"
anythingllm_base_url: str = "http://127.0.0.1:3001" 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( tools: list[ToolToggle] = Field(
default_factory=lambda: [ default_factory=lambda: [
ToolToggle(name="brave_search", enabled=True), ToolToggle(name="brave_search", enabled=True),
ToolToggle(name="second_brain", enabled=True), ToolToggle(name="second_brain", enabled=True),
ToolToggle(name="browser_use", 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="searxng_search", enabled=False),
ToolToggle(name="web_fetch", enabled=True), ToolToggle(name="web_fetch", enabled=True),
ToolToggle(name="apple_notes", enabled=True), ToolToggle(name="apple_notes", enabled=True),
@@ -105,6 +107,21 @@ class TelegramStatus(BaseModel):
message: str 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): class AutomationRecord(BaseModel):
id: int id: int
telegram_user_id: int telegram_user_id: int

View File

@@ -1,13 +1,17 @@
import asyncio import asyncio
import json import json
from contextlib import suppress from contextlib import suppress
from pathlib import Path
from typing import Any from typing import Any
from telegram import BotCommand, InputMediaPhoto, Update from telegram import BotCommand, InputMediaPhoto, Update
from telegram.constants import ChatAction from telegram.constants import ChatAction
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from app.db import AuditLogORM
from app.orchestrator import WiseClawOrchestrator from app.orchestrator import WiseClawOrchestrator
from app.telegram.auth import is_authorized
from app.tools.registry import build_tools
class TelegramBotService: class TelegramBotService:
@@ -75,6 +79,7 @@ class TelegramBotService:
return return
self.application = Application.builder().token(self.token).build() self.application = Application.builder().token(self.token).build()
self.application.add_handler(CommandHandler("start", self._on_start)) self.application.add_handler(CommandHandler("start", self._on_start))
self.application.add_handler(CommandHandler("clean_chat", self._on_clean_chat))
self.application.add_handler(CommandHandler("tanisalim", self._on_command_passthrough)) self.application.add_handler(CommandHandler("tanisalim", self._on_command_passthrough))
self.application.add_handler(CommandHandler("profilim", self._on_command_passthrough)) self.application.add_handler(CommandHandler("profilim", self._on_command_passthrough))
self.application.add_handler(CommandHandler("tercihlerim", self._on_command_passthrough)) self.application.add_handler(CommandHandler("tercihlerim", self._on_command_passthrough))
@@ -85,6 +90,7 @@ class TelegramBotService:
self.application.add_handler(CommandHandler("otomasyon_baslat", self._on_command_passthrough)) self.application.add_handler(CommandHandler("otomasyon_baslat", self._on_command_passthrough))
self.application.add_handler(CommandHandler("otomasyon_sil", self._on_command_passthrough)) self.application.add_handler(CommandHandler("otomasyon_sil", self._on_command_passthrough))
self.application.add_handler(CommandHandler("notlarima_ekle", self._on_command_passthrough)) self.application.add_handler(CommandHandler("notlarima_ekle", self._on_command_passthrough))
self.application.add_handler(MessageHandler(filters.Document.ALL | filters.PHOTO, self._on_attachment))
self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text)) self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
await self.application.initialize() await self.application.initialize()
await self.application.bot.set_my_commands(self._telegram_commands()) await self.application.bot.set_my_commands(self._telegram_commands())
@@ -110,6 +116,8 @@ class TelegramBotService:
async def _on_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message is None or update.effective_user is None or update.message.text is None: if update.message is None or update.effective_user is None or update.message.text is None:
return return
if await self._maybe_handle_drive_upload_from_reply(update, context):
return
typing_task = asyncio.create_task(self._send_typing(update.effective_chat.id, context)) typing_task = asyncio.create_task(self._send_typing(update.effective_chat.id, context))
try: try:
reply = await self.process_message_payload(update.effective_user.id, update.message.text) reply = await self.process_message_payload(update.effective_user.id, update.message.text)
@@ -127,6 +135,36 @@ class TelegramBotService:
for chunk in self._chunk_message(text_reply): for chunk in self._chunk_message(text_reply):
await update.message.reply_text(chunk) await update.message.reply_text(chunk)
async def _on_attachment(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message is None or update.effective_user is None:
return
if not self._message_has_supported_attachment(update.message):
return
if self._looks_like_drive_upload_request(update.message.caption or ""):
await self._handle_drive_upload(update, context, update.message)
return
await update.message.reply_text(
"Dosyayi aldim. Google Drive'a yuklemek icin bu mesaja reply yapip `Bunu Google Drive'a yukle` yazabilirsin.",
)
async def _on_clean_chat(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message is None or update.effective_chat is None:
return
chat_id = update.effective_chat.id
latest_message_id = update.message.message_id
consecutive_failures = 0
for message_id in range(latest_message_id, 0, -1):
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
consecutive_failures = 0
except Exception:
consecutive_failures += 1
if consecutive_failures >= 50:
break
continue
async def _on_command_passthrough(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_command_passthrough(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
del context del context
if update.message is None or update.effective_user is None or update.message.text is None: if update.message is None or update.effective_user is None or update.message.text is None:
@@ -147,6 +185,119 @@ class TelegramBotService:
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
await asyncio.sleep(4) await asyncio.sleep(4)
async def _maybe_handle_drive_upload_from_reply(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
if update.message is None or update.effective_user is None or update.message.text is None:
return False
if not self._looks_like_drive_upload_request(update.message.text):
return False
reply_target = update.message.reply_to_message
if reply_target is None or not self._message_has_supported_attachment(reply_target):
await update.message.reply_text(
"Google Drive'a yuklemek icin once dosya veya fotograf gonder, sonra o mesaja reply yapip `Bunu Google Drive'a yukle` yaz.",
)
return True
await self._handle_drive_upload(update, context, reply_target)
return True
async def _handle_drive_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE, source_message: Any) -> None:
if update.message is None or update.effective_user is None or update.effective_chat is None:
return
with self.orchestrator_factory() as session:
if not is_authorized(session, update.effective_user.id):
await update.message.reply_text("This Telegram user is not authorized for WiseClaw.")
return
orchestrator = WiseClawOrchestrator(session)
runtime = orchestrator.get_runtime_settings()
tools = build_tools(runtime, Path(__file__).resolve().parents[2], session)
drive_tool = tools.get("google_drive")
if drive_tool is None:
await update.message.reply_text("Google Drive araci etkin degil.")
return
temp_file = None
try:
attachment = await self._download_attachment(context, update.effective_chat.id, source_message)
if attachment is None:
await update.message.reply_text("Bu mesajdan yuklenebilir bir dosya cikarilamadi.")
return
temp_file = attachment["local_path"]
result = await drive_tool.run(
{
"action": "upload",
"local_path": attachment["local_path"],
"filename": attachment["filename"],
"mime_type": attachment["mime_type"],
}
)
session.add(
AuditLogORM(
category="tool",
message=f"tool:google_drive:{json.dumps({'action': 'upload', 'filename': attachment['filename']}, ensure_ascii=False)}",
)
)
if result.get("status") != "ok":
message = str(result.get("message", "Google Drive upload failed."))
await update.message.reply_text(f"Dosyayi Google Drive'a yukleyemedim: {message}")
return
file_info = result.get("file", {})
if not isinstance(file_info, dict):
file_info = {}
link = str(file_info.get("web_view_link") or file_info.get("web_content_link") or "").strip()
file_id = str(file_info.get("id", "")).strip()
name = str(file_info.get("name", attachment["filename"])).strip()
response_lines = [f"Dosya Google Drive'a yuklendi: {name}"]
if link:
response_lines.append(f"Link: {link}")
if file_id:
response_lines.append(f"Dosya ID: {file_id}")
await update.message.reply_text("\n".join(response_lines))
finally:
if temp_file:
with suppress(OSError):
Path(temp_file).unlink()
session.commit()
async def _download_attachment(self, context: ContextTypes.DEFAULT_TYPE, chat_id: int, message: Any) -> dict[str, str] | None:
if getattr(message, "document", None) is not None:
document = message.document
tg_file = await context.bot.get_file(document.file_id)
filename = document.file_name or f"telegram_document_{message.message_id}"
mime_type = document.mime_type or "application/octet-stream"
elif getattr(message, "photo", None):
photo = message.photo[-1]
tg_file = await context.bot.get_file(photo.file_id)
filename = f"telegram_photo_{message.message_id}.jpg"
mime_type = "image/jpeg"
else:
return None
temp_dir = Path(__file__).resolve().parents[2] / "tmp" / "telegram_uploads"
temp_dir.mkdir(parents=True, exist_ok=True)
safe_name = self._sanitize_filename(filename)
local_path = temp_dir / f"{chat_id}_{message.message_id}_{safe_name}"
await tg_file.download_to_drive(custom_path=str(local_path))
return {
"local_path": str(local_path),
"filename": filename,
"mime_type": mime_type,
}
def _looks_like_drive_upload_request(self, text: str) -> bool:
normalized = text.casefold()
references_drive = "drive" in normalized or "google drive" in normalized
upload_intent = any(term in normalized for term in ("yukle", "yükle", "gonder", "gönder", "upload"))
return references_drive and upload_intent
def _message_has_supported_attachment(self, message: Any) -> bool:
return bool(getattr(message, "document", None) is not None or getattr(message, "photo", None))
def _sanitize_filename(self, filename: str) -> str:
cleaned = "".join(char if char.isalnum() or char in {"-", "_", "."} else "_" for char in filename.strip())
return cleaned or "attachment.bin"
def _chunk_message(self, text: str) -> list[str]: def _chunk_message(self, text: str) -> list[str]:
if len(text) <= self.MAX_MESSAGE_LEN: if len(text) <= self.MAX_MESSAGE_LEN:
return [text] return [text]
@@ -170,6 +321,7 @@ class TelegramBotService:
BotCommand("profilim", "Kayitli profil ozetimi goster (wc)"), BotCommand("profilim", "Kayitli profil ozetimi goster (wc)"),
BotCommand("tercihlerim", "Kayitli iletisim tercihlerini goster (wc)"), BotCommand("tercihlerim", "Kayitli iletisim tercihlerini goster (wc)"),
BotCommand("tanisalim_sifirla", "Tanisma profilini sifirla (wc)"), BotCommand("tanisalim_sifirla", "Tanisma profilini sifirla (wc)"),
BotCommand("clean_chat", "Telegram ekranindaki mesajlari temizle (wc)"),
BotCommand("otomasyon_ekle", "Yeni otomasyon wizard'ini baslat (wc)"), BotCommand("otomasyon_ekle", "Yeni otomasyon wizard'ini baslat (wc)"),
BotCommand("otomasyonlar", "Otomasyon listesini goster (wc)"), BotCommand("otomasyonlar", "Otomasyon listesini goster (wc)"),
BotCommand("otomasyon_durdur", "Bir otomasyonu durdur: /otomasyon_durdur <id> (wc)"), BotCommand("otomasyon_durdur", "Bir otomasyonu durdur: /otomasyon_durdur <id> (wc)"),

110
backend/app/tools/gmail.py Normal file
View File

@@ -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,
}

View File

@@ -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)

View File

@@ -4,11 +4,14 @@ from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.db import SecretORM from app.db import SecretORM
from app.google.auth import GoogleAuthManager
from app.models import RuntimeSettings from app.models import RuntimeSettings
from app.tools.apple_notes import AppleNotesTool from app.tools.apple_notes import AppleNotesTool
from app.tools.browser_use import BrowserUseTool from app.tools.browser_use import BrowserUseTool
from app.tools.brave_search import BraveSearchTool from app.tools.brave_search import BraveSearchTool
from app.tools.files import FilesTool 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.second_brain import SecondBrainTool
from app.tools.terminal import TerminalTool from app.tools.terminal import TerminalTool
from app.tools.web_fetch import WebFetchTool 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} enabled = {tool.name for tool in runtime.tools if tool.enabled}
tools: dict[str, object] = {} tools: dict[str, object] = {}
settings = get_settings() settings = get_settings()
google_auth = GoogleAuthManager(settings, Path(__file__).resolve().parents[2])
if "files" in enabled: if "files" in enabled:
tools["files"] = FilesTool(workspace_root) 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, workspace_slug=runtime.anythingllm_workspace_slug,
api_key=api_key, 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: if "web_fetch" in enabled:
tools["web_fetch"] = WebFetchTool() tools["web_fetch"] = WebFetchTool()
if "terminal" in enabled: if "terminal" in enabled:

View File

@@ -113,6 +113,49 @@ class SecondBrainTool(Tool):
"raw": data, "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: def _build_query_prompt(self, query: str, mode: str) -> str:
if mode == "query": if mode == "query":
return ( return (
@@ -128,13 +171,18 @@ class SecondBrainTool(Tool):
try: try:
payload = response.json() payload = response.json()
except ValueError: except ValueError:
return f"HTTP {response.status_code}" text = response.text.strip()
return text or f"HTTP {response.status_code}"
if isinstance(payload, dict): if isinstance(payload, dict):
for key in ("error", "message"): for key in ("error", "message"):
value = payload.get(key) value = payload.get(key)
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():
return 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: def _extract_text_response(self, data: Any) -> str:
if isinstance(data, dict): if isinstance(data, dict):
@@ -162,3 +210,24 @@ class SecondBrainTool(Tool):
} }
) )
return sources 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

View File

@@ -17,6 +17,9 @@ dependencies = [
"python-telegram-bot>=22.0,<23.0", "python-telegram-bot>=22.0,<23.0",
"browser-use>=0.12.2,<1.0.0", "browser-use>=0.12.2,<1.0.0",
"anthropic>=0.76.0,<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] [tool.setuptools.packages.find]

View File

@@ -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()

View File

@@ -2,8 +2,10 @@ import { FormEvent, useEffect, useState } from "react";
import { api } from "./api"; import { api } from "./api";
import type { import type {
AnythingLLMStatus,
AutomationRecord, AutomationRecord,
DashboardSnapshot, DashboardSnapshot,
GoogleIntegrationStatus,
MemoryRecord, MemoryRecord,
OllamaStatus, OllamaStatus,
RuntimeSettings, RuntimeSettings,
@@ -20,11 +22,13 @@ const defaultSettings: RuntimeSettings = {
local_model: "qwen3-vl-8b-instruct-mlx@5bit", local_model: "qwen3-vl-8b-instruct-mlx@5bit",
zai_model: "glm-5", zai_model: "glm-5",
anythingllm_base_url: "http://127.0.0.1:3001", anythingllm_base_url: "http://127.0.0.1:3001",
anythingllm_workspace_slug: "wiseclaw", anythingllm_workspace_slug: "benim-calisma-alanim",
tools: [ tools: [
{ name: "brave_search", enabled: true }, { name: "brave_search", enabled: true },
{ name: "second_brain", enabled: true }, { name: "second_brain", enabled: true },
{ name: "browser_use", enabled: true }, { name: "browser_use", enabled: true },
{ name: "gmail", enabled: true },
{ name: "google_drive", enabled: true },
{ name: "searxng_search", enabled: false }, { name: "searxng_search", enabled: false },
{ name: "web_fetch", enabled: true }, { name: "web_fetch", enabled: true },
{ name: "apple_notes", enabled: true }, { name: "apple_notes", enabled: true },
@@ -46,8 +50,14 @@ export function App() {
const [zaiSecretValue, setZaiSecretValue] = useState(""); const [zaiSecretValue, setZaiSecretValue] = useState("");
const [anythingSecretMask, setAnythingSecretMask] = useState(""); const [anythingSecretMask, setAnythingSecretMask] = useState("");
const [anythingSecretValue, setAnythingSecretValue] = 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<OllamaStatus | null>(null); const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(null);
const [telegramStatus, setTelegramStatus] = useState<TelegramStatus | null>(null); const [telegramStatus, setTelegramStatus] = useState<TelegramStatus | null>(null);
const [googleStatus, setGoogleStatus] = useState<GoogleIntegrationStatus | null>(null);
const [anythingStatus, setAnythingStatus] = useState<AnythingLLMStatus | null>(null);
const [status, setStatus] = useState("Loading WiseClaw admin..."); const [status, setStatus] = useState("Loading WiseClaw admin...");
const providerLabel = settings.model_provider === "local" ? "Local (LM Studio)" : "Z.AI"; const providerLabel = settings.model_provider === "local" ? "Local (LM Studio)" : "Z.AI";
const searchProviderLabel = settings.search_provider === "brave" ? "Brave" : "SearXNG"; const searchProviderLabel = settings.search_provider === "brave" ? "Brave" : "SearXNG";
@@ -63,7 +73,23 @@ export function App() {
async function load() { async function load() {
try { 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([ await Promise.all([
api.getDashboard(), api.getDashboard(),
api.getSettings(), api.getSettings(),
@@ -74,8 +100,12 @@ export function App() {
api.getSecretMask("brave_api_key"), api.getSecretMask("brave_api_key"),
api.getSecretMask("zai_api_key"), api.getSecretMask("zai_api_key"),
api.getSecretMask("anythingllm_api_key"), api.getSecretMask("anythingllm_api_key"),
api.getSecretMask("google_client_id"),
api.getSecretMask("google_client_secret"),
api.getOllamaStatus(), api.getOllamaStatus(),
api.getTelegramStatus(), api.getTelegramStatus(),
api.getAnythingLLMStatus(),
api.getGoogleStatus(),
]); ]);
setDashboard(dashboardData); setDashboard(dashboardData);
setSettings(settingsData); setSettings(settingsData);
@@ -86,8 +116,12 @@ export function App() {
setSecretMask(secretData.masked); setSecretMask(secretData.masked);
setZaiSecretMask(zaiSecretData.masked); setZaiSecretMask(zaiSecretData.masked);
setAnythingSecretMask(anythingSecretData.masked); setAnythingSecretMask(anythingSecretData.masked);
setGoogleClientIdMask(googleClientIdData.masked);
setGoogleClientSecretMask(googleClientSecretData.masked);
setOllamaStatus(ollamaData); setOllamaStatus(ollamaData);
setTelegramStatus(telegramData); setTelegramStatus(telegramData);
setAnythingStatus(anythingllmData);
setGoogleStatus(googleData);
setStatus("WiseClaw admin ready."); setStatus("WiseClaw admin ready.");
} catch (error) { } catch (error) {
setStatus(error instanceof Error ? error.message : "Failed to load admin data."); setStatus(error instanceof Error ? error.message : "Failed to load admin data.");
@@ -135,6 +169,18 @@ export function App() {
await load(); 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<HTMLFormElement>) { async function handleAddUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const form = new FormData(event.currentTarget); const form = new FormData(event.currentTarget);
@@ -197,8 +243,8 @@ export function App() {
</div> </div>
<div className="hero-grid"> <div className="hero-grid">
<div> <div>
<span>Terminal mode</span> <span>AnythingLLM</span>
<strong>{settings.terminal_mode}</strong> <strong>{anythingStatus?.reachable && anythingStatus?.workspace_found ? "Active" : "Off"}</strong>
</div> </div>
<div> <div>
<span>Search provider</span> <span>Search provider</span>
@@ -220,6 +266,21 @@ export function App() {
<strong>{telegramStatus?.configured ? "Configured" : "Missing token"}</strong> <strong>{telegramStatus?.configured ? "Configured" : "Missing token"}</strong>
<p>{telegramStatus?.message || "Checking..."}</p> <p>{telegramStatus?.message || "Checking..."}</p>
</div> </div>
<div className="integration-card">
<span>Google auth:</span>
<strong>{googleStatus?.connected ? "Connected" : "Not connected"}</strong>
<p>{googleStatus?.message || "Checking Google OAuth status..."}</p>
{googleStatus?.connect_url ? (
<a
className="button-link"
href={googleStatus.connect_url}
target="_blank"
rel="noreferrer"
>
{googleStatus.connected ? "Reconnect Google" : "Connect Google"}
</a>
) : null}
</div>
</div> </div>
</section> </section>
@@ -293,7 +354,7 @@ export function App() {
<input <input
value={settings.anythingllm_workspace_slug} value={settings.anythingllm_workspace_slug}
onChange={(event) => setSettings({ ...settings, anythingllm_workspace_slug: event.target.value })} onChange={(event) => setSettings({ ...settings, anythingllm_workspace_slug: event.target.value })}
placeholder="wiseclaw" placeholder="benim-calisma-alanim"
/> />
</label> </label>
@@ -406,6 +467,32 @@ export function App() {
</label> </label>
</form> </form>
<form className="panel secret-panel" onSubmit={handleGoogleClientSubmit}>
<div className="panel-head">
<h3>Google OAuth</h3>
<button type="submit">Update</button>
</div>
<p className="muted">Current client ID: {googleClientIdMask || "not configured"}</p>
<p className="muted">Current client secret: {googleClientSecretMask || "not configured"}</p>
<label>
Google client ID
<input
value={googleClientIdValue}
onChange={(event) => setGoogleClientIdValue(event.target.value)}
placeholder="Paste Google OAuth client ID"
/>
</label>
<label>
Google client secret
<input
type="password"
value={googleClientSecretValue}
onChange={(event) => setGoogleClientSecretValue(event.target.value)}
placeholder="Paste Google OAuth client secret"
/>
</label>
</form>
<form className="panel" onSubmit={handleAddUser}> <form className="panel" onSubmit={handleAddUser}>
<div className="panel-head"> <div className="panel-head">
<h3>Telegram Whitelist</h3> <h3>Telegram Whitelist</h3>

View File

@@ -1,6 +1,8 @@
import type { import type {
AnythingLLMStatus,
AutomationRecord, AutomationRecord,
DashboardSnapshot, DashboardSnapshot,
GoogleIntegrationStatus,
MemoryRecord, MemoryRecord,
OllamaStatus, OllamaStatus,
RuntimeSettings, RuntimeSettings,
@@ -53,6 +55,13 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ key, value }), 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<OllamaStatus>("/admin/integrations/llm"), getOllamaStatus: () => request<OllamaStatus>("/admin/integrations/llm"),
getTelegramStatus: () => request<TelegramStatus>("/admin/integrations/telegram"), getTelegramStatus: () => request<TelegramStatus>("/admin/integrations/telegram"),
getAnythingLLMStatus: () => request<AnythingLLMStatus>("/admin/integrations/anythingllm"),
getGoogleStatus: () => request<GoogleIntegrationStatus>("/admin/integrations/google"),
}; };

View File

@@ -167,7 +167,7 @@ label {
.integration-grid { .integration-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.9rem; gap: 0.9rem;
} }
@@ -192,6 +192,19 @@ label {
color: #4f5b57; 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 { .grid.two-up {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -85,3 +85,18 @@ export type TelegramStatus = {
polling_active: boolean; polling_active: boolean;
message: string; 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;
};