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