diff --git a/__init__.py b/__init__.py index 14c1590..974c20c 100644 --- a/__init__.py +++ b/__init__.py @@ -21,6 +21,9 @@ events_static_files = [ scheduled_tasks: list[asyncio.Task] = [] +# Module-level NostrClient — None when nostrclient is unavailable +nostr_client = None + def events_stop(): for task in scheduled_tasks: @@ -29,12 +32,32 @@ def events_stop(): except Exception as ex: logger.warning(ex) + global nostr_client + if nostr_client: + asyncio.get_event_loop().create_task(nostr_client.stop()) + def events_start(): from lnbits.tasks import create_permanent_unique_task - task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) - scheduled_tasks.append(task) + task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices) + scheduled_tasks.append(task1) + + async def _start_nostr_client(): + global nostr_client + await asyncio.sleep(10) # Wait for nostrclient to be ready + try: + from .nostr.nostr_client import NostrClient + + nostr_client = NostrClient() + logger.info("[EVENTS] Starting NostrClient for NIP-52 publishing") + await nostr_client.run_forever() + except Exception as e: + logger.warning(f"[EVENTS] NostrClient failed to start: {e}") + logger.info("[EVENTS] Events will work without Nostr publishing") + + task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client) + scheduled_tasks.append(task2) __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/migrations.py b/migrations.py index 357c3ed..22fe495 100644 --- a/migrations.py +++ b/migrations.py @@ -202,3 +202,15 @@ async def m008_add_event_status(db): await db.execute( "ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';" ) + + +async def m009_add_nostr_columns(db): + """ + Add columns to track published NIP-52 Nostr calendar events. + """ + await db.execute( + "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT;" + ) + await db.execute( + "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER;" + ) diff --git a/models.py b/models.py index 0b92e15..56f6dff 100644 --- a/models.py +++ b/models.py @@ -80,6 +80,8 @@ class Event(BaseModel): banner: str | None = None extra: EventExtra = Field(default_factory=EventExtra) status: str = "approved" # proposed, approved, rejected + nostr_event_id: str | None = None + nostr_event_created_at: int | None = None class TicketExtra(BaseModel): diff --git a/nostr/__init__.py b/nostr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/event.py b/nostr/event.py new file mode 100644 index 0000000..7da8288 --- /dev/null +++ b/nostr/event.py @@ -0,0 +1,27 @@ +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: + return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] + + def serialize_json(self) -> str: + e = self.serialize() + return json.dumps(e, separators=(",", ":"), ensure_ascii=False) + + @property + def event_id(self) -> str: + data = self.serialize_json() + return hashlib.sha256(data.encode()).hexdigest() diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py new file mode 100644 index 0000000..09f57d4 --- /dev/null +++ b/nostr/nostr_client.py @@ -0,0 +1,100 @@ +""" +Publish-only Nostr client for the events extension. + +Connects to the nostrclient extension's internal WebSocket to publish +NIP-52 calendar events. No subscription/receive capabilities — this +is a stripped-down version of nostrmarket's NostrClient. +""" + +import asyncio +import json +from asyncio import Queue +from typing import Optional + +from loguru import logger +from websocket import WebSocketApp + +from lnbits.helpers import encrypt_internal_message +from lnbits.settings import settings + +from .event import NostrEvent + + +class NostrClient: + def __init__(self): + self.send_req_queue: Queue = Queue() + self.ws: Optional[WebSocketApp] = None + self.running = False + + @property + def is_websocket_connected(self): + 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("[EVENTS] Connecting to nostrclient WebSocket...") + + def on_open(_): + logger.info("[EVENTS] Connected to nostrclient WebSocket") + + def on_message(_, message): + # Log relay responses (OK, NOTICE) but don't process + logger.debug(f"[EVENTS] Relay response: {message[:200]}") + + def on_error(_, error): + logger.warning(f"[EVENTS] WebSocket error: {error}") + + def on_close(_, status_code, message): + logger.warning( + f"[EVENTS] WebSocket closed: {status_code} {message}" + ) + + 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"[EVENTS] NostrClient error: {ex}") + await asyncio.sleep(60) + + async def publish_nostr_event(self, e: NostrEvent): + await self.send_req_queue.put(["EVENT", e.dict()]) + + async def stop(self): + self.running = False + if self.ws: + try: + self.ws.close() + except Exception: + pass + self.ws = None diff --git a/nostr_publisher.py b/nostr_publisher.py new file mode 100644 index 0000000..fe0431b --- /dev/null +++ b/nostr_publisher.py @@ -0,0 +1,115 @@ +""" +NIP-52 calendar event publishing for the events extension. + +Builds kind 31922 (date-based) calendar events from the Event model, +signs them with the event creator's Account keypair, and publishes +via the NostrClient to nostrclient relays. + +Reference: https://github.com/nostr-protocol/nips/blob/master/52.md +""" + +import time +from typing import Optional + +import coincurve +from loguru import logger + +from .models import Event +from .nostr.event import NostrEvent + + +def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: + """ + Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event. + + Tags: + d - event.id (addressable identifier) + title - event.name + start - event.event_start_date (ISO date string) + end - event.event_end_date (optional) + image - event.banner (optional) + Content: event.info (description) + """ + tags = [ + ["d", event.id], + ["title", event.name], + ["start", event.event_start_date], + ] + + if event.event_end_date: + tags.append(["end", event.event_end_date]) + if event.banner: + tags.append(["image", event.banner]) + + nostr_event = NostrEvent( + pubkey=pubkey, + created_at=int(time.time()), + kind=31922, + tags=tags, + content=event.info, + ) + nostr_event.id = nostr_event.event_id + return nostr_event + + +def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent: + """ + Build a kind 5 delete event for a published NIP-52 calendar event. + + Uses an 'a' tag to reference the parameterized replaceable event + (kind 31922) per NIP-09. + """ + nostr_event = NostrEvent( + pubkey=pubkey, + created_at=int(time.time()), + kind=5, + tags=[ + ["a", f"31922:{pubkey}:{event.id}"], + ], + content="Event canceled", + ) + nostr_event.id = nostr_event.event_id + return nostr_event + + +def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None: + """Sign a NostrEvent in-place using Schnorr signature.""" + 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_to_nostr( + nostr_client, + event: Event, + account_pubkey: str, + account_prvkey: str, + delete: bool = False, +) -> Optional[NostrEvent]: + """ + Build, sign, and publish a NIP-52 calendar event (or delete event). + + Returns the published NostrEvent for metadata storage, or None on failure. + """ + if not nostr_client: + logger.debug("[EVENTS] No NostrClient available, skipping publish") + return None + + try: + if delete: + nostr_event = build_nip52_delete_event(event, account_pubkey) + else: + nostr_event = build_nip52_event(event, account_pubkey) + + sign_nostr_event(nostr_event, account_prvkey) + await nostr_client.publish_nostr_event(nostr_event) + + logger.info( + f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} " + f"event: {nostr_event.id[:16]}... (kind {nostr_event.kind})" + ) + return nostr_event + + except Exception as e: + logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}") + return None diff --git a/views_api.py b/views_api.py index 73c91c9..f69d3c0 100644 --- a/views_api.py +++ b/views_api.py @@ -35,11 +35,38 @@ from .crud import ( update_ticket, ) from .models import CreateEvent, CreateTicket, Ticket +from .nostr_publisher import publish_event_to_nostr from .services import refund_tickets, set_ticket_paid events_api_router = APIRouter() +async def _publish_or_delete_nostr_event(event, delete=False): + """Publish (or delete) a NIP-52 calendar event using the creator's keypair.""" + try: + from lnbits.core.crud.wallets import get_wallet + from lnbits.core.crud.users import get_account + + from . import nostr_client + + wallet_obj = await get_wallet(event.wallet) + if not wallet_obj: + return + account = await get_account(wallet_obj.user) + if not account or not account.pubkey or not account.prvkey: + return + + nostr_event = await publish_event_to_nostr( + nostr_client, event, account.pubkey, account.prvkey, delete=delete + ) + if nostr_event and not delete: + event.nostr_event_id = nostr_event.id + event.nostr_event_created_at = nostr_event.created_at + await update_event(event) + except Exception as e: + logger.warning(f"[EVENTS] Nostr publish failed: {e}") + + @events_api_router.get("/api/v1/events") async def api_events( all_wallets: bool = Query(False), @@ -96,6 +123,10 @@ async def api_event_create( for k, v in data.dict().items(): setattr(event, k, v) event = await update_event(event) + + # Republish to Nostr if event is approved (kind 31922 is replaceable) + if event.status == "approved" and event.nostr_event_id: + await _publish_or_delete_nostr_event(event) else: if not data.wallet: data.wallet = wallet.wallet.id @@ -111,6 +142,10 @@ async def api_event_create( data.status = "proposed" event = await create_event(data) + # Publish to Nostr if auto-approved (admin-created) + if event.status == "approved": + await _publish_or_delete_nostr_event(event) + return event.dict() @@ -131,6 +166,10 @@ async def api_event_cancel( event = await update_event(event) await refund_tickets(event.id) + # Delete NIP-52 event from Nostr if it was published + if event.nostr_event_id: + await _publish_or_delete_nostr_event(event, delete=True) + return event.dict() @@ -147,6 +186,10 @@ async def api_form_delete( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + # Delete NIP-52 event from Nostr if it was published + if event.nostr_event_id: + await _publish_or_delete_nostr_event(event, delete=True) + await delete_event(event_id) await delete_event_tickets(event_id) return "", HTTPStatus.NO_CONTENT @@ -197,6 +240,10 @@ async def api_event_approve( ) event.status = "approved" event = await update_event(event) + + # Publish NIP-52 calendar event to Nostr + await _publish_or_delete_nostr_event(event) + return event.dict()