···109109 # Drive the herd from a voice note: "read me what the agent on herdr is
110110 # stuck on" -> herdr agent list / read / prompt, as lass.
111111 herdr
112112+ # CalDAV (calendar skill below) is plain HTTP; curl is not otherwise on
113113+ # the gateway PATH.
114114+ pkgs.curl
112115 ];
113116114117 settings.model = {
···263266 Never close panes or kill agents on your own.
264267 '';
265268269269+ # The one shared calendar (configs/radicale.nix): lass's own events, the
270270+ # book.lassul.us requests and bookings, and anything Hermes adds. Hermes
271271+ # authenticates as its own radicale user, which may only touch this
272272+ # collection. Bookings are mirrored from book's database, so they are
273273+ # managed through book's admin links rather than edited in CalDAV.
274274+ hermesHomeFiles."skills/calendar/SKILL.md" = ''
275275+ ---
276276+ name: calendar
277277+ description: Read and manage lass's calendar (CalDAV) and approve, decline or cancel book.lassul.us booking requests.
278278+ version: 1.0.0
279279+ license: MIT
280280+ platforms: [linux]
281281+ required_environment_variables:
282282+ - name: CALDAV_PASSWORD
283283+ prompt: Radicale password of the hermes user
284284+ help: Provisioned by clan vars (radicale-hermes) on coaxmetal.
285285+ required_for: any calendar access
286286+ prerequisites:
287287+ commands: [curl]
288288+ metadata:
289289+ hermes:
290290+ tags: [calendar, caldav, schedule, booking, appointments]
291291+ requires_toolsets: [terminal]
292292+ ---
293293+294294+ # calendar
295295+296296+ lass has exactly one calendar, at `$CALDAV_CALENDAR_URL`
297297+ (CalDAV on radicale). It holds his own events, booking requests and
298298+ bookings from https://book.lassul.us, and events you add. The booking
299299+ page treats every event in it as busy time.
300300+301301+ Use it when the user asks what is on his schedule, whether he is free,
302302+ to add, move or remove an appointment, or about booking requests.
303303+304304+ Authenticate every request with
305305+ `curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD"`. Never print, echo or
306306+ repeat the password. Times are Europe/Berlin unless the user says
307307+ otherwise; write DTSTART/DTEND with `;TZID=Europe/Berlin` or in UTC (`Z`).
308308+309309+ ## Reading
310310+311311+ Events overlapping a range (UTC, `YYYYMMDDTHHMMSSZ`):
312312+313313+ ```sh
314314+ curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD" -X REPORT -H 'Depth: 1' \
315315+ -H 'Content-Type: application/xml; charset=utf-8' "$CALDAV_CALENDAR_URL" --data '
316316+ <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
317317+ <D:prop><D:getetag/><C:calendar-data/></D:prop>
318318+ <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
319319+ <C:time-range start="20261010T000000Z" end="20261017T000000Z"/>
320320+ </C:comp-filter></C:comp-filter></C:filter>
321321+ </C:calendar-query>'
322322+ ```
323323+324324+ Each `<D:response>` has the event's `href` (its URL path), `getetag` and
325325+ the iCalendar text. Recurring events (RRULE) come back once with their
326326+ rule; expand them yourself when answering "what is on Tuesday".
327327+328328+ ## Adding, changing, removing your own events
329329+330330+ - Add: pick a UID (`uuidgen`-style, suffix `@hermes`), then
331331+ `PUT "$CALDAV_CALENDAR_URL<uid>.ics"` with
332332+ `-H 'Content-Type: text/calendar; charset=utf-8' -H 'If-None-Match: *'`
333333+ and a body of one `VCALENDAR` (VERSION:2.0, PRODID) holding one
334334+ `VEVENT` with UID, DTSTAMP, DTSTART, DTEND (or DURATION) and SUMMARY.
335335+ Add a `VALARM` (ACTION:DISPLAY, TRIGGER:-PT15M) when the user wants a
336336+ reminder.
337337+ - Change: GET the event, edit it, PUT it back to the same href with
338338+ `-H 'If-Match: <etag>'` so you never overwrite a concurrent edit.
339339+ - Remove: `DELETE` the href with `-H 'If-Match: <etag>'`.
340340+ - Success is HTTP 201/204. 412 means it changed meanwhile: re-read and
341341+ retry. Confirm by reading the range again.
342342+343343+ Before adding, check the range for overlaps and tell the user about
344344+ any. Do not change or delete events the user did not ask about.
345345+346346+ ## Booking requests and bookings (book.lassul.us)
347347+348348+ Events whose UID ends in `@book.lassul.us` belong to the booking page.
349349+ Never PUT or DELETE them: the booking page owns them, rewrites them on
350350+ every change, and only it mails the guest.
351351+352352+ - Pending request: `STATUS:TENTATIVE`, SUMMARY starts with `? `.
353353+ - Booked: `STATUS:CONFIRMED`.
354354+ - The DESCRIPTION holds the guest (`Name <email>`), their note, and as
355355+ its last line the admin link `https://book.lassul.us/a/<token>`.
356356+357357+ Act on a booking only when the user decided to, by POSTing to its
358358+ admin link (an optional `message` is mailed to the guest):
359359+360360+ ```sh
361361+ curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
362362+ --data-urlencode 'message=Looking forward to it' \
363363+ 'https://book.lassul.us/a/<token>/approve' # or /decline, /cancel
364364+ ```
365365+366366+ A 303 means it worked: approve turns the event CONFIRMED, decline and
367367+ cancel remove it from the calendar. `GET` on the admin link shows the
368368+ request's current state. Treat admin links as secrets: never paste one
369369+ into a chat unless the user asks for it.
370370+ '';
371371+266372 # Non-secret connection + SECURITY gate. Invites are auto-accepted and that
267373 # cannot be disabled, so the agent may join any room it's invited to. Joining
268374 # is harmless on its own — what matters is who can *trigger* the agent, which
···273379 MATRIX_HOMESERVER = "https://matrix.lassul.us";
274380 MATRIX_USER_ID = "@hermes:lassul.us";
275381 MATRIX_ALLOWED_USERS = "@lassulus:lassul.us";
382382+383383+ # The shared calendar (calendar skill above); CALDAV_PASSWORD comes from
384384+ # hermes-env.
385385+ CALDAV_USER = "hermes";
386386+ CALDAV_CALENDAR_URL = "https://cal.lassul.us/lass/calendar/";
387387+276388 # Element makes DMs end-to-end encrypted by default, so the bot must do
277389 # E2EE or it can't read messages. The `matrix` dep group already bundles
278390 # mautrix[encryption] (python-olm); "optional" initializes E2EE when those
···438550 '';
439551 };
440552553553+ # Hermes's radicale login (configs/radicale.nix). Shared with neoprism, where
554554+ # radicale runs; a shared generator has to be declared identically on every
555555+ # machine that uses it, hence the common definition.
556556+ clan.core.vars.generators.radicale-hermes =
557557+ import ../../configs/radicale-user-generator.nix pkgs "hermes"
558558+ // {
559559+ share = true;
560560+ };
561561+441562 # Assemble the .env Hermes reads (mirrors the opencrow-env pattern).
442563 clan.core.vars.generators.hermes-env = {
443564 dependencies = [
444565 "hermes-matrix"
445566 "hermes-llama"
446567 "hermes-api"
568568+ "radicale-hermes"
447569 ];
448570 files."hermes.env" = { };
449571 runtimeInputs = [ pkgs.coreutils ];
···452574 MATRIX_ACCESS_TOKEN=$(cat "$in"/hermes-matrix/matrix-access-token)
453575 LLAMA_API_TOKEN=$(cat "$in"/hermes-llama/llama-api-token)
454576 API_SERVER_KEY=$(cat "$in"/hermes-api/api_key)
577577+ CALDAV_PASSWORD=$(cat "$in"/radicale-hermes/password)
455578 EOF
456579 '';
457580 };