Compare commits

..

No commits in common. "2db010285712a799cf7b399523097a687802c030" and "1ad99aa3d62c38fd59d2bb6dff53b727804e5c35" have entirely different histories.

8 changed files with 2 additions and 328 deletions

View file

@ -21,9 +21,6 @@ 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:
@ -32,32 +29,12 @@ 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
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)
task = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task)
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]

View file

@ -202,15 +202,3 @@ 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;"
)

View file

@ -80,8 +80,6 @@ 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):

View file

View file

@ -1,27 +0,0 @@
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()

View file

@ -1,100 +0,0 @@
"""
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

View file

@ -1,115 +0,0 @@
"""
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

View file

@ -35,38 +35,11 @@ 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),
@ -123,10 +96,6 @@ 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
@ -142,10 +111,6 @@ 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()
@ -166,10 +131,6 @@ 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()
@ -186,10 +147,6 @@ 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
@ -240,10 +197,6 @@ 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()