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:
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
|
||||
Reference in New Issue
Block a user