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:
parent
201c387722
commit
b155548036
5 changed files with 601 additions and 0 deletions
190
nostr_publisher.py
Normal file
190
nostr_publisher.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue