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
This commit is contained in:
Padreug 2026-07-19 20:34:12 +02:00
commit b6ca1b0e02
3 changed files with 166 additions and 3 deletions

View file

@ -23,7 +23,7 @@ from loguru import logger
from .. import crud, services
from ..models import Booking, Room
from . import events
from . import events, giftwrap
from .kinds import KIND_AVAILABILITY_QUERY
try:
@ -104,7 +104,12 @@ async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) ->
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:
@ -120,6 +125,40 @@ async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) ->
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))