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
0
nostr/__init__.py
Normal file
0
nostr/__init__.py
Normal file
36
nostr/event.py
Normal file
36
nostr/event.py
Normal file
|
|
@ -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()
|
||||||
137
nostr/nostr_client.py
Normal file
137
nostr/nostr_client.py
Normal file
|
|
@ -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
|
||||||
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
|
||||||
238
nostr_sync.py
Normal file
238
nostr_sync.py
Normal file
|
|
@ -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": "<id>",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"menu_item_id": "<id>",
|
||||||
|
"quantity": 1,
|
||||||
|
"selected_modifiers": [{"modifier_id": "<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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue