events/nostr_publisher.py
Padreug cc730256ab
Some checks failed
lint.yml / feat(nostr): confirm publishes against the relay's OK (pull_request) Failing after 0s
feat(nostr): confirm publishes against the relay's OK
`publish_nostr_event` returned as soon as the EVENT was on the send
queue, and the publisher logged "Published" on the next line. Queueing
is not delivery: nostrclient drops an EVENT outright when no relay is
connected, answering `OK false "error: no relays connected"`. We threw
that reply away.

On cfaun this cost a completed repair. The #55 sweep republished a
14-day-stale calendar event 21 seconds before nostrclient had finished
connecting to its relay, got `OK false`, logged `Published`, reported
`1/1 recovered` and cleared `nostr_publish_pending` — leaving the count
stale, the row unflagged and the log asserting success. It took a
manual re-arm of the flag to finish the job.

So the flag's contract was never true: it claimed to clear only on a
confirmed success but cleared on a confirmed enqueue.

`publish_nostr_event` now registers a future per event id, awaits the
`OK`, and returns whether it was accepted. `publish_event_to_nostr`
returns None when unconfirmed, which keeps the row flagged so the sweep
retries rather than recording a delivery that never happened.

Correlation lives in `get_event`, the one place relay messages cross
from the websocket thread into the event loop — no cross-thread future
juggling. OK frames are consumed there rather than forwarded; the sync
loop never handled them. A disconnect settles every in-flight publish
immediately instead of making callers wait out the timeout.

On latency: the timeout is not the common cost. A disconnected relay is
rejected by nostrclient's router in milliseconds (230ms measured on
aio-demo), so the 12s budget only applies when relays are connected but
silent, which nostrclient itself bounds at 10s. `set_ticket_paid` runs
on the invoice-listener task, so that narrow case does stall the loop;
if it ever matters, the remedy is to stop awaiting on the sale path
while leaving the flag set — the sweep already guarantees eventual
delivery — not to go back to reporting unverified success.

Closes #56
2026-09-27 22:11:21 +02:00

228 lines
8.9 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, effective_payment_methods
from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
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])
# Rails the organizer accepts, resolved through the same helper the
# ticket endpoint enforces with, so a client can render exactly the
# buttons that will be accepted (e.g. a card-only event) without a REST
# round-trip. Comma-separated, lowercase.
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
# NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
# created_at anchored on the last published value so a same-second
# republish still outranks the prior version and relays push it to open
# subscriptions — a bare int(time.time()) can tie and be silently
# dropped, stalling clients' live "tickets remaining" badge.
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=monotonic_created_at(event.nostr_event_created_at),
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:
# WARNING, not debug: with no client the event is never queued, so
# the relay keeps serving stale inventory and nothing downstream
# can tell. At debug this skip is invisible at the INFO level
# instances actually run at (aiolabs/events#35, #51).
logger.warning(
"[EVENTS] No NostrClient, skipping NIP-52 "
f"{'delete' if delete else 'publish'} for event {event.id}"
)
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"]
accepted = await nostr_client.publish_nostr_event(nostr_event)
if not accepted:
# Returning None keeps `nostr_publish_pending` set, so the
# sweep retries instead of recording a delivery that never
# happened (aiolabs/events#56).
logger.warning(
f"[EVENTS] Relay did not confirm NIP-52 "
f"{'delete' if delete else 'calendar'} event for {event.id}"
)
return None
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:
# ERROR, not warning: this is the signer-outage shape of
# aiolabs/events#35 — the calendar event never reaches the relay
# and the published ticket counts stop tracking the DB.
logger.error(f"[EVENTS] Failed to publish event {event.id} to Nostr: {e}")
return None