restaurant/nostr_publisher.py
Padreug cedd548963 feat(nostr): ancestor 't' tags on menu listings
When a menu item's NIP-99 kind-30402 listing is published, the
extension now emits one 't' tag per ancestor node name (root-first,
slugified to lowercase ASCII). This lets Nostr clients filter the
global listing stream by category — e.g.
    {"#t": ["hot-beverages"]}
    {"#t": ["coffee-based"]}
without having to know the publisher's pubkey or pull markdown
content. The 'menu' anchor stays first so subscribers can still
get the universal stream. Allergen / ingredient prefixes
(allergen:<x>, ingr:<x>) and dietary tags are unchanged.

nostr_publisher.py:
  - Add _slugify(name) -> str (lowercase, [^a-z0-9]+ -> '-', strip).
  - build_menu_item_event takes ancestor_names: tuple[str, ...] kw
    and emits dedup'd slugs. Stays DB-free; the caller does the
    walk.

views_api.py:
  - _ancestor_names_for_node walks the materialized path of an
    item's node to (root.name, ..., leaf.name).
  - _publish_menu_item passes them to the builder.
  - api_update_menu_node detects a name change and calls
    _republish_subtree_items(node_id), which re-publishes every
    menu_item in the subtree so the new ancestor slug lands on
    each listing. <=50 items per restaurant in practice; eager
    re-publish keeps the relay state consistent without a
    background sync.
2026-05-09 07:11:06 +02:00

232 lines
7.9 KiB
Python

"""
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 re
import time
from typing import Optional
import coincurve
from loguru import logger
from .models import MenuItem, Restaurant
from .nostr.event import NostrEvent
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def _slugify(name: str) -> str:
"""
Convert a node name to a lowercase ASCII slug suitable for the
Nostr `t` (hashtag) tag. Relays and clients filter on `#t` values
by exact match, so 'Hot Beverages' must become 'hot-beverages'
for the filter `{"#t": ["hot-beverages"]}` to find it.
Best-effort:
* Lowercase
* Replace any run of non-alphanumeric characters with '-'
* Strip leading/trailing dashes
* Empty / whitespace-only input returns ''
"""
s = (name or "").strip().lower()
s = _SLUG_NON_ALNUM.sub("-", s)
s = s.strip("-")
return s
# --------------------------------------------------------------------- #
# 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,
*,
ancestor_names: tuple[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", each ancestor node name (slugified, root-first),
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
`ancestor_names` is the chain of node names from the root down to
(and including) the item's own node, e.g.
("Drinks", "Hot Beverages", "Coffee-based")
Each is slugified to lowercase ASCII so `#t=hot-beverages` filters
work cleanly. Caller (views_api._publish_menu_item) walks the
materialized path; this builder stays DB-free.
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])
# Ancestor categories — slugified, deduped, root-first.
seen_slugs: set[str] = set()
for ancestor in ancestor_names:
slug = _slugify(ancestor)
if slug and slug not in seen_slugs:
seen_slugs.add(slug)
tags.append(["t", slug])
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