chatelet/nostr/events.py
Padreug cf9f8699da feat: relay publish + availability subscription via nostrclient (#2)
Wire the Nostr layer through the nostrclient extension's relay manager
in-process (spirekeeper pattern), replacing the sketch stubs:

- _sign_and_publish: sign as operator (resolve_signer), optional NIP-44
  encrypt, publish via nostr_client.relay_manager.publish_message. Soft-
  fails (logs, returns None) if no operator onboarded, nostrclient absent,
  or signer can't encrypt — never crashes the booking flow.
- publish_listing (30402) + publish_block_calendar (31923): public, work
  today (sign_event only).
- publish_reservation (30078): NIP-44 encrypted to guest; soft-fails on a
  LocalSigner until a bunker/server-signing signer lands (lnbits#18).
- subscribe_inbound: permanent task answering kind:22000 availability
  queries with kind:22001 (plaintext — availability is public info), the
  client-agnostic availability path parallel to the RPC.

events.py: drop the _plaintext scaffolding (service.py encrypts content in
place) and a broken {operator_pubkey} calendar tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 18:09:35 +02:00

122 lines
4.2 KiB
Python

"""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,
# plaintext JSON; service.py NIP-44-encrypts this to the guest before
# signing (publish_reservation passes encrypt_to=guest_pubkey).
"content": json.dumps(payload),
"tags": [
["d", booking.id],
["p", guest_pubkey],
],
}
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],
],
}
def build_availability_response(
result: AvailabilityResult, requester_pubkey: str
) -> dict:
"""Aiolabs kind:22001 (ephemeral) reply to an availability query.
Plaintext by design: room availability for a date range is public
information (the same facts published in the NIP-52 calendar), so this
needs no encryption — which also means it works today without a bunker/
server-signing signer (only sign_event is required, not nip44_encrypt).
p-tagged to the requester so their client can match the reply."""
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]],
}