chatelet/nostr/service.py
Padreug 9ab52f2c69 feat: per-operator settings — house rules + card acceptance (multi-tenant)
Chatelet is multi-tenant: any LNbits user can host rooms. What an operator
decides for all their rooms now lives in chatelet.operator_settings, keyed
by user id and created lazily (m003, which also indexes bookings by guest):
check-in/out times, cancellation policy, and accept_fiat.

Guests see it: the public room view (both doors) gains house_rules and
payment_methods, and the kind:30402 listing carries payment_methods,
checkin_time and checkout_time tags so a generic Nostr client can render
the right pay buttons and rules without our RPC. The check-in DM reads the
room owner's rules instead of the instance row.

Card is offered only when the operator opted in, the room is fiat-priced,
and LNbits core has a fiat provider for that user — resolved through
settings.get_fiat_providers_for_user(owner), the one seam lnbits#67's
per-user Stripe credentials will plug into; chatelet never sees creds.

Operator endpoints: GET/PUT /api/v1/operator (admin key → wallet user) and
RPC twins chatelet_operator_get/update (AUTH_WALLET); saving re-publishes
the owner's active listings. Admin UI moves the house-rule inputs into a
per-operator card with the card toggle and a provider hint. The old
house-rule columns on settings stay for old rows but are no longer read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:16:14 +02:00

269 lines
10 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), carrying
the owner's rails + house rules so the event matches the API view."""
owner = await services.room_owner_id(room)
ops = await crud.get_or_create_operator_settings(owner)
return await _sign_and_publish(
events.build_listing_event(
room,
payment_methods=services.payment_methods_for_room(room, owner, ops),
house_rules=services.house_rules_dict(ops),
)
)
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)