feat(nostr): NIP-99 menu listings, NIP-01 profile, NIP-17 stub

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.
This commit is contained in:
Padreug 2026-04-29 23:42:01 +02:00
commit b155548036
5 changed files with 601 additions and 0 deletions

238
nostr_sync.py Normal file
View file

@ -0,0 +1,238 @@
"""
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