nostr/event.py
Bare NIP-01 NostrEvent with canonical id computation.
nostr/nostr_client.py
Bidirectional WebSocket client (lifted from events ext, kept
local). Connects to nostrclient ext's internal relay endpoint,
dedups by event id (LRU 1000).
nostr_publisher.py
Builders for:
* kind 0 — restaurant profile (NIP-01 metadata)
* kind 30402 — menu item (NIP-99 classified listing,
parameterized replaceable by item.id)
* kind 5 — deletion request (NIP-09)
Schnorr signing via coincurve (BIP-340).
Menu listings carry structured price tags (["price", n, currency]),
status (active|sold) so customers see sold-out items, and 't' tags
for category, dietary, allergens (allergen:<x>) and ingredients
(ingr:<x>) so webapps can filter without parsing markdown.
Restaurants can sign with their own keypair (per-restaurant Nostr
identity) or fall back to the LNbits Account keypair.
nostr_sync.py
Subscribes to:
* kind 30402 #t=menu — backfill 200 + live (echo confirmation
for now; foreign-menu indexing deferred until we settle on a
federated cache table).
* kind 1059 — NIP-17 gift-wrapped DMs, only when
settings.nostr_orders_enabled. Decryption stubbed (needs
NIP-44 v2 unwrap); REST stays the supported transport
until that's wired up. _place_order_from_dm is complete and
ready for the decryption hook.
238 lines
8.2 KiB
Python
238 lines
8.2 KiB
Python
"""
|
|
Nostr inbound sync for the restaurant extension.
|
|
|
|
Two streams are processed:
|
|
|
|
1. Menu listings published by *other* restaurants (kind 30402, tag
|
|
`t=menu`). We index them so a single LNbits instance running this
|
|
extension can serve a webapp that aggregates many restaurants.
|
|
|
|
2. Order DMs from customers (NIP-17 gift-wrapped DMs, kind 1059
|
|
unwrapping to kind 14). When `settings.nostr_orders_enabled` is
|
|
set, the customer's webapp sends carts + payment requests this
|
|
way instead of via REST. The order is then placed via
|
|
services.place_order() exactly as if it had arrived over HTTP.
|
|
|
|
NIP-17 unwrapping requires the restaurant's secret key, NIP-44
|
|
encryption, and ephemeral seal handling. For the MVP scaffold the
|
|
unwrap step is stubbed — it will accept and dispatch only when the
|
|
runtime keypair is wired up. REST remains the supported transport
|
|
until then.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Optional
|
|
|
|
from loguru import logger
|
|
|
|
from .crud import (
|
|
get_menu_item_by_nostr_event,
|
|
get_settings,
|
|
)
|
|
from .nostr.nostr_client import NostrClient
|
|
|
|
|
|
async def wait_for_nostr_events(nostr_client: NostrClient) -> None:
|
|
"""
|
|
Subscribe to Nostr filters and dispatch events as they arrive.
|
|
|
|
Filters:
|
|
* NIP-99 menu listings (kind 30402, tag `t=menu`) — limit 200
|
|
for backfill on startup, then live.
|
|
* NIP-17 gift-wrapped DMs (kind 1059) — only when orders-over-Nostr
|
|
is enabled in settings.
|
|
"""
|
|
logger.info("[RESTAURANT] Starting Nostr inbound sync")
|
|
|
|
settings = await get_settings()
|
|
filters = [
|
|
{"kinds": [30402], "#t": ["menu"], "limit": 200},
|
|
]
|
|
if settings.nostr_orders_enabled:
|
|
filters.append({"kinds": [1059], "limit": 50})
|
|
|
|
await nostr_client.subscribe(filters)
|
|
|
|
while True:
|
|
try:
|
|
message = await nostr_client.get_event()
|
|
await process_nostr_message(nostr_client, message)
|
|
except ValueError as ex:
|
|
# WebSocket closed; the run_forever loop will reconnect and
|
|
# we re-subscribe below.
|
|
logger.warning(f"[RESTAURANT] Nostr WS closed: {ex}; resubscribing")
|
|
await asyncio.sleep(5)
|
|
await nostr_client.subscribe(filters)
|
|
except Exception as ex:
|
|
logger.exception(f"[RESTAURANT] Nostr sync loop error: {ex}")
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Dispatcher #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
async def process_nostr_message(nostr_client: NostrClient, message: str) -> None:
|
|
"""Decode a relay frame and route by kind."""
|
|
try:
|
|
data = json.loads(message)
|
|
except json.JSONDecodeError:
|
|
return
|
|
|
|
if not isinstance(data, list) or len(data) < 2:
|
|
return
|
|
|
|
msg_type = data[0]
|
|
|
|
if msg_type == "EVENT" and len(data) >= 3:
|
|
event_data = data[2]
|
|
await _handle_event(nostr_client, event_data)
|
|
elif msg_type == "EOSE":
|
|
logger.debug("[RESTAURANT] EOSE — backfill complete")
|
|
elif msg_type == "NOTICE":
|
|
logger.info(f"[RESTAURANT] Relay notice: {data[1]}")
|
|
|
|
|
|
async def _handle_event(nostr_client: NostrClient, event_data: dict) -> None:
|
|
kind = event_data.get("kind")
|
|
event_id = event_data.get("id", "")
|
|
|
|
if not event_id or nostr_client.is_duplicate_event(event_id):
|
|
return
|
|
|
|
if kind == 30402:
|
|
await _index_menu_listing(event_data)
|
|
elif kind == 1059:
|
|
await _handle_gift_wrapped_dm(event_data)
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Menu listings (NIP-99) #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
async def _index_menu_listing(event_data: dict) -> None:
|
|
"""
|
|
Record that we've seen another restaurant's NIP-99 menu listing.
|
|
|
|
For now we only update existing local rows whose nostr_event_id
|
|
we recognize (e.g. menu items we ourselves published from this
|
|
instance — round-trip echo). Federated indexing of foreign
|
|
restaurants' menus belongs in a future migration once we decide
|
|
how to scope a 'foreign menu cache' table.
|
|
"""
|
|
event_id = event_data.get("id", "")
|
|
existing = await get_menu_item_by_nostr_event(event_id)
|
|
if not existing:
|
|
# Not ours; nothing to do at this stage.
|
|
return
|
|
|
|
incoming_created_at = event_data.get("created_at", 0)
|
|
if (
|
|
existing.nostr_event_created_at
|
|
and incoming_created_at <= existing.nostr_event_created_at
|
|
):
|
|
return # We already have this version (or newer)
|
|
|
|
# No-op for now; we trust our own DB as the source of truth and only
|
|
# use this branch to confirm our published events were accepted by
|
|
# relays. If we later add federated menus, this is where we'd merge
|
|
# foreign restaurants' updates into a `foreign_menu_items` table.
|
|
logger.debug(
|
|
f"[RESTAURANT] Echo received for menu_item {existing.id} "
|
|
f"(event {event_id[:16]}...)"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Order DMs (NIP-17) #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
async def _handle_gift_wrapped_dm(event_data: dict) -> None:
|
|
"""
|
|
Decrypt + dispatch a NIP-17 gift-wrapped DM as an order.
|
|
|
|
Stub: NIP-17 requires
|
|
- the recipient's signing key (the restaurant's nostr_pubkey
|
|
keypair), and
|
|
- NIP-44 v2 ChaCha20 + HMAC-SHA256 unwrap of the seal, and
|
|
- a second NIP-44 unwrap of the rumor.
|
|
|
|
These primitives aren't wired up yet; we log and skip. REST
|
|
remains the supported order transport until this lands.
|
|
"""
|
|
event_id = event_data.get("id", "?")[:16]
|
|
logger.info(
|
|
f"[RESTAURANT] NIP-17 DM received ({event_id}...) — "
|
|
"decryption stub; orders-over-Nostr not yet implemented"
|
|
)
|
|
_ = event_data # keep until decoder is wired up
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------- #
|
|
# Inbound order construction (called once decryption is wired up) #
|
|
# --------------------------------------------------------------------- #
|
|
|
|
|
|
async def _place_order_from_dm(
|
|
decrypted_payload: dict, sender_pubkey: str
|
|
) -> Optional[str]:
|
|
"""
|
|
Translate a decrypted NIP-17 order DM payload into a CreateOrder
|
|
request, dispatch through services.place_order, and return the
|
|
order id.
|
|
|
|
Expected payload shape (subject to change as the webapp ships):
|
|
{
|
|
"restaurant_id": "<id>",
|
|
"items": [
|
|
{
|
|
"menu_item_id": "<id>",
|
|
"quantity": 1,
|
|
"selected_modifiers": [{"modifier_id": "<id>"}, ...],
|
|
"note": "..."
|
|
},
|
|
...
|
|
],
|
|
"tip_msat": 0,
|
|
"note": "..."
|
|
}
|
|
"""
|
|
from .models import CreateOrder, CreateOrderItem, SelectedModifier
|
|
from .services import place_order
|
|
|
|
try:
|
|
items = [
|
|
CreateOrderItem(
|
|
menu_item_id=i["menu_item_id"],
|
|
quantity=int(i.get("quantity", 1)),
|
|
selected_modifiers=[
|
|
SelectedModifier(
|
|
modifier_id=m.get("modifier_id"),
|
|
name=m.get("name", ""),
|
|
price_delta=float(m.get("price_delta", 0)),
|
|
)
|
|
for m in i.get("selected_modifiers", [])
|
|
],
|
|
note=i.get("note"),
|
|
)
|
|
for i in decrypted_payload.get("items", [])
|
|
]
|
|
order_data = CreateOrder(
|
|
restaurant_id=decrypted_payload["restaurant_id"],
|
|
customer_pubkey=sender_pubkey,
|
|
items=items,
|
|
tip_msat=int(decrypted_payload.get("tip_msat", 0)),
|
|
note=decrypted_payload.get("note"),
|
|
channel="nostr",
|
|
payment_method="lightning",
|
|
)
|
|
order, _invoice = await place_order(order_data)
|
|
return order.id
|
|
except Exception as ex:
|
|
logger.warning(f"[RESTAURANT] Failed to place order from Nostr DM: {ex}")
|
|
return None
|