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:
2026-02-14 10:29:28 -07:00
parent 8afff96bb5
commit dc14baf426
10 changed files with 1555 additions and 1 deletions

7
google_tools/__init__.py Normal file
View 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"]

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

View 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)}

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