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

0
nostr/__init__.py Normal file
View file

36
nostr/event.py Normal file
View file

@ -0,0 +1,36 @@
"""
Bare NIP-01 event model.
Same shape as the events extension; kept independent so the two
extensions can evolve their Nostr payloads without coupling.
"""
import hashlib
import json
from typing import List, Optional
from pydantic import BaseModel
class NostrEvent(BaseModel):
id: str = ""
pubkey: str
created_at: int
kind: int
tags: List[List[str]] = []
content: str = ""
sig: Optional[str] = None
def serialize(self) -> List:
# Per NIP-01, the canonical event id is sha256 of:
# [0, pubkey, created_at, kind, tags, content]
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def serialize_json(self) -> str:
return json.dumps(
self.serialize(), separators=(",", ":"), ensure_ascii=False
)
@property
def event_id(self) -> str:
return hashlib.sha256(self.serialize_json().encode()).hexdigest()

137
nostr/nostr_client.py Normal file
View file

@ -0,0 +1,137 @@
"""
Bidirectional Nostr client for the restaurant extension.
Connects to the nostrclient extension's internal WebSocket to publish
menu listings (NIP-99) and subscribe to incoming order DMs (eventually
NIP-17). Pattern lifted from the events extension's NostrClient, kept
local so the two extensions can diverge as needed.
"""
import asyncio
import json
from asyncio import Queue
from collections import OrderedDict
from typing import Optional
from loguru import logger
from websocket import WebSocketApp
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
from lnbits.settings import settings
from .event import NostrEvent
MAX_SEEN_EVENTS = 1000
class NostrClient:
def __init__(self):
self.receive_event_queue: Queue = Queue()
self.send_req_queue: Queue = Queue()
self.ws: Optional[WebSocketApp] = None
self.subscription_id = "restaurant-" + urlsafe_short_hash()[:32]
self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict()
@property
def is_websocket_connected(self) -> bool:
if not self.ws:
return False
return self.ws.keep_running
async def connect(self) -> WebSocketApp:
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
ws_url = (
f"ws://localhost:{settings.port}"
f"/nostrclient/api/v1/{relay_endpoint}"
)
logger.info("[RESTAURANT] Connecting to nostrclient WebSocket...")
def on_open(_):
logger.info("[RESTAURANT] Connected to nostrclient WebSocket")
def on_message(_, message):
try:
self.receive_event_queue.put_nowait(message)
except Exception as e:
logger.error(f"[RESTAURANT] Failed to queue message: {e}")
def on_error(_, error):
logger.warning(f"[RESTAURANT] WebSocket error: {error}")
def on_close(_, status_code, message):
logger.warning(
f"[RESTAURANT] WebSocket closed: {status_code} {message}"
)
self.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
ws = WebSocketApp(
ws_url,
on_message=on_message,
on_open=on_open,
on_close=on_close,
on_error=on_error,
)
from threading import Thread
wst = Thread(target=ws.run_forever)
wst.daemon = True
wst.start()
return ws
async def run_forever(self):
self.running = True
while self.running:
try:
if not self.is_websocket_connected:
self.ws = await self.connect()
await asyncio.sleep(5)
req = await self.send_req_queue.get()
assert self.ws
self.ws.send(json.dumps(req))
except Exception as ex:
logger.warning(f"[RESTAURANT] NostrClient error: {ex}")
await asyncio.sleep(60)
def is_duplicate_event(self, event_id: str) -> bool:
if event_id in self._seen_events:
return True
self._seen_events[event_id] = None
if len(self._seen_events) > MAX_SEEN_EVENTS:
self._seen_events.popitem(last=False)
return False
async def get_event(self):
value = await self.receive_event_queue.get()
if isinstance(value, ValueError):
raise value
return value
async def publish_nostr_event(self, e: NostrEvent) -> None:
await self.send_req_queue.put(["EVENT", e.dict()])
async def subscribe(self, filters: list[dict]) -> None:
self.subscription_id = "restaurant-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put(
["REQ", self.subscription_id] + filters
)
logger.info(
f"[RESTAURANT] Subscribed (sub: {self.subscription_id[:20]}...)"
)
async def unsubscribe(self) -> None:
await self.send_req_queue.put(["CLOSE", self.subscription_id])
async def stop(self) -> None:
await self.unsubscribe()
self.running = False
await asyncio.sleep(2)
if self.ws:
try:
self.ws.close()
except Exception:
pass
self.ws = None

190
nostr_publisher.py Normal file
View file

