Add Gmail and Google Calendar integration
Implements on-demand Google tools (not adapter) for email and calendar access via OAuth2. Features: - OAuth2 user consent flow with automatic token refresh - 3 Gmail tools: send_email, read_emails, get_email - 3 Calendar tools: read_calendar, create_calendar_event, search_calendar - Lazy loading pattern for Google clients - Secure token storage with file permissions - Browser-based setup: python bot_runner.py --setup-google Architecture: - Tools-only approach (zero API calls when not in use) - User-initiated actions only (no continuous polling) - MIME message creation for emails with threading support - HTML to text conversion for email parsing - ISO 8601 timestamp handling for calendar events Files added: - google_tools/oauth_manager.py: OAuth2 flow and token management - google_tools/gmail_client.py: Gmail API wrapper - google_tools/calendar_client.py: Calendar API wrapper - google_tools/utils.py: Email/MIME helpers - config/scheduled_tasks.yaml: Example scheduled tasks config Files modified: - tools.py: Added 6 Google tool handlers with lazy initialization - bot_runner.py: Added --setup-google command for OAuth authorization - requirements.txt: Added Google API dependencies - .gitignore: Added google_credentials.yaml and google_oauth_token.json Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
7
google_tools/__init__.py
Normal file
7
google_tools/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Google Tools - Gmail and Calendar integration for ajarbot."""
|
||||
|
||||
from .calendar_client import CalendarClient
|
||||
from .gmail_client import GmailClient
|
||||
from .oauth_manager import GoogleOAuthManager
|
||||
|
||||
__all__ = ["GoogleOAuthManager", "GmailClient", "CalendarClient"]
|
||||
296
google_tools/calendar_client.py
Normal file
296
google_tools/calendar_client.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Google Calendar API client for managing events."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from .oauth_manager import GoogleOAuthManager
|
||||
|
||||
|
||||
class CalendarClient:
|
||||
"""Client for Google Calendar API operations."""
|
||||
|
||||
def __init__(self, oauth_manager: GoogleOAuthManager):
|
||||
"""Initialize Calendar client.
|
||||
|
||||
Args:
|
||||
oauth_manager: Initialized OAuth manager with valid credentials
|
||||
"""
|
||||
self.oauth_manager = oauth_manager
|
||||
self.service = None
|
||||
self._initialize_service()
|
||||
|
||||
def _initialize_service(self) -> bool:
|
||||
"""Initialize Calendar API service.
|
||||
|
||||
Returns:
|
||||
True if initialized successfully, False otherwise
|
||||
"""
|
||||
credentials = self.oauth_manager.get_credentials()
|
||||
if not credentials:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.service = build("calendar", "v3", credentials=credentials)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Calendar] Failed to initialize service: {e}")
|
||||
return False
|
||||
|
||||
def list_events(
|
||||
self,
|
||||
days_ahead: int = 7,
|
||||
calendar_id: str = "primary",
|
||||
max_results: int = 20,
|
||||
) -> Dict:
|
||||
"""List upcoming events.
|
||||
|
||||
Args:
|
||||
days_ahead: Number of days ahead to look (max: 30)
|
||||
calendar_id: Calendar ID (default: "primary")
|
||||
max_results: Maximum number of events to return
|
||||
|
||||
Returns:
|
||||
Dict with success status and events or error
|
||||
"""
|
||||
if not self.service:
|
||||
if not self._initialize_service():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
||||
}
|
||||
|
||||
try:
|
||||
# Limit days_ahead to 30
|
||||
days_ahead = min(days_ahead, 30)
|
||||
|
||||
now = datetime.utcnow()
|
||||
time_min = now.isoformat() + "Z"
|
||||
time_max = (now + timedelta(days=days_ahead)).isoformat() + "Z"
|
||||
|
||||
events_result = (
|
||||
self.service.events()
|
||||
.list(
|
||||
calendarId=calendar_id,
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
maxResults=max_results,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
events = events_result.get("items", [])
|
||||
|
||||
if not events:
|
||||
return {
|
||||
"success": True,
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"summary": f"No events in the next {days_ahead} days.",
|
||||
}
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
start = event["start"].get("dateTime", event["start"].get("date"))
|
||||
end = event["end"].get("dateTime", event["end"].get("date"))
|
||||
|
||||
formatted_events.append({
|
||||
"id": event["id"],
|
||||
"summary": event.get("summary", "No title"),
|
||||
"start": start,
|
||||
"end": end,
|
||||
"location": event.get("location", ""),
|
||||
"description": event.get("description", ""),
|
||||
"html_link": event.get("htmlLink", ""),
|
||||
})
|
||||
|
||||
summary = self._format_events_summary(formatted_events)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"events": formatted_events,
|
||||
"count": len(formatted_events),
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def create_event(
|
||||
self,
|
||||
summary: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
calendar_id: str = "primary",
|
||||
) -> Dict:
|
||||
"""Create a new calendar event.
|
||||
|
||||
Args:
|
||||
summary: Event title
|
||||
start_time: Start time (ISO 8601 format)
|
||||
end_time: End time (ISO 8601 format)
|
||||
description: Optional event description
|
||||
location: Optional event location
|
||||
calendar_id: Calendar ID (default: "primary")
|
||||
|
||||
Returns:
|
||||
Dict with success status and event details or error
|
||||
"""
|
||||
if not self.service:
|
||||
if not self._initialize_service():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
||||
}
|
||||
|
||||
try:
|
||||
# Detect if all-day event (time is 00:00:00)
|
||||
is_all_day = "T00:00:00" in start_time
|
||||
|
||||
if is_all_day:
|
||||
# Use date format for all-day events
|
||||
event_body = {
|
||||
"summary": summary,
|
||||
"start": {"date": start_time.split("T")[0]},
|
||||
"end": {"date": end_time.split("T")[0]},
|
||||
}
|
||||
else:
|
||||
# Use dateTime format for timed events
|
||||
event_body = {
|
||||
"summary": summary,
|
||||
"start": {"dateTime": start_time, "timeZone": "UTC"},
|
||||
"end": {"dateTime": end_time, "timeZone": "UTC"},
|
||||
}
|
||||
|
||||
if description:
|
||||
event_body["description"] = description
|
||||
|
||||
if location:
|
||||
event_body["location"] = location
|
||||
|
||||
event = (
|
||||
self.service.events()
|
||||
.insert(calendarId=calendar_id, body=event_body)
|
||||
.execute()
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"event_id": event.get("id"),
|
||||
"html_link": event.get("htmlLink"),
|
||||
"summary": event.get("summary"),
|
||||
"start": event["start"].get("dateTime", event["start"].get("date")),
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def search_events(
|
||||
self,
|
||||
query: str,
|
||||
calendar_id: str = "primary",
|
||||
) -> Dict:
|
||||
"""Search calendar events by text query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
calendar_id: Calendar ID (default: "primary")
|
||||
|
||||
Returns:
|
||||
Dict with success status and matching events or error
|
||||
"""
|
||||
if not self.service:
|
||||
if not self._initialize_service():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
||||
}
|
||||
|
||||
try:
|
||||
events_result = (
|
||||
self.service.events()
|
||||
.list(
|
||||
calendarId=calendar_id,
|
||||
q=query,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
events = events_result.get("items", [])
|
||||
|
||||
if not events:
|
||||
return {
|
||||
"success": True,
|
||||
"events": [],
|
||||
"count": 0,
|
||||
"summary": f'No events found matching "{query}".',
|
||||
}
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
start = event["start"].get("dateTime", event["start"].get("date"))
|
||||
end = event["end"].get("dateTime", event["end"].get("date"))
|
||||
|
||||
formatted_events.append({
|
||||
"id": event["id"],
|
||||
"summary": event.get("summary", "No title"),
|
||||
"start": start,
|
||||
"end": end,
|
||||
"location": event.get("location", ""),
|
||||
"description": event.get("description", ""),
|
||||
})
|
||||
|
||||
summary = self._format_events_summary(formatted_events)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"events": formatted_events,
|
||||
"count": len(formatted_events),
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _format_events_summary(self, events: List[Dict]) -> str:
|
||||
"""Format events into readable summary.
|
||||
|
||||
Args:
|
||||
events: List of event dicts
|
||||
|
||||
Returns:
|
||||
Formatted string summary
|
||||
"""
|
||||
if not events:
|
||||
return "No events."
|
||||
|
||||
lines = []
|
||||
for i, event in enumerate(events, 1):
|
||||
start = event["start"]
|
||||
# Parse datetime for better formatting
|
||||
try:
|
||||
if "T" in start:
|
||||
dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||
start_str = dt.strftime("%b %d at %I:%M %p")
|
||||
else:
|
||||
dt = datetime.fromisoformat(start)
|
||||
start_str = dt.strftime("%b %d (all day)")
|
||||
except:
|
||||
start_str = start
|
||||
|
||||
lines.append(f"{i}. {event['summary']} - {start_str}")
|
||||
|
||||
if event.get("location"):
|
||||
lines.append(f" Location: {event['location']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
220
google_tools/gmail_client.py
Normal file
220
google_tools/gmail_client.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Gmail API client for sending and reading emails."""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from .oauth_manager import GoogleOAuthManager
|
||||
from .utils import (
|
||||
create_mime_message,
|
||||
format_email_summary,
|
||||
get_email_body,
|
||||
parse_email_message,
|
||||
)
|
||||
|
||||
|
||||
class GmailClient:
|
||||
"""Client for Gmail API operations."""
|
||||
|
||||
def __init__(self, oauth_manager: GoogleOAuthManager):
|
||||
"""Initialize Gmail client.
|
||||
|
||||
Args:
|
||||
oauth_manager: Initialized OAuth manager with valid credentials
|
||||
"""
|
||||
self.oauth_manager = oauth_manager
|
||||
self.service = None
|
||||
self._initialize_service()
|
||||
|
||||
def _initialize_service(self) -> bool:
|
||||
"""Initialize Gmail API service.
|
||||
|
||||
Returns:
|
||||
True if initialized successfully, False otherwise
|
||||
"""
|
||||
credentials = self.oauth_manager.get_credentials()
|
||||
if not credentials:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.service = build("gmail", "v1", credentials=credentials)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Gmail] Failed to initialize service: {e}")
|
||||
return False
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
cc: Optional[List[str]] = None,
|
||||
reply_to_message_id: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Send an email.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
body: Email body (plain text or HTML)
|
||||
cc: Optional list of CC recipients
|
||||
reply_to_message_id: Optional message ID to reply to (for threading)
|
||||
|
||||
Returns:
|
||||
Dict with success status and message_id or error
|
||||
"""
|
||||
if not self.service:
|
||||
if not self._initialize_service():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
||||
}
|
||||
|
||||
try:
|
||||
message = create_mime_message(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
cc=cc,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
|
||||
result = (
|
||||
self.service.users()
|
||||
.messages()
|
||||
.send(userId="me", body=message)
|
||||
.execute()
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": result.get("id"),
|
||||
"thread_id": result.get("threadId"),
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def search_emails(
|
||||
self,
|
||||
query: str = "",
|
||||
max_results: int = 10,
|
||||
include_body: bool = False,
|
||||
) -> Dict:
|
||||
"""Search emails using Gmail search syntax.
|
||||
|
||||
Args:
|
||||
query: Gmail search query (e.g., "from:john@example.com after:2026/02/10")
|
||||
max_results: Maximum number of results to return (max: 50)
|
||||
include_body: Whether to include full email body
|
||||
|
||||
Returns:
|
||||
Dict with success status and emails or error
|
||||
"""
|
||||
if not self.service:
|
||||
if not self._initialize_service():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
||||
}
|
||||
|
||||
try:
|
||||
# Limit max_results to 50 to avoid token overload
|
||||
max_results = min(max_results, 50)
|
||||
|
||||
# Search for messages
|
||||
results = (
|
||||
self.service.users()
|
||||
.messages()
|
||||
.list(userId="me", q=query, maxResults=max_results)
|
||||
.execute()
|
||||
)
|
||||
|
||||
messages = results.get("messages", [])
|
||||
|
||||
if not messages:
|
||||
return {
|
||||
"success": True,
|
||||
"emails": [],
|
||||
"count": 0,
|
||||
"summary": "No emails found.",
|
||||
}
|
||||
|
||||
# Fetch full message details
|
||||
emails = []
|
||||
for msg in messages:
|
||||
message = (
|
||||
self.service.users()
|
||||
.messages()
|
||||
.get(userId="me", id=msg["id"], format="full")
|
||||
.execute()
|
||||
)
|
||||
|
||||
email_data = parse_email_message(message)
|
||||
|
||||
if include_body:
|
||||
email_data["body"] = get_email_body(message)
|
||||
|
||||
emails.append(email_data)
|
||||
|
||||
summary = format_email_summary(emails, include_body=include_body)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"emails": emails,
|
||||
"count": len(emails),
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_email(self, message_id: str, format_type: str = "text") -> Dict:
|
||||
"""Get full email by ID.
|
||||
|
||||
Args:
|
||||
message_id: Gmail message ID
|
||||
format_type: "text" or "html" (default: text)
|
||||
|
||||
Returns:
|
||||
Dict with success status and email data or error
|
||||
"""
|
||||
if not self.service:
|
||||
if not self._initialize_service():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
||||
}
|
||||
|
||||
try:
|
||||
message = (
|
||||
self.service.users()
|
||||
.messages()
|
||||
.get(userId="me", id=message_id, format="full")
|
||||
.execute()
|
||||
)
|
||||
|
||||
email_data = parse_email_message(message)
|
||||
email_data["body"] = get_email_body(message)
|
||||
|
||||
# Get attachment info if any
|
||||
payload = message.get("payload", {})
|
||||
attachments = []
|
||||
for part in payload.get("parts", []):
|
||||
if part.get("filename"):
|
||||
attachments.append({
|
||||
"filename": part["filename"],
|
||||
"mime_type": part.get("mimeType"),
|
||||
"size": part.get("body", {}).get("size", 0),
|
||||
})
|
||||
|
||||
email_data["attachments"] = attachments
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"email": email_data,
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
247
google_tools/oauth_manager.py
Normal file
247
google_tools/oauth_manager.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""OAuth2 Manager for Google APIs (Gmail, Calendar)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import webbrowser
|
||||
from datetime import datetime, timedelta
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Dict, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
|
||||
# OAuth scopes
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
]
|
||||
|
||||
# Token file location
|
||||
TOKEN_FILE = Path("config/google_oauth_token.json")
|
||||
CREDENTIALS_FILE = Path("config/google_credentials.yaml")
|
||||
|
||||
|
||||
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Handles OAuth2 callback from browser."""
|
||||
|
||||
authorization_code: Optional[str] = None
|
||||
|
||||
def do_GET(self) -> None:
|
||||
"""Handle GET request from OAuth callback."""
|
||||
query = urlparse(self.path).query
|
||||
params = parse_qs(query)
|
||||
|
||||
if "code" in params:
|
||||
OAuth CallbackHandler.authorization_code = params["code"][0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<html><body><h1>Authorization successful!</h1>"
|
||||
b"<p>You can close this window and return to the terminal.</p>"
|
||||
b"</body></html>"
|
||||
)
|
||||
else:
|
||||
self.send_response(400)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<html><body><h1>Authorization failed</h1>"
|
||||
b"<p>No authorization code received.</p></body></html>"
|
||||
)
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
"""Suppress logging to keep console clean."""
|
||||
pass
|
||||
|
||||
|
||||
class GoogleOAuthManager:
|
||||
"""Manages OAuth2 authentication for Google APIs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token_file: Path = TOKEN_FILE,
|
||||
credentials_file: Path = CREDENTIALS_FILE,
|
||||
) -> None:
|
||||
self.token_file = token_file
|
||||
self.credentials_file = credentials_file
|
||||
self.credentials: Optional[Credentials] = None
|
||||
|
||||
def is_authorized(self) -> bool:
|
||||
"""Check if valid OAuth tokens exist."""
|
||||
if not self.token_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
self.credentials = Credentials.from_authorized_user_file(
|
||||
str(self.token_file), SCOPES
|
||||
)
|
||||
return self.credentials and self.credentials.valid
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_credentials(self) -> Optional[Credentials]:
|
||||
"""Get valid OAuth credentials, refreshing if needed.
|
||||
|
||||
Returns:
|
||||
Credentials object if authorized, None otherwise.
|
||||
"""
|
||||
# Load existing tokens
|
||||
if self.token_file.exists():
|
||||
try:
|
||||
self.credentials = Credentials.from_authorized_user_file(
|
||||
str(self.token_file), SCOPES
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[OAuth] Error loading tokens: {e}")
|
||||
return None
|
||||
|
||||
# Refresh if expired
|
||||
if self.credentials and not self.credentials.valid:
|
||||
if self.credentials.expired and self.credentials.refresh_token:
|
||||
try:
|
||||
print("[OAuth] Refreshing access token...")
|
||||
self.credentials.refresh(Request())
|
||||
self._save_credentials()
|
||||
print("[OAuth] Token refreshed successfully")
|
||||
except Exception as e:
|
||||
print(f"[OAuth] Token refresh failed: {e}")
|
||||
print("[OAuth] Please run: python bot_runner.py --setup-google")
|
||||
return None
|
||||
else:
|
||||
print("[OAuth] Credentials invalid, re-authorization needed")
|
||||
print("[OAuth] Please run: python bot_runner.py --setup-google")
|
||||
return None
|
||||
|
||||
return self.credentials
|
||||
|
||||
def needs_refresh_soon(self) -> bool:
|
||||
"""Check if token will expire within 5 minutes."""
|
||||
if not self.credentials or not self.credentials.expiry:
|
||||
return False
|
||||
|
||||
expiry_threshold = datetime.utcnow() + timedelta(minutes=5)
|
||||
return self.credentials.expiry < expiry_threshold
|
||||
|
||||
def run_oauth_flow(self, manual: bool = False) -> bool:
|
||||
"""Run OAuth2 authorization flow.
|
||||
|
||||
Args:
|
||||
manual: If True, requires manual code paste instead of browser callback.
|
||||
|
||||
Returns:
|
||||
True if authorization successful, False otherwise.
|
||||
"""
|
||||
if not self.credentials_file.exists():
|
||||
print(f"[OAuth] Error: {self.credentials_file} not found")
|
||||
print("[OAuth] Please create config/google_credentials.yaml with:")
|
||||
print(" client_id: YOUR_CLIENT_ID")
|
||||
print(" client_secret: YOUR_CLIENT_SECRET")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Load client credentials
|
||||
import yaml
|
||||
with open(self.credentials_file) as f:
|
||||
creds_config = yaml.safe_load(f)
|
||||
|
||||
client_config = {
|
||||
"installed": {
|
||||
"client_id": creds_config["client_id"],
|
||||
"client_secret": creds_config["client_secret"],
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"redirect_uris": ["http://localhost:8080"],
|
||||
}
|
||||
}
|
||||
|
||||
if manual:
|
||||
# Manual flow - user pastes code
|
||||
flow = InstalledAppFlow.from_client_config(
|
||||
client_config, SCOPES
|
||||
)
|
||||
self.credentials = flow.run_console()
|
||||
else:
|
||||
# Automated flow with local server
|
||||
flow = InstalledAppFlow.from_client_config(
|
||||
client_config, SCOPES
|
||||
)
|
||||
flow.redirect_uri = "http://localhost:8080"
|
||||
|
||||
auth_url, _ = flow.authorization_url(prompt="consent")
|
||||
|
||||
print("\nPlease visit this URL to authorize:")
|
||||
print(f"\n{auth_url}\n")
|
||||
|
||||
# Try to open browser automatically
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start local server to receive callback
|
||||
server = HTTPServer(("localhost", 8080), OAuthCallbackHandler)
|
||||
print("Waiting for authorization... (press Ctrl+C to cancel)\n")
|
||||
|
||||
# Wait for callback
|
||||
server.handle_request()
|
||||
|
||||
if OAuthCallbackHandler.authorization_code:
|
||||
flow.fetch_token(code=OAuthCallbackHandler.authorization_code)
|
||||
self.credentials = flow.credentials
|
||||
else:
|
||||
print("[OAuth] Authorization failed - no code received")
|
||||
return False
|
||||
|
||||
# Save tokens
|
||||
self._save_credentials()
|
||||
print("\n✓ Authorization successful!")
|
||||
print(f"✓ Tokens saved to {self.token_file}\n")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[OAuth] Authorization failed: {e}")
|
||||
return False
|
||||
|
||||
def _save_credentials(self) -> None:
|
||||
"""Save credentials to token file with secure permissions."""
|
||||
if not self.credentials:
|
||||
return
|
||||
|
||||
# Ensure config directory exists
|
||||
self.token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to temp file first (atomic write)
|
||||
temp_file = self.token_file.with_suffix(".tmp")
|
||||
with open(temp_file, "w") as f:
|
||||
f.write(self.credentials.to_json())
|
||||
|
||||
# Set file permissions to 600 (owner read/write only) on Unix systems
|
||||
if os.name != "nt": # Not Windows
|
||||
os.chmod(temp_file, 0o600)
|
||||
|
||||
# Atomic rename
|
||||
temp_file.replace(self.token_file)
|
||||
|
||||
def revoke_authorization(self) -> bool:
|
||||
"""Revoke OAuth authorization and delete tokens.
|
||||
|
||||
Returns:
|
||||
True if revoked successfully, False otherwise.
|
||||
"""
|
||||
if not self.credentials:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.credentials.revoke(Request())
|
||||
if self.token_file.exists():
|
||||
self.token_file.unlink()
|
||||
print("[OAuth] Authorization revoked successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[OAuth] Failed to revoke authorization: {e}")
|
||||
return False
|
||||
208
google_tools/utils.py
Normal file
208
google_tools/utils.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Utility functions for Gmail/Calendar tools."""
|
||||
|
||||
import base64
|
||||
import email
|
||||
import re
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from html.parser import HTMLParser
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class HTMLToText(HTMLParser):
|
||||
"""Convert HTML to plain text."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text = []
|
||||
self.skip = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.skip:
|
||||
self.text.append(data)
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ["script", "style"]:
|
||||
self.skip = True
|
||||
elif tag == "br":
|
||||
self.text.append("\n")
|
||||
elif tag == "p":
|
||||
self.text.append("\n\n")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ["script", "style"]:
|
||||
self.skip = False
|
||||
elif tag in ["p", "div"]:
|
||||
self.text.append("\n")
|
||||
|
||||
def get_text(self):
|
||||
return "".join(self.text).strip()
|
||||
|
||||
|
||||
def html_to_text(html: str) -> str:
|
||||
"""Convert HTML to plain text.
|
||||
|
||||
Args:
|
||||
html: HTML content
|
||||
|
||||
Returns:
|
||||
Plain text content
|
||||
"""
|
||||
parser = HTMLToText()
|
||||
parser.feed(html)
|
||||
return parser.get_text()
|
||||
|
||||
|
||||
def create_mime_message(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
from_email: str = "me",
|
||||
cc: Optional[List[str]] = None,
|
||||
reply_to_message_id: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Create a MIME message for Gmail API.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject
|
||||
body: Email body (plain text or HTML)
|
||||
from_email: Sender email (default: "me")
|
||||
cc: Optional list of CC recipients
|
||||
reply_to_message_id: Optional message ID to reply to
|
||||
|
||||
Returns:
|
||||
Dict with 'raw' key containing base64url-encoded message
|
||||
"""
|
||||
message = MIMEMultipart("alternative")
|
||||
message["To"] = to
|
||||
message["From"] = from_email
|
||||
message["Subject"] = subject
|
||||
|
||||
if cc:
|
||||
message["Cc"] = ", ".join(cc)
|
||||
|
||||
if reply_to_message_id:
|
||||
message["In-Reply-To"] = reply_to_message_id
|
||||
message["References"] = reply_to_message_id
|
||||
|
||||
# Try to detect if body is HTML
|
||||
is_html = bool(re.search(r"<[a-z][\s\S]*>", body, re.IGNORECASE))
|
||||
|
||||
if is_html:
|
||||
# Add both plain text and HTML versions
|
||||
text_part = MIMEText(html_to_text(body), "plain")
|
||||
html_part = MIMEText(body, "html")
|
||||
message.attach(text_part)
|
||||
message.attach(html_part)
|
||||
else:
|
||||
# Plain text only
|
||||
text_part = MIMEText(body, "plain")
|
||||
message.attach(text_part)
|
||||
|
||||
# Encode as base64url
|
||||
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
return {"raw": raw_message}
|
||||
|
||||
|
||||
def parse_email_message(message: Dict) -> Dict:
|
||||
"""Parse Gmail API message into readable format.
|
||||
|
||||
Args:
|
||||
message: Gmail API message object
|
||||
|
||||
Returns:
|
||||
Dict with parsed fields: from, to, subject, date, body, snippet
|
||||
"""
|
||||
headers = {
|
||||
h["name"].lower(): h["value"]
|
||||
for h in message.get("payload", {}).get("headers", [])
|
||||
}
|
||||
|
||||
result = {
|
||||
"id": message.get("id"),
|
||||
"thread_id": message.get("threadId"),
|
||||
"from": headers.get("from", ""),
|
||||
"to": headers.get("to", ""),
|
||||
"cc": headers.get("cc", ""),
|
||||
"subject": headers.get("subject", ""),
|
||||
"date": headers.get("date", ""),
|
||||
"snippet": message.get("snippet", ""),
|
||||
"labels": message.get("labelIds", []),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_email_body(message: Dict) -> str:
|
||||
"""Extract email body from Gmail API message.
|
||||
|
||||
Args:
|
||||
message: Gmail API message object
|
||||
|
||||
Returns:
|
||||
Email body as plain text
|
||||
"""
|
||||
payload = message.get("payload", {})
|
||||
|
||||
def get_body_from_part(part: Dict) -> Optional[str]:
|
||||
"""Recursively extract body from MIME parts."""
|
||||
mime_type = part.get("mimeType", "")
|
||||
body_data = part.get("body", {}).get("data")
|
||||
|
||||
if body_data:
|
||||
decoded = base64.urlsafe_b64decode(body_data).decode("utf-8", errors="ignore")
|
||||
if mime_type == "text/html":
|
||||
return html_to_text(decoded)
|
||||
elif mime_type == "text/plain":
|
||||
return decoded
|
||||
|
||||
# Check nested parts
|
||||
for subpart in part.get("parts", []):
|
||||
result = get_body_from_part(subpart)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
# Try to get body
|
||||
body = get_body_from_part(payload)
|
||||
|
||||
if not body:
|
||||
# Fallback to snippet
|
||||
body = message.get("snippet", "")
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def format_email_summary(emails: List[Dict], include_body: bool = False) -> str:
|
||||
"""Format emails into a readable summary.
|
||||
|
||||
Args:
|
||||
emails: List of parsed email dicts
|
||||
include_body: Whether to include full body
|
||||
|
||||
Returns:
|
||||
Formatted string summary
|
||||
"""
|
||||
if not emails:
|
||||
return "No emails found."
|
||||
|
||||
lines = []
|
||||
for i, email_data in enumerate(emails, 1):
|
||||
lines.append(f"{i}. From: {email_data['from']}")
|
||||
lines.append(f" Subject: {email_data['subject']}")
|
||||
lines.append(f" Date: {email_data['date']}")
|
||||
|
||||
if include_body and "body" in email_data:
|
||||
# Truncate long bodies
|
||||
body = email_data["body"]
|
||||
if len(body) > 500:
|
||||
body = body[:500] + "..."
|
||||
lines.append(f" Body: {body}")
|
||||
else:
|
||||
lines.append(f" Snippet: {email_data['snippet']}")
|
||||
|
||||
lines.append("") # Blank line
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user