ozellik: google oauth, gmail-drive araclari ve admin durum kartlarini ekle

This commit is contained in:
2026-03-22 18:50:06 +03:00
parent 177fd8e1a7
commit ad847b1cf4
20 changed files with 970 additions and 14 deletions

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 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(
(
"<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:
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:]}"

View File

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

View File

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

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

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.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:

View File

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

View File

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

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