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
260 lines
9.6 KiB
Python
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)
|