Closes aiolabs/events#23. Pre-cascade prerequisite for aiolabs/lnbits#17 (signer abstraction phase 1), which lands an m002 startup job that NULLs the legacy `accounts.prvkey` column. After this migration, the events extension reads no plaintext nsec and works with any NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). ## What changed ### nostr_hooks.py — publish_or_delete_nostr_event Was: pulled `(account.pubkey, account.prvkey)` from the wallet owner, passed both to `publish_event_to_nostr`. Hard-skipped publish when `account.prvkey` was None. Now: calls `await resolve_for_wallet(event.wallet)` (the DRY helper from aiolabs/lnbits#23 — wallet → account → signer → can_sign-check in one call, returns None on any soft-fail). Passes the resolved `NostrSigner` to the publisher. Soft-skip on None (wallet missing, account unclassified, or ClientSideOnlySigner where the server has no signing authority) — matching previous "no prvkey" behavior. ### nostr_publisher.py — publish_event_to_nostr Was: accepted `(account_pubkey, account_prvkey)` and signed via a local `sign_nostr_event` helper that called `coincurve.PrivateKey .sign_schnorr` directly on the plaintext nsec. Now: accepts `signer: NostrSigner`. Builds the unsigned event dict (`kind`/`created_at`/`tags`/`content`), hands it to `await signer.sign_event(...)`, reconstructs the local `NostrEvent` model from the signed dict (`id`/`pubkey`/`sig` fields). The signer backend (LocalSigner / RemoteBunkerSigner) is transparent. Removed the `sign_nostr_event` helper entirely — the signer abstraction handles all signing now. Dropped the `coincurve` import; no direct crypto in this extension. ## Acceptance - [x] keypair helper replaced (nostr_hooks no longer touches account.prvkey) - [x] publish_event_to_nostr accepts NostrSigner instead of (pubkey, prvkey) - [x] extension-local Schnorr code removed (sign_nostr_event gone) - [x] re-grep `events/`: zero `account.prvkey` references - [x] version bumped: 1.6.1-aio.3 → 1.6.1-aio.4 Manual smoke testing + tag + catalog entry follow the migration landing; will run against the regtest stack with lnbits on `issue-18-phase-2.3` (which validates both LocalSigner and RemoteBunkerSigner signing paths end-to-end). ## Cross-references - aiolabs/events#23 — issue this commit closes - aiolabs/lnbits#17 — the cascading signer-abstraction PR - aiolabs/lnbits#23 — the resolve_for_wallet helper this uses - aiolabs/lnbits#26 — phase 2.3 (sign_event over bunker, validated against aiolabs/nsecbunkerd@fb1c239) - aiolabs/lnbits#21 — umbrella audit identifying 5 affected extensions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.1 KiB
Python
197 lines
7.1 KiB
Python
"""
|
|
NIP-52 calendar event publishing for the events extension.
|
|
|
|
Builds NIP-52 calendar events from the Event model, signs them via the
|
|
core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
|
|
RemoteBunkerSigner, etc.), and publishes via the NostrClient.
|
|
|
|
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
|
|
when event_start_date / event_end_date include a time component.
|
|
|
|
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
|
|
"""
|
|
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
from lnbits.core.signers import NostrSigner
|
|
from loguru import logger
|
|
|
|
from .models import Event
|
|
from .nostr.event import NostrEvent
|
|
|
|
|
|
def _has_time(value: str | None) -> bool:
|
|
"""ISO 8601 datetime strings contain a 'T' between date and time."""
|
|
return value is not None and "T" in value
|
|
|
|
|
|
def _to_unix(value: str) -> int:
|
|
"""Parse ISO 8601 datetime (assume UTC if naive) to unix seconds."""
|
|
dt = datetime.fromisoformat(value)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return int(dt.timestamp())
|
|
|
|
|
|
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
|
"""
|
|
Convert an Event model to a NIP-52 calendar event.
|
|
|
|
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
|
|
date-based (kind 31922). Tags:
|
|
d - event.id
|
|
title - event.name
|
|
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
|
end - same encoding (optional)
|
|
image, location, t (categories) - optional
|
|
tickets_available - current remaining capacity (omitted when unlimited)
|
|
tickets_sold - running paid-count (always emitted; clients can
|
|
derive original_capacity = available + sold)
|
|
tickets_price - price_per_ticket (always emitted; 0 means free)
|
|
tickets_currency - the currency string
|
|
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
|
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
|
Content: event.info
|
|
|
|
The four ticket_* tags are AIO custom additions outside the NIP-52
|
|
spec; spec-compliant clients ignore unknown tags so this stays
|
|
backwards-compatible. They let connected clients render the
|
|
"X tickets remaining" badge and the Buy CTA without an extra REST hop,
|
|
and pick up live inventory updates via the same relay subscription.
|
|
"""
|
|
time_based = _has_time(event.event_start_date)
|
|
kind = 31923 if time_based else 31922
|
|
start_value = (
|
|
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
|
|
)
|
|
|
|
tags = [
|
|
["d", event.id],
|
|
["title", event.name],
|
|
["start", start_value],
|
|
]
|
|
|
|
end_unix: int | None = None
|
|
if event.event_end_date:
|
|
end_value = (
|
|
str(_to_unix(event.event_end_date)) if time_based else event.event_end_date
|
|
)
|
|
tags.append(["end", end_value])
|
|
if time_based:
|
|
end_unix = _to_unix(event.event_end_date)
|
|
|
|
if time_based:
|
|
start_unix = _to_unix(event.event_start_date)
|
|
start_day = start_unix // 86400
|
|
end_day = (end_unix // 86400) if end_unix is not None else start_day
|
|
for day in range(start_day, end_day + 1):
|
|
tags.append(["D", str(day)])
|
|
|
|
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])
|
|
|
|
# `amount_tickets == 0` means unlimited capacity in this extension's
|
|
# schema. Omitting the tag is how clients distinguish unlimited from
|
|
# "0 left" (sold out).
|
|
if event.amount_tickets > 0:
|
|
tags.append(["tickets_available", str(event.amount_tickets)])
|
|
tags.append(["tickets_sold", str(event.sold)])
|
|
tags.append(["tickets_price", str(event.price_per_ticket)])
|
|
tags.append(["tickets_currency", event.currency])
|
|
# Fiat-checkout config — only emitted when allow_fiat is on so
|
|
# clients can branch the buy UI without re-reading the schema.
|
|
if event.allow_fiat:
|
|
tags.append(["tickets_allow_fiat", "true"])
|
|
if event.fiat_currency:
|
|
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
|
|
|
nostr_event = NostrEvent(
|
|
pubkey=pubkey,
|
|
created_at=int(time.time()),
|
|
kind=kind,
|
|
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 per
|
|
NIP-09. The referenced kind must match what we published — 31923 for
|
|
time-based events, 31922 for date-only.
|
|
"""
|
|
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
|
|
nostr_event = NostrEvent(
|
|
pubkey=pubkey,
|
|
created_at=int(time.time()),
|
|
kind=5,
|
|
tags=[
|
|
["a", f"{referenced_kind}:{pubkey}:{event.id}"],
|
|
],
|
|
content="Event canceled",
|
|
)
|
|
nostr_event.id = nostr_event.event_id
|
|
return nostr_event
|
|
|
|
|
|
async def publish_event_to_nostr(
|
|
nostr_client,
|
|
event: Event,
|
|
signer: NostrSigner,
|
|
delete: bool = False,
|
|
) -> NostrEvent | None:
|
|
"""
|
|
Build, sign, and publish a NIP-52 calendar event (or delete event).
|
|
|
|
Signing routes through the core `NostrSigner` abstraction —
|
|
`signer.pubkey` for the event identity, `await signer.sign_event(...)`
|
|
for the Schnorr signature. The signer backend (LocalSigner /
|
|
RemoteBunkerSigner) is transparent to this function.
|
|
|
|
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, signer.pubkey)
|
|
else:
|
|
nostr_event = build_nip52_event(event, signer.pubkey)
|
|
|
|
# Hand the unsigned event to the signer — it fills in `id`,
|
|
# `pubkey`, and `sig`. The signer's serialization rules match
|
|
# NIP-01 (same as the local `event_id` property uses), so the
|
|
# returned id matches what we'd have computed locally.
|
|
unsigned = {
|
|
"kind": nostr_event.kind,
|
|
"created_at": nostr_event.created_at,
|
|
"tags": nostr_event.tags,
|
|
"content": nostr_event.content,
|
|
}
|
|
signed = await signer.sign_event(unsigned)
|
|
nostr_event.id = signed["id"]
|
|
nostr_event.pubkey = signed["pubkey"]
|
|
nostr_event.sig = signed["sig"]
|
|
|
|
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
|