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