@ -0,0 +1,190 @@
"""
Nostr publishing for the restaurant extension.
Three event types are published:
1. Restaurant profile kind 0 (NIP-01 metadata)
2. Menu items kind 30402 (NIP-99 classified listing,
parameterized replaceable)
3. Deletions kind 5 (NIP-09 deletion request)
Customer-facing webapps subscribe to a restaurant's pubkey to assemble
its menu in real time. Festivals / collective spaces are external curated
lists (NIP-51) that simply enumerate restaurant pubkeys; the extension
itself has no awareness of festivals.
Signing
-------
Events are signed with the *restaurant's* keypair if `restaurant.nostr_pubkey`
is set, otherwise with the LNbits Account keypair of the wallet's owner.
This lets a single LNbits account host multiple restaurants under
distinct Nostr identities, while keeping a sane default for owners
who don't care about identity separation.
"""
import json
import time
from typing import Optional
import coincurve
from loguru import logger
from .models import MenuItem, Restaurant
from .nostr.event import NostrEvent
# --------------------------------------------------------------------- #
# Builders #
# --------------------------------------------------------------------- #
def build_restaurant_metadata_event(restaurant: Restaurant, pubkey: str) -> NostrEvent:
"""
Build a kind 0 (NIP-01 metadata) event for a restaurant profile.
`content` is a JSON object with the canonical metadata fields
(`name`, `about`, `picture`, `banner`, `website`, ...).
"""
content = {
"name": restaurant.name,
"display_name": restaurant.name,
"about": restaurant.description or "",
}
if restaurant.logo_url:
content["picture"] = restaurant.logo_url
if restaurant.banner_url:
content["banner"] = restaurant.banner_url
if restaurant.social_links.website:
content["website"] = restaurant.social_links.website
tags: list[list[str]] = [["t", "restaurant"]]
if restaurant.location:
tags.append(["location", restaurant.location])
if restaurant.geohash:
tags.append(["g", restaurant.geohash])
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=0,
tags=tags,
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_menu_item_event(
item: MenuItem, restaurant: Restaurant, pubkey: str
) -> NostrEvent:
"""
Build a NIP-99 classified listing (kind 30402) for a menu item.
Tags
----
d item.id (addressable identifier replaceable per NIP-33)
title item.name
summary item.description (truncated, optional)
price [price, "<amount>", "<currency>"]
image each entry in item.images
t "menu", "<category-name>", each dietary tag, each allergen
(prefixed `allergen:`), each ingredient (prefixed `ingr:`)
l "restaurant:<restaurant.id>" (link back to the operator)
location restaurant.location (if set)
g restaurant.geohash (if set)
status "active" | "sold" (NIP-99 standard) sold-out state
Content is markdown currently `item.description`; can be expanded
later to include rich allergen/ingredient blocks.
"""
price_currency = (item.currency or "sat").upper()
tags: list[list[str]] = [
["d", item.id],
["title", item.name],
["price", f"{item.price:g}", price_currency],
["l", f"restaurant:{restaurant.id}"],
["t", "menu"],
]
if item.description:
tags.append(["summary", item.description[:140]])
for img in item.images or []:
tags.append(["image", img])
for diet in item.dietary or []:
tags.append(["t", diet])
for allergen in item.allergens or []:
tags.append(["t", f"allergen:{allergen}"])
for ingredient in item.ingredients or []:
tags.append(["t", f"ingr:{ingredient}"])
if restaurant.location:
tags.append(["location", restaurant.location])
if restaurant.geohash:
tags.append(["g", restaurant.geohash])
sold_out = item.stock is not None and item.stock <= 0
tags.append(["status", "sold" if sold_out or not item.is_available else "active"])
content = item.description or item.name
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=30402,
tags=tags,
content=content,
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_delete_event(
addressable_kind: int, identifier: str, pubkey: str, reason: str = ""
) -> NostrEvent:
"""
Build a NIP-09 deletion request (kind 5) for a parameterized
replaceable event. `addressable_kind` is the kind of the target
(e.g. 30402 for a menu item) and `identifier` is the `d`-tag.
"""
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=5,
tags=[["a", f"{addressable_kind}:{pubkey}:{identifier}"]],
content=reason,
)
nostr_event.id = nostr_event.event_id
return nostr_event
# --------------------------------------------------------------------- #
# Signing + publishing #
# --------------------------------------------------------------------- #
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Schnorr-sign a NostrEvent in place (BIP-340)."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event(
nostr_client,
nostr_event: NostrEvent,
private_key_hex: str,
) -> Optional[NostrEvent]:
"""Sign and publish a built NostrEvent. Returns the event on success
so callers can persist its id + created_at, or None on failure."""
if not nostr_client:
logger.debug("[RESTAURANT] No NostrClient; skipping publish")
return None
try:
sign_nostr_event(nostr_event, private_key_hex)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[RESTAURANT] Published kind {nostr_event.kind} "
f"event {nostr_event.id[:16]}..."
)
return nostr_event
except Exception as e:
logger.warning(f"[RESTAURANT] Failed to publish: {e}")
return None

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