Files
simbarag/utils/google_service.py
ryan 9214afb206 Keep Google service enabled when token mount is read-only
token.json is mounted :ro, so writing refreshed creds back raised
PermissionError at startup once the stored access token expired. The broad
except in agents.py then disabled Calendar and Gmail tools entirely.
Keep the refreshed creds in memory when the write fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:42:37 -04:00

181 lines
5.6 KiB
Python

"""Google Calendar and Gmail service using the Python SDK."""
import asyncio
import base64
import os
from typing import Any
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
SCOPES = [
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/gmail.readonly",
]
class GoogleService:
def __init__(self):
token_path = os.getenv("GOOGLE_TOKEN_FILE", "/app/config/google-token.json")
if not os.path.exists(token_path):
raise FileNotFoundError(f"Google token file not found: {token_path}")
self._token_path = token_path
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
try:
with open(token_path, "w") as f:
f.write(creds.to_json())
except OSError:
pass # read-only mount; refreshed creds live in memory
self._creds = creds
self._calendar = build("calendar", "v3", credentials=creds)
self._gmail = build("gmail", "v1", credentials=creds)
async def get_calendar_events(
self,
time_min: str,
time_max: str,
calendar_id: str = "primary",
max_results: int = 20,
) -> list[dict[str, Any]]:
def _fetch():
result = (
self._calendar.events()
.list(
calendarId=calendar_id,
timeMin=time_min,
timeMax=time_max,
maxResults=max_results,
singleEvents=True,
orderBy="startTime",
)
.execute()
)
return result.get("items", [])
return await asyncio.to_thread(_fetch)
async def create_calendar_event(
self,
summary: str,
start_time: str,
end_time: str,
description: str = "",
location: str = "",
calendar_id: str = "primary",
) -> dict[str, Any]:
def _create():
body: dict[str, Any] = {
"summary": summary,
"start": {"dateTime": start_time},
"end": {"dateTime": end_time},
}
if description:
body["description"] = description
if location:
body["location"] = location
return (
self._calendar.events()
.insert(calendarId=calendar_id, body=body)
.execute()
)
return await asyncio.to_thread(_create)
async def search_emails(
self,
query: str,
max_results: int = 10,
) -> list[dict[str, Any]]:
def _fetch():
result = (
self._gmail.users()
.messages()
.list(userId="me", q=query, maxResults=max_results)
.execute()
)
messages = result.get("messages", [])
emails = []
for msg in messages:
msg_data = (
self._gmail.users()
.messages()
.get(
userId="me",
id=msg["id"],
format="metadata",
metadataHeaders=["From", "To", "Subject", "Date"],
)
.execute()
)
headers = {
h["name"]: h["value"]
for h in msg_data.get("payload", {}).get("headers", [])
}
emails.append(
{
"id": msg_data["id"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"snippet": msg_data.get("snippet", ""),
}
)
return emails
return await asyncio.to_thread(_fetch)
async def get_email(self, message_id: str) -> dict[str, Any]:
def _fetch():
msg_data = (
self._gmail.users()
.messages()
.get(userId="me", id=message_id, format="full")
.execute()
)
headers = {
h["name"]: h["value"]
for h in msg_data.get("payload", {}).get("headers", [])
}
body = _extract_body(msg_data.get("payload", {}))
return {
"id": msg_data["id"],
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"subject": headers.get("Subject", ""),
"date": headers.get("Date", ""),
"body": body[:4000],
"snippet": msg_data.get("snippet", ""),
}
return await asyncio.to_thread(_fetch)
def _extract_body(payload: dict[str, Any]) -> str:
mime = payload.get("mimeType", "")
data = payload.get("body", {}).get("data", "")
if mime == "text/plain" and data:
return base64.urlsafe_b64decode(data).decode("utf-8", errors="replace")
if mime == "text/html" and data:
import html2text
html = base64.urlsafe_b64decode(data).decode("utf-8", errors="replace")
return html2text.html2text(html)
for part in payload.get("parts", []):
result = _extract_body(part)
if result:
return result
return ""