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
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