537 lines
20 KiB
Python
537 lines
20 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, 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,
|
|
},
|
|
)
|
|
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={config.sync_token}"
|
|
# 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
|
|
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)
|