diff --git a/models.py b/models.py index e759d66..6b80537 100644 --- a/models.py +++ b/models.py @@ -34,9 +34,11 @@ class CreateEvent(BaseModel): wallet: str | None = None # filled from caller's wallet if absent name: str # title (required) info: str = "" # description (optional) - closing_date: str | None = None # defaults to event_end_date - event_start_date: str # required - event_end_date: str | None = None # defaults to event_start_date + closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date + # ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30"). + # Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time). + 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" diff --git a/nostr_publisher.py b/nostr_publisher.py index bc109b2..bbee871 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -1,14 +1,17 @@ """ 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. +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. Reference: https://github.com/nostr-protocol/nips/blob/master/52.md """ import time +from datetime import datetime, timezone import coincurve from loguru import logger @@ -17,26 +20,60 @@ 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 kind 31922 (date-based) calendar event. + Convert an Event model to a NIP-52 calendar event. - Tags: - d - event.id (addressable identifier) + 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 - event.event_start_date (ISO date string) - end - event.event_end_date (optional) - image - event.banner (optional) - Content: event.info (description) + start - unix timestamp (31923) or YYYY-MM-DD (31922) + end - same encoding (optional) + image, location, t (categories) - optional + Content: event.info """ + 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", event.event_start_date], + ["start", start_value], ] + end_unix: int | None = None if event.event_end_date: - tags.append(["end", 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: @@ -47,7 +84,7 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: nostr_event = NostrEvent( pubkey=pubkey, created_at=int(time.time()), - kind=31922, + kind=kind, tags=tags, content=event.info or "", ) @@ -59,15 +96,17 @@ 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. + 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"31922:{pubkey}:{event.id}"], + ["a", f"{referenced_kind}:{pubkey}:{event.id}"], ], content="Event canceled", ) diff --git a/static/js/index.js b/static/js/index.js index 9044ef1..022399c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -275,11 +275,39 @@ window.PageEvents = { .catch(LNbits.utils.notifyApiError) }) }, + foldDateTime(day, time) { + // Combine separate date/time inputs into the wire format + // expected by the events extension: "YYYY-MM-DD" or + // "YYYY-MM-DDTHH:MM" (time is optional). + if (!day) return null + return time ? `${day}T${time}` : day + }, + splitDateTime(value) { + // Inverse of foldDateTime: split a stored string back into the + // day/time pieces the form inputs bind to. + if (!value) return {day: '', time: ''} + const [day, time = ''] = value.split('T') + // Time inputs only accept HH:MM, drop any seconds we stored. + return {day, time: time.slice(0, 5)} + }, sendEventData() { const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) - const data = this.formDialog.data + const data = {...this.formDialog.data} + data.event_start_date = this.foldDateTime( + data.event_start_day, + data.event_start_time + ) + data.event_end_date = this.foldDateTime( + data.event_end_day, + data.event_end_time + ) + delete data.event_start_day + delete data.event_start_time + delete data.event_end_day + delete data.event_end_time + if (data.extra?.promo_codes) { data.extra.promo_codes = data.extra.promo_codes .filter(code => code.code?.trim() !== '') @@ -303,12 +331,24 @@ window.PageEvents = { openEventDialog(data = false) { if (data && data.id) { - this.formDialog.data = {...data} + const start = this.splitDateTime(data.event_start_date) + const end = this.splitDateTime(data.event_end_date) + this.formDialog.data = { + ...data, + event_start_day: start.day, + event_start_time: start.time, + event_end_day: end.day, + event_end_time: end.time + } } else { this.formDialog.data = { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', + event_start_day: '', + event_start_time: '', + event_end_day: '', + event_end_time: '', extra: { conditional: false, min_tickets: 1, diff --git a/static/js/index.vue b/static/js/index.vue index 88ae90d..4117f47 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -475,28 +475,46 @@ > -