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
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:
parent
4c8e06a6a9
commit
42a373bff1
10 changed files with 600 additions and 5 deletions
118
nostr_publisher.py
Normal file
118
nostr_publisher.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""
|
||||
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
|
||||
|
||||
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])
|
||||
if event.location:
|
||||
tags.append(["location", event.location])
|
||||
for cat in (event.categories or []):
|
||||
tags.append(["t", cat])
|
||||
|
||||
nostr_event = NostrEvent(
|
||||
pubkey=pubkey,
|
||||
created_at=int(time.time()),
|
||||
kind=31922,
|
||||
tags=tags,
|
||||
content=event.info or "",
|
||||
)
|
||||
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,
|
||||
) -> NostrEvent | None:
|
||||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue