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

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