Compare commits
5 commits
1ad99aa3d6
...
2db0102857
| Author | SHA1 | Date | |
|---|---|---|---|
| 2db0102857 | |||
| e8fcecac40 | |||
| 5013709be7 | |||
| f76e21e960 | |||
| f965cf07c9 |
8 changed files with 328 additions and 2 deletions
27
__init__.py
27
__init__.py
|
|
@ -21,6 +21,9 @@ events_static_files = [
|
||||||
|
|
||||||
scheduled_tasks: list[asyncio.Task] = []
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
# Module-level NostrClient — None when nostrclient is unavailable
|
||||||
|
nostr_client = None
|
||||||
|
|
||||||
|
|
||||||
def events_stop():
|
def events_stop():
|
||||||
for task in scheduled_tasks:
|
for task in scheduled_tasks:
|
||||||
|
|
@ -29,12 +32,32 @@ def events_stop():
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
|
|
||||||
|
global nostr_client
|
||||||
|
if nostr_client:
|
||||||
|
asyncio.get_event_loop().create_task(nostr_client.stop())
|
||||||
|
|
||||||
|
|
||||||
def events_start():
|
def events_start():
|
||||||
from lnbits.tasks import create_permanent_unique_task
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
|
|
||||||
task = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
|
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
|
||||||
scheduled_tasks.append(task)
|
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"]
|
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]
|
||||||
|
|
|
||||||
|
|
@ -202,3 +202,15 @@ async def m008_add_event_status(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';"
|
"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;"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ class Event(BaseModel):
|
||||||
banner: str | None = None
|
banner: str | None = None
|
||||||
extra: EventExtra = Field(default_factory=EventExtra)
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
status: str = "approved" # proposed, approved, rejected
|
status: str = "approved" # proposed, approved, rejected
|
||||||
|
nostr_event_id: str | None = None
|
||||||
|
nostr_event_created_at: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class TicketExtra(BaseModel):
|
class TicketExtra(BaseModel):
|
||||||
|
|
|
||||||
0
nostr/__init__.py
Normal file
0
nostr/__init__.py
Normal file
27
nostr/event.py
Normal file
27
nostr/event.py
Normal file
|
|
@ -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()
|
||||||
100
nostr/nostr_client.py
Normal file
100
nostr/nostr_client.py
Normal file
|
|
@ -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
|
||||||
115
nostr_publisher.py
Normal file
115
nostr_publisher.py
Normal file
|
|
@ -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
|
||||||
47
views_api.py
47
views_api.py
|
|
@ -35,11 +35,38 @@ from .crud import (
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import CreateEvent, CreateTicket, Ticket
|
from .models import CreateEvent, CreateTicket, Ticket
|
||||||
|
from .nostr_publisher import publish_event_to_nostr
|
||||||
from .services import refund_tickets, set_ticket_paid
|
from .services import refund_tickets, set_ticket_paid
|
||||||
|
|
||||||
events_api_router = APIRouter()
|
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")
|
@events_api_router.get("/api/v1/events")
|
||||||
async def api_events(
|
async def api_events(
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
|
|
@ -96,6 +123,10 @@ async def api_event_create(
|
||||||
for k, v in data.dict().items():
|
for k, v in data.dict().items():
|
||||||
setattr(event, k, v)
|
setattr(event, k, v)
|
||||||
event = await update_event(event)
|
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:
|
else:
|
||||||
if not data.wallet:
|
if not data.wallet:
|
||||||
data.wallet = wallet.wallet.id
|
data.wallet = wallet.wallet.id
|
||||||
|
|
@ -111,6 +142,10 @@ async def api_event_create(
|
||||||
data.status = "proposed"
|
data.status = "proposed"
|
||||||
event = await create_event(data)
|
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()
|
return event.dict()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -131,6 +166,10 @@ async def api_event_cancel(
|
||||||
event = await update_event(event)
|
event = await update_event(event)
|
||||||
await refund_tickets(event.id)
|
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()
|
return event.dict()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -147,6 +186,10 @@ async def api_form_delete(
|
||||||
if event.wallet != wallet.wallet.id:
|
if event.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
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(event_id)
|
||||||
await delete_event_tickets(event_id)
|
await delete_event_tickets(event_id)
|
||||||
return "", HTTPStatus.NO_CONTENT
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
@ -197,6 +240,10 @@ async def api_event_approve(
|
||||||
)
|
)
|
||||||
event.status = "approved"
|
event.status = "approved"
|
||||||
event = await update_event(event)
|
event = await update_event(event)
|
||||||
|
|
||||||
|
# Publish NIP-52 calendar event to Nostr
|
||||||
|
await _publish_or_delete_nostr_event(event)
|
||||||
|
|
||||||
return event.dict()
|
return event.dict()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue