Compare commits

..

No commits in common. "d7a25e9bb3c6bee9188185d68fd4ceb44133c1aa" and "80a934be0612e848940572e5848d24ef31d7f86a" have entirely different histories.

6 changed files with 33 additions and 138 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.3.0-aio.3", "version": "1.3.0-aio.2",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",

View file

@ -30,11 +30,9 @@ class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required) name: str # title (required)
info: str = "" # description (optional) info: str = "" # description (optional)
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date closing_date: str | None = None # defaults to event_end_date
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30"). event_start_date: str # required
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time). event_end_date: str | None = None # defaults to event_start_date
event_start_date: str
event_end_date: str | None = None # same format as event_start_date
currency: str = "sat" currency: str = "sat"
amount_tickets: int = 0 # 0 = unlimited / not ticketed amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free price_per_ticket: float = 0 # 0 = free

View file

@ -1,17 +1,14 @@
""" """
NIP-52 calendar event publishing for the events extension. NIP-52 calendar event publishing for the events extension.
Builds NIP-52 calendar events from the Event model, signs them with the Builds kind 31922 (date-based) calendar events from the Event model,
creator's Account keypair, and publishes via the NostrClient. signs them with the event creator's Account keypair, and publishes
via the NostrClient to nostrclient relays.
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 Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
""" """
import time import time
from datetime import datetime, timezone
import coincurve import coincurve
from loguru import logger from loguru import logger
@ -20,60 +17,26 @@ from .models import Event
from .nostr.event import NostrEvent 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: def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
""" """
Convert an Event model to a NIP-52 calendar event. Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event.
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise Tags:
date-based (kind 31922). Tags: d - event.id (addressable identifier)
d - event.id
title - event.name title - event.name
start - unix timestamp (31923) or YYYY-MM-DD (31922) start - event.event_start_date (ISO date string)
end - same encoding (optional) end - event.event_end_date (optional)
image, location, t (categories) - optional image - event.banner (optional)
Content: event.info Content: event.info (description)
""" """
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 = [ tags = [
["d", event.id], ["d", event.id],
["title", event.name], ["title", event.name],
["start", start_value], ["start", event.event_start_date],
] ]
end_unix: int | None = None
if event.event_end_date: if event.event_end_date:
end_value = ( tags.append(["end", event.event_end_date])
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: if event.banner:
tags.append(["image", event.banner]) tags.append(["image", event.banner])
if event.location: if event.location:
@ -84,7 +47,7 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
nostr_event = NostrEvent( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=int(time.time()), created_at=int(time.time()),
kind=kind, kind=31922,
tags=tags, tags=tags,
content=event.info or "", content=event.info or "",
) )
@ -96,17 +59,15 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
""" """
Build a kind 5 delete event for a published NIP-52 calendar event. Build a kind 5 delete event for a published NIP-52 calendar event.
Uses an 'a' tag to reference the parameterized replaceable event per Uses an 'a' tag to reference the parameterized replaceable event
NIP-09. The referenced kind must match what we published 31923 for (kind 31922) per NIP-09.
time-based events, 31922 for date-only.
""" """
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
nostr_event = NostrEvent( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=int(time.time()), created_at=int(time.time()),
kind=5, kind=5,
tags=[ tags=[
["a", f"{referenced_kind}:{pubkey}:{event.id}"], ["a", f"31922:{pubkey}:{event.id}"],
], ],
content="Event canceled", content="Event canceled",
) )

View file

@ -233,39 +233,11 @@ window.PageEvents = {
.catch(LNbits.utils.notifyApiError) .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() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet 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 && !data.extra.promo_codes) { if (data.extra && !data.extra.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.trim() !== '') .filter(code => code.trim() !== '')
@ -281,22 +253,10 @@ window.PageEvents = {
openEventDialog(data = false) { openEventDialog(data = false) {
if (data && data.id) { if (data && data.id) {
const start = this.splitDateTime(data.event_start_date) this.formDialog.data = {...data}
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 { } else {
this.formDialog.data = { this.formDialog.data = {
currency: 'sats', currency: 'sats',
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: { extra: {
conditional: false, conditional: false,
min_tickets: 1, min_tickets: 1,

View file

@ -471,46 +471,28 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-5"> <div class="col-8">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_day" v-model.trim="formDialog.data.event_start_date"
type="date" type="date"
></q-input> ></q-input>
</div> </div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_time"
type="time"
hint="Optional"
></q-input>
</div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row">
<div class="col-4">Event ends</div> <div class="col-4">Event ends</div>
<div class="col-5"> <div class="col-8">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_end_day" v-model.trim="formDialog.data.event_end_date"
type="date" type="date"
></q-input> ></q-input>
</div> </div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_time"
type="time"
hint="Optional"
></q-input>
</div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
@ -668,8 +650,8 @@
formDialog.data.name == null || formDialog.data.name == null ||
formDialog.data.info == null || formDialog.data.info == null ||
formDialog.data.closing_date == null || formDialog.data.closing_date == null ||
formDialog.data.event_start_day == null || formDialog.data.event_start_date == null ||
formDialog.data.event_end_day == null || formDialog.data.event_end_date == null ||
formDialog.data.amount_tickets == null || formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null formDialog.data.price_per_ticket == null
" "

View file

@ -140,15 +140,9 @@ async def api_get_event(event_id: str) -> Event:
# closing_date is filled in by create_event (defaults to end_date or # closing_date is filled in by create_event (defaults to end_date or
# start_date) but the field is typed Optional, so guard for the typechecker. # start_date) but the field is typed Optional, so guard for the typechecker.
closing_date = event.closing_date or event.event_end_date or event.event_start_date closing_date = event.closing_date or event.event_end_date or event.event_start_date
# Accept either YYYY-MM-DD or full ISO 8601 datetime (e.g. event_end_date is_window_open = datetime.now(timezone.utc) < datetime.strptime(
# may carry a time component since v1.3.0-aio.3). closing_date, "%Y-%m-%d"
try: ).replace(tzinfo=timezone.utc)
closing_dt = datetime.fromisoformat(closing_date)
except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
is_window_open = datetime.now(timezone.utc) < closing_dt
is_min_tickets_met = ( is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True event.sold >= event.extra.min_tickets if event.extra.conditional else True
) )