feat(10-02): Google Calendar OAuth endpoints and per-tenant calendar tool
- Add calendar_auth.py: OAuth install/callback/status endpoints with HMAC-signed state - Replace calendar_lookup.py service account stub with per-tenant OAuth token lookup - Support list, check_availability, and create actions with natural language responses - Token auto-refresh: write updated credentials back to channel_connections on refresh - Add migration 013: add google_calendar to channel_type CHECK constraint - Add unit tests: 16 tests covering all actions, not-connected path, token refresh write-back
This commit is contained in:
@@ -1,108 +1,302 @@
|
||||
"""
|
||||
Built-in tool: calendar_lookup
|
||||
|
||||
Reads calendar events from Google Calendar for a given date.
|
||||
Reads and creates Google Calendar events using per-tenant OAuth tokens.
|
||||
|
||||
Authentication options (in priority order):
|
||||
1. GOOGLE_SERVICE_ACCOUNT_KEY env var — JSON key for service account impersonation
|
||||
2. Per-tenant OAuth (future: Phase 3 portal) — not yet implemented
|
||||
3. Graceful degradation: returns informative message if not configured
|
||||
Authentication:
|
||||
Tokens are stored per-tenant in channel_connections (channel_type='google_calendar').
|
||||
The tenant admin must complete the OAuth flow via /api/portal/calendar/install first.
|
||||
If no token is found, returns an informative message asking admin to connect.
|
||||
|
||||
This tool is read-only (requires_confirmation=False in registry).
|
||||
Actions:
|
||||
- list: List events for the given date (default)
|
||||
- check_availability: Return free/busy summary for the given date
|
||||
- create: Create a new calendar event
|
||||
|
||||
Token auto-refresh:
|
||||
google.oauth2.credentials.Credentials auto-refreshes expired access tokens
|
||||
using the stored refresh_token. After each API call, if credentials.token
|
||||
changed (refresh occurred), the updated token is encrypted and written back
|
||||
to channel_connections so subsequent calls don't re-trigger refresh.
|
||||
|
||||
All results are formatted as natural language strings — no raw JSON exposed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
# Module-level imports for patchability in tests.
|
||||
# google-auth and googleapiclient are optional dependencies — import errors handled
|
||||
# gracefully in the functions that use them.
|
||||
try:
|
||||
from googleapiclient.discovery import build # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
build = None # type: ignore[assignment]
|
||||
|
||||
from shared.config import settings
|
||||
from shared.crypto import KeyEncryptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Google Calendar API scope (must match what was requested during OAuth)
|
||||
_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar"
|
||||
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
|
||||
|
||||
def google_credentials_from_token(token_dict: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Build a google.oauth2.credentials.Credentials object from a stored token dict.
|
||||
|
||||
The token dict is the JSON structure written by calendar_auth.py during OAuth:
|
||||
{
|
||||
"token": "ya29.access_token",
|
||||
"refresh_token": "1//refresh_token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "...",
|
||||
"client_secret": "...",
|
||||
"scopes": ["https://www.googleapis.com/auth/calendar"]
|
||||
}
|
||||
|
||||
Args:
|
||||
token_dict: Parsed token dictionary.
|
||||
|
||||
Returns:
|
||||
google.oauth2.credentials.Credentials instance.
|
||||
|
||||
Raises:
|
||||
ImportError: If google-auth is not installed.
|
||||
"""
|
||||
from google.oauth2.credentials import Credentials # type: ignore[import-untyped]
|
||||
|
||||
return Credentials(
|
||||
token=token_dict.get("token"),
|
||||
refresh_token=token_dict.get("refresh_token"),
|
||||
token_uri=token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
|
||||
client_id=token_dict.get("client_id"),
|
||||
client_secret=token_dict.get("client_secret"),
|
||||
scopes=token_dict.get("scopes", [_CALENDAR_SCOPE]),
|
||||
)
|
||||
|
||||
|
||||
async def calendar_lookup(
|
||||
date: str,
|
||||
action: str = "list",
|
||||
event_summary: str | None = None,
|
||||
event_start: str | None = None,
|
||||
event_end: str | None = None,
|
||||
calendar_id: str = "primary",
|
||||
tenant_id: str | None = None,
|
||||
_session: Any = None, # Injected in tests; production uses DB session from task context
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""
|
||||
Look up calendar events for a specific date.
|
||||
Look up, check availability, or create Google Calendar events for a specific date.
|
||||
|
||||
Args:
|
||||
date: Date in YYYY-MM-DD format.
|
||||
calendar_id: Google Calendar ID. Defaults to 'primary'.
|
||||
date: Date in YYYY-MM-DD format (required).
|
||||
action: One of "list", "check_availability", "create". Default: "list".
|
||||
event_summary: Event title (required for action="create").
|
||||
event_start: ISO 8601 datetime with timezone (required for action="create").
|
||||
event_end: ISO 8601 datetime with timezone (required for action="create").
|
||||
calendar_id: Google Calendar ID. Defaults to 'primary'.
|
||||
tenant_id: Konstruct tenant UUID string. Required for token lookup.
|
||||
_session: Injected AsyncSession (for testing). Production passes None.
|
||||
|
||||
Returns:
|
||||
Formatted string listing events for the given date,
|
||||
or an informative message if Google Calendar is not configured.
|
||||
Natural language string describing the result.
|
||||
"""
|
||||
service_account_key_json = os.getenv("GOOGLE_SERVICE_ACCOUNT_KEY", "")
|
||||
if not service_account_key_json:
|
||||
return (
|
||||
"Calendar lookup is not configured. "
|
||||
"Set the GOOGLE_SERVICE_ACCOUNT_KEY environment variable to enable calendar access."
|
||||
)
|
||||
# Guard: tenant_id is required to look up per-tenant OAuth token
|
||||
if not tenant_id:
|
||||
return "Calendar not available: missing tenant context."
|
||||
|
||||
# Get DB session
|
||||
session = _session
|
||||
if session is None:
|
||||
# Production: obtain a session from the DB pool
|
||||
# Import here to avoid circular imports at module load time
|
||||
try:
|
||||
from shared.db import async_session_factory
|
||||
session = async_session_factory()
|
||||
# Note: caller is responsible for closing the session
|
||||
# In practice, the orchestrator task context manages session lifecycle
|
||||
except Exception:
|
||||
logger.exception("Failed to create DB session for calendar_lookup")
|
||||
return "Calendar lookup failed: unable to connect to the database."
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
tenant_uuid = uuid.UUID(tenant_id)
|
||||
except ValueError:
|
||||
return f"Calendar lookup failed: invalid tenant ID '{tenant_id}'."
|
||||
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
_fetch_calendar_events_sync,
|
||||
service_account_key_json,
|
||||
calendar_id,
|
||||
date,
|
||||
# Load per-tenant OAuth token from channel_connections
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
|
||||
|
||||
result = await session.execute(
|
||||
select(ChannelConnection).where(
|
||||
ChannelConnection.tenant_id == tenant_uuid,
|
||||
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
|
||||
)
|
||||
)
|
||||
return result
|
||||
conn = result.scalar_one_or_none()
|
||||
except Exception:
|
||||
logger.exception("Calendar lookup failed for date=%s calendar=%s", date, calendar_id)
|
||||
logger.exception("DB error loading calendar connection for tenant=%s", tenant_id)
|
||||
return "Calendar lookup failed: database error loading calendar connection."
|
||||
|
||||
if conn is None:
|
||||
return (
|
||||
"Google Calendar is not connected for this tenant. "
|
||||
"Ask an admin to connect it in Settings."
|
||||
)
|
||||
|
||||
# Decrypt token
|
||||
encrypted_token = conn.config.get("token", "")
|
||||
if not encrypted_token:
|
||||
return "Calendar lookup failed: no token found in connection config."
|
||||
|
||||
try:
|
||||
if not settings.platform_encryption_key:
|
||||
return "Calendar lookup failed: encryption key not configured."
|
||||
|
||||
enc_svc = KeyEncryptionService(
|
||||
primary_key=settings.platform_encryption_key,
|
||||
previous_key=settings.platform_encryption_key_previous,
|
||||
)
|
||||
token_json: str = enc_svc.decrypt(encrypted_token)
|
||||
token_dict: dict[str, Any] = json.loads(token_json)
|
||||
except Exception:
|
||||
logger.exception("Failed to decrypt calendar token for tenant=%s", tenant_id)
|
||||
return "Calendar lookup failed: unable to decrypt stored credentials."
|
||||
|
||||
# Build Google credentials
|
||||
try:
|
||||
creds = google_credentials_from_token(token_dict)
|
||||
except ImportError:
|
||||
return (
|
||||
"Google Calendar library not installed. "
|
||||
"Run: uv add google-api-python-client google-auth"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to build Google credentials for tenant=%s", tenant_id)
|
||||
return "Calendar lookup failed: invalid stored credentials."
|
||||
|
||||
# Record the token before the API call to detect refresh
|
||||
token_before = creds.token
|
||||
|
||||
# Execute the API call in a thread executor (blocking SDK)
|
||||
try:
|
||||
result_str = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
_execute_calendar_action,
|
||||
creds,
|
||||
action,
|
||||
date,
|
||||
calendar_id,
|
||||
event_summary,
|
||||
event_start,
|
||||
event_end,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Calendar API call failed for tenant=%s date=%s action=%s", tenant_id, date, action)
|
||||
return f"Calendar lookup failed for {date}. Please try again."
|
||||
|
||||
# Token refresh write-back: if token changed after the API call, persist the update
|
||||
if creds.token and creds.token != token_before:
|
||||
try:
|
||||
new_token_dict = {
|
||||
"token": creds.token,
|
||||
"refresh_token": creds.refresh_token or token_dict.get("refresh_token", ""),
|
||||
"token_uri": token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
|
||||
"client_id": token_dict.get("client_id", ""),
|
||||
"client_secret": token_dict.get("client_secret", ""),
|
||||
"scopes": token_dict.get("scopes", [_CALENDAR_SCOPE]),
|
||||
}
|
||||
new_encrypted = enc_svc.encrypt(json.dumps(new_token_dict))
|
||||
conn.config = {"token": new_encrypted}
|
||||
await session.commit()
|
||||
logger.debug("Calendar token refreshed and written back for tenant=%s", tenant_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to write back refreshed calendar token for tenant=%s", tenant_id)
|
||||
# Non-fatal: the API call succeeded, just log the refresh failure
|
||||
|
||||
def _fetch_calendar_events_sync(
|
||||
service_account_key_json: str,
|
||||
calendar_id: str,
|
||||
return result_str
|
||||
|
||||
|
||||
def _execute_calendar_action(
|
||||
creds: Any,
|
||||
action: str,
|
||||
date: str,
|
||||
calendar_id: str,
|
||||
event_summary: str | None,
|
||||
event_start: str | None,
|
||||
event_end: str | None,
|
||||
) -> str:
|
||||
"""
|
||||
Synchronous implementation — runs in thread executor to avoid blocking event loop.
|
||||
Synchronous calendar action — runs in thread executor to avoid blocking.
|
||||
|
||||
Uses google-api-python-client with service account credentials.
|
||||
Args:
|
||||
creds: Google Credentials object.
|
||||
action: One of "list", "check_availability", "create".
|
||||
date: Date in YYYY-MM-DD format.
|
||||
calendar_id: Google Calendar ID (default "primary").
|
||||
event_summary: Title for create action.
|
||||
event_start: ISO 8601 start for create action.
|
||||
event_end: ISO 8601 end for create action.
|
||||
|
||||
Returns:
|
||||
Natural language result string.
|
||||
"""
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError:
|
||||
if build is None:
|
||||
return (
|
||||
"Google Calendar library not installed. "
|
||||
"Run: uv add google-api-python-client google-auth"
|
||||
)
|
||||
|
||||
try:
|
||||
key_data = json.loads(service_account_key_json)
|
||||
except json.JSONDecodeError:
|
||||
return "Invalid GOOGLE_SERVICE_ACCOUNT_KEY: not valid JSON."
|
||||
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
key_data,
|
||||
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
|
||||
)
|
||||
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
|
||||
except Exception as exc:
|
||||
return f"Failed to create Google credentials: {exc}"
|
||||
logger.warning("Failed to build Google Calendar service: %s", exc)
|
||||
return f"Calendar service error: {exc}"
|
||||
|
||||
# Parse the date and create RFC3339 time boundaries for the day
|
||||
if action == "create":
|
||||
return _action_create(service, calendar_id, event_summary, event_start, event_end)
|
||||
elif action == "check_availability":
|
||||
return _action_check_availability(service, calendar_id, date)
|
||||
else:
|
||||
# Default: "list"
|
||||
return _action_list(service, calendar_id, date)
|
||||
|
||||
|
||||
def _time_boundaries(date: str) -> tuple[str, str]:
|
||||
"""Return (time_min, time_max) RFC3339 strings for the full given day (UTC)."""
|
||||
return f"{date}T00:00:00Z", f"{date}T23:59:59Z"
|
||||
|
||||
|
||||
def _format_event_time(event: dict[str, Any]) -> str:
|
||||
"""Extract and format the start time of a calendar event."""
|
||||
start = event.get("start", {})
|
||||
raw = start.get("dateTime") or start.get("date") or "Unknown time"
|
||||
# Trim the timezone part for readability if full datetime
|
||||
if "T" in raw:
|
||||
try:
|
||||
# e.g. "2026-03-26T09:00:00+00:00" → "09:00"
|
||||
time_part = raw.split("T")[1][:5]
|
||||
return time_part
|
||||
except IndexError:
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
def _action_list(service: Any, calendar_id: str, date: str) -> str:
|
||||
"""List calendar events for the given date."""
|
||||
time_min, time_max = _time_boundaries(date)
|
||||
try:
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return f"Invalid date format: {date!r}. Expected YYYY-MM-DD."
|
||||
|
||||
time_min = date_obj.strftime("%Y-%m-%dT00:00:00Z")
|
||||
time_max = date_obj.strftime("%Y-%m-%dT23:59:59Z")
|
||||
|
||||
try:
|
||||
service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
|
||||
events_result = (
|
||||
service.events()
|
||||
.list(
|
||||
@@ -115,17 +309,89 @@ def _fetch_calendar_events_sync(
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Google Calendar API error: %s", exc)
|
||||
return f"Calendar API error: {exc}"
|
||||
logger.warning("Google Calendar list error: %s", exc)
|
||||
return f"Calendar error listing events for {date}: {exc}"
|
||||
|
||||
items = events_result.get("items", [])
|
||||
if not items:
|
||||
return f"No events found on {date}."
|
||||
|
||||
lines = [f"Calendar events for {date}:\n"]
|
||||
lines = [f"Calendar events for {date}:"]
|
||||
for event in items:
|
||||
start = event["start"].get("dateTime", event["start"].get("date", "Unknown time"))
|
||||
time_str = _format_event_time(event)
|
||||
summary = event.get("summary", "Untitled event")
|
||||
lines.append(f"- {start}: {summary}")
|
||||
lines.append(f"- {time_str}: {summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _action_check_availability(service: Any, calendar_id: str, date: str) -> str:
|
||||
"""Return a free/busy summary for the given date."""
|
||||
time_min, time_max = _time_boundaries(date)
|
||||
try:
|
||||
events_result = (
|
||||
service.events()
|
||||
.list(
|
||||
calendarId=calendar_id,
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Google Calendar availability check error: %s", exc)
|
||||
return f"Calendar error checking availability for {date}: {exc}"
|
||||
|
||||
items = events_result.get("items", [])
|
||||
if not items:
|
||||
return f"No events on {date} — the entire day is free."
|
||||
|
||||
lines = [f"Busy slots on {date}:"]
|
||||
for event in items:
|
||||
time_str = _format_event_time(event)
|
||||
summary = event.get("summary", "Untitled event")
|
||||
lines.append(f"- {time_str}: {summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _action_create(
|
||||
service: Any,
|
||||
calendar_id: str,
|
||||
event_summary: str | None,
|
||||
event_start: str | None,
|
||||
event_end: str | None,
|
||||
) -> str:
|
||||
"""Create a new calendar event."""
|
||||
if not event_summary or not event_start or not event_end:
|
||||
missing = []
|
||||
if not event_summary:
|
||||
missing.append("event_summary")
|
||||
if not event_start:
|
||||
missing.append("event_start")
|
||||
if not event_end:
|
||||
missing.append("event_end")
|
||||
return f"Cannot create event: missing required fields: {', '.join(missing)}."
|
||||
|
||||
event_body = {
|
||||
"summary": event_summary,
|
||||
"start": {"dateTime": event_start},
|
||||
"end": {"dateTime": event_end},
|
||||
}
|
||||
|
||||
try:
|
||||
created = (
|
||||
service.events()
|
||||
.insert(calendarId=calendar_id, body=event_body)
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Google Calendar create error: %s", exc)
|
||||
return f"Failed to create calendar event: {exc}"
|
||||
|
||||
summary = created.get("summary", event_summary)
|
||||
start = created.get("start", {}).get("dateTime", event_start)
|
||||
end = created.get("end", {}).get("dateTime", event_end)
|
||||
return f"Event created: {summary} from {start} to {end}."
|
||||
|
||||
Reference in New Issue
Block a user