feat: add NIP-52 Nostr publish + sync of calendar events
Some checks failed
lint.yml / feat: add NIP-52 Nostr publish + sync of calendar events (push) Failing after 0s

Approved events are mirrored to Nostr as NIP-52 calendar events (kind
31922) signed by the wallet owner's pubkey, and incoming kind 31922/31923
events from subscribed relays are synced into the local DB so events
created on other LNbits instances or Nostr clients show up locally.

- m009 stores nostr_event_id + nostr_event_created_at on each event
  (used for replaceable updates and NIP-09 deletes); m011 adds location
  + JSON-encoded categories list (NIP-52 location/`t` tags).
- models: Event/PublicEvent/CreateEvent gain location, categories,
  nostr_event_id, nostr_event_created_at; parse_categories validator
  decodes the JSON column on read.
- nostr/{event,nostr_client}.py: Schnorr signing, websocket relay client,
  and a NostrEvent model (publish-only and subscribe variants).
- nostr_publisher.py: build/sign NIP-52 kind 31922 events and NIP-09
  delete events; publish via the relay client.
- nostr_sync.py: subscribe to kinds 31922/31923, dedupe by nostr_event_id
  / d-tag, upsert Events; auto-approves discovered Nostr events since
  they're already public.
- nostr_hooks.py: thin bridge that views_api handlers call to publish
  or delete a NIP-52 event for a given local event. Lives in its own
  module to keep `from . import nostr_client` out of the view layer
  and avoid the views_api -> publisher import cycle.
- views_api: hooks publish_or_delete_nostr_event into create-on-approved,
  update-when-already-published, cancel (delete), delete (delete), and
  approve (publish).
- __init__.py: 3-task lifespan — wait_for_paid_invoices (upstream),
  NostrClient bootstrap, and the NIP-52 sync loop. Module-level
  nostr_client global is set by the bootstrap and read dynamically by
  publish_or_delete_nostr_event so the import order works regardless of
  whether nostrclient is up at startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-05 18:51:43 +02:00
commit 42a373bff1
10 changed files with 600 additions and 5 deletions

View file

@ -22,6 +22,11 @@ events_static_files = [
scheduled_tasks: list[asyncio.Task] = []
# Module-level NostrClient — None when nostrclient is unavailable. Set by the
# bootstrap task in events_start() and read via dynamic attribute lookup
# from nostr_hooks.publish_or_delete_nostr_event.
nostr_client = None
def events_stop():
for task in scheduled_tasks:
@ -30,12 +35,50 @@ 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 sync")
await nostr_client.run_forever()
except Exception as exc:
logger.warning(f"[EVENTS] NostrClient failed to start: {exc}")
logger.info("[EVENTS] Events will work without Nostr sync")
task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client)
scheduled_tasks.append(task2)
async def _sync_nostr_events():
global nostr_client
await asyncio.sleep(15) # Wait for NostrClient to connect
if not nostr_client:
logger.info("[EVENTS] No NostrClient, skipping Nostr sync")
return
try:
from .nostr_sync import wait_for_nostr_events
await wait_for_nostr_events(nostr_client)
except Exception as exc:
logger.error(f"[EVENTS] Nostr sync task failed: {exc}")
task3 = create_permanent_unique_task(
"ext_events_nostr_sync", _sync_nostr_events
)
scheduled_tasks.append(task3)
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]