feat: Nostr relay publish + availability subscription via nostrclient (#2) #11
6 changed files with 311 additions and 66 deletions
15
README.md
15
README.md
|
|
@ -4,9 +4,18 @@ Nostr-native room rentals for LNbits — an "Airbnb for the castle". List
|
||||||
rooms, take booking requests, arbitrate availability, and settle stays over
|
rooms, take booking requests, arbitrate availability, and settle stays over
|
||||||
Lightning, with Nostr as the interop layer.
|
Lightning, with Nostr as the interop layer.
|
||||||
|
|
||||||
> **Status:** design sketch + scaffold. Data model, migrations, availability
|
> **Status:** functional prototype. Data model, migrations, availability
|
||||||
> arbiter, REST surface, and Nostr event model are in place; relay plumbing,
|
> arbiter (atomic hold), FX + invoice + settlement, the REST + kind-21000 RPC
|
||||||
> FX/invoice wiring, and the guest UI are stubbed with `TODO(...)` markers.
|
> doors, and the nostrclient relay layer (publish + availability queries) are
|
||||||
|
> in place and tested. The guest UI and encrypted-event delivery pre-bunker
|
||||||
|
> are the remaining gaps.
|
||||||
|
>
|
||||||
|
> **Soft dependency:** publishing/subscribing to relays uses the **nostrclient**
|
||||||
|
> extension (imported in-process). Install + activate it to reach relays; if
|
||||||
|
> absent, the booking flow still works over HTTP/RPC and relay publish is
|
||||||
|
> skipped with a logged warning. Encrypted events (reservation `30078`, check-in
|
||||||
|
> DMs) additionally need a bunker/server-signing signer (lnbits #18); on a
|
||||||
|
> LocalSigner they soft-fail until then.
|
||||||
|
|
||||||
## Why not just reuse an existing NIP?
|
## Why not just reuse an existing NIP?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,33 @@ wallet, so the guest confirms by polling `chatelet_booking_get` until
|
||||||
| `1059` | 17/59 | both | Private booking DMs (request → quote → confirm → check-in) |
|
| `1059` | 17/59 | both | Private booking DMs (request → quote → confirm → check-in) |
|
||||||
| `22000/22001` | aiolabs | guest/operator | Live availability query + response (ephemeral) |
|
| `22000/22001` | aiolabs | guest/operator | Live availability query + response (ephemeral) |
|
||||||
|
|
||||||
|
## Relay transport — nostrclient (in-process)
|
||||||
|
|
||||||
|
Publishing app/discovery events and subscribing to inbound queries go through
|
||||||
|
the **`nostrclient` extension's** relay manager, imported in-process
|
||||||
|
(`nostr_client.relay_manager.publish_message(...)` / `.add_subscription(...)`),
|
||||||
|
the same pattern `spirekeeper`/`nostrmarket` use. This is separate from the
|
||||||
|
core kind-21000 RPC transport (Door 2): the core pool is RPC-only and can't
|
||||||
|
publish arbitrary kinds, so app events ride nostrclient. Adds a **soft runtime
|
||||||
|
dependency** on nostrclient — if it isn't installed, publish/subscribe skip
|
||||||
|
with a logged warning and the booking flow (HTTP/RPC) is unaffected.
|
||||||
|
|
||||||
|
`nostr/service.py` implements it:
|
||||||
|
|
||||||
|
- **Publish** (`_sign_and_publish`): sign as the operator (`resolve_signer`),
|
||||||
|
optionally NIP-44-encrypt, then `publish_message`.
|
||||||
|
- **Subscribe** (`subscribe_inbound`, a permanent task): register a
|
||||||
|
`kind:22000` subscription, poll `NostrRouter.received_subscription_events`,
|
||||||
|
answer each with a `kind:22001` reply.
|
||||||
|
|
||||||
|
**Public vs encrypted — what works pre-bunker:** a `LocalSigner` can
|
||||||
|
`sign_event` but its `nip44_encrypt` raises (bunker-forward by design, lnbits
|
||||||
|
#18). So **public** events (listing `30402`, calendar `31923`, availability
|
||||||
|
`22001` — availability is public info, so `22000/22001` are plaintext) publish
|
||||||
|
today; **encrypted** events (reservation `30078`, and the `#5` check-in DM)
|
||||||
|
sign-encrypt via the operator signer and soft-fail with a clear log until the
|
||||||
|
operator has a bunker/server-signing signer. Nothing crashes either way.
|
||||||
|
|
||||||
## Happy path
|
## Happy path
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
90
tests/test_events.py
Normal file
90
tests/test_events.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Nostr event builders — tag/kind/content shape (no signing, no relays)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..models import AvailabilityResult, Booking, BookingStatus
|
||||||
|
from ..nostr import events
|
||||||
|
from ..nostr.kinds import (
|
||||||
|
KIND_AVAILABILITY_RESPONSE,
|
||||||
|
KIND_CALENDAR_EVENT,
|
||||||
|
KIND_LISTING,
|
||||||
|
KIND_RESERVATION,
|
||||||
|
)
|
||||||
|
from .conftest import make_room
|
||||||
|
|
||||||
|
|
||||||
|
def _tag(ev, name):
|
||||||
|
"""First tag value for `name`, or None."""
|
||||||
|
return next((t[1] for t in ev["tags"] if t[0] == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _tags(ev, name):
|
||||||
|
return [t[1] for t in ev["tags"] if t[0] == name]
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_event_shape():
|
||||||
|
room = make_room(
|
||||||
|
currency="EUR", price=80.0,
|
||||||
|
)
|
||||||
|
room.amenities = ["wifi", "breakfast"]
|
||||||
|
room.location = "Château"
|
||||||
|
room.geohash = "u0j2"
|
||||||
|
room.images = ["https://img/1.jpg"]
|
||||||
|
|
||||||
|
ev = events.build_listing_event(room)
|
||||||
|
|
||||||
|
assert ev["kind"] == KIND_LISTING # 30402
|
||||||
|
assert _tag(ev, "d") == room.id # addressable: re-publish replaces
|
||||||
|
assert _tag(ev, "title") == "Tower Room"
|
||||||
|
price = next(t for t in ev["tags"] if t[0] == "price")
|
||||||
|
assert price == ["price", "80.0", "EUR", "night"]
|
||||||
|
assert _tag(ev, "status") == "active"
|
||||||
|
assert _tag(ev, "location") == "Château"
|
||||||
|
assert _tag(ev, "g") == "u0j2"
|
||||||
|
assert _tags(ev, "image") == ["https://img/1.jpg"]
|
||||||
|
assert set(_tags(ev, "t")) == {"wifi", "breakfast"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reservation_event_is_plaintext_json_no_scaffolding():
|
||||||
|
booking = Booking(
|
||||||
|
id="bk1", room_id="room1", guest_pubkey="npub_guest",
|
||||||
|
check_in="2026-08-01", check_out="2026-08-04", nights=3, num_guests=2,
|
||||||
|
currency="sat", price_fiat=300.0, amount_sat=300, deposit_sat=300,
|
||||||
|
status=BookingStatus.confirmed,
|
||||||
|
)
|
||||||
|
ev = events.build_reservation_event(booking, booking.guest_pubkey)
|
||||||
|
|
||||||
|
assert ev["kind"] == KIND_RESERVATION # 30078
|
||||||
|
assert _tag(ev, "d") == "bk1"
|
||||||
|
assert _tag(ev, "p") == "npub_guest"
|
||||||
|
assert "_plaintext" not in ev # scaffolding removed; service.py encrypts content
|
||||||
|
payload = json.loads(ev["content"])
|
||||||
|
assert payload["amount_sat"] == 300 # canonical, carried verbatim
|
||||||
|
assert payload["status"] == "confirmed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_calendar_event_shape():
|
||||||
|
room = make_room()
|
||||||
|
ev = events.build_block_calendar_event(room, "2026-09-01", "2026-09-05", "blk1")
|
||||||
|
|
||||||
|
assert ev["kind"] == KIND_CALENDAR_EVENT # 31923
|
||||||
|
assert _tag(ev, "d") == "room1:blk1"
|
||||||
|
assert _tag(ev, "start") == "2026-09-01"
|
||||||
|
assert _tag(ev, "end") == "2026-09-05"
|
||||||
|
# the broken {operator_pubkey} 'a' tag was removed
|
||||||
|
assert not any(t[0] == "a" for t in ev["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_availability_response_is_plaintext():
|
||||||
|
result = AvailabilityResult(
|
||||||
|
room_id="room1", check_in="2026-08-01", check_out="2026-08-04",
|
||||||
|
available=True, nights=3, quote_sat=300, quote_fiat=3.0, currency="sat",
|
||||||
|
)
|
||||||
|
ev = events.build_availability_response(result, "npub_requester")
|
||||||
|
|
||||||
|
assert ev["kind"] == KIND_AVAILABILITY_RESPONSE # 22001
|
||||||
|
assert _tag(ev, "p") == "npub_requester"
|
||||||
|
assert "_plaintext" not in ev # public info: no encryption
|
||||||
|
payload = json.loads(ev["content"])
|
||||||
|
assert payload["available"] is True
|
||||||
|
assert payload["quote_sat"] == 300
|
||||||
Loading…
Add table
Add a link
Reference in a new issue