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

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)