SmartestHome/digest-engine/ingest/caldav.py

184 lines
7.8 KiB
Python

"""Nextcloud calendar ingestion over CalDAV.
READ-ONLY INVARIANT: this module only ever issues CalDAV REPORT/PROPFIND reads
(`Calendar.search()` and the principal/calendar discovery it needs). It never
calls `save_event`, `add_event`, `Event.delete()`, `make_calendar()` or anything
else that would mutate the collection, per docs/project-plan.md Phase 12 step 8
and the Phase 8 "no silent mutation" precedent. The digest can say an event needs
attention; it can never move, create or delete one.
Auth is a Nextcloud **app password**, not OAuth2 and not the account password —
the same reasoning email_imap.py documents for Gmail App Passwords. Nextcloud
issues them at Settings -> Security -> Devices & sessions -> Create new app
password, and they are mandatory for CalDAV once two-factor authentication is on,
because the DAV endpoints have no way to prompt for a second factor. Even without
2FA they are the right credential here: revocable on their own, scoped to this
one integration, and they leave the account password out of a file on the
container host.
Sourcing, verified 2026-07-28:
The protocol is not hand-rolled. `caldav` on PyPI (3.2.1, 2026-05-28, Python
>=3.10, https://pypi.org/project/caldav/) does discovery, the calendar-query
REPORT and recurrence expansion; hand-writing that XML against a real server is
a bad trade. It pulls in `icalendar`, which is what actually parses the
returned VEVENTs here.
URL: point CALDAV_URL at Nextcloud's DAV root, `https://<host>/remote.php/dav`
— the library discovers the principal and its calendars from there. Nextcloud
documents the equivalent per-user form
`https://<host>/remote.php/dav/principals/users/<username>/`; either works.
`search(start=..., end=..., event=True, expand=True)` is the documented way to
get a time-bounded list of events with recurrences expanded into concrete
occurrences. Without `expand`, a weekly recurring event comes back once, as its
original master VEVENT with an RRULE, and the digest would report a meeting on
the day it was first created. caldav 2.0+ can expand client-side when the
server will not, but sabre/dav servers that reject the request outright are
handled by the retry below.
The window is deliberately asymmetric: everything else in this component looks
backwards over the digest window, but a calendar is mostly useful forwards, so
this reads `lookback_hours` back (to catch what happened earlier today, and
events still in progress) and CALDAV_LOOKAHEAD_HOURS forwards.
"""
import logging
import os
from datetime import date, datetime, timedelta, timezone
LOG = logging.getLogger(__name__)
DEFAULT_LOOKAHEAD_HOURS = 48
DEFAULT_MAX_EVENTS = 100
MAX_DESCRIPTION_CHARS = 500
def _csv_set(name):
return {item.strip().lower() for item in os.environ.get(name, "").split(",") if item.strip()}
def _stamp(value):
if value is None:
return None, False
if isinstance(value, datetime):
# A floating (tz-naive) DTSTART is legal iCalendar; treating it as UTC keeps
# the ordering sane instead of raising on the comparison.
aware = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), False
if isinstance(value, date):
return value.isoformat(), True
return None, False
def _text(component, key, limit=None):
value = component.get(key)
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text[:limit] if limit else text
def _events_from(raw, calendar_name, ical_parser):
events = []
for component in ical_parser.from_ical(raw).walk("VEVENT"):
start_property = component.get("dtstart")
end_property = component.get("dtend")
start, all_day = _stamp(getattr(start_property, "dt", None))
end, _ = _stamp(getattr(end_property, "dt", None))
if start is None:
continue
events.append(
{
"source": "caldav",
"category": "calendar_event",
"calendar": calendar_name,
"uid": _text(component, "uid"),
"summary": _text(component, "summary") or "(no title)",
"start": start,
"end": end,
"all_day": all_day,
"location": _text(component, "location"),
"description": _text(component, "description", MAX_DESCRIPTION_CHARS),
"status": _text(component, "status"),
"recurring": component.get("rrule") is not None,
}
)
return events
def _search(calendar, window_start, window_end):
try:
return calendar.search(start=window_start, end=window_end, event=True, expand=True)
except Exception:
# Some sabre/dav deployments reject an expand request outright. An
# unexpanded window is still worth having; the only cost is that a
# recurring series shows up as its master event.
LOG.warning("caldav: expanded search failed, retrying unexpanded", exc_info=True)
return calendar.search(start=window_start, end=window_end, event=True)
def _calendar_name(calendar):
try:
return str(calendar.name or "")
except Exception:
# .name is a lazy PROPFIND; a calendar that will not name itself is still
# readable, and dropping it over a missing display name would be absurd.
return ""
def fetch(lookback_hours):
url = os.environ.get("CALDAV_URL", "").strip()
username = os.environ.get("CALDAV_USERNAME", "").strip()
password = os.environ.get("CALDAV_PASSWORD", "")
if not (url and username and password):
LOG.warning("caldav: CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD not all set, skipping")
return []
lookahead_hours = float(os.environ.get("CALDAV_LOOKAHEAD_HOURS", DEFAULT_LOOKAHEAD_HOURS))
max_events = int(os.environ.get("CALDAV_MAX_EVENTS", DEFAULT_MAX_EVENTS))
wanted = _csv_set("CALDAV_CALENDARS")
verify = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true"
if not verify:
LOG.warning("caldav: CALDAV_VERIFY_TLS is false, the app password is sent over an unverified session")
now = datetime.now(timezone.utc)
window_start = now - timedelta(hours=float(lookback_hours))
window_end = now + timedelta(hours=lookahead_hours)
events = []
try:
# Imported here, not at module scope, so a missing/broken optional
# dependency degrades this one source instead of the whole run. The
# absolute import resolves to the PyPI `caldav` package, not to this
# module, which is only ever reachable as `ingest.caldav`.
import caldav as caldav_lib
from icalendar import Calendar as ICalendar
with caldav_lib.DAVClient(
url=url, username=username, password=password, ssl_verify_cert=verify
) as client:
for calendar in client.principal().calendars():
name = _calendar_name(calendar)
if wanted and name.lower() not in wanted:
continue
try:
for item in _search(calendar, window_start, window_end):
events.extend(_events_from(item.data, name, ICalendar))
except Exception:
LOG.warning("caldav: calendar %r could not be read, skipping", name, exc_info=True)
except Exception:
LOG.warning("caldav: ingestion failed, returning nothing", exc_info=True)
return []
events.sort(key=lambda event: event["start"])
if len(events) > max_events:
LOG.warning("caldav: %d events in the window, truncating to %d", len(events), max_events)
events = events[:max_events]
LOG.info("caldav: %d event(s) from -%sh to +%sh", len(events), lookback_hours, lookahead_hours)
return events