chatelet/tasks.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

68 lines
2.6 KiB
Python

"""Background tasks: settle paid bookings, expire stale holds.
Payment is what promotes a booking from `held`/`awaiting_payment` to
`confirmed` — not any Nostr event. We listen on the LNbits invoice
dispatcher (same pattern as lnurlp/spirekeeper), and separately sweep
expired holds so abandoned requests free their dates.
"""
import asyncio
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from . import crud
from .models import BookingStatus
from .nostr import service as nostr
async def wait_for_paid_invoices():
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "ext_chatelet")
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment):
if not payment.extra or payment.extra.get("tag") != "chatelet":
return
booking = await crud.get_booking_by_payment_hash(payment.payment_hash)
if not booking:
logger.warning(f"chatelet: paid invoice with no matching booking: {payment.payment_hash}")
return
if booking.status not in (BookingStatus.held, BookingStatus.awaiting_payment):
return # already settled / cancelled — ignore duplicate settle
# Payment confirms the booking: hard-block the dates, clear the hold,
# publish the updated reservation object + send check-in details.
booking.status = BookingStatus.confirmed
booking.expires_at = None
await crud.update_booking(booking)
booking.reservation_event_id = await nostr.publish_reservation(booking)
await crud.update_booking(booking)
# Send the guest their private check-in details (NIP-17 gift-wrapped DM).
# Best-effort: a publish failure must not undo a confirmed, paid booking.
try:
room = await crud.get_room(booking.room_id)
settings = await crud.get_or_create_settings()
if room:
await nostr.send_checkin_dm(booking, room, settings)
except Exception as exc: # noqa: BLE001
logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}")
logger.info(f"chatelet: booking {booking.id} confirmed")
async def expire_holds_loop():
"""Sweep lapsed holds every minute so abandoned requests free dates."""
while True:
try:
expired = await crud.expire_stale_holds()
for b in expired:
logger.debug(f"chatelet: hold {b.id} expired, dates freed")
except Exception as exc: # noqa: BLE001
logger.error(f"chatelet: hold-expiry sweep failed: {exc}")
await asyncio.sleep(60)