diff --git a/__init__.py b/__init__.py index 01b145e..b6b58a9 100644 --- a/__init__.py +++ b/__init__.py @@ -46,38 +46,6 @@ def events_start(): task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices) scheduled_tasks.append(task1) - # Register nostr-transport RPCs. Swallow ImportError on older LNbits - # versions that pre-date the transport (the events extension still - # works fine via HTTP without it). - try: - from lnbits.core.services.nostr_transport.dispatcher import ( - AUTH_WALLET, - register_rpc, - ) - - from .transport_rpcs import ( - handle_events_list_event_tickets, - handle_events_ticket_register, - ) - - register_rpc( - "events_ticket_register", handle_events_ticket_register, AUTH_WALLET - ) - register_rpc( - "events_list_event_tickets", - handle_events_list_event_tickets, - AUTH_WALLET, - ) - logger.info( - "[EVENTS] Registered nostr-transport RPCs: " - "events_ticket_register, events_list_event_tickets" - ) - except ImportError: - logger.info( - "[EVENTS] nostr_transport not available on this LNbits — " - "ticket scanner over Nostr disabled, HTTP endpoint still works" - ) - async def _start_nostr_client(): global nostr_client await asyncio.sleep(10) # Wait for nostrclient to be ready diff --git a/config.json b/config.json index 02272e3..c8adf29 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.7", + "version": "1.3.0-aio.3", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", @@ -36,7 +36,7 @@ { "name": "padreug", "uri": "https://git.atitlan.io/padreug", - "role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync + edit gating)" + "role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync)" } ], "images": [ diff --git a/crud.py b/crud.py index 551a3bc..0a51727 100644 --- a/crud.py +++ b/crud.py @@ -41,19 +41,8 @@ async def create_ticket( email: str | None = None, user_id: str | None = None, extra: dict | None = None, - ticket_id: str | None = None, ) -> Ticket: - """Persist one ticket row. - - `payment_hash` is the LNbits invoice hash shared across all rows - of a multi-ticket purchase. `ticket_id` is the row primary key / - scannable id; defaults to `payment_hash` for single-ticket - purchases so the legacy id == payment_hash invariant holds. - Multi-ticket callers pass a unique uuid here so each attendee - gets a distinct scannable QR. - """ now = datetime.now(timezone.utc) - row_id = ticket_id or payment_hash # name/email columns are NOT NULL in the schema, so we store "" when only # user_id is supplied. _parse_ticket_row reverses this on read. @@ -65,7 +54,7 @@ async def create_ticket( db_email = email or "" db_ticket = Ticket( - id=row_id, + id=payment_hash, wallet=wallet, event=event, name=db_name, @@ -76,12 +65,11 @@ async def create_ticket( reg_timestamp=now, time=now, extra=TicketExtra(**extra) if extra else TicketExtra(), - payment_hash=payment_hash, ) await db.insert("events.ticket", db_ticket) return Ticket( - id=row_id, + id=payment_hash, wallet=wallet, event=event, name=name, @@ -92,7 +80,6 @@ async def create_ticket( reg_timestamp=now, time=now, extra=TicketExtra(**extra) if extra else TicketExtra(), - payment_hash=payment_hash, ) @@ -106,23 +93,8 @@ async def update_ticket(ticket: Ticket) -> Ticket: return ticket -async def get_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]: - """All ticket rows sharing the given LNbits invoice payment_hash. - - For a single-ticket purchase returns one row (legacy invariant - `id == payment_hash` still holds). For a multi-ticket purchase - returns the N rows created with shared `payment_hash` but - distinct `id`s — each attendee's scannable QR. - """ - rows = await db.fetchall( - "SELECT * FROM events.ticket WHERE payment_hash = :ph", - {"ph": payment_hash}, - ) - return [Ticket(**_parse_ticket_row(row)) for row in rows] - - async def get_ticket(payment_hash: str) -> Ticket | None: - row = await db.fetchone( + row: dict | None = await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", {"id": payment_hash}, ) @@ -135,22 +107,15 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})") - return [Ticket(**_parse_ticket_row(row)) for row in rows] - - -async def get_tickets_by_event(event_id: str) -> list[Ticket]: - """All ticket rows for the given calendar event id.""" - rows = await db.fetchall( - "SELECT * FROM events.ticket WHERE event = :event_id", - {"event_id": event_id}, + rows: list[dict] = await db.fetchall( + f"SELECT * FROM events.ticket WHERE wallet IN ({q})" ) return [Ticket(**_parse_ticket_row(row)) for row in rows] async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: """All tickets owned by the given LNbits user_id.""" - rows = await db.fetchall( + rows: list[dict] = await db.fetchall( "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", {"user_id": user_id}, ) @@ -243,7 +208,7 @@ async def get_pending_events() -> list[Event]: async def get_settings() -> EventsSettings: """Singleton settings row, seeded by m010.""" - row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1") + row: dict | None = await db.fetchone("SELECT * FROM events.settings WHERE id = 1") if row: return EventsSettings(**dict(row)) return EventsSettings() @@ -262,7 +227,7 @@ async def delete_event(event_id: str) -> None: async def get_event_tickets(event_id: str) -> list[Ticket]: - rows = await db.fetchall( + rows: list[dict] = await db.fetchall( "SELECT * FROM events.ticket WHERE event = :event", {"event": event_id}, ) diff --git a/migrations.py b/migrations.py index 512540d..016ded1 100644 --- a/migrations.py +++ b/migrations.py @@ -162,36 +162,96 @@ async def m005_add_image_banner(db): await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") +async def _alter_add_column_safe(db, sql: str) -> None: + """ALTER TABLE ADD COLUMN that swallows duplicate-column errors. + + Earlier aiolabs/events forks added some of these columns under different + migration names (e.g. our former m007). Skipping the error keeps the + migration log monotonic for both fresh installs and pre-rebase upgrades. + """ + try: + await db.execute(sql) + except Exception as exc: + msg = str(exc).lower() + if "duplicate column" in msg or "already exists" in msg: + return + raise + + async def m006_add_extra_fields(db): """ Add a canceled and 'extra' column to events and ticket tables to support promo codes and ticket metadata. """ - # Add canceled and 'extra' columns to events table - await db.execute( - "ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;" + await _alter_add_column_safe( + db, + "ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE", ) - await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;") - - # Add 'extra' column to ticket table - await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") + await _alter_add_column_safe(db, "ALTER TABLE events.events ADD COLUMN extra TEXT") + await _alter_add_column_safe(db, "ALTER TABLE events.ticket ADD COLUMN extra TEXT") -async def m007_add_allow_fiat(db): +async def m007_add_user_id_support(db): """ - Add an allow_fiat column so event owners can explicitly enable fiat checkout. + Add user_id column to ticket table so a ticket can reference an LNbits + user id instead of (name, email). Application logic enforces that exactly + one identifier scheme is used per ticket. + """ + await _alter_add_column_safe( + db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT" + ) + + +async def m008_add_event_status(db): + """ + Add status column to events table for the proposal/approval workflow. + Values: 'proposed', 'approved', 'rejected'. Existing rows default to + 'approved' so they stay visible after upgrade. + """ + await _alter_add_column_safe( + db, + "ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'", + ) + + +async def m009_add_nostr_columns(db): + """ + Track the most recent NIP-52 calendar event we published for this event + (used for replaceable updates and NIP-09 deletes). + """ + await _alter_add_column_safe( + db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT" + ) + await _alter_add_column_safe( + db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER" + ) + + +async def m010_add_events_settings(db): + """ + Create the extension settings singleton row used by the admin UI to + toggle e.g. auto_approve. """ await db.execute(""" - ALTER TABLE events.events - ADD COLUMN allow_fiat BOOLEAN NOT NULL DEFAULT FALSE; + CREATE TABLE IF NOT EXISTS events.settings ( + id INTEGER PRIMARY KEY DEFAULT 1, + auto_approve BOOLEAN NOT NULL DEFAULT FALSE + ) """) + await db.execute( + "INSERT INTO events.settings (id, auto_approve) " + "SELECT 1, FALSE WHERE NOT EXISTS " + "(SELECT 1 FROM events.settings WHERE id = 1)" + ) -async def m008_add_fiat_currency(db): +async def m011_add_location_and_categories(db): """ - Add a fiat_currency column for sat-denominated events using fiat checkout. + Add NIP-52 calendar metadata (location and a JSON-encoded category list). """ - await db.execute(""" - ALTER TABLE events.events - ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP'; - """) + await _alter_add_column_safe( + db, "ALTER TABLE events.events ADD COLUMN location TEXT" + ) + await _alter_add_column_safe( + db, "ALTER TABLE events.events ADD COLUMN categories TEXT" + ) diff --git a/migrations_fork.py b/migrations_fork.py deleted file mode 100644 index 864cbb8..0000000 --- a/migrations_fork.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Fork-specific database migrations for the aiolabs events extension. - -These migrations are tracked separately under `events_fork` in the -`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`), -so they do not collide with upstream's `m{NNN}_*` numbering in -`migrations.py`. Keeping the upstream-tracked file untouched means -`git pull upstream` stays rebase-clean for schema changes. - -Conventions: - - Sequential numbering starting from m001. - - Each migration is `async def m{NNN}_(db)`. - - DDL must be idempotent: a fresh install runs every migration; an - install that previously ran the OLD versions of these as - `m007-m011` in `migrations.py` has the columns/tables already. - Use `_alter_add_column_safe` / `_create_table_safe` so re-runs are - no-ops instead of crashes. - -History compressed into m001 (was m007-m011 in migrations.py pre-v1.6 -rebase): - - m007 add_user_id_support (ticket.user_id column) - - m008 add_event_status (events.status column) - - m009 add_nostr_columns (events.nostr_event_id + created_at) - - m010 add_events_settings (events.settings singleton table) - - m011 add_location_and_categories (events.location + categories) -""" - - -async def _alter_add_column_safe(db, sql: str) -> None: - """ALTER TABLE ADD COLUMN that swallows duplicate-column errors. - - Re-running the squashed migration on a database that already has - these columns (from the pre-squash `m007-m011` in migrations.py) - must be a silent no-op. Same swallow we used in the old migrations. - """ - try: - await db.execute(sql) - except Exception as exc: - msg = str(exc).lower() - if "duplicate column" in msg or "already exists" in msg: - return - raise - - -async def m001_aio_event_schema(db): - """ - Apply every aiolabs schema delta on top of upstream events v1.3.0. - - This is the squashed equivalent of the pre-v1.6 sequence - m007 → m011. Order matters for the settings table seed insert - but the individual column adds are independent and idempotent. - """ - - # --- ticket.user_id ---------------------------------------------- - # Lets a ticket reference an LNbits user id instead of (name, email). - # Application logic enforces that exactly one identifier scheme is - # used per ticket. - await _alter_add_column_safe( - db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT" - ) - - # --- events.status ----------------------------------------------- - # Proposal / approval workflow. Existing rows default to 'approved' - # so they stay visible after upgrade. - await _alter_add_column_safe( - db, - "ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'", - ) - - # --- events.nostr_event_id, nostr_event_created_at --------------- - # Track the most recent NIP-52 calendar event we published, so - # subsequent edits can issue replaceable updates and NIP-09 deletes - # against the right addressable coordinate. - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT" - ) - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER" - ) - - # --- events.settings --------------------------------------------- - # Singleton settings row used by the admin UI to toggle e.g. - # auto_approve. CREATE TABLE IF NOT EXISTS + a guarded seed keeps - # this idempotent. - await db.execute(""" - CREATE TABLE IF NOT EXISTS events.settings ( - id INTEGER PRIMARY KEY DEFAULT 1, - auto_approve BOOLEAN NOT NULL DEFAULT FALSE - ) - """) - await db.execute( - "INSERT INTO events.settings (id, auto_approve) " - "SELECT 1, FALSE WHERE NOT EXISTS " - "(SELECT 1 FROM events.settings WHERE id = 1)" - ) - - # --- events.location, events.categories -------------------------- - # NIP-52 calendar metadata. `categories` carries a JSON-encoded - # list of hashtags (the NIP-52 `t` tags). - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN location TEXT" - ) - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN categories TEXT" - ) - - -async def m002_ticket_payment_hash(db): - """ - Add `ticket.payment_hash` for multi-ticket purchases. - - Multi-ticket purchases land as N rows sharing one LNbits invoice - (so each attendee gets a distinct scannable QR but the buyer - pays once). `ticket.id` stays the row primary key — for legacy - single-purchase rows it equals payment_hash; for multi-purchase - children it's a uuid generated at create-time. `payment_hash` - is the new join key for invoice lookup. - - Backfill existing rows from id so the - GET-tickets-by-payment-hash path keeps working for pre-migration - data (id was the payment_hash by invariant before this column). - """ - await _alter_add_column_safe( - db, "ALTER TABLE events.ticket ADD COLUMN payment_hash TEXT" - ) - await db.execute( - "UPDATE events.ticket SET payment_hash = id " - "WHERE payment_hash IS NULL OR payment_hash = ''" - ) - diff --git a/models.py b/models.py index 7f1feac..a617a13 100644 --- a/models.py +++ b/models.py @@ -24,10 +24,6 @@ class EventExtra(BaseModel): promo_codes: list[PromoCode] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 - email_notifications: bool = False - nostr_notifications: bool = False - notification_subject: str = "" - notification_body: str = "" class CreateEvent(BaseModel): @@ -40,8 +36,6 @@ class CreateEvent(BaseModel): event_start_date: str event_end_date: str | None = None # same format as event_start_date currency: str = "sat" - allow_fiat: bool = False - fiat_currency: str = "GBP" amount_tickets: int = 0 # 0 = unlimited / not ticketed price_per_ticket: float = 0 # 0 = free banner: str | None = None @@ -61,8 +55,6 @@ class Event(BaseModel): event_start_date: str event_end_date: str | None = None currency: str = "sat" - allow_fiat: bool = False - fiat_currency: str = "GBP" amount_tickets: int = 0 price_per_ticket: float = 0 time: datetime @@ -90,14 +82,9 @@ class PublicEvent(BaseModel): canceled: bool event_start_date: str event_end_date: str | None = None - currency: str - allow_fiat: bool = False - fiat_currency: str = "GBP" - price_per_ticket: float banner: str | None location: str | None = None categories: list[str] = Field(default_factory=list) - extra: EventExtra = Field(default_factory=EventExtra) status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner @validator("categories", pre=True) @@ -117,10 +104,6 @@ class TicketExtra(BaseModel): applied_promo_code: str | None = None sats_paid: int | None = None refund_address: str | None = None - nostr_identifier: str | None = None - ticket_base_url: str | None = None - email_notification_sent: bool = False - nostr_notification_sent: bool = False refunded: bool = False @@ -130,12 +113,6 @@ class CreateTicket(BaseModel): user_id: str | None = None # LNbits user id (alternative to name+email) promo_code: str | None = None refund_address: str | None = None - nostr_identifier: str | None = None - payment_method: str | None = None - fiat_provider: str | None = None - # Number of tickets to buy on this single invoice. Bounded so a - # bad client can't run away with the organizer's capacity. - quantity: int = Field(default=1, ge=1, le=10) @root_validator def validate_identifiers(cls, values): @@ -161,11 +138,6 @@ class Ticket(BaseModel): time: datetime reg_timestamp: datetime extra: TicketExtra = Field(default_factory=TicketExtra) - # Shared LNbits invoice payment_hash. Equals `id` for single-ticket - # purchases (legacy + post-migration default). Multi-ticket - # purchases create N rows sharing one payment_hash so each attendee - # gets a distinct scannable id while the buyer pays once. - payment_hash: str | None = None class PublicTicket(BaseModel): @@ -179,16 +151,4 @@ class PublicTicket(BaseModel): class TicketPaymentRequest(BaseModel): payment_hash: str - payment_request: str | None = None - fiat_payment_request: str | None = None - fiat_provider: str | None = None - is_fiat: bool = False - # True when the tickets are already issued + paid with no invoice to - # settle — free events (price 0) or a 100%-off promo. The client skips - # the QR / payment-poll step and goes straight to the ticket QRs. - paid: bool = False - # Row ids created on this invoice — one for single-ticket - # purchases, N for multi-ticket (each independently scannable at - # the door). Buyers fetch these after payment to render N QRs in - # My Tickets. - ticket_ids: list[str] = Field(default_factory=list) + payment_request: str diff --git a/nostr_hooks.py b/nostr_hooks.py index 32ea11c..daf1fc0 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -15,30 +15,29 @@ from .nostr_publisher import publish_event_to_nostr async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None: """Publish or delete the NIP-52 calendar event for `event`. - Resolves a `NostrSigner` for the wallet owner — backend-agnostic - (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The - signer abstraction handles the actual key material; this hook - only needs `signer.pubkey` for event construction and - `await signer.sign_event(...)` for signing. Failures are logged - and swallowed so a Nostr outage doesn't break the HTTP flow that - triggered the publish. + Pulls the wallet owner's pubkey/prvkey to sign with the user's identity. + Failures are logged and swallowed so a Nostr outage doesn't break the + HTTP flow that triggered the publish. """ try: - from lnbits.core.signers import resolve_for_wallet + from lnbits.core.crud.users import get_account + from lnbits.core.crud.wallets import get_wallet from . import nostr_client - signer = await resolve_for_wallet(event.wallet) - if signer is None: - # Wallet missing, account missing, unclassified row, or - # ClientSideOnlySigner account (server can't sign for them). - # Soft-fail: skip the publish silently. The user can still - # publish kind-31922/31923 events client-side once we have - # that path. + wallet_obj = await get_wallet(event.wallet) + if not wallet_obj: + return + account = await get_account(wallet_obj.user) + if not account or not account.pubkey or not account.prvkey: # type: ignore[attr-defined] return nostr_event = await publish_event_to_nostr( - nostr_client, event, signer, delete=delete + nostr_client, + event, + account.pubkey, + account.prvkey, # type: ignore[attr-defined] + delete=delete, ) if nostr_event and not delete: event.nostr_event_id = nostr_event.id diff --git a/nostr_publisher.py b/nostr_publisher.py index 2588fcb..a6d487b 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -1,9 +1,8 @@ """ 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. +Builds NIP-52 calendar events from the Event model, signs them with the +creator's Account keypair, 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. @@ -14,12 +13,11 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md import time from datetime import datetime, timezone -from lnbits.core.signers import NostrSigner +import coincurve from loguru import logger from .models import Event from .nostr.event import NostrEvent -from .nostr_timestamp import monotonic_created_at def _has_time(value: str | None) -> bool: @@ -41,25 +39,12 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: 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) + 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 @@ -96,30 +81,9 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: 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]) - - # 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), + created_at=int(time.time()), kind=kind, tags=tags, content=event.info or "", @@ -150,20 +114,23 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent: 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, - signer: NostrSigner, + account_pubkey: str, + account_prvkey: str, 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: @@ -172,25 +139,11 @@ async def publish_event_to_nostr( try: if delete: - nostr_event = build_nip52_delete_event(event, signer.pubkey) + nostr_event = build_nip52_delete_event(event, account_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"] + 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( diff --git a/nostr_sync.py b/nostr_sync.py index 1dc52bc..11869cd 100644 --- a/nostr_sync.py +++ b/nostr_sync.py @@ -50,7 +50,7 @@ async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict): return tags = {t[0]: t[1] for t in event_data.get("tags", []) if len(t) >= 2} - tag_lists = {} + tag_lists: dict[str, list[str]] = {} for t in event_data.get("tags", []): if len(t) >= 2: tag_lists.setdefault(t[0], []).append(t[1]) diff --git a/nostr_timestamp.py b/nostr_timestamp.py deleted file mode 100644 index 625b21c..0000000 --- a/nostr_timestamp.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Monotonic ``created_at`` for replaceable / addressable Nostr events. - -Relays only push a replaceable update to OPEN subscriptions when its -``created_at`` is strictly newer than the version they already hold. -``created_at`` is integer seconds, so a publisher that stamps -``int(time.time())`` can emit two versions within the same wall-clock -second (e.g. two ticket sales republishing the NIP-52 calendar event) — -the relay treats the second as not-newer and never propagates it to live -subscribers (it only surfaces on a reload / fresh REQ). - -Returning ``max(now, last_created_at + 1)`` guarantees a strictly -increasing timestamp across successive publishes of the same replaceable -event. When enough real seconds have elapsed it tracks wall-clock; only -same-second (or clock-skewed) republishes get nudged forward. - -Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts) -and ``docs/nostr-patterns/replaceable-events.md``. -""" - -import time - - -def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int: - """Strictly-newer ``created_at`` for the next publish of a coord. - - :param last_created_at: ``created_at`` of the previously published - version (seconds), or ``None`` if none has been published yet. - :param now: Current time in seconds — injectable for tests; defaults - to ``int(time.time())``. - """ - base = int(time.time()) if now is None else now - if last_created_at is None: - return base - return max(base, last_created_at + 1) diff --git a/services.py b/services.py index 0a2de28..9099ef0 100644 --- a/services.py +++ b/services.py @@ -1,16 +1,3 @@ -from __future__ import annotations - -import asyncio -from asyncio.tasks import create_task - -from lnbits.core.models.users import UserNotifications -from lnbits.core.services.nostr import send_nostr_dm -from lnbits.core.services.notifications import ( - send_email_notification, - send_user_notification, -) -from lnbits.settings import settings -from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute from loguru import logger @@ -21,146 +8,25 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, Ticket -from .nostr_hooks import publish_or_delete_nostr_event - -DEFAULT_NOSTR_RELAYS = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nostr.band", -] - -# Per-event lock: serializes the counter-update + Nostr republish for a -# single event_id so two paid invoices landing on the listener queue back- -# to-back can't reorder the published state. Lazy-populated; entries are -# left in memory for the lifetime of the process (cheap — one asyncio.Lock -# object per event ever sold). -_event_paid_locks: dict[str, asyncio.Lock] = {} - - -def _event_paid_lock(event_id: str) -> asyncio.Lock: - lock = _event_paid_locks.get(event_id) - if lock is None: - lock = asyncio.Lock() - _event_paid_locks[event_id] = lock - return lock +from .models import Ticket async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket - async with _event_paid_lock(ticket.event): - ticket.paid = True - await update_ticket(ticket) + ticket.paid = True + await update_ticket(ticket) - event = await get_event(ticket.event) - assert event, "Couldn't get event from ticket being paid" - event.sold += 1 - event.amount_tickets -= 1 - await update_event(event) - - # Republish the NIP-52 calendar event so connected clients see - # the new tickets_available / tickets_sold counters via their - # existing relay subscription. Failures are logged + swallowed - # inside publish_or_delete_nostr_event so a Nostr outage doesn't - # break the payment flow. - await publish_or_delete_nostr_event(event) + event = await get_event(ticket.event) + assert event, "Couldn't get event from ticket being paid" + event.sold += 1 + event.amount_tickets -= 1 + await update_event(event) return ticket -def send_ticket_notification_in_background(ticket: Ticket) -> None: - create_task(_send_ticket_notification(ticket)) - - -async def _send_ticket_notification(ticket: Ticket) -> None: - event = await get_event(ticket.event) - if not event: - logger.warning(f"Event {ticket.event} not found for ticket notification.") - return - - subject, message = _ticket_notification_message(ticket, event) - updated = False - - if ( - event.extra.email_notifications - and settings.lnbits_email_notifications_enabled - and ticket.email - ): - try: - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to email ticket {ticket.id}: {exc}") - - if ( - event.extra.nostr_notifications - and settings.is_nostr_notifications_configured() - and ticket.extra.nostr_identifier - ): - try: - await _send_nostr_ticket_notification( - ticket.extra.nostr_identifier, message - ) - ticket.extra.nostr_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") - - if updated: - await update_ticket(ticket) - - -async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: - event = await get_event(ticket.event) - if not event: - raise ValueError("Event does not exist.") - if not settings.lnbits_email_notifications_enabled: - raise ValueError("Email notifications are not enabled.") - if not ticket.email: - raise ValueError("Ticket does not have an email address.") - - subject, message = _ticket_notification_message(ticket, event) - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - return await update_ticket(ticket) - - -def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: - ticket_url = _ticket_url(ticket) - subject = ( - event.extra.notification_subject.strip() - or f"Your ticket for '{event.name}' is ready" - ) - body = ( - event.extra.notification_body.strip() - or f"Your ticket for '{event.name}' is ready." - ) - - return subject, f"{body}\n\nOpen it here: {ticket_url}" - - -async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: - if "@" in identifier: - await send_user_notification( - UserNotifications(nostr_identifier=identifier), - message, - "text_message", - ) - return - - private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key) - public_key = normalize_public_key(identifier) - await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS) - - -def _ticket_url(ticket: Ticket) -> str: - base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") - return f"{base_url}/events/ticket/{ticket.id}" - - async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement. diff --git a/static/js/display.js b/static/js/display.js index d8be8e9..a652ba5 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -11,9 +11,7 @@ window.PageEventsDisplay = { data: { name: '', email: '', - refund: '', - nostr_identifier: '', - payment_method: 'lightning' + refund: '' } }, ticketLink: { @@ -25,8 +23,7 @@ window.PageEventsDisplay = { receive: { show: false, status: 'pending', - paymentReq: null, - isFiat: false + paymentReq: null }, paymentDismissMsg: null, paymentWebsocket: null @@ -38,25 +35,7 @@ window.PageEventsDisplay = { }, computed: { formatDescription() { - return LNbits.utils.convertMarkdown(this.event?.info || '') - }, - allowFiatCheckout() { - return Boolean(this.event?.allow_fiat) - }, - fiatCheckoutLabel() { - if (!this.allowFiatCheckout) return 'Fiat' - const unit = ['sat', 'sats'].includes( - (this.event?.currency || '').toLowerCase() - ) - ? this.event?.fiat_currency - : this.event?.currency - return `Fiat (${(unit || 'GBP').toUpperCase()})` - }, - allowEmailNotifications() { - return Boolean(this.event?.extra?.email_notifications) - }, - allowNostrNotifications() { - return Boolean(this.event?.extra?.nostr_notifications) + return LNbits.utils.convertMarkdown(this.info) } }, methods: { @@ -77,8 +56,6 @@ window.PageEventsDisplay = { this.formDialog.data.name = '' this.formDialog.data.email = '' this.formDialog.data.refund = '' - this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = 'lightning' }, closeReceiveDialog() { @@ -110,9 +87,6 @@ window.PageEventsDisplay = { this.paymentReq = null this.formDialog.data.name = '' this.formDialog.data.email = '' - this.formDialog.data.refund = '' - this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = 'lightning' Quasar.Notify.create({ type: 'positive', message: 'Sent, thank you!', @@ -121,8 +95,7 @@ window.PageEventsDisplay = { this.receive = { show: false, status: 'complete', - paymentReq: null, - isFiat: false + paymentReq: null } this.ticketLink = { show: true, @@ -130,7 +103,9 @@ window.PageEventsDisplay = { link: `/events/ticket/${paymentHash}` } } - window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener') + setTimeout(() => { + window.location.href = `/events/ticket/${paymentHash}` + }, 5000) }, async createInvoice() { try { @@ -142,15 +117,10 @@ window.PageEventsDisplay = { name: this.formDialog.data.name, email: this.formDialog.data.email, promo_code: this.formDialog.data.promo_code || null, - refund_address: this.formDialog.data.refund || null, - nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.formDialog.data.payment_method + refund_address: this.formDialog.data.refund || null } ) - const isFiat = Boolean(data.is_fiat) - this.paymentReq = isFiat - ? data.fiat_payment_request || null - : data.payment_request + this.paymentReq = data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -160,34 +130,30 @@ window.PageEventsDisplay = { this.receive = { show: true, status: 'pending', - paymentReq: this.paymentReq, - isFiat + paymentReq: this.paymentReq } - if (isFiat && this.paymentReq) { - window.open(this.paymentReq, '_blank', 'noopener') - } - this.paymentWatcher(this.paymentHash) + this.websocketListener(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) } }, - paymentWatcher(paymentHash) { + websocketListener(paymentHash) { if (this.paymentWebsocket) { this.paymentWebsocket.close() } const url = new URL(window.location) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/api/v1/ws/${paymentHash}` + url.protocol = url.protocol === 'https:' ? 'wss' : 'ws' + url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` url.search = '' url.hash = '' - const ws = new WebSocket(url.toString()) + const ws = new WebSocket(url) this.paymentWebsocket = ws ws.onmessage = event => { const data = JSON.parse(event.data) - if (data.pending === false) { + if (data.paid) { this.paymentSuccess(paymentHash) ws.close() } diff --git a/static/js/display.vue b/static/js/display.vue index 9b27783..58fec04 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -41,77 +41,41 @@
Buy Ticket
-
-
- -
-
- -
-
- -
-
+ + + -
-
- -
-
- -
-
Link to your ticket! +

