calendar.mx raw
1 package protocol
2
3 import (
4 "git.smesh.lol/nostr/pkg/lol/errorf"
5 )
6
7 // CalendarEvent is the payload of a NIP-52 event. Kind 31923 is the time-based
8 // form and doubles as the event roster: pre-show it carries the confirmed
9 // performers, post-show the same address carries the full participant record
10 // with all calendar fields kept. There is no separate roster event.
11 //
12 // Kind 31922 is the date-based form (festivals, release dates); the same
13 // struct serves both, with Start/End empty and Days set for the date form.
14 type CalendarEvent struct {
15 D string
16 Title string
17 Summary string
18 Image string // a URL on calendar events, unlike the music layer's blob ref
19 Start string // unix seconds, time-based form
20 End string
21 Days []string // the NIP-52 D tag: floor(unix/86400), one per covered day
22 StartTzid string // IANA timezone
23 Location string
24 Geohash string
25 Participants []Participant
26 Hashtags []string
27 Price *Price
28 References []string // r tags: ticket pages, maps, streams
29 Capacity string // Musiquay convention: NIP-52 has no capacity field
30 Content string
31 Extra [][]string
32 }
33
34 // Day is the NIP-52 D tag value for a time: floor(unix_seconds / 86400).
35 func Day(unixSeconds int64) (s string) {
36 d := unixSeconds / 86400
37 if unixSeconds < 0 && unixSeconds%86400 != 0 {
38 d--
39 }
40 return intStr(d)
41 }
42
43 // DaysCovering lists one D value per day an event covers, inclusive. NIP-52
44 // requires the D tag on time-based events and repeats it per covered day.
45 func DaysCovering(startUnix, endUnix int64) (days []string) {
46 if endUnix < startUnix {
47 startUnix, endUnix = endUnix, startUnix
48 }
49 first := startUnix / 86400
50 last := endUnix / 86400
51 if startUnix < 0 && startUnix%86400 != 0 {
52 first--
53 }
54 if endUnix < 0 && endUnix%86400 != 0 {
55 last--
56 }
57 n := last - first + 1
58 days = []string{:0:n}
59 for d := first; d <= last; d++ {
60 days = push(days, intStr(d))
61 }
62 return
63 }
64
65 func (c *CalendarEvent) Tags() (tags [][]string) {
66 tags = appendKV(nil, TagD, c.D)
67 tags = appendKV(tags, TagTitle, c.Title)
68 tags = appendKV(tags, TagSummary, c.Summary)
69 tags = appendKV(tags, TagImage, c.Image)
70 tags = appendKV(tags, TagStart, c.Start)
71 tags = appendKV(tags, TagEnd, c.End)
72 for i := range c.Days {
73 tags = appendKV(tags, TagDay, c.Days[i])
74 }
75 tags = appendKV(tags, TagStartTzid, c.StartTzid)
76 tags = appendKV(tags, TagLocation, c.Location)
77 tags = appendKV(tags, TagG, c.Geohash)
78 for i := range c.Participants {
79 tags = appendTag(tags, c.Participants[i].Tag())
80 }
81 for i := range c.Hashtags {
82 tags = appendKV(tags, TagT, c.Hashtags[i])
83 }
84 tags = appendTag(tags, c.Price.Tag())
85 for i := range c.References {
86 tags = appendKV(tags, TagR, c.References[i])
87 }
88 tags = appendKV(tags, TagCapacity, c.Capacity)
89 tags = appendTags(tags, c.Extra)
90 return
91 }
92
93 func ParseCalendarEvent(tags [][]string, content string) (c *CalendarEvent, err error) {
94 c = &CalendarEvent{Content: content}
95 for i := range tags {
96 tg := tags[i]
97 if len(tg) < 1 {
98 continue
99 }
100 switch tg[0] {
101 case TagD:
102 c.D = elem(tg, 1)
103 case TagTitle:
104 c.Title = elem(tg, 1)
105 case TagSummary:
106 c.Summary = elem(tg, 1)
107 case TagImage:
108 c.Image = elem(tg, 1)
109 case TagStart:
110 c.Start = elem(tg, 1)
111 case TagEnd:
112 c.End = elem(tg, 1)
113 case TagDay:
114 c.Days = push(c.Days, elem(tg, 1))
115 case TagStartTzid:
116 c.StartTzid = elem(tg, 1)
117 case TagLocation:
118 c.Location = elem(tg, 1)
119 case TagG:
120 c.Geohash = elem(tg, 1)
121 case TagP:
122 p, perr := parseParticipant(tg)
123 if perr != nil {
124 err = perr
125 return
126 }
127 c.Participants = push(c.Participants, p)
128 case TagT:
129 c.Hashtags = push(c.Hashtags, elem(tg, 1))
130 case TagPrice:
131 c.Price = parsePrice(tg)
132 case TagR:
133 c.References = push(c.References, elem(tg, 1))
134 case TagCapacity:
135 c.Capacity = elem(tg, 1)
136 default:
137 c.Extra = appendTag(c.Extra, tg)
138 }
139 }
140 return
141 }
142
143 func (c *CalendarEvent) Validate() (err error) {
144 if c == nil {
145 err = errorf.E("nil calendar event")
146 return
147 }
148 if c.D == "" {
149 err = errorf.E("calendar event: empty d tag")
150 return
151 }
152 timeBased := c.Start != "" || c.End != ""
153 if timeBased && len(c.Days) == 0 {
154 // NIP-52 requires the D tag on time-based events; the derivation is
155 // mechanical, so a missing one is a builder bug.
156 err = errorf.E("calendar event: a time-based event needs a D tag")
157 return
158 }
159 for i := range c.Participants {
160 if !IsHex64(c.Participants[i].Pubkey) {
161 err = errorf.E("calendar event: participant %d pubkey is not 64 hex characters", i)
162 return
163 }
164 if c.Participants[i].Role == "" {
165 err = errorf.E("calendar event: participant %d has no role", i)
166 return
167 }
168 }
169 if c.Capacity != "" {
170 if _, cerr := ParseInt64(c.Capacity); cerr != nil {
171 err = errorf.E("calendar event: capacity %q is not a number", c.Capacity)
172 return
173 }
174 }
175 return
176 }
177
178 func (c *CalendarEvent) Warnings() (w []string) {
179 if c.Title == "" {
180 w = push(w, "no title tag")
181 }
182 if c.Geohash == "" {
183 w = push(w, "no geohash: the event is not discoverable by area")
184 }
185 for i := range c.Participants {
186 if !KnownRole(c.Participants[i].Role) {
187 w = push(w, "unknown role: " | c.Participants[i].Role)
188 }
189 }
190 return
191 }
192
193 // CreditLabel is the payload of a NIP-32 kind 1985 event. One kind covers both
194 // validation acts, told apart by the namespace:
195 //
196 // musiquay.credit confirmed | denied | corrected - a participant on their own entry
197 // musiquay.attest attests - a witness vouching for someone
198 //
199 // Kind 1985 is regular, so a person can publish several; for a given
200 // (signer, target, role) the latest created_at wins, tie-broken by the lowest
201 // event id, and earlier ones stay visible as history. A denial never removes
202 // the roster entry.
203 type CreditLabel struct {
204 Target *Address
205 Subject string // the participant: self for a confirmation, vouched-for for an attestation
206 Namespace string
207 Label string
208 Role string
209 Content string
210 Extra [][]string
211 }
212
213 func (l *CreditLabel) Tags() (tags [][]string) {
214 tags = appendTag(nil, l.Target.Tag())
215 tags = appendKV(tags, TagP, l.Subject)
216 tags = appendKV(tags, TagL, l.Namespace)
217 if l.Label != "" {
218 lab := []string{:3}
219 lab[0] = TagLValue
220 lab[1] = l.Label
221 lab[2] = l.Namespace
222 tags = appendTag(tags, lab)
223 }
224 tags = appendKV(tags, TagRole, l.Role)
225 tags = appendTags(tags, l.Extra)
226 return
227 }
228
229 func ParseCreditLabel(tags [][]string, content string) (l *CreditLabel, err error) {
230 l = &CreditLabel{Content: content}
231 for i := range tags {
232 tg := tags[i]
233 if len(tg) < 1 {
234 continue
235 }
236 switch tg[0] {
237 case TagA:
238 a, aerr := AddressFromTag(tg)
239 if aerr != nil {
240 err = aerr
241 return
242 }
243 l.Target = a
244 case TagP:
245 l.Subject = elem(tg, 1)
246 case TagL:
247 l.Namespace = elem(tg, 1)
248 case TagLValue:
249 l.Label = elem(tg, 1)
250 if l.Namespace == "" {
251 l.Namespace = elem(tg, 2)
252 }
253 case TagRole:
254 l.Role = elem(tg, 1)
255 default:
256 l.Extra = appendTag(l.Extra, tg)
257 }
258 }
259 return
260 }
261
262 // IsConfirmation reports whether the label validates the signer's own entry
263 // rather than vouching for someone else.
264 func (l *CreditLabel) IsConfirmation() (ok bool) {
265 return l.Namespace == NamespaceCredit
266 }
267
268 func (l *CreditLabel) Validate() (err error) {
269 if l == nil {
270 err = errorf.E("nil credit label")
271 return
272 }
273 if l.Target == nil {
274 err = errorf.E("credit label: no target address")
275 return
276 }
277 if l.Target.Kind != KindCalendar && l.Target.Kind != KindReleaseCredits && l.Target.Kind != KindTrackCredits {
278 err = errorf.E("credit label: target is kind %d, want a roster or credits address", l.Target.Kind)
279 return
280 }
281 if !IsHex64(l.Subject) {
282 err = errorf.E("credit label: subject pubkey is not 64 hex characters")
283 return
284 }
285 if l.Role == "" {
286 err = errorf.E("credit label: no role")
287 return
288 }
289 switch l.Namespace {
290 case NamespaceCredit:
291 switch l.Label {
292 case LabelConfirmed, LabelDenied, LabelCorrected:
293 case "":
294 err = errorf.E("credit label: no label")
295 return
296 default:
297 err = errorf.E("credit label: %q is not a confirmation label", l.Label)
298 return
299 }
300 case NamespaceAttest:
301 if l.Label != LabelAttests {
302 err = errorf.E("credit label: %q is not an attestation label", l.Label)
303 return
304 }
305 default:
306 err = errorf.E("credit label: unknown namespace %q", l.Namespace)
307 return
308 }
309 return
310 }
311
312 // Contribution is the payload of a NIP-22 kind 1111 event: a wiki-style
313 // addition to a roster or credits record. It carries values (a pubkey and a
314 // role), which is why NIP-32 labels do not fit: a label carries no values.
315 //
316 // The root tags (A/K/P) and the parent tags (a/e/k/p) both point at the
317 // record being extended; the extra p tag names the missing participant, and
318 // the contribution is only a suggestion until that participant confirms.
319 type Contribution struct {
320 RootAddress *Address
321 RootKind string
322 RootAuthor string
323 ParentAddress *Address
324 ParentEvent string
325 ParentKind string
326 Creator string
327 Subject Participant // the missing participant, with an optional relay hint
328 Role string
329 Content string
330 Extra [][]string
331 }
332
333 func (c *Contribution) Tags() (tags [][]string) {
334 // The root reference is the uppercase A form; the address syntax is the
335 // same, only the scope differs.
336 root := c.RootAddress.Tag()
337 if len(root) > 0 {
338 root[0] = TagARoot
339 }
340 tags = appendTag(nil, root)
341 tags = appendKV(tags, TagKRoot, c.RootKind)
342 tags = appendKV(tags, TagPRoot, c.RootAuthor)
343 tags = appendTag(tags, c.ParentAddress.Tag())
344 tags = appendKV(tags, TagE, c.ParentEvent)
345 tags = appendKV(tags, TagK, c.ParentKind)
346 tags = appendKV(tags, TagP, c.Creator)
347 subject := c.Subject.Tag()
348 if len(subject) > 0 {
349 // The subject is a p tag too; the creator's p tag already occupies the
350 // plain slot, so the subject keeps its relay hint.
351 tags = appendTag(tags, subject)
352 }
353 tags = appendKV(tags, TagRole, c.Role)
354 tags = appendTags(tags, c.Extra)
355 return
356 }
357
358 // IsRootTag reports whether a tag name is one of the NIP-22 uppercase root
359 // scope tags.
360 func IsRootTag(name string) (ok bool) {
361 switch name {
362 case TagARoot, TagKRoot, TagPRoot:
363 return true
364 }
365 return false
366 }
367
368 func ParseContribution(tags [][]string, content string) (c *Contribution, err error) {
369 c = &Contribution{Content: content}
370 for i := range tags {
371 tg := tags[i]
372 if len(tg) < 1 {
373 continue
374 }
375 switch tg[0] {
376 case TagARoot:
377 a, aerr := AddressFromTag(tg)
378 if aerr != nil {
379 err = errorf.E("contribution root: %s", aerr.Error())
380 return
381 }
382 c.RootAddress = a
383 case TagKRoot:
384 c.RootKind = elem(tg, 1)
385 case TagPRoot:
386 c.RootAuthor = elem(tg, 1)
387 case TagA:
388 a, aerr := AddressFromTag(tg)
389 if aerr != nil {
390 err = aerr
391 return
392 }
393 c.ParentAddress = a
394 case TagE:
395 c.ParentEvent = elem(tg, 1)
396 case TagK:
397 c.ParentKind = elem(tg, 1)
398 case TagP:
399 // The creator's p tag has no relay hint; the subject's does.
400 if len(tg) > 2 && c.Subject.Pubkey == "" {
401 c.Subject = Participant{Pubkey: elem(tg, 1), Relay: elem(tg, 2)}
402 } else if c.Creator == "" {
403 c.Creator = elem(tg, 1)
404 } else {
405 c.Subject = Participant{Pubkey: elem(tg, 1), Relay: elem(tg, 2)}
406 }
407 case TagRole:
408 c.Role = elem(tg, 1)
409 default:
410 c.Extra = appendTag(c.Extra, tg)
411 }
412 }
413 return
414 }
415
416 func (c *Contribution) Validate() (err error) {
417 if c == nil {
418 err = errorf.E("nil contribution")
419 return
420 }
421 if c.RootAddress == nil {
422 err = errorf.E("contribution: no root address")
423 return
424 }
425 if c.ParentAddress == nil {
426 err = errorf.E("contribution: no parent address")
427 return
428 }
429 if !IsHex64(c.Subject.Pubkey) {
430 err = errorf.E("contribution: subject pubkey is not 64 hex characters")
431 return
432 }
433 if c.Role == "" {
434 err = errorf.E("contribution: no role")
435 return
436 }
437 if c.Creator != "" && !IsHex64(c.Creator) {
438 err = errorf.E("contribution: creator pubkey is not 64 hex characters")
439 return
440 }
441 return
442 }
443
444 // RSVP is the payload of a NIP-52 kind 31925 event. The same kind carries the
445 // waitlist entry: status waitlist plus position and joined_at.
446 //
447 // Queue order is joined_at ascending, tie-broken by d ascending; position is
448 // informational and clients recompute it. Promotion is a creator action, not a
449 // protocol transition.
450 type RSVP struct {
451 Event *Address
452 D string
453 Status string
454 Fb string // free or busy, beside an attendance status
455 Position string // waitlist only, informational
456 JoinedAt string // waitlist only
457 Content string
458 Extra [][]string
459 }
460
461 func (r *RSVP) Tags() (tags [][]string) {
462 tags = appendTag(nil, r.Event.Tag())
463 tags = appendKV(tags, TagD, r.D)
464 tags = appendKV(tags, TagStatus, r.Status)
465 tags = appendKV(tags, TagFb, r.Fb)
466 tags = appendKV(tags, TagPosition, r.Position)
467 tags = appendKV(tags, TagJoinedAt, r.JoinedAt)
468 tags = appendTags(tags, r.Extra)
469 return
470 }
471
472 func ParseRSVP(tags [][]string, content string) (r *RSVP, err error) {
473 r = &RSVP{Content: content}
474 for i := range tags {
475 tg := tags[i]
476 if len(tg) < 1 {
477 continue
478 }
479 switch tg[0] {
480 case TagA:
481 a, aerr := AddressFromTag(tg)
482 if aerr != nil {
483 err = aerr
484 return
485 }
486 r.Event = a
487 case TagD:
488 r.D = elem(tg, 1)
489 case TagStatus:
490 r.Status = elem(tg, 1)
491 case TagFb:
492 r.Fb = elem(tg, 1)
493 case TagPosition:
494 r.Position = elem(tg, 1)
495 case TagJoinedAt:
496 r.JoinedAt = elem(tg, 1)
497 default:
498 r.Extra = appendTag(r.Extra, tg)
499 }
500 }
501 return
502 }
503
504 // IsWaitlist reports whether the RSVP is a queue entry rather than an
505 // attendance answer.
506 func (r *RSVP) IsWaitlist() (ok bool) {
507 return r.Status == StatusWaitlist
508 }
509
510 func (r *RSVP) Validate() (err error) {
511 if r == nil {
512 err = errorf.E("nil rsvp")
513 return
514 }
515 if r.Event == nil {
516 err = errorf.E("rsvp: no event address")
517 return
518 }
519 if r.Event.Kind != KindCalendar && r.Event.Kind != KindCalendarDay {
520 err = errorf.E("rsvp: event is kind %d, want a calendar event", r.Event.Kind)
521 return
522 }
523 if r.D == "" {
524 err = errorf.E("rsvp: empty d tag")
525 return
526 }
527 switch r.Status {
528 case StatusAccepted, StatusDeclined, StatusTentative, StatusWaitlist:
529 case "":
530 err = errorf.E("rsvp: no status")
531 return
532 default:
533 err = errorf.E("rsvp: %q is not a status", r.Status)
534 return
535 }
536 if r.Status == StatusWaitlist && r.JoinedAt == "" {
537 err = errorf.E("rsvp: a waitlist entry needs joined_at for queue order")
538 return
539 }
540 if r.Fb != "" && r.Fb != FbFree && r.Fb != FbBusy {
541 err = errorf.E("rsvp: %q is not a free/busy value", r.Fb)
542 return
543 }
544 return
545 }
546