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

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