+

You'll be redirected in a few moments...

@@ -153,37 +119,6 @@ class="q-pa-lg q-pt-xl lnbits__dialog-card" > - -
-
Continue to checkout
-
- Your fiat checkout opened in a new tab. If it did not, use the - button below. -
- - Go to checkout - -
-
- Copy payment link - Close -
-
{ - if (row.extra && row.extra.conditional && row.canceled) { - return 'Yes' - } - return 'No' - } - }, - {name: 'status', align: 'left', label: 'Status', field: 'status'} - ] - }, eventsTable: { columns: [ {name: 'id', align: 'left', label: 'ID', field: 'id'}, @@ -97,7 +49,7 @@ window.PageEvents = { align: 'left', label: 'Price', field: row => { - if (this.isFiatCurrency(row.currency)) { + if (row.currency != 'sats') { return LNbits.utils.formatCurrency( row.price_per_ticket.toFixed(2), row.currency @@ -153,21 +105,14 @@ window.PageEvents = { show: false, data: { currency: 'sats', - allow_fiat: false, - fiat_currency: 'GBP', extra: { - promo_codes: [], - notification_subject: '', - notification_body: '' + promo_codes: [] } } } } }, methods: { - isFiatCurrency(currency) { - return !['sat', 'sats'].includes((currency || '').toLowerCase()) - }, getTickets() { LNbits.api .request( @@ -200,35 +145,6 @@ window.PageEvents = { .catch(LNbits.utils.notifyApiError) }) }, - resendTicketEmail(ticket) { - if (!ticket.paid || !ticket.email) return - const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) - if (!wallet) return - - this.resendingTicketEmails.push(ticket.id) - LNbits.api - .request( - 'POST', - '/events/api/v1/tickets/' + ticket.id + '/resend-email', - wallet.adminkey - ) - .then(response => { - this.tickets = this.tickets.map(obj => - obj.id === ticket.id ? response.data : obj - ) - Quasar.Notify.create({ - type: 'positive', - message: 'Ticket email resent.', - icon: null - }) - }) - .catch(LNbits.utils.notifyApiError) - .finally(() => { - this.resendingTicketEmails = this.resendingTicketEmails.filter( - ticketId => ticketId !== ticket.id - ) - }) - }, exportticketsCSV() { LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) }, @@ -271,12 +187,7 @@ window.PageEvents = { }, saveSettings() { LNbits.api - .request( - 'PUT', - '/events/api/v1/events/settings', - null, - this.settings - ) + .request('PUT', '/events/api/v1/events/settings', null, this.settings) .then(() => { Quasar.Notify.create({type: 'positive', message: 'Settings saved'}) }) @@ -322,63 +233,6 @@ window.PageEvents = { .catch(LNbits.utils.notifyApiError) }) }, - republishAllEvents() { - LNbits.utils - .confirmDialog( - 'Re-emit every approved event to Nostr relays? This is safe ' + - 'to run multiple times but generates one event per approved row.' - ) - .onOk(() => { - this.republishing = true - LNbits.api - .request('POST', '/events/api/v1/events/republish-all') - .then(response => { - Quasar.Notify.create({ - type: 'positive', - message: - 'Republished ' + - response.data.republished + - ' of ' + - response.data.total + - ' events' - }) - }) - .catch(LNbits.utils.notifyApiError) - .finally(() => { - this.republishing = false - }) - }) - }, - republishMyEvents() { - LNbits.utils - .confirmDialog( - 'Re-emit your approved events to Nostr relays?' - ) - .onOk(() => { - this.republishingMine = true - LNbits.api - .request( - 'POST', - '/events/api/v1/events/republish-mine?all_wallets=true', - this.g.user.wallets[0].adminkey - ) - .then(response => { - Quasar.Notify.create({ - type: 'positive', - message: - 'Republished ' + - response.data.republished + - ' of your ' + - response.data.total + - ' events' - }) - }) - .catch(LNbits.utils.notifyApiError) - .finally(() => { - this.republishingMine = false - }) - }) - }, foldDateTime(day, time) { // Combine separate date/time inputs into the wire format // expected by the events extension: "YYYY-MM-DD" or @@ -412,18 +266,10 @@ window.PageEvents = { delete data.event_end_day delete data.event_end_time - if (data.extra?.promo_codes) { + if (data.extra && !data.extra.promo_codes) { data.extra.promo_codes = data.extra.promo_codes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase() - })) - } - if (!this.isFiatCurrency(data.currency)) { - if (!data.allow_fiat) { - data.fiat_currency = 'GBP' - } + .filter(code => code.trim() !== '') + .map(code => code.trim().toUpperCase()) } if (data.id) { @@ -447,8 +293,6 @@ window.PageEvents = { } else { this.formDialog.data = { currency: 'sats', - allow_fiat: false, - fiat_currency: 'GBP', event_start_day: '', event_start_time: '', event_end_day: '', @@ -456,11 +300,7 @@ window.PageEvents = { extra: { conditional: false, min_tickets: 1, - email_notifications: false, - nostr_notifications: false, - promo_codes: [], - notification_subject: '', - notification_body: '' + promo_codes: [] } } } @@ -469,15 +309,8 @@ window.PageEvents = { resetEventDialog() { this.formDialog.show = false this.formDialog.data = { - currency: 'sats', - allow_fiat: false, - fiat_currency: 'GBP', extra: { - email_notifications: false, - nostr_notifications: false, - promo_codes: [], - notification_subject: '', - notification_body: '' + promo_codes: [] } } }, diff --git a/static/js/index.vue b/static/js/index.vue index 6e6891f..df3a990 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -15,50 +15,14 @@ >
- -
-
- Republish to Nostr -
- Re-emit every approved event so connected clients pick - up the latest tag set. Useful after the extension - publisher changes (e.g. new tickets_* tags) so existing - events don't need a per-event edit. -
-
-
- -
-
-
- New Event - -
-
- Re-emit your approved events to Nostr relays. Useful after - a publisher upgrade or if a relay dropped your events. -
+ New Event
@@ -228,7 +192,13 @@ @@ -286,6 +256,57 @@ + + +
+
+
+ All Users' Events + +
+
+
+ + + + +
+
+
@@ -308,12 +329,10 @@ >