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:
2026-03-26 09:07:37 -06:00
parent e8d3e8a108
commit 08572fcc40
5 changed files with 1318 additions and 62 deletions

View File

@@ -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}."