chatelet/nostr/service.py
Padreug b6ca1b0e02 feat: NIP-17 gift-wrapped check-in DM on confirmation (#5)
On settlement, send the guest their private check-in details as a NIP-59
gift-wrapped DM (nostr/giftwrap.py, built from lnbits core primitives — no
vendored crypto):

- rumor (kind 14) -> seal (kind 13, operator-encrypted + operator-signed via
  the signer abstraction) -> gift wrap (kind 1059, ephemeral-key encrypted +
  signed locally via core nip44_encrypt + sign_event). created_at randomised
  into the past per NIP-59.
- service.send_checkin_dm builds the message (room.checkin_instructions +
  settings times/policy) and publishes via nostrclient (_publish_signed,
  extracted from _sign_and_publish).
- tasks.on_invoice_paid calls it best-effort — a DM failure never undoes a
  confirmed, paid booking.

Encrypted layer (seal) soft-fails on a LocalSigner until bunker/server-
signing (lnbits#18), same as the reservation event; the ephemeral wrap layer
always works.

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

260 lines
9.6 KiB
Python

"""Nostr I/O for Chatelet: sign + publish outbound events, and subscribe to
inbound availability queries — over the `nostrclient` extension's relay
manager, in-process (the spirekeeper pattern, aiolabs/spirekeeper
nostr_publish.py + tasks.py).
Signing + NIP-44 go through `lnbits.core.signers.resolve_signer`: the
operator account's signer produces the signature and (for encrypted events)
the NIP-44 ciphertext. No nsec is read here — works with a server-signing /
NIP-46 bunker signer. A LocalSigner can `sign_event` but its `nip44_encrypt`
raises (bunker-forward by design), so **public** events (listing 30402,
calendar 31923, availability 22001) publish today, while **encrypted** events
(reservation 30078) soft-fail with a clear log until a bunker lands (lnbits
#18). Everything degrades gracefully: no operator onboarded, nostrclient not
installed, or signer can't encrypt → publish is skipped, never crashes the
booking flow (which stands on its own over HTTP/RPC).
"""
import asyncio
import json
import time
from loguru import logger
from .. import crud, services
from ..models import Booking, Room
from . import events, giftwrap
from .kinds import KIND_AVAILABILITY_QUERY
try:
from lnbits.core.crud import get_account
from lnbits.core.signers import resolve_signer
from lnbits.core.signers.base import SignerUnavailableError
_SIGNER_AVAILABLE = True
except ImportError: # pre-signer lnbits — Nostr layer soft-disables
_SIGNER_AVAILABLE = False
_AVAILABILITY_SUB_ID = "chatelet_availability_query"
_POLL_INTERVAL_S = 1.0
_BACKOFF_S = 30
class _NostrclientUnavailable(Exception):
"""nostrclient extension not importable; caller backs off and retries."""
def _nostrclient():
try:
from nostrclient.router import ( # type: ignore[import-not-found]
NostrRouter,
nostr_client,
)
except ImportError as exc:
raise _NostrclientUnavailable() from exc
return NostrRouter, nostr_client
# --- signer -----------------------------------------------------------------
async def _operator_signer():
"""(account, signer) for the configured operator, or (None, None) if the
Nostr layer is unavailable / no operator onboarded."""
if not _SIGNER_AVAILABLE:
return None, None
settings = await crud.get_or_create_settings()
if not settings.operator_id:
logger.debug("chatelet: no operator_id configured; skipping publish")
return None, None
account = await get_account(settings.operator_id)
if not account or not account.pubkey:
logger.warning("chatelet: operator account has no Nostr pubkey; skipping")
return None, None
return account, resolve_signer(account)
# --- outbound: sign + publish -----------------------------------------------
async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None:
"""Encrypt (if `encrypt_to`), sign as the operator, and publish via
nostrclient. Returns the event id, or None on any soft-fail."""
_account, signer = await _operator_signer()
if not signer:
return None
if encrypt_to:
try:
unsigned["content"] = await signer.nip44_encrypt(
unsigned.get("content", ""), encrypt_to
)
except SignerUnavailableError as exc:
logger.warning(
f"chatelet: cannot NIP-44 encrypt kind={unsigned.get('kind')} "
f"(needs bunker/server-signing signer, lnbits#18): {exc}"
)
return None
unsigned["created_at"] = int(time.time()) # part of the event-id hash
try:
signed = await signer.sign_event(unsigned)
except SignerUnavailableError as exc:
logger.warning(f"chatelet: signer cannot sign kind={unsigned.get('kind')}: {exc}")
return None
if not signed:
return None
return _publish_signed(signed)
def _publish_signed(signed: dict) -> str | None:
"""Publish an already-signed event via nostrclient. Returns the event id,
or None if nostrclient isn't installed."""
try:
_, nostr_client = _nostrclient()
except _NostrclientUnavailable:
logger.warning(
"chatelet: nostrclient extension not installed; publish skipped. "
"Install + activate nostrclient to reach relays."
)
return None
nostr_client.relay_manager.publish_message(json.dumps(["EVENT", signed]))
logger.debug(
f"chatelet: published kind={signed['kind']} id={signed.get('id', '')[:12]}"
)
return signed.get("id")
async def send_checkin_dm(booking, room, settings) -> str | None:
"""On confirmation, send the guest a NIP-17 gift-wrapped DM with the
private check-in details. Encrypted end-to-end to the guest; soft-fails
(returns None) if the operator signer can't encrypt (LocalSigner pre-
bunker) or nostrclient isn't installed."""
account, signer = await _operator_signer()
if not signer:
return None
wrap = await giftwrap.build_dm(
signer=signer,
sender_pubkey=account.pubkey,
recipient_pubkey=booking.guest_pubkey,
content=_checkin_message(booking, room, settings),
)
if not wrap:
return None
return _publish_signed(wrap)
def _checkin_message(booking, room, settings) -> str:
lines = [
f"Your booking at {room.title} is confirmed! 🏰",
"",
f"Check-in: {booking.check_in} from {settings.checkin_time}",
f"Check-out: {booking.check_out} by {settings.checkout_time}",
f"Guests: {booking.num_guests}",
]
if room.checkin_instructions:
lines += ["", room.checkin_instructions]
if settings.cancellation_policy:
lines += ["", f"Cancellation policy: {settings.cancellation_policy}"]
return "\n".join(lines)
async def publish_listing(room: Room) -> str | None:
"""Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
return await _sign_and_publish(events.build_listing_event(room))
async def publish_reservation(booking: Booking) -> str | None:
"""Publish the guest's kind:30078 reservation object, NIP-44 encrypted to
them. Soft-fails on a LocalSigner (no server-side encrypt) until bunker."""
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:
"""Publish a kind:31923 unavailable-range event (public, no PII)."""
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, requester_pubkey: str) -> str | None:
"""Publish a kind:22001 availability response (plaintext, public info)."""
return await _sign_and_publish(
events.build_availability_response(result, requester_pubkey)
)
# --- inbound: availability queries (kind:22000) -----------------------------
async def subscribe_inbound() -> None:
"""Permanent task: subscribe to kind:22000 availability queries over
nostrclient and answer each with a kind:22001 response. This is the
client-agnostic availability path (any Nostr client can ask), parallel
to the chatelet_availability RPC. Booking itself stays on the RPC/HTTP
doors for now (see issue #2 / event-flow.md)."""
registered = False
while True:
try:
registered = await _availability_tick(registered)
await asyncio.sleep(_POLL_INTERVAL_S)
except _NostrclientUnavailable:
logger.warning(
"chatelet: nostrclient not installed; availability subscription "
f"sleeping {_BACKOFF_S}s before retry."
)
registered = False
await asyncio.sleep(_BACKOFF_S)
except Exception as exc: # a listener must never die
logger.error(f"chatelet: availability consumer error (continuing): {exc}")
await asyncio.sleep(_POLL_INTERVAL_S)
async def _availability_tick(registered: bool) -> bool:
NostrRouter, nostr_client = _nostrclient()
if not registered:
nostr_client.relay_manager.add_subscription(
_AVAILABILITY_SUB_ID,
[{"kinds": [KIND_AVAILABILITY_QUERY]}], # type: ignore[list-item]
)
logger.info(
f"chatelet: subscribed to kind:{KIND_AVAILABILITY_QUERY} "
"availability queries"
)
inbound = NostrRouter.received_subscription_events.get(_AVAILABILITY_SUB_ID)
while inbound:
event_message = inbound.pop(0)
try:
await _handle_availability_query(event_message)
except Exception as exc:
logger.warning(f"chatelet: availability query handler failed (skip): {exc}")
return True
async def _handle_availability_query(event_message) -> None:
"""Parse a kind:22000 query, compute availability, publish a 22001 reply
p-tagged to the asker. The event author is the requester (unspoofable —
it's the signed pubkey)."""
event = json.loads(event_message.event)
requester = event.get("pubkey")
body = json.loads(event.get("content") or "{}")
room_id, check_in, check_out = (
body.get("room_id"),
body.get("check_in"),
body.get("check_out"),
)
if not (requester and room_id and check_in and check_out):
return # malformed query — ignore
try:
result = await services.get_availability(room_id, check_in, check_out)
except ValueError:
return # unknown room / bad dates — no reply
await respond_availability(result, requester)