fischer-agentkit/src/agentkit/calendar/sync/outlook_provider.py

550 lines
21 KiB
Python

"""Outlook sync provider — bidirectional sync with Microsoft Graph API (U7).
Uses ``httpx.AsyncClient`` for all Graph API calls. Conflict resolution is
last-write-wins based on ``last_modified``; conflicts emit a
``calendar_sync_conflict`` WS notification via the injectable ``notify_callback``
(G4).
ponytail: browser OAuth flow (auth-code grant + redirect) is deferred to U12
settings UI. This provider assumes tokens are already stored in
``ExternalCalendarConfig.credentials``. Upgrade: add an ``OutlookOAuthFlow``
helper that performs the device-code or auth-code flow and writes tokens.
"""
from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, quote, urlparse
import httpx
from agentkit.calendar.db import (
DEFAULT_CALENDAR_DB_PATH,
get_event_by_external_id,
insert_event,
update_event,
update_external_config,
)
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso
from agentkit.calendar.sync.base import AbstractSyncProvider
logger = logging.getLogger(__name__)
# Async callback signature: (event_type: str, payload: dict) -> None
NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
DEFAULT_SCOPE = "https://graph.microsoft.com/Calendars.ReadWrite offline_access"
_DAY_MAP = {
"monday": "MO",
"tuesday": "TU",
"wednesday": "WE",
"thursday": "TH",
"friday": "FR",
"saturday": "SA",
"sunday": "SU",
}
_DAY_MAP_REVERSE = {v: k for k, v in _DAY_MAP.items()}
_FREQ_MAP = {
"daily": "DAILY",
"weekly": "WEEKLY",
"absoluteMonthly": "MONTHLY",
"absoluteYearly": "YEARLY",
}
_FREQ_MAP_REVERSE = {v: k for k, v in _FREQ_MAP.items()}
def _parse_iso(dt_str: str) -> datetime:
"""Parse ISO 8601 string to UTC-aware datetime."""
dt = datetime.fromisoformat(dt_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _outlook_dt_to_iso(dt_obj: dict[str, Any]) -> str:
"""Convert Outlook dateTimeTimeZone to ISO 8601 UTC.
ponytail: assumes Graph returns UTC (no ``Prefer: outlook.timezone`` header
is sent). If a non-UTC timezone is returned, it's treated as UTC. Upgrade:
use ``zoneinfo`` with a Windows→IANA timezone mapping for correct conversion.
"""
if not dt_obj:
return ""
dt_str = dt_obj.get("dateTime", "")
if not dt_str:
return ""
if "T" in dt_str:
dt = datetime.fromisoformat(dt_str)
else:
# Date only (all-day event)
dt = datetime.fromisoformat(dt_str + "T00:00:00")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat()
def _iso_to_outlook_dt(iso_str: str, is_all_day: bool) -> dict[str, str]:
"""Convert ISO 8601 UTC to Outlook dateTimeTimeZone."""
if not iso_str:
return {"dateTime": "", "timeZone": "UTC"}
dt = _parse_iso(iso_str)
if is_all_day:
return {"dateTime": dt.strftime("%Y-%m-%d"), "timeZone": "UTC"}
return {"dateTime": dt.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"}
def _outlook_recurrence_to_rrule(recurrence: dict[str, Any] | None) -> str | None:
"""Convert Outlook recurrence pattern to RRULE string."""
if not recurrence:
return None
pattern = recurrence.get("pattern", {}) or {}
range_obj = recurrence.get("range", {}) or {}
parts: list[str] = []
freq = _FREQ_MAP.get(pattern.get("type", ""))
if freq:
parts.append(f"FREQ={freq}")
interval = pattern.get("interval")
if interval and interval > 1:
parts.append(f"INTERVAL={interval}")
days = pattern.get("daysOfWeek", [])
if days:
bydays = [_DAY_MAP[d] for d in days if d in _DAY_MAP]
if bydays:
parts.append(f"BYDAY={','.join(bydays)}")
count = pattern.get("numberOfOccurrences")
if count:
parts.append(f"COUNT={count}")
elif range_obj.get("type") == "endDate" and range_obj.get("endDate"):
end_date = range_obj["endDate"] # "2026-12-31"
parts.append(f"UNTIL={end_date.replace('-', '')}T235959Z")
return ";".join(parts) if parts else None
def _rrule_to_outlook_recurrence(rrule: str, start_date: str) -> dict[str, Any] | None:
"""Convert RRULE string to Outlook recurrence pattern.
``start_date`` is the event's start date in ``YYYY-MM-DD`` format (required
by Graph API for the ``range.startDate`` field).
"""
parts: dict[str, str] = {}
for part in rrule.split(";"):
if "=" in part:
k, v = part.split("=", 1)
parts[k.upper()] = v
freq = parts.get("FREQ", "").upper()
pattern_type = _FREQ_MAP_REVERSE.get(freq)
if not pattern_type:
return None
pattern: dict[str, Any] = {"type": pattern_type}
interval = parts.get("INTERVAL")
pattern["interval"] = int(interval) if interval else 1
byday = parts.get("BYDAY")
if byday:
pattern["daysOfWeek"] = [
_DAY_MAP_REVERSE[d] for d in byday.split(",") if d in _DAY_MAP_REVERSE
]
count = parts.get("COUNT")
until = parts.get("UNTIL")
if count:
pattern["numberOfOccurrences"] = int(count)
range_obj: dict[str, Any] = {
"type": "numbered",
"startDate": start_date,
"numberOfOccurrences": int(count),
}
elif until:
# UNTIL=20261231T235959Z → "2026-12-31"
date_str = until[:8]
end_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
range_obj = {"type": "endDate", "startDate": start_date, "endDate": end_date}
else:
range_obj = {"type": "noEnd", "startDate": start_date}
return {"pattern": pattern, "range": range_obj}
def _extract_delta_token(delta_link: str) -> str | None:
"""Extract ``$deltaToken`` from a Graph delta link URL."""
parsed = urlparse(delta_link)
params = parse_qs(parsed.query)
values = params.get("$deltaToken", [])
return values[0] if values else None
class OutlookSyncProvider(AbstractSyncProvider):
"""Bidirectional Outlook sync provider via Microsoft Graph REST API.
The ``client_factory`` parameter allows tests to inject a mock
``httpx.AsyncClient`` without making real HTTP calls. When ``None``, a
real ``httpx.AsyncClient`` is constructed per-operation.
"""
def __init__(
self,
db_path: str | Path | None = None,
client_factory: Callable[[], Any] | None = None,
notify_callback: NotifyCallback | None = None,
) -> None:
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
self._client_factory = client_factory
self._notify = notify_callback
# ponytail: conflicts list is in-memory only; if the process restarts
# before a sync completes, conflict history is lost. Upgrade: persist
# to a calendar_sync_conflicts table.
# ------------------------------------------------------------------
# Client / auth
# ------------------------------------------------------------------
def _get_client(self) -> Any:
"""Return an httpx.AsyncClient (real or mock from factory)."""
if self._client_factory is not None:
return self._client_factory()
return httpx.AsyncClient(timeout=30.0)
def _load_creds(self, config: ExternalCalendarConfig) -> dict[str, Any]:
return json.loads(config.credentials) if config.credentials else {}
def _save_creds(self, config: ExternalCalendarConfig, creds: dict[str, Any]) -> None:
config.credentials = json.dumps(creds)
async def _refresh_token(self, client: Any, config: ExternalCalendarConfig) -> dict[str, Any]:
"""Refresh the access token using the refresh_token grant.
Posts to the Azure AD token endpoint, updates ``config.credentials``
in-memory, and persists the new credentials to the DB.
"""
creds = self._load_creds(config)
resp = await client.request(
"POST",
TOKEN_URL,
data={
"client_id": creds.get("client_id", ""),
"grant_type": "refresh_token",
"refresh_token": creds.get("refresh_token", ""),
"scope": DEFAULT_SCOPE,
},
)
if resp.status_code in (400, 401):
logger.error("Outlook refresh token expired or revoked (status=%s)", resp.status_code)
if self._notify is not None:
await self._notify(
"calendar_sync_error",
{
"config_id": config.id,
"message": "Outlook authentication expired, please re-authenticate",
},
)
# ponytail: re-auth UI is in U12. Upgrade: trigger OAuth re-flow automatically.
raise RuntimeError("Outlook refresh token expired — re-authentication required")
resp.raise_for_status()
payload = resp.json()
creds["access_token"] = payload["access_token"]
if "refresh_token" in payload:
creds["refresh_token"] = payload["refresh_token"]
creds["expires_at"] = (
datetime.now(timezone.utc) + timedelta(seconds=payload.get("expires_in", 3600))
).isoformat()
self._save_creds(config, creds)
await update_external_config(config.id, {"credentials": config.credentials}, self.db_path)
return creds
async def _request(
self,
client: Any,
config: ExternalCalendarConfig,
method: str,
url: str,
*,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make an authenticated Graph API request with 401 auto-refresh + retry."""
creds = self._load_creds(config)
headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"}
resp = await client.request(method, url, headers=headers, json=json_body)
if resp.status_code == 401:
creds = await self._refresh_token(client, config)
headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"}
resp = await client.request(method, url, headers=headers, json=json_body)
resp.raise_for_status()
return resp.json() if resp.text else {}
# ------------------------------------------------------------------
# Pull
# ------------------------------------------------------------------
async def pull_changes(
self, config: ExternalCalendarConfig, since: str | None = None
) -> list[CalendarEvent]:
"""Pull remote Outlook events via delta query.
First call (no ``sync_token``) is a full sync within a date range.
Subsequent calls use the stored delta token for incremental sync.
Returns pulled/updated events.
"""
client = self._get_client()
try:
remote_events, delta_token = await self._pull_delta(client, config)
finally:
await client.aclose()
# Persist delta token for next incremental sync
if delta_token:
config.sync_token = delta_token
await update_external_config(config.id, {"sync_token": delta_token}, self.db_path)
result: list[CalendarEvent] = []
for remote in remote_events:
local = await get_event_by_external_id(
remote.external_id, "outlook", config.user_id, self.db_path
)
if local is None:
# New remote event → create local
await insert_event(remote, self.db_path)
result.append(remote)
else:
# Existing → check for conflict
resolved = await self._resolve_pull_conflict(local, remote)
if resolved is not None:
result.append(resolved)
return result
async def _pull_delta(
self, client: Any, config: ExternalCalendarConfig
) -> tuple[list[CalendarEvent], str | None]:
"""Call /me/calendarView/delta. Returns (events, delta_token)."""
url = self._build_delta_url(config)
# ponytail: single-page fetch; pagination via @odata.nextLink is not
# followed. Upgrade: loop on nextLink until exhausted, then read
# deltaLink from the final page.
payload = await self._request(client, config, "GET", url)
events: list[CalendarEvent] = []
for raw in payload.get("value", []):
parsed = self._parse_outlook_event(raw, config.user_id)
if parsed is not None:
events.append(parsed)
delta_link = payload.get("@odata.deltaLink")
delta_token = _extract_delta_token(delta_link) if delta_link else None
return events, delta_token
def _build_delta_url(self, config: ExternalCalendarConfig) -> str:
"""Build the delta query URL. Uses sync_token if present (incremental)."""
if config.sync_token:
return f"{GRAPH_BASE}/me/calendarView/delta?$deltaToken={quote(config.sync_token, safe='')}"
# Initial sync — use date range to scope the fetch
start = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
end = (datetime.now(timezone.utc) + timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ")
return f"{GRAPH_BASE}/me/calendarView/delta?startDateTime={start}&endDateTime={end}"
def _parse_outlook_event(self, raw: dict[str, Any], user_id: str) -> CalendarEvent | None:
"""Convert a Graph event JSON to a CalendarEvent dataclass."""
eid = raw.get("id")
if not eid:
return None
title = raw.get("subject") or ""
if not title:
return None
start_str = _outlook_dt_to_iso(raw.get("start", {}))
end_str = _outlook_dt_to_iso(raw.get("end", {})) or start_str
is_all_day = bool(raw.get("isAllDay", False))
body = raw.get("body", {}) or {}
description = body.get("content", "") or ""
location_obj = raw.get("location", {}) or {}
location = location_obj.get("displayName", "") or ""
rrule = _outlook_recurrence_to_rrule(raw.get("recurrence"))
last_modified = raw.get("lastModifiedDateTime", "") or _now_iso()
now = _now_iso()
return CalendarEvent(
id=uuid.uuid4().hex,
user_id=user_id,
title=title,
description=description,
start_time=start_str,
end_time=end_str,
is_all_day=is_all_day,
location=location,
rrule=rrule,
source="manual",
external_id=eid,
external_provider="outlook",
last_modified=last_modified,
created_at=now,
)
async def _resolve_pull_conflict(
self, local: CalendarEvent, remote: CalendarEvent
) -> CalendarEvent | None:
"""Resolve conflict when both local and remote exist (last-write-wins).
If remote is newer → update local. If local is newer → conflict
(last-write-wins keeps local, but log + notify). If equal → no-op.
Returns the winning event (or None if local kept unchanged).
"""
local_lm = (
_parse_iso(local.last_modified)
if local.last_modified
else datetime.min.replace(tzinfo=timezone.utc)
)
remote_lm = (
_parse_iso(remote.last_modified)
if remote.last_modified
else datetime.min.replace(tzinfo=timezone.utc)
)
if remote_lm > local_lm:
# Remote wins → update local
await self._notify_conflict(local, remote, winner="remote")
fields = {
"title": remote.title,
"description": remote.description,
"start_time": remote.start_time,
"end_time": remote.end_time,
"is_all_day": remote.is_all_day,
"location": remote.location,
"rrule": remote.rrule,
"last_modified": remote.last_modified,
}
await update_event(local.id, fields, self.db_path)
return remote
if local_lm > remote_lm:
# Local wins → conflict, keep local, notify
await self._notify_conflict(local, remote, winner="local")
return None
# Equal timestamps → no change needed
return None
# ------------------------------------------------------------------
# Push
# ------------------------------------------------------------------
async def push_changes(
self, config: ExternalCalendarConfig, events: list[CalendarEvent]
) -> list[CalendarEvent]:
"""Push local events to Outlook. Returns events with external_id set."""
client = self._get_client()
try:
result: list[CalendarEvent] = []
for event in events:
updated = await self._push_single(client, config, event)
result.append(updated)
finally:
await client.aclose()
return result
async def _push_single(
self, client: Any, config: ExternalCalendarConfig, event: CalendarEvent
) -> CalendarEvent:
"""Push a single event to Outlook, return event with external_id set."""
body = self._event_to_outlook(event)
if event.external_id:
# Update existing remote event
url = f"{GRAPH_BASE}/me/events/{event.external_id}"
await self._request(client, config, "PATCH", url, json_body=body)
fields = {"last_modified": _now_iso()}
await update_event(event.id, fields, self.db_path)
return event
# Create new remote event
url = f"{GRAPH_BASE}/me/events"
payload = await self._request(client, config, "POST", url, json_body=body)
new_id = payload.get("id")
if new_id:
fields = {
"external_id": new_id,
"external_provider": "outlook",
"last_modified": _now_iso(),
}
await update_event(event.id, fields, self.db_path)
event.external_id = new_id
event.external_provider = "outlook"
return event
def _event_to_outlook(self, event: CalendarEvent) -> dict[str, Any]:
"""Convert CalendarEvent to Outlook Graph event JSON."""
body: dict[str, Any] = {
"subject": event.title,
"start": _iso_to_outlook_dt(event.start_time, event.is_all_day),
"end": _iso_to_outlook_dt(event.end_time, event.is_all_day),
"isAllDay": event.is_all_day,
}
if event.description:
body["body"] = {"contentType": "Text", "content": event.description}
if event.location:
body["location"] = {"displayName": event.location}
if event.rrule:
start_date = event.start_time[:10] if event.start_time else "2026-01-01"
rec = _rrule_to_outlook_recurrence(event.rrule, start_date)
if rec is not None:
body["recurrence"] = rec
return body
# ------------------------------------------------------------------
# Test connection
# ------------------------------------------------------------------
async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]:
"""Test Outlook connectivity via GET /me. Returns (ok, error_msg)."""
client = self._get_client()
try:
try:
await self._request(client, config, "GET", f"{GRAPH_BASE}/me")
ok, msg = True, ""
except Exception as e:
ok, msg = False, str(e)
finally:
await client.aclose()
return ok, msg
# ------------------------------------------------------------------
# Conflict notification
# ------------------------------------------------------------------
async def _notify_conflict(
self, local: CalendarEvent, remote: CalendarEvent, winner: str
) -> None:
"""Log conflict and send WS notification via callback (G4)."""
logger.info(
"Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s",
local.id,
local.external_id,
local.last_modified,
remote.last_modified,
winner,
)
if self._notify is not None:
payload = {
"event_id": local.id,
"title": local.title,
"external_id": local.external_id,
"local_last_modified": local.last_modified,
"remote_last_modified": remote.last_modified,
"winner": winner,
}
await self._notify("calendar_sync_conflict", payload)