feat: nostr event layer (builders + sign/publish service)

Implements ADR-0001. kinds.py pins the allocations; events.py has pure
builders (30402 listing, 30078 encrypted reservation, 31923 blocked-range
calendar, 22001 availability response); service.py signs/encrypts via
resolve_signer (spirekeeper hybrid pattern — no nsec at rest) and sketches
publish + inbound subscription. Relay plumbing marked TODO(relay).

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 00:16:39 +02:00
commit 900b682986
4 changed files with 264 additions and 0 deletions

114
nostr/service.py Normal file
View file

@ -0,0 +1,114 @@
"""Nostr I/O for Chatelet: sign+publish outbound events, subscribe to
inbound guest requests. SKETCH the relay plumbing is stubbed; the shape
and the signer/encryption boundaries are what matter here.
Signing + NIP-44 go through lnbits.core.signers.resolve_signer, following
the spirekeeper hybrid pattern (aiolabs/spirekeeper nostr_publish.py):
resolve the operator account's signer, then sign_event / nip44_encrypt.
No nsec is ever read here works with LocalSigner now, NIP-46 bunker
after lnbits#18. If the signer module isn't present (pre-#17 lnbits), the
whole Nostr layer soft-disables and Chatelet still works over REST.
"""
from loguru import logger
from .. import crud
from ..models import AvailabilityResult, Booking, Room
from . import events
try:
from lnbits.core.crud import get_account
from lnbits.core.signers import resolve_signer
_SIGNER_AVAILABLE = True
except ImportError: # pre-#17 lnbits — Nostr layer soft-disables
_SIGNER_AVAILABLE = False
async def _operator_signer():
"""Resolve the operator account + its signer, or (None, None) if the
Nostr layer is unavailable / not onboarded."""
if not _SIGNER_AVAILABLE:
return None, None
settings = await crud.get_or_create_settings()
if not settings.operator_id:
logger.warning("chatelet: no operator_id configured; skipping publish")
return None, None
account = await get_account(settings.operator_id)
if not account or not account.pubkey:
return None, None
return account, resolve_signer(account)
async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None:
"""Sign `unsigned` as the operator, NIP-44-encrypting `content` to
`encrypt_to` first if requested, and publish to configured relays.
Returns the event id, or None if the Nostr layer is off.
TODO(relay): wire actual relay publish. Options, cheapest first:
1. reuse the `nostrclient` extension's relay manager if installed;
2. the core nostr_transport relay pool (lnbits#4);
3. a direct websockets fan-out like lnurlp/tasks.py send_to_relay.
"""
_account, signer = await _operator_signer()
if not signer:
return None
plaintext = unsigned.pop("_plaintext", None)
if encrypt_to and plaintext is not None:
unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"])
signed = await signer.sign_event(unsigned) # adds pubkey/created_at/id/sig
# TODO(relay): await relay_pool.publish(signed, settings.relays)
logger.debug(f"chatelet: (would) publish kind={signed['kind']} id={signed.get('id')}")
return signed.get("id")
# --- outbound: operator-published events -----------------------------------
async def publish_listing(room: Room) -> str | None:
"""Publish/refresh a room's NIP-99 listing. Returns event id to persist
on room.listing_event_id."""
return await _sign_and_publish(events.build_listing_event(room))
async def publish_reservation(booking: Booking) -> str | None:
"""Publish the guest's encrypted kind:30078 reservation object. Called
on every status transition so the guest's durable copy stays current."""
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:
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: AvailabilityResult, requester_pubkey: str) -> str | None:
unsigned = events.build_availability_response(result, requester_pubkey)
return await _sign_and_publish(unsigned, encrypt_to=requester_pubkey)
# --- inbound: guest-originated events ---------------------------------------
async def subscribe_inbound() -> None:
"""Long-running subscription for guest events on configured relays:
* kind:22000 availability query -> compute + respond_availability()
* NIP-59 giftwrap booking request -> hold booking, DM back a quote
* NIP-59 giftwrap booking confirm -> (payment drives confirm; see tasks)
SKETCH no relay connection yet. Started as a permanent task from
__init__.py:chatelet_start() once relay plumbing lands.
TODO(relay): open subscription, NIP-44-decrypt via the operator signer,
dispatch to handlers that reuse the same crud paths as the REST API so
HTTP and Nostr entry points converge on one booking flow.
"""
logger.info("chatelet: inbound Nostr subscription not yet wired (sketch)")