Compare commits
No commits in common. "7b8dd82a6e035e5d1367f1568362c813bca7dd35" and "7d704a8549b4a0a12eafd08a647979e40553a068" have entirely different histories.
7b8dd82a6e
...
7d704a8549
6 changed files with 66 additions and 311 deletions
15
README.md
15
README.md
|
|
@ -4,18 +4,9 @@ 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:** functional prototype. Data model, migrations, availability
|
> **Status:** design sketch + scaffold. Data model, migrations, availability
|
||||||
> arbiter (atomic hold), FX + invoice + settlement, the REST + kind-21000 RPC
|
> arbiter, REST surface, and Nostr event model are in place; relay plumbing,
|
||||||
> doors, and the nostrclient relay layer (publish + availability queries) are
|
> FX/invoice wiring, and the guest UI are stubbed with `TODO(...)` markers.
|
||||||
> 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,15 +36,6 @@ 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,33 +70,6 @@ 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,
|
||||||
# plaintext JSON; service.py NIP-44-encrypts this to the guest before
|
# placeholder — service.py replaces with NIP-44(payload) for guest
|
||||||
# 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,6 +91,7 @@ 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
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,13 +99,8 @@ 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.
|
"""Aiolabs kind:22001 (ephemeral) reply to an availability query. Content
|
||||||
|
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,
|
||||||
|
|
@ -119,4 +115,5 @@ 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,141 +1,86 @@
|
||||||
"""Nostr I/O for Chatelet: sign + publish outbound events, and subscribe to
|
"""Nostr I/O for Chatelet: sign+publish outbound events, subscribe to
|
||||||
inbound availability queries — over the `nostrclient` extension's relay
|
inbound guest requests. SKETCH — the relay plumbing is stubbed; the shape
|
||||||
manager, in-process (the spirekeeper pattern, aiolabs/spirekeeper
|
and the signer/encryption boundaries are what matter here.
|
||||||
nostr_publish.py + tasks.py).
|
|
||||||
|
|
||||||
Signing + NIP-44 go through `lnbits.core.signers.resolve_signer`: the
|
Signing + NIP-44 go through lnbits.core.signers.resolve_signer, following
|
||||||
operator account's signer produces the signature and (for encrypted events)
|
the spirekeeper hybrid pattern (aiolabs/spirekeeper nostr_publish.py):
|
||||||
the NIP-44 ciphertext. No nsec is read here — works with a server-signing /
|
resolve the operator account's signer, then sign_event / nip44_encrypt.
|
||||||
NIP-46 bunker signer. A LocalSigner can `sign_event` but its `nip44_encrypt`
|
No nsec is ever read here — works with LocalSigner now, NIP-46 bunker
|
||||||
raises (bunker-forward by design), so **public** events (listing 30402,
|
after lnbits#18. If the signer module isn't present (pre-#17 lnbits), the
|
||||||
calendar 31923, availability 22001) publish today, while **encrypted** events
|
whole Nostr layer soft-disables and Chatelet still works over REST.
|
||||||
(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, services
|
from .. import crud
|
||||||
from ..models import Booking, Room
|
from ..models import AvailabilityResult, 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-signer lnbits — Nostr layer soft-disables
|
except ImportError: # pre-#17 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():
|
||||||
"""(account, signer) for the configured operator, or (None, None) if the
|
"""Resolve the operator account + its signer, or (None, None) if the
|
||||||
Nostr layer is unavailable / no operator onboarded."""
|
Nostr layer is unavailable / not 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.debug("chatelet: no operator_id configured; skipping publish")
|
logger.warning("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)
|
||||||
|
|
||||||
|
|
||||||
# --- outbound: sign + publish -----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None:
|
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
|
"""Sign `unsigned` as the operator, NIP-44-encrypting `content` to
|
||||||
nostrclient. Returns the event id, or None on any soft-fail."""
|
`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()
|
_account, signer = await _operator_signer()
|
||||||
if not signer:
|
if not signer:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if encrypt_to:
|
plaintext = unsigned.pop("_plaintext", None)
|
||||||
try:
|
if encrypt_to and plaintext is not None:
|
||||||
unsigned["content"] = await signer.nip44_encrypt(
|
unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"])
|
||||||
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
|
signed = await signer.sign_event(unsigned) # adds pubkey/created_at/id/sig
|
||||||
try:
|
# TODO(relay): await relay_pool.publish(signed, settings.relays)
|
||||||
signed = await signer.sign_event(unsigned)
|
logger.debug(f"chatelet: (would) publish kind={signed['kind']} id={signed.get('id')}")
|
||||||
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 kind:30402 listing (public)."""
|
"""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))
|
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 kind:30078 reservation object, NIP-44 encrypted to
|
"""Publish the guest's encrypted kind:30078 reservation object. Called
|
||||||
them. Soft-fails on a LocalSigner (no server-side encrypt) until bunker."""
|
on every status transition so the guest's durable copy stays current."""
|
||||||
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(
|
async def publish_block_calendar(room: Room, start: str, end: str, block_id: str) -> str | None:
|
||||||
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
|
||||||
|
|
@ -144,78 +89,26 @@ async def publish_block_calendar(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def respond_availability(result, requester_pubkey: str) -> str | None:
|
async def respond_availability(result: AvailabilityResult, requester_pubkey: str) -> str | None:
|
||||||
"""Publish a kind:22001 availability response (plaintext, public info)."""
|
unsigned = events.build_availability_response(result, requester_pubkey)
|
||||||
return await _sign_and_publish(
|
return await _sign_and_publish(unsigned, encrypt_to=requester_pubkey)
|
||||||
events.build_availability_response(result, requester_pubkey)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- inbound: availability queries (kind:22000) -----------------------------
|
# --- inbound: guest-originated events ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def subscribe_inbound() -> None:
|
async def subscribe_inbound() -> None:
|
||||||
"""Permanent task: subscribe to kind:22000 availability queries over
|
"""Long-running subscription for guest events on configured relays:
|
||||||
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)
|
||||||
|
|
||||||
async def _availability_tick(registered: bool) -> bool:
|
SKETCH — no relay connection yet. Started as a permanent task from
|
||||||
NostrRouter, nostr_client = _nostrclient()
|
__init__.py:chatelet_start() once relay plumbing lands.
|
||||||
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)
|
TODO(relay): open subscription, NIP-44-decrypt via the operator signer,
|
||||||
while inbound:
|
dispatch to handlers that reuse the same crud paths as the REST API so
|
||||||
event_message = inbound.pop(0)
|
HTTP and Nostr entry points converge on one booking flow.
|
||||||
try:
|
"""
|
||||||
await _handle_availability_query(event_message)
|
logger.info("chatelet: inbound Nostr subscription not yet wired (sketch)")
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
"""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