feat: relay publish + availability subscription via nostrclient (#2)
Wire the Nostr layer through the nostrclient extension's relay manager
in-process (spirekeeper pattern), replacing the sketch stubs:
- _sign_and_publish: sign as operator (resolve_signer), optional NIP-44
encrypt, publish via nostr_client.relay_manager.publish_message. Soft-
fails (logs, returns None) if no operator onboarded, nostrclient absent,
or signer can't encrypt — never crashes the booking flow.
- publish_listing (30402) + publish_block_calendar (31923): public, work
today (sign_event only).
- publish_reservation (30078): NIP-44 encrypted to guest; soft-fails on a
LocalSigner until a bunker/server-signing signer lands (lnbits#18).
- subscribe_inbound: permanent task answering kind:22000 availability
queries with kind:22001 (plaintext — availability is public info), the
client-agnostic availability path parallel to the RPC.
events.py: drop the _plaintext scaffolding (service.py encrypts content in
place) and a broken {operator_pubkey} calendar tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
parent
7d704a8549
commit
cf9f8699da
3 changed files with 182 additions and 63 deletions
|
|
@ -36,6 +36,15 @@ def chatelet_start():
|
||||||
create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop)
|
create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Inbound Nostr: answer kind:22000 availability queries over relays via
|
||||||
|
# nostrclient (client-agnostic availability path). Self-backs-off if
|
||||||
|
# nostrclient isn't installed.
|
||||||
|
from .nostr.service import subscribe_inbound
|
||||||
|
|
||||||
|
scheduled_tasks.append(
|
||||||
|
create_permanent_unique_task("chatelet_availability_sub", subscribe_inbound)
|
||||||
|
)
|
||||||
|
|
||||||
# Expose the booking flow over the core LNbits nostr transport (kind-21000
|
# Expose the booking flow over the core LNbits nostr transport (kind-21000
|
||||||
# RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire
|
# RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire
|
||||||
# the booking-owner resolver so the operator can stream booking
|
# the booking-owner resolver so the operator can stream booking
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,13 @@ def build_reservation_event(booking: Booking, guest_pubkey: str) -> dict:
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"kind": KIND_RESERVATION,
|
"kind": KIND_RESERVATION,
|
||||||
# placeholder — service.py replaces with NIP-44(payload) for guest
|
# plaintext JSON; service.py NIP-44-encrypts this to the guest before
|
||||||
|
# signing (publish_reservation passes encrypt_to=guest_pubkey).
|
||||||
"content": json.dumps(payload),
|
"content": json.dumps(payload),
|
||||||
"tags": [
|
"tags": [
|
||||||
["d", booking.id],
|
["d", booking.id],
|
||||||
["p", guest_pubkey],
|
["p", guest_pubkey],
|
||||||
],
|
],
|
||||||
"_plaintext": payload, # consumed + stripped by service.py before signing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,7 +91,6 @@ def build_block_calendar_event(
|
||||||
["title", f"{room.title} — unavailable"],
|
["title", f"{room.title} — unavailable"],
|
||||||
["start", start_date],
|
["start", start_date],
|
||||||
["end", end_date],
|
["end", end_date],
|
||||||
["a", f"{KIND_LISTING}:{{operator_pubkey}}:{room.id}"], # link to listing
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,8 +98,13 @@ def build_block_calendar_event(
|
||||||
def build_availability_response(
|
def build_availability_response(
|
||||||
result: AvailabilityResult, requester_pubkey: str
|
result: AvailabilityResult, requester_pubkey: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Aiolabs kind:22001 (ephemeral) reply to an availability query. Content
|
"""Aiolabs kind:22001 (ephemeral) reply to an availability query.
|
||||||
MUST be NIP-44 encrypted to the requester by service.py."""
|
|
||||||
|
Plaintext by design: room availability for a date range is public
|
||||||
|
information (the same facts published in the NIP-52 calendar), so this
|
||||||
|
needs no encryption — which also means it works today without a bunker/
|
||||||
|
server-signing signer (only sign_event is required, not nip44_encrypt).
|
||||||
|
p-tagged to the requester so their client can match the reply."""
|
||||||
payload = {
|
payload = {
|
||||||
"room_id": result.room_id,
|
"room_id": result.room_id,
|
||||||
"check_in": result.check_in,
|
"check_in": result.check_in,
|
||||||
|
|
@ -115,5 +119,4 @@ def build_availability_response(
|
||||||
"kind": KIND_AVAILABILITY_RESPONSE,
|
"kind": KIND_AVAILABILITY_RESPONSE,
|
||||||
"content": json.dumps(payload),
|
"content": json.dumps(payload),
|
||||||
"tags": [["p", requester_pubkey]],
|
"tags": [["p", requester_pubkey]],
|
||||||
"_plaintext": payload,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
213
nostr/service.py
213
nostr/service.py
|
|
@ -1,86 +1,141 @@
|
||||||
"""Nostr I/O for Chatelet: sign+publish outbound events, subscribe to
|
"""Nostr I/O for Chatelet: sign + publish outbound events, and subscribe to
|
||||||
inbound guest requests. SKETCH — the relay plumbing is stubbed; the shape
|
inbound availability queries — over the `nostrclient` extension's relay
|
||||||
and the signer/encryption boundaries are what matter here.
|
manager, in-process (the spirekeeper pattern, aiolabs/spirekeeper
|
||||||
|
nostr_publish.py + tasks.py).
|
||||||
|
|
||||||
Signing + NIP-44 go through lnbits.core.signers.resolve_signer, following
|
Signing + NIP-44 go through `lnbits.core.signers.resolve_signer`: the
|
||||||
the spirekeeper hybrid pattern (aiolabs/spirekeeper nostr_publish.py):
|
operator account's signer produces the signature and (for encrypted events)
|
||||||
resolve the operator account's signer, then sign_event / nip44_encrypt.
|
the NIP-44 ciphertext. No nsec is read here — works with a server-signing /
|
||||||
No nsec is ever read here — works with LocalSigner now, NIP-46 bunker
|
NIP-46 bunker signer. A LocalSigner can `sign_event` but its `nip44_encrypt`
|
||||||
after lnbits#18. If the signer module isn't present (pre-#17 lnbits), the
|
raises (bunker-forward by design), so **public** events (listing 30402,
|
||||||
whole Nostr layer soft-disables and Chatelet still works over REST.
|
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 loguru import logger
|
||||||
|
|
||||||
from .. import crud
|
from .. import crud, services
|
||||||
from ..models import AvailabilityResult, Booking, Room
|
from ..models import Booking, Room
|
||||||
from . import events
|
from . import events
|
||||||
|
from .kinds import KIND_AVAILABILITY_QUERY
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from lnbits.core.crud import get_account
|
from lnbits.core.crud import get_account
|
||||||
from lnbits.core.signers import resolve_signer
|
from lnbits.core.signers import resolve_signer
|
||||||
|
from lnbits.core.signers.base import SignerUnavailableError
|
||||||
|
|
||||||
_SIGNER_AVAILABLE = True
|
_SIGNER_AVAILABLE = True
|
||||||
except ImportError: # pre-#17 lnbits — Nostr layer soft-disables
|
except ImportError: # pre-signer lnbits — Nostr layer soft-disables
|
||||||
_SIGNER_AVAILABLE = False
|
_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():
|
async def _operator_signer():
|
||||||
"""Resolve the operator account + its signer, or (None, None) if the
|
"""(account, signer) for the configured operator, or (None, None) if the
|
||||||
Nostr layer is unavailable / not onboarded."""
|
Nostr layer is unavailable / no operator onboarded."""
|
||||||
if not _SIGNER_AVAILABLE:
|
if not _SIGNER_AVAILABLE:
|
||||||
return None, None
|
return None, None
|
||||||
settings = await crud.get_or_create_settings()
|
settings = await crud.get_or_create_settings()
|
||||||
if not settings.operator_id:
|
if not settings.operator_id:
|
||||||
logger.warning("chatelet: no operator_id configured; skipping publish")
|
logger.debug("chatelet: no operator_id configured; skipping publish")
|
||||||
return None, None
|
return None, None
|
||||||
account = await get_account(settings.operator_id)
|
account = await get_account(settings.operator_id)
|
||||||
if not account or not account.pubkey:
|
if not account or not account.pubkey:
|
||||||
|
logger.warning("chatelet: operator account has no Nostr pubkey; skipping")
|
||||||
return None, None
|
return None, None
|
||||||
return account, resolve_signer(account)
|
return account, resolve_signer(account)
|
||||||
|
|
||||||
|
|
||||||
async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None:
|
# --- outbound: sign + publish -----------------------------------------------
|
||||||
"""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;
|
async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None:
|
||||||
2. the core nostr_transport relay pool (lnbits#4);
|
"""Encrypt (if `encrypt_to`), sign as the operator, and publish via
|
||||||
3. a direct websockets fan-out like lnurlp/tasks.py send_to_relay.
|
nostrclient. Returns the event id, or None on any soft-fail."""
|
||||||
"""
|
|
||||||
_account, signer = await _operator_signer()
|
_account, signer = await _operator_signer()
|
||||||
if not signer:
|
if not signer:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
plaintext = unsigned.pop("_plaintext", None)
|
if encrypt_to:
|
||||||
if encrypt_to and plaintext is not None:
|
try:
|
||||||
unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"])
|
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
|
||||||
|
|
||||||
signed = await signer.sign_event(unsigned) # adds pubkey/created_at/id/sig
|
unsigned["created_at"] = int(time.time()) # part of the event-id hash
|
||||||
# TODO(relay): await relay_pool.publish(signed, settings.relays)
|
try:
|
||||||
logger.debug(f"chatelet: (would) publish kind={signed['kind']} id={signed.get('id')}")
|
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
|
||||||
|
|
||||||
|
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")
|
return signed.get("id")
|
||||||
|
|
||||||
|
|
||||||
# --- outbound: operator-published events -----------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def publish_listing(room: Room) -> str | None:
|
async def publish_listing(room: Room) -> str | None:
|
||||||
"""Publish/refresh a room's NIP-99 listing. Returns event id to persist
|
"""Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
|
||||||
on room.listing_event_id."""
|
|
||||||
return await _sign_and_publish(events.build_listing_event(room))
|
return await _sign_and_publish(events.build_listing_event(room))
|
||||||
|
|
||||||
|
|
||||||
async def publish_reservation(booking: Booking) -> str | None:
|
async def publish_reservation(booking: Booking) -> str | None:
|
||||||
"""Publish the guest's encrypted kind:30078 reservation object. Called
|
"""Publish the guest's kind:30078 reservation object, NIP-44 encrypted to
|
||||||
on every status transition so the guest's durable copy stays current."""
|
them. Soft-fails on a LocalSigner (no server-side encrypt) until bunker."""
|
||||||
unsigned = events.build_reservation_event(booking, booking.guest_pubkey)
|
unsigned = events.build_reservation_event(booking, booking.guest_pubkey)
|
||||||
return await _sign_and_publish(unsigned, encrypt_to=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:
|
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()
|
settings = await crud.get_or_create_settings()
|
||||||
if not settings.publish_availability:
|
if not settings.publish_availability:
|
||||||
return None
|
return None
|
||||||
|
|
@ -89,26 +144,78 @@ async def publish_block_calendar(room: Room, start: str, end: str, block_id: str
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def respond_availability(result: AvailabilityResult, requester_pubkey: str) -> str | None:
|
async def respond_availability(result, requester_pubkey: str) -> str | None:
|
||||||
unsigned = events.build_availability_response(result, requester_pubkey)
|
"""Publish a kind:22001 availability response (plaintext, public info)."""
|
||||||
return await _sign_and_publish(unsigned, encrypt_to=requester_pubkey)
|
return await _sign_and_publish(
|
||||||
|
events.build_availability_response(result, requester_pubkey)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- inbound: guest-originated events ---------------------------------------
|
# --- inbound: availability queries (kind:22000) -----------------------------
|
||||||
|
|
||||||
|
|
||||||
async def subscribe_inbound() -> None:
|
async def subscribe_inbound() -> None:
|
||||||
"""Long-running subscription for guest events on configured relays:
|
"""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)
|
||||||
|
|
||||||
* 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
|
async def _availability_tick(registered: bool) -> bool:
|
||||||
__init__.py:chatelet_start() once relay plumbing lands.
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
TODO(relay): open subscription, NIP-44-decrypt via the operator signer,
|
inbound = NostrRouter.received_subscription_events.get(_AVAILABILITY_SUB_ID)
|
||||||
dispatch to handlers that reuse the same crud paths as the REST API so
|
while inbound:
|
||||||
HTTP and Nostr entry points converge on one booking flow.
|
event_message = inbound.pop(0)
|
||||||
"""
|
try:
|
||||||
logger.info("chatelet: inbound Nostr subscription not yet wired (sketch)")
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue