package protocol import ( "git.smesh.lol/nostr/pkg/lol/errorf" ) // CalendarEvent is the payload of a NIP-52 event. Kind 31923 is the time-based // form and doubles as the event roster: pre-show it carries the confirmed // performers, post-show the same address carries the full participant record // with all calendar fields kept. There is no separate roster event. // // Kind 31922 is the date-based form (festivals, release dates); the same // struct serves both, with Start/End empty and Days set for the date form. type CalendarEvent struct { D string Title string Summary string Image string // a URL on calendar events, unlike the music layer's blob ref Start string // unix seconds, time-based form End string Days []string // the NIP-52 D tag: floor(unix/86400), one per covered day StartTzid string // IANA timezone Location string Geohash string Participants []Participant Hashtags []string Price *Price References []string // r tags: ticket pages, maps, streams Capacity string // Musiquay convention: NIP-52 has no capacity field Content string Extra [][]string } // Day is the NIP-52 D tag value for a time: floor(unix_seconds / 86400). func Day(unixSeconds int64) (s string) { d := unixSeconds / 86400 if unixSeconds < 0 && unixSeconds%86400 != 0 { d-- } return intStr(d) } // DaysCovering lists one D value per day an event covers, inclusive. NIP-52 // requires the D tag on time-based events and repeats it per covered day. func DaysCovering(startUnix, endUnix int64) (days []string) { if endUnix < startUnix { startUnix, endUnix = endUnix, startUnix } first := startUnix / 86400 last := endUnix / 86400 if startUnix < 0 && startUnix%86400 != 0 { first-- } if endUnix < 0 && endUnix%86400 != 0 { last-- } n := last - first + 1 days = []string{:0:n} for d := first; d <= last; d++ { days = push(days, intStr(d)) } return } func (c *CalendarEvent) Tags() (tags [][]string) { tags = appendKV(nil, TagD, c.D) tags = appendKV(tags, TagTitle, c.Title) tags = appendKV(tags, TagSummary, c.Summary) tags = appendKV(tags, TagImage, c.Image) tags = appendKV(tags, TagStart, c.Start) tags = appendKV(tags, TagEnd, c.End) for i := range c.Days { tags = appendKV(tags, TagDay, c.Days[i]) } tags = appendKV(tags, TagStartTzid, c.StartTzid) tags = appendKV(tags, TagLocation, c.Location) tags = appendKV(tags, TagG, c.Geohash) for i := range c.Participants { tags = appendTag(tags, c.Participants[i].Tag()) } for i := range c.Hashtags { tags = appendKV(tags, TagT, c.Hashtags[i]) } tags = appendTag(tags, c.Price.Tag()) for i := range c.References { tags = appendKV(tags, TagR, c.References[i]) } tags = appendKV(tags, TagCapacity, c.Capacity) tags = appendTags(tags, c.Extra) return } func ParseCalendarEvent(tags [][]string, content string) (c *CalendarEvent, err error) { c = &CalendarEvent{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagD: c.D = elem(tg, 1) case TagTitle: c.Title = elem(tg, 1) case TagSummary: c.Summary = elem(tg, 1) case TagImage: c.Image = elem(tg, 1) case TagStart: c.Start = elem(tg, 1) case TagEnd: c.End = elem(tg, 1) case TagDay: c.Days = push(c.Days, elem(tg, 1)) case TagStartTzid: c.StartTzid = elem(tg, 1) case TagLocation: c.Location = elem(tg, 1) case TagG: c.Geohash = elem(tg, 1) case TagP: p, perr := parseParticipant(tg) if perr != nil { err = perr return } c.Participants = push(c.Participants, p) case TagT: c.Hashtags = push(c.Hashtags, elem(tg, 1)) case TagPrice: c.Price = parsePrice(tg) case TagR: c.References = push(c.References, elem(tg, 1)) case TagCapacity: c.Capacity = elem(tg, 1) default: c.Extra = appendTag(c.Extra, tg) } } return } func (c *CalendarEvent) Validate() (err error) { if c == nil { err = errorf.E("nil calendar event") return } if c.D == "" { err = errorf.E("calendar event: empty d tag") return } timeBased := c.Start != "" || c.End != "" if timeBased && len(c.Days) == 0 { // NIP-52 requires the D tag on time-based events; the derivation is // mechanical, so a missing one is a builder bug. err = errorf.E("calendar event: a time-based event needs a D tag") return } for i := range c.Participants { if !IsHex64(c.Participants[i].Pubkey) { err = errorf.E("calendar event: participant %d pubkey is not 64 hex characters", i) return } if c.Participants[i].Role == "" { err = errorf.E("calendar event: participant %d has no role", i) return } } if c.Capacity != "" { if _, cerr := ParseInt64(c.Capacity); cerr != nil { err = errorf.E("calendar event: capacity %q is not a number", c.Capacity) return } } return } func (c *CalendarEvent) Warnings() (w []string) { if c.Title == "" { w = push(w, "no title tag") } if c.Geohash == "" { w = push(w, "no geohash: the event is not discoverable by area") } for i := range c.Participants { if !KnownRole(c.Participants[i].Role) { w = push(w, "unknown role: " | c.Participants[i].Role) } } return } // CreditLabel is the payload of a NIP-32 kind 1985 event. One kind covers both // validation acts, told apart by the namespace: // // musiquay.credit confirmed | denied | corrected - a participant on their own entry // musiquay.attest attests - a witness vouching for someone // // Kind 1985 is regular, so a person can publish several; for a given // (signer, target, role) the latest created_at wins, tie-broken by the lowest // event id, and earlier ones stay visible as history. A denial never removes // the roster entry. type CreditLabel struct { Target *Address Subject string // the participant: self for a confirmation, vouched-for for an attestation Namespace string Label string Role string Content string Extra [][]string } func (l *CreditLabel) Tags() (tags [][]string) { tags = appendTag(nil, l.Target.Tag()) tags = appendKV(tags, TagP, l.Subject) tags = appendKV(tags, TagL, l.Namespace) if l.Label != "" { lab := []string{:3} lab[0] = TagLValue lab[1] = l.Label lab[2] = l.Namespace tags = appendTag(tags, lab) } tags = appendKV(tags, TagRole, l.Role) tags = appendTags(tags, l.Extra) return } func ParseCreditLabel(tags [][]string, content string) (l *CreditLabel, err error) { l = &CreditLabel{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } l.Target = a case TagP: l.Subject = elem(tg, 1) case TagL: l.Namespace = elem(tg, 1) case TagLValue: l.Label = elem(tg, 1) if l.Namespace == "" { l.Namespace = elem(tg, 2) } case TagRole: l.Role = elem(tg, 1) default: l.Extra = appendTag(l.Extra, tg) } } return } // IsConfirmation reports whether the label validates the signer's own entry // rather than vouching for someone else. func (l *CreditLabel) IsConfirmation() (ok bool) { return l.Namespace == NamespaceCredit } func (l *CreditLabel) Validate() (err error) { if l == nil { err = errorf.E("nil credit label") return } if l.Target == nil { err = errorf.E("credit label: no target address") return } if l.Target.Kind != KindCalendar && l.Target.Kind != KindReleaseCredits && l.Target.Kind != KindTrackCredits { err = errorf.E("credit label: target is kind %d, want a roster or credits address", l.Target.Kind) return } if !IsHex64(l.Subject) { err = errorf.E("credit label: subject pubkey is not 64 hex characters") return } if l.Role == "" { err = errorf.E("credit label: no role") return } switch l.Namespace { case NamespaceCredit: switch l.Label { case LabelConfirmed, LabelDenied, LabelCorrected: case "": err = errorf.E("credit label: no label") return default: err = errorf.E("credit label: %q is not a confirmation label", l.Label) return } case NamespaceAttest: if l.Label != LabelAttests { err = errorf.E("credit label: %q is not an attestation label", l.Label) return } default: err = errorf.E("credit label: unknown namespace %q", l.Namespace) return } return } // Contribution is the payload of a NIP-22 kind 1111 event: a wiki-style // addition to a roster or credits record. It carries values (a pubkey and a // role), which is why NIP-32 labels do not fit: a label carries no values. // // The root tags (A/K/P) and the parent tags (a/e/k/p) both point at the // record being extended; the extra p tag names the missing participant, and // the contribution is only a suggestion until that participant confirms. type Contribution struct { RootAddress *Address RootKind string RootAuthor string ParentAddress *Address ParentEvent string ParentKind string Creator string Subject Participant // the missing participant, with an optional relay hint Role string Content string Extra [][]string } func (c *Contribution) Tags() (tags [][]string) { // The root reference is the uppercase A form; the address syntax is the // same, only the scope differs. root := c.RootAddress.Tag() if len(root) > 0 { root[0] = TagARoot } tags = appendTag(nil, root) tags = appendKV(tags, TagKRoot, c.RootKind) tags = appendKV(tags, TagPRoot, c.RootAuthor) tags = appendTag(tags, c.ParentAddress.Tag()) tags = appendKV(tags, TagE, c.ParentEvent) tags = appendKV(tags, TagK, c.ParentKind) tags = appendKV(tags, TagP, c.Creator) subject := c.Subject.Tag() if len(subject) > 0 { // The subject is a p tag too; the creator's p tag already occupies the // plain slot, so the subject keeps its relay hint. tags = appendTag(tags, subject) } tags = appendKV(tags, TagRole, c.Role) tags = appendTags(tags, c.Extra) return } // IsRootTag reports whether a tag name is one of the NIP-22 uppercase root // scope tags. func IsRootTag(name string) (ok bool) { switch name { case TagARoot, TagKRoot, TagPRoot: return true } return false } func ParseContribution(tags [][]string, content string) (c *Contribution, err error) { c = &Contribution{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagARoot: a, aerr := AddressFromTag(tg) if aerr != nil { err = errorf.E("contribution root: %s", aerr.Error()) return } c.RootAddress = a case TagKRoot: c.RootKind = elem(tg, 1) case TagPRoot: c.RootAuthor = elem(tg, 1) case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } c.ParentAddress = a case TagE: c.ParentEvent = elem(tg, 1) case TagK: c.ParentKind = elem(tg, 1) case TagP: // The creator's p tag has no relay hint; the subject's does. if len(tg) > 2 && c.Subject.Pubkey == "" { c.Subject = Participant{Pubkey: elem(tg, 1), Relay: elem(tg, 2)} } else if c.Creator == "" { c.Creator = elem(tg, 1) } else { c.Subject = Participant{Pubkey: elem(tg, 1), Relay: elem(tg, 2)} } case TagRole: c.Role = elem(tg, 1) default: c.Extra = appendTag(c.Extra, tg) } } return } func (c *Contribution) Validate() (err error) { if c == nil { err = errorf.E("nil contribution") return } if c.RootAddress == nil { err = errorf.E("contribution: no root address") return } if c.ParentAddress == nil { err = errorf.E("contribution: no parent address") return } if !IsHex64(c.Subject.Pubkey) { err = errorf.E("contribution: subject pubkey is not 64 hex characters") return } if c.Role == "" { err = errorf.E("contribution: no role") return } if c.Creator != "" && !IsHex64(c.Creator) { err = errorf.E("contribution: creator pubkey is not 64 hex characters") return } return } // RSVP is the payload of a NIP-52 kind 31925 event. The same kind carries the // waitlist entry: status waitlist plus position and joined_at. // // Queue order is joined_at ascending, tie-broken by d ascending; position is // informational and clients recompute it. Promotion is a creator action, not a // protocol transition. type RSVP struct { Event *Address D string Status string Fb string // free or busy, beside an attendance status Position string // waitlist only, informational JoinedAt string // waitlist only Content string Extra [][]string } func (r *RSVP) Tags() (tags [][]string) { tags = appendTag(nil, r.Event.Tag()) tags = appendKV(tags, TagD, r.D) tags = appendKV(tags, TagStatus, r.Status) tags = appendKV(tags, TagFb, r.Fb) tags = appendKV(tags, TagPosition, r.Position) tags = appendKV(tags, TagJoinedAt, r.JoinedAt) tags = appendTags(tags, r.Extra) return } func ParseRSVP(tags [][]string, content string) (r *RSVP, err error) { r = &RSVP{Content: content} for i := range tags { tg := tags[i] if len(tg) < 1 { continue } switch tg[0] { case TagA: a, aerr := AddressFromTag(tg) if aerr != nil { err = aerr return } r.Event = a case TagD: r.D = elem(tg, 1) case TagStatus: r.Status = elem(tg, 1) case TagFb: r.Fb = elem(tg, 1) case TagPosition: r.Position = elem(tg, 1) case TagJoinedAt: r.JoinedAt = elem(tg, 1) default: r.Extra = appendTag(r.Extra, tg) } } return } // IsWaitlist reports whether the RSVP is a queue entry rather than an // attendance answer. func (r *RSVP) IsWaitlist() (ok bool) { return r.Status == StatusWaitlist } func (r *RSVP) Validate() (err error) { if r == nil { err = errorf.E("nil rsvp") return } if r.Event == nil { err = errorf.E("rsvp: no event address") return } if r.Event.Kind != KindCalendar && r.Event.Kind != KindCalendarDay { err = errorf.E("rsvp: event is kind %d, want a calendar event", r.Event.Kind) return } if r.D == "" { err = errorf.E("rsvp: empty d tag") return } switch r.Status { case StatusAccepted, StatusDeclined, StatusTentative, StatusWaitlist: case "": err = errorf.E("rsvp: no status") return default: err = errorf.E("rsvp: %q is not a status", r.Status) return } if r.Status == StatusWaitlist && r.JoinedAt == "" { err = errorf.E("rsvp: a waitlist entry needs joined_at for queue order") return } if r.Fb != "" && r.Fb != FbFree && r.Fb != FbBusy { err = errorf.E("rsvp: %q is not a free/busy value", r.Fb) return } return }