CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-travel

Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay plugin.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

High

Do not use without reviewing

Overview
Quality
Evals
Security
Files

fetch_events.pyskills/drive-engine/

"""Wide-window primary-calendar event fetch for the drive-engine reconcile sweep.

The sweep needs the upcoming calendar events in one shot so `scan.py` can
classify them (Epic #59 §4). This module is that fetch: an events.list over
the primary calendar (singleEvents, a wide `[timeMin, timeMax]` window),
returning the raw Google Calendar event dicts in the exact shape
`scan(events=...)` consumes (`id`, `summary`, `location`, `start`, `end`,
`description`, `extendedProperties`). Block state IS the calendar event (Epic
#59 §4 — fetched by API), carried in `extendedProperties.private` since the
#178 writer flip (and in the `description` for pre-flip blocks the reader still
accepts), so both come back verbatim: `exclude_drive_block_events` reads them
through `parse_block` to drop the engine's own blocks from the scan input, and
`scan.py` reads the `description` to recognize a legacy drive-planner block by
its marker.

This used to be a second, self-contained Composio transport — its own HTTP
POST, its own auth headers, its own success/failure envelope handling, its own
pagination loop and its own error type — sitting next to flight-assist's. Both
were the same calls to the same API, so #638 collapsed them: the transport,
the page draining (including the `maxResults` + bound that keep #171 fixed),
and the error taxonomy now come from `google_calendar_client`, and what is
left here is this fetch's own — the window contract and the projection down
to what `scan.py` reads.

`GoogleCalendarClient` ships in the co-located flight-assist bundle; it is
imported via the runtime-mount-with-dev-fallback pattern, the same way
`reconcile_sweep.py` reaches its own cross-bundle imports.

stdlib-only per `coding-policy: dependency-management` (Stdlib First).

Public API:
    from fetch_events import CalendarFetcher

    fetcher = CalendarFetcher()
    events = fetcher.fetch_window(time_min=now, time_max=now + timedelta(days=14))
    results = scan(events, now=now, home_address=home, skip_state=active)
"""

from __future__ import annotations

import sys
from datetime import datetime
from pathlib import Path

_BUNDLE_DIR = Path(__file__).resolve().parent
_FLIGHT_ASSIST_RUNTIME = Path("/home/node/.claude/skills/tessl__flight-assist")
_FLIGHT_ASSIST_DEV = _BUNDLE_DIR.parent / "flight-assist"


def _flight_assist_dir() -> Path:
    if _FLIGHT_ASSIST_RUNTIME.is_dir():
        return _FLIGHT_ASSIST_RUNTIME
    if _FLIGHT_ASSIST_DEV.is_dir():
        return _FLIGHT_ASSIST_DEV
    raise FileNotFoundError(
        "drive-engine fetch_events: cannot locate the co-shipped flight-assist skill at "
        f"{_FLIGHT_ASSIST_RUNTIME} (runtime) or {_FLIGHT_ASSIST_DEV} (dev) — "
        "google_calendar_client ships there; both skills are part of jbaruch/nanoclaw-travel"
    )


if str(_flight_assist_dir()) not in sys.path:
    sys.path.insert(0, str(_flight_assist_dir()))

from google_calendar_client import (  # noqa: E402
    GoogleCalendarClient,
    GoogleCalendarError,
)

# The primary calendar is the operator's main one (where meetings live).
# `singleEvents` expands recurring events into instances so a weekly standup
# surfaces as datable occurrences `scan.py` can classify.
_BASE_ARGS = {"calendar_id": "primary", "singleEvents": True}

# Event fields carried through verbatim from the raw event. `scan.py` reads
# id/summary/location/start/end/description/extendedProperties. A drive block's
# machine state is the calendar event itself (Epic #59 §4 — fetched by API):
# since the #178 writer flip it rides in `extendedProperties.private`, and in the
# `description` for pre-flip blocks and the legacy drive-planner marker `scan.py`
# still recognizes. `block_codec.parse_block` reads `extendedProperties.private`
# first, the description second, so both must be carried through — that is what
# lets `exclude_drive_block_events` and `scan` tell a block from a meeting.
_EVENT_FIELDS = (
    "id",
    "summary",
    "location",
    "start",
    "end",
    "description",
    "extendedProperties",
    "attendees",  # scan.py reads the operator's RSVP to skip declined meetings
    "status",  # scan.py skips cancelled events
)


class CalendarFetcher:
    """The drive-engine reconcile sweep's primary-calendar fetch.

    Holds a `GoogleCalendarClient` (no credential — the OneCLI gateway injects
    the Bearer on the wire; see that module). Not thread-safe — one instance
    per process.
    """

    def __init__(self, *, client=None, timeout: float = 30.0):
        self._client = client if client is not None else GoogleCalendarClient(timeout=timeout)

    def fetch_window(self, *, time_min: datetime, time_max: datetime) -> list:
        """Fetch primary-calendar events in [time_min, time_max] as scan-shaped dicts.

        The client drains the whole window across pages, so a busy calendar can
        never come back truncated and leave the sweep planning against a
        partial view (#171).

        Args:
            time_min: window start (tz-aware).
            time_max: window end (tz-aware, after time_min).

        Returns:
            A list of raw event dicts carrying the fields scan.py reads.
            Empty when the window genuinely has no events. Any non-dict entry
            in the upstream list is passed through verbatim for scan.py to
            classify as `filtered` — fetch never silently drops one.

        Raises:
            ValueError: on a naive datetime or time_max <= time_min.
            GoogleCalendarError: on an API-level failure (incl. a window that
                will not drain within the client's page bound).
            urllib.error.URLError: on transport failure.
        """
        if time_min.tzinfo is None or time_max.tzinfo is None:
            raise ValueError("fetch_window: time_min and time_max must be timezone-aware")
        if time_max <= time_min:
            raise ValueError("fetch_window: time_max must be after time_min")

        resource = self._client.find_events(
            {**_BASE_ARGS, "timeMin": time_min.isoformat(), "timeMax": time_max.isoformat()}
        )
        # `find_events` guarantees `items` (it merges every page into that one
        # shape), so there is nothing to probe for. The old `FetchError` guard
        # here existed because Composio's `data` envelope could carry the list
        # under any of several keys, or none, and a mis-read looked exactly
        # like an empty calendar — which would have made the sweep a silent
        # no-op. Malformed ENTRIES are still passed through untouched:
        # `_project_event` leaves a non-dict alone so `scan.py` can classify it
        # as `filtered` rather than have the fetch quietly drop it.
        return [_project_event(event) for event in resource["items"]]


def _project_event(event: object) -> object:
    """Keep only the fields scan.py reads, dropping the rest of the GCal resource.

    A non-dict entry is passed through untouched for `scan.py` to classify as
    `filtered` — fetch must not silently drop it (that would turn a shape
    regression into an invisible empty sweep).
    """
    if not isinstance(event, dict):
        return event
    return {field: event[field] for field in _EVENT_FIELDS if field in event}


# GoogleCalendarError is re-exported so callers that catch fetch failures can
# name the API-level type without reaching across bundles for it themselves.
__all__ = ["CalendarFetcher", "GoogleCalendarError"]

README.md

tile.json