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

398 lines
15 KiB
Python

"""CalDAV sync provider — bidirectional sync with Apple Calendar (U6).
Uses the ``caldav`` library for the CalDAV protocol and ``icalendar`` for
ICS serialization/parsing. Conflict resolution is last-write-wins based on
``last_modified``; conflicts emit a ``calendar_sync_conflict`` WS notification
via the injectable ``notify_callback`` (G4).
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import caldav
from icalendar import Calendar, Event
from icalendar.prop import vRecur
from agentkit.calendar.db import (
DEFAULT_CALENDAR_DB_PATH,
get_event_by_external_id,
insert_event,
update_event,
)
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, object]], Awaitable[None]]
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 _to_iso_utc(dt: datetime) -> str:
"""Convert datetime to ISO 8601 UTC string."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat()
def _extract_dt(component: object, key: str) -> tuple[str, bool]:
"""Extract date/datetime from icalendar component. Returns (iso, is_all_day)."""
prop = component.get(key)
if prop is None:
return "", False
val = prop.dt
if isinstance(val, date) and not isinstance(val, datetime):
return val.isoformat(), True
return _to_iso_utc(val), False
class CalDAVSyncProvider(AbstractSyncProvider):
"""Bidirectional CalDAV sync provider for Apple Calendar.
The ``client_factory`` parameter allows tests to inject a mock CalDAV
client without touching ``caldav.DAVClient``. When ``None``, a real
``caldav.DAVClient`` is constructed from ``config.credentials``.
"""
def __init__(
self,
db_path: str | Path | None = None,
client_factory: Callable[[ExternalCalendarConfig], object] | 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 construction
# ------------------------------------------------------------------
def _make_client(self, config: ExternalCalendarConfig) -> object:
"""Build a caldav.DAVClient from config credentials."""
if self._client_factory is not None:
return self._client_factory(config)
# ponytail: credentials stored as plain JSON dict; encryption deferred.
# Upgrade: use agentkit.server.auth.crypto to encrypt at rest.
creds = json.loads(config.credentials) if config.credentials else {}
return caldav.DAVClient(
url=creds.get("url", ""),
username=creds.get("username", ""),
password=creds.get("password", ""),
# ponytail: 30s timeout prevents indefinite hangs on unreachable
# CalDAV servers. Upgrade: make configurable per-config.
timeout=30,
)
def _get_calendar(self, config: ExternalCalendarConfig) -> object:
"""Connect and return the first calendar from the principal."""
client = self._make_client(config)
principal = client.principal()
calendars = principal.calendars()
if not calendars:
raise RuntimeError("No CalDAV calendars found for this account")
return calendars[0]
# ------------------------------------------------------------------
# Pull
# ------------------------------------------------------------------
async def pull_changes(
self, config: ExternalCalendarConfig, since: str | None = None
) -> list[CalendarEvent]:
"""Pull remote CalDAV events into local DB.
Fetches events in a date range starting from ``since`` (or 1 year ago
if None). Matches by ``external_id`` (CalDAV UID). Creates new local
events or updates existing ones. Conflicts (both sides modified) are
resolved last-write-wins with a WS notification.
"""
# caldav is synchronous — run in a thread to avoid blocking the loop
remote_events = await asyncio.to_thread(self._fetch_remote_events, config, since)
result: list[CalendarEvent] = []
for remote in remote_events:
local = await get_event_by_external_id(
remote.external_id, "caldav", 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
def _fetch_remote_events(
self, config: ExternalCalendarConfig, since: str | None
) -> list[CalendarEvent]:
"""Synchronous CalDAV fetch — runs in thread."""
calendar = self._get_calendar(config)
# Date range: since → now+90d (or wide default)
if since:
start_dt = _parse_iso(since)
else:
start_dt = datetime.now(timezone.utc) - timedelta(days=365)
end_dt = datetime.now(timezone.utc) + timedelta(days=90)
caldav_events = calendar.date_search(start_dt, end_dt)
events: list[CalendarEvent] = []
for ce in caldav_events:
parsed = self._parse_caldav_event(ce, config.user_id)
if parsed is not None:
events.append(parsed)
return events
def _parse_caldav_event(self, caldav_event: object, user_id: str) -> CalendarEvent | None:
"""Convert a caldav.Event to a CalendarEvent dataclass."""
try:
ical_data = caldav_event.data
cal = Calendar.from_ical(ical_data)
except Exception as e:
logger.warning("Failed to parse CalDAV event: %s", e)
return None
for component in cal.walk("VEVENT"):
uid = str(component.get("UID", "") or "") or None
if not uid:
continue
title = str(component.get("SUMMARY", "") or "")
if not title:
continue
start_str, is_all_day = _extract_dt(component, "DTSTART")
end_str, _ = _extract_dt(component, "DTEND")
if not end_str:
end_str = start_str
description = str(component.get("DESCRIPTION", "") or "")
location = str(component.get("LOCATION", "") or "")
rrule_str: str | None = None
rrule = component.get("RRULE")
if rrule is not None:
rrule_str = rrule.to_ical().decode("utf-8")
# LAST-MODIFIED from VEVENT (for conflict resolution)
lm_prop = component.get("LAST-MODIFIED")
if lm_prop is not None:
last_modified = _to_iso_utc(lm_prop.dt)
else:
last_modified = _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_str,
source="manual",
external_id=uid,
external_provider="caldav",
last_modified=last_modified,
created_at=now,
)
return None
async def _resolve_pull_conflict(
self, local: CalendarEvent, remote: CalendarEvent
) -> CalendarEvent | None:
"""Resolve conflict when both local and remote exist.
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 CalDAV. Returns events with external_id set."""
result: list[CalendarEvent] = []
for event in events:
updated = await self._push_single(config, event)
result.append(updated)
return result
async def _push_single(
self, config: ExternalCalendarConfig, event: CalendarEvent
) -> CalendarEvent:
"""Push a single event to CalDAV, return event with external_id set."""
ical_bytes = self._event_to_ics(event)
# caldav is synchronous — run in thread
saved_uid = await asyncio.to_thread(self._save_remote_event, config, ical_bytes, event)
# If event had no external_id, set it from the saved UID
if not event.external_id and saved_uid:
fields = {
"external_id": saved_uid,
"external_provider": "caldav",
"last_modified": _now_iso(),
}
await update_event(event.id, fields, self.db_path)
event.external_id = saved_uid
event.external_provider = "caldav"
return event
def _save_remote_event(
self, config: ExternalCalendarConfig, ical_bytes: bytes, event: CalendarEvent
) -> str | None:
"""Synchronous CalDAV save — runs in thread. Returns remote UID."""
calendar = self._get_calendar(config)
saved = calendar.save_event(ical_bytes)
# Extract UID from saved event
try:
cal = Calendar.from_ical(saved.data)
for comp in cal.walk("VEVENT"):
uid = str(comp.get("UID", "") or "") or None
if uid:
return uid
except Exception as e:
logger.warning("Failed to extract UID from saved event: %s", e)
return event.external_id
def _event_to_ics(self, event: CalendarEvent) -> bytes:
"""Convert CalendarEvent to ICS bytes using icalendar library."""
cal = Calendar()
cal.add("prodid", "-//Fischer AgentKit//Calendar//EN")
cal.add("version", "2.0")
vevent = Event()
# Use external_id if available, else local id as UID
vevent.add("uid", event.external_id or event.id)
vevent.add("summary", event.title)
if event.start_time:
start_dt = _parse_iso(event.start_time)
if event.is_all_day:
vevent.add("dtstart", start_dt.date())
else:
vevent.add("dtstart", start_dt)
if event.end_time:
end_dt = _parse_iso(event.end_time)
if event.is_all_day:
vevent.add("dtend", end_dt.date())
else:
vevent.add("dtend", end_dt)
if event.description:
vevent.add("description", event.description)
if event.location:
vevent.add("location", event.location)
if event.rrule:
vevent.add("rrule", vRecur.from_ical(event.rrule))
# LAST-MODIFIED for conflict resolution
if event.last_modified:
vevent.add("last-modified", _parse_iso(event.last_modified))
cal.add_component(vevent)
return cal.to_ical()
# ------------------------------------------------------------------
# Test connection
# ------------------------------------------------------------------
async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]:
"""Test CalDAV connectivity. Returns (ok, error_msg)."""
try:
await asyncio.to_thread(self._get_calendar, config)
return True, ""
except Exception as e:
return False, str(e)
# ------------------------------------------------------------------
# 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)