Compare commits
No commits in common. "733f17600f8a7358f70ee01603af4bf4bc556ede" and "efd46a72c7b09921cf5f0eb6e69af2ae59d97ead" have entirely different histories.
733f17600f
...
efd46a72c7
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
|
||||
Lightning, with Nostr as the interop layer.
|
||||
|
||||
> **Status:** functional prototype. Data model, migrations, availability
|
||||
> arbiter (atomic hold), FX + invoice + settlement, the REST + kind-21000 RPC
|
||||
> 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.
|
||||
> **Status:** design sketch + scaffold. Data model, migrations, availability
|
||||
> arbiter, REST surface, and Nostr event model are in place; relay plumbing,
|
||||
> FX/invoice wiring, and the guest UI are stubbed with `TODO(...)` markers.
|
||||
|
||||
## Why not just reuse an existing NIP?
|
||||
|
||||
|
|
|
|||
|
|
@ -36,15 +36,6 @@ def chatelet_start():
|
|||
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
|
||||
# RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire
|
||||
# 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) |
|
||||
| `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
|
||||
|
||||
```mermaid
|
||||
|
|
|
|||
|
|
@ -67,13 +67,13 @@ def build_reservation_event(booking: Booking, guest_pubkey: str) -> dict:
|
|||
}
|
||||
return {
|
||||
"kind": KIND_RESERVATION,
|
||||
# plaintext JSON; service.py NIP-44-encrypts this to the guest before
|
||||
# signing (publish_reservation passes encrypt_to=guest_pubkey).
|
||||
# placeholder — service.py replaces with NIP-44(payload) for guest
|
||||
"content": json.dumps(payload),
|
||||
"tags": [
|
||||
["d", booking.id],
|
||||
["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"],
|
||||
["start", start_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(
|
||||
result: AvailabilityResult, requester_pubkey: str
|
||||
) -> dict:
|
||||
"""Aiolabs kind:22001 (ephemeral) reply to an availability query.
|
||||
|
||||
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."""
|
||||
"""Aiolabs kind:22001 (ephemeral) reply to an availability query. Content
|
||||
MUST be NIP-44 encrypted to the requester by service.py."""
|
||||
payload = {
|
||||
"room_id": result.room_id,
|
||||
"check_in": result.check_in,
|
||||
|
|
@ -119,4 +115,5 @@ def build_availability_response(
|
|||
"kind": KIND_AVAILABILITY_RESPONSE,
|
||||
"content": json.dumps(payload),
|
||||
"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
|
||||
inbound availability queries — over the `nostrclient` extension's relay
|
||||
manager, in-process (the spirekeeper pattern, aiolabs/spirekeeper
|
||||
nostr_publish.py + tasks.py).
|
||||
"""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`: 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).
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .. import crud, services
|
||||
from ..models import Booking, Room
|
||||
from .. import crud
|
||||
from ..models import AvailabilityResult, Booking, Room
|
||||
from . import events
|
||||
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
|
||||
except ImportError: # pre-#17 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."""
|
||||
"""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.debug("chatelet: no operator_id configured; skipping publish")
|
||||
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:
|
||||
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."""
|
||||
"""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
|
||||
|
||||
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
|
||||
plaintext = unsigned.pop("_plaintext", None)
|
||||
if encrypt_to and plaintext is not None:
|
||||
unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"])
|
||||
|
||||
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
|
||||
|
||||
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]}"
|
||||
)
|
||||
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 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))
|
||||
|
||||
|
||||
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."""
|
||||
"""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:
|
||||
"""Publish a kind:31923 unavailable-range event (public, no PII)."""
|
||||
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
|
||||
|
|
@ -144,78 +89,26 @@ async def publish_block_calendar(
|
|||
)
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
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: availability queries (kind:22000) -----------------------------
|
||||
# --- inbound: guest-originated events ---------------------------------------
|
||||
|
||||
|
||||
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)
|
||||
"""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)
|
||||
|
||||
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"
|
||||
)
|
||||
SKETCH — no relay connection yet. Started as a permanent task from
|
||||
__init__.py:chatelet_start() once relay plumbing lands.
|
||||
|
||||
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)
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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