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.
232 lines
7.9 KiB
Python
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
|