From b155548036d9f544962242a2c58dc50ffeeb1f2e Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 29 Apr 2026 23:42:01 +0200 Subject: [PATCH] feat(nostr): NIP-99 menu listings, NIP-01 profile, NIP-17 stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:) and ingredients (ingr:) 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. --- nostr/__init__.py | 0 nostr/event.py | 36 +++++++ nostr/nostr_client.py | 137 ++++++++++++++++++++++++ nostr_publisher.py | 190 +++++++++++++++++++++++++++++++++ nostr_sync.py | 238 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 601 insertions(+) create mode 100644 nostr/__init__.py create mode 100644 nostr/event.py create mode 100644 nostr/nostr_client.py create mode 100644 nostr_publisher.py create mode 100644 nostr_sync.py diff --git a/nostr/__init__.py b/nostr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/event.py b/nostr/event.py new file mode 100644 index 0000000..7534a9c --- /dev/null +++ b/nostr/event.py @@ -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() diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py new file mode 100644 index 0000000..b5dee70 --- /dev/null +++ b/nostr/nostr_client.py @@ -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 diff --git a/nostr_publisher.py b/nostr_publisher.py new file mode 100644 index 0000000..c980449 --- /dev/null +++ b/nostr_publisher.py @@ -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, "", ""] + image each entry in item.images + t "menu", "", each dietary tag, each allergen + (prefixed `allergen:`), each ingredient (prefixed `ingr:`) + l "restaurant:" (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 diff --git a/nostr_sync.py b/nostr_sync.py new file mode 100644 index 0000000..743d294 --- /dev/null +++ b/nostr_sync.py @@ -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": "", + "items": [ + { + "menu_item_id": "", + "quantity": 1, + "selected_modifiers": [{"modifier_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