Some checks failed
lint.yml / fix(nostr): always publish tickets_available, zero is not unlimited (pull_request) Failing after 0s
Omitting the tag used to mean "unlimited capacity". Nothing else in the codebase agreed: `api_get_event` and `api_ticket_create` both treat `amount_tickets < 1` as sold out. So a zero-capacity event advertised "Unlimited tickets" on the card while the detail page and the purchase both returned 410. Observed on aio-demo — three approved, listed, free events in that state. For `PKBVuusKikfJFU4PYGtBTW`: relay tickets_available absent -> webapp renders "Unlimited" GET event 410 "Event is sold out." POST ticket 410 "Event is sold out." The admin form was advertising it too (`min="0"`, `hint="0 = unlimited"`), so organizers were being invited into the broken state. Now the tag is always emitted and zero reads as sold out, which is what every other part of the system already believed. Clients that must handle an absent tag — a NIP-52 event from another publisher — are unaffected, since we simply never omit it. Scoped to removing the contradiction. Making capacity a *required* field is the other half of #34 and is blocked on the webapp: its create dialog uses a falsy check (`if (formValues.amount_tickets)`), so a 0 omits the field entirely and a server-side `ge=1` would 422 it. Refs #34
231 lines
9.2 KiB
Python
231 lines
9.2 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 (always emitted; 0 = sold out)
|
|
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])
|
|
|
|
# Always emitted, including zero. Omitting it used to mean "unlimited",
|
|
# which contradicted every other reader: `api_get_event` and
|
|
# `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a
|
|
# zero-capacity event advertised "Unlimited tickets" on the card while
|
|
# the detail page and the purchase both returned 410 (aiolabs/events#34).
|
|
# Clients that must still handle an absent tag — a NIP-52 event from
|
|
# some other publisher — are unaffected; we simply never omit it.
|
|
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
|