diff --git a/nostr/__init__.py b/nostr/__init__.py new file mode 100644 index 0000000..704ec4e --- /dev/null +++ b/nostr/__init__.py @@ -0,0 +1,4 @@ +"""Chatelet's Nostr layer: event kind allocations, event builders, and the +sign/publish/subscribe service. Kept in its own package so the booking +logic (crud.py) stays transport-agnostic and the REST + Nostr entry points +can share it.""" diff --git a/nostr/events.py b/nostr/events.py new file mode 100644 index 0000000..2ad6cdb --- /dev/null +++ b/nostr/events.py @@ -0,0 +1,119 @@ +"""Builders that turn Chatelet rows into unsigned Nostr events. + +These are pure functions: they return unsigned event dicts (kind, tags, +content) with no pubkey/id/sig. Signing + NIP-44 encryption happen in +service.py via lnbits.core.signers.resolve_signer, so no nsec is handled +here and the same code works with a LocalSigner today or a NIP-46 bunker +later. See docs/event-flow.md for who publishes what, when. +""" + +import json + +from ..models import AvailabilityResult, Booking, Room +from .kinds import ( + KIND_AVAILABILITY_RESPONSE, + KIND_CALENDAR_EVENT, + KIND_LISTING, + KIND_RESERVATION, +) + + +def build_listing_event(room: Room) -> dict: + """NIP-99 kind:30402 classified listing for a room. Public, signed by + the operator's identity. `d` == room.id so re-publishing replaces.""" + tags = [ + ["d", room.id], + ["title", room.title], + ["price", str(room.price_amount), room.price_currency, room.price_frequency], + ["status", "active"], + ] + if room.location: + tags.append(["location", room.location]) + if room.geohash: + tags.append(["g", room.geohash]) + for url in room.images: + tags.append(["image", url]) + for amenity in room.amenities: + tags.append(["t", amenity]) + return { + "kind": KIND_LISTING, + "content": room.description, + "tags": tags, + } + + +def build_reservation_event(booking: Booking, guest_pubkey: str) -> dict: + """NIP-78 kind:30078 reservation object — the guest's durable, private + copy of their booking. `content` MUST be NIP-44 encrypted to the guest + by the caller (service.py) before signing; here we return the plaintext + payload so the signer can seal it. `d` == booking.id, addressable so + status transitions replace in place. + + amount_sat is copied verbatim from the booking (canonical value) — do + not recompute it here. + """ + payload = { + "booking_id": booking.id, + "room_id": booking.room_id, + "check_in": booking.check_in, + "check_out": booking.check_out, + "nights": booking.nights, + "num_guests": booking.num_guests, + "status": booking.status.value, + "amount_sat": booking.amount_sat, + "deposit_sat": booking.deposit_sat, + "currency": booking.currency, + "price_fiat": booking.price_fiat, + } + return { + "kind": KIND_RESERVATION, + # placeholder — service.py replaces with NIP-44(payload) for guest + "content": json.dumps(payload), + "tags": [ + ["d", booking.id], + ["p", guest_pubkey], + ], + "_plaintext": payload, # consumed + stripped by service.py before signing + } + + +def build_block_calendar_event( + room: Room, start_date: str, end_date: str, block_id: str +) -> dict: + """NIP-52 kind:31923 marking a range as unavailable on the room's public + calendar. Deliberately carries NO guest PII — just 'these dates are + taken'. Only published when settings.publish_availability is true.""" + return { + "kind": KIND_CALENDAR_EVENT, + "content": "", + "tags": [ + ["d", f"{room.id}:{block_id}"], + ["title", f"{room.title} — unavailable"], + ["start", start_date], + ["end", end_date], + ["a", f"{KIND_LISTING}:{{operator_pubkey}}:{room.id}"], # link to listing + ], + } + + +def build_availability_response( + result: AvailabilityResult, requester_pubkey: str +) -> dict: + """Aiolabs kind:22001 (ephemeral) reply to an availability query. Content + MUST be NIP-44 encrypted to the requester by service.py.""" + payload = { + "room_id": result.room_id, + "check_in": result.check_in, + "check_out": result.check_out, + "available": result.available, + "nights": result.nights, + "quote_sat": result.quote_sat, + "quote_fiat": result.quote_fiat, + "currency": result.currency, + } + return { + "kind": KIND_AVAILABILITY_RESPONSE, + "content": json.dumps(payload), + "tags": [["p", requester_pubkey]], + "_plaintext": payload, + } diff --git a/nostr/kinds.py b/nostr/kinds.py new file mode 100644 index 0000000..ae93904 --- /dev/null +++ b/nostr/kinds.py @@ -0,0 +1,27 @@ +"""Nostr event-kind allocations used by Chatelet. + +Rationale for each choice lives in docs/adr-0001-nostr-event-model.md. +Short version: reuse standard NIPs wherever one fits the semantics, and +only reach into the aiolabs custom band for the live availability RPC that +no standard kind models well. + +Aiolabs custom band is 22000-22099 (see workspace CLAUDE.md). The CLINK +band 21001-21099 is OFF-LIMITS. 22000/22001 are hereby allocated to +Chatelet — register them in workspace CLAUDE.md before this ships so a +future extension doesn't collide. +""" + +# --- standard NIPs (preferred) --- +KIND_LISTING = 30402 # NIP-99 classified listing (the room). Addressable. +KIND_RESERVATION = 30078 # NIP-78 app-specific data: the guest's durable +# reservation object, NIP-44 encrypted. Addressable +# so status updates (confirmed->checked_in->...) replace +# in place under the same d-tag (booking id). +KIND_CALENDAR = 31924 # NIP-52 calendar aggregating a room's blocked ranges. +KIND_CALENDAR_EVENT = 31923 # NIP-52 time-based event: one blocked range (no PII). +KIND_GIFTWRAP = 1059 # NIP-59 giftwrap carrying the private booking DMs +# (request -> quote -> confirm -> check-in details). + +# --- aiolabs custom band (only where no standard kind fits) --- +KIND_AVAILABILITY_QUERY = 22000 # ephemeral: "is room X free for [in,out)?" +KIND_AVAILABILITY_RESPONSE = 22001 # ephemeral: open/closed + non-binding quote diff --git a/nostr/service.py b/nostr/service.py new file mode 100644 index 0000000..7345ef2 --- /dev/null +++ b/nostr/service.py @@ -0,0 +1,114 @@ +"""Nostr I/O for Chatelet: sign+publish outbound events, subscribe to +inbound guest requests. SKETCH — the relay plumbing is stubbed; the shape +and the signer/encryption boundaries are what matter here. + +Signing + NIP-44 go through lnbits.core.signers.resolve_signer, following +the spirekeeper hybrid pattern (aiolabs/spirekeeper nostr_publish.py): +resolve the operator account's signer, then sign_event / nip44_encrypt. +No nsec is ever read here — works with LocalSigner now, NIP-46 bunker +after lnbits#18. If the signer module isn't present (pre-#17 lnbits), the +whole Nostr layer soft-disables and Chatelet still works over REST. +""" + +from loguru import logger + +from .. import crud +from ..models import AvailabilityResult, Booking, Room +from . import events + +try: + from lnbits.core.crud import get_account + from lnbits.core.signers import resolve_signer + + _SIGNER_AVAILABLE = True +except ImportError: # pre-#17 lnbits — Nostr layer soft-disables + _SIGNER_AVAILABLE = False + + +async def _operator_signer(): + """Resolve the operator account + its signer, or (None, None) if the + Nostr layer is unavailable / not onboarded.""" + if not _SIGNER_AVAILABLE: + return None, None + settings = await crud.get_or_create_settings() + if not settings.operator_id: + logger.warning("chatelet: no operator_id configured; skipping publish") + return None, None + account = await get_account(settings.operator_id) + if not account or not account.pubkey: + return None, None + return account, resolve_signer(account) + + +async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None: + """Sign `unsigned` as the operator, NIP-44-encrypting `content` to + `encrypt_to` first if requested, and publish to configured relays. + Returns the event id, or None if the Nostr layer is off. + + TODO(relay): wire actual relay publish. Options, cheapest first: + 1. reuse the `nostrclient` extension's relay manager if installed; + 2. the core nostr_transport relay pool (lnbits#4); + 3. a direct websockets fan-out like lnurlp/tasks.py send_to_relay. + """ + _account, signer = await _operator_signer() + if not signer: + return None + + plaintext = unsigned.pop("_plaintext", None) + if encrypt_to and plaintext is not None: + unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"]) + + signed = await signer.sign_event(unsigned) # adds pubkey/created_at/id/sig + # TODO(relay): await relay_pool.publish(signed, settings.relays) + logger.debug(f"chatelet: (would) publish kind={signed['kind']} id={signed.get('id')}") + return signed.get("id") + + +# --- outbound: operator-published events ----------------------------------- + + +async def publish_listing(room: Room) -> str | None: + """Publish/refresh a room's NIP-99 listing. Returns event id to persist + on room.listing_event_id.""" + return await _sign_and_publish(events.build_listing_event(room)) + + +async def publish_reservation(booking: Booking) -> str | None: + """Publish the guest's encrypted kind:30078 reservation object. Called + on every status transition so the guest's durable copy stays current.""" + unsigned = events.build_reservation_event(booking, booking.guest_pubkey) + return await _sign_and_publish(unsigned, encrypt_to=booking.guest_pubkey) + + +async def publish_block_calendar(room: Room, start: str, end: str, block_id: str) -> str | None: + settings = await crud.get_or_create_settings() + if not settings.publish_availability: + return None + return await _sign_and_publish( + events.build_block_calendar_event(room, start, end, block_id) + ) + + +async def respond_availability(result: AvailabilityResult, requester_pubkey: str) -> str | None: + unsigned = events.build_availability_response(result, requester_pubkey) + return await _sign_and_publish(unsigned, encrypt_to=requester_pubkey) + + +# --- inbound: guest-originated events --------------------------------------- + + +async def subscribe_inbound() -> None: + """Long-running subscription for guest events on configured relays: + + * kind:22000 availability query -> compute + respond_availability() + * NIP-59 giftwrap booking request -> hold booking, DM back a quote + * NIP-59 giftwrap booking confirm -> (payment drives confirm; see tasks) + + SKETCH — no relay connection yet. Started as a permanent task from + __init__.py:chatelet_start() once relay plumbing lands. + + TODO(relay): open subscription, NIP-44-decrypt via the operator signer, + dispatch to handlers that reuse the same crud paths as the REST API so + HTTP and Nostr entry points converge on one booking flow. + """ + logger.info("chatelet: inbound Nostr subscription not yet wired (sketch)")