1package daemon23// NIP-54 wiki rules that sit on top of stored events: how a merge request is4// read, how its answer is derived from the destination author's reactions,5// and the device notice sent when one arrives. The relay never merges on6// its own; an accepted merge request is applied when the destination author7// publishes a new version of their article.89import (10 "context"11 "sort"12 "strconv"13 "strings"14 "time"1516 "github.com/FelineStateMachine/tinyrelay/internal/event"17 "github.com/FelineStateMachine/tinyrelay/internal/storage"18 "github.com/FelineStateMachine/tinyrelay/internal/wiki"19)2021const (22 kindWikiArticle = 3081823 kindWikiMerge = 81824 kindWikiRedirect = 3081925 kindReaction = 726 wikiQueryLimit = 200027)2829const (30 wikiMergeOpen = "open"31 wikiMergeAccepted = "accepted"32 wikiMergeRejected = "rejected"33)3435type wikiRef struct {36 Coordinate string `json:"a,omitempty"`37 Event string `json:"e,omitempty"`38}3940type wikiVersion struct {41 ID string `json:"id"`42 Author string `json:"author"`43 CreatedAt int64 `json:"created_at"`44 D string `json:"d"`45 Title string `json:"title"`46 Summary string `json:"summary,omitempty"`47 Coordinate string `json:"coordinate"`48 Likes int `json:"likes"`49 Fork *wikiRef `json:"fork,omitempty"`50 Defer *wikiRef `json:"defer,omitempty"`51 Content string `json:"content,omitempty"`52 Links []string `json:"links,omitempty"`53 // Revision numbers this event among the author's revisions of the54 // page, counted from 1 at the oldest kept revision; Revisions is how55 // many the author has. SupersededBy names the event that replaced an56 // archived revision and is empty for the author's current version.57 Revision int `json:"revision,omitempty"`58 Revisions int `json:"revisions,omitempty"`59 SupersededBy string `json:"superseded_by,omitempty"`60 // Proposal marks a version from an agent whose grant says wiki:61 // propose. Approval is pending, approved or rejected; the other fields62 // name the deciding reaction, its time and its author once one exists.63 Proposal bool `json:"proposal,omitempty"`64 Approval string `json:"approval,omitempty"`65 ApprovalEvent string `json:"approval_event,omitempty"`66 ApprovalAt int64 `json:"approval_at,omitempty"`67 ApprovalBy string `json:"approval_by,omitempty"`68}6970type wikiAnswer struct {71 Status string `json:"status"`72 AnsweredAt int64 `json:"answered_at,omitempty"`73 Reaction string `json:"reaction,omitempty"`74}7576type wikiMerge struct {77 ID string `json:"id"`78 Author string `json:"author"`79 CreatedAt int64 `json:"created_at"`80 Content string `json:"content"`81 Target string `json:"target"`82 TargetD string `json:"target_d"`83 Destination string `json:"destination"`84 Base string `json:"base,omitempty"`85 Source string `json:"source,omitempty"`86 wikiAnswer87}8889type wikiRedirect struct {90 ID string `json:"id"`91 Author string `json:"author"`92 CreatedAt int64 `json:"created_at"`93 D string `json:"d"`94 Target string `json:"target"`95 TargetD string `json:"target_d"`96 TargetAuthor string `json:"target_author"`97}9899// wikiCoordinate splits a "30818:<pubkey>:<d>" reference.100func wikiCoordinate(value string) (author, d string, ok bool) {101 parts := strings.SplitN(value, ":", 3)102 if len(parts) != 3 || parts[0] != strconv.Itoa(kindWikiArticle) || len(parts[1]) != 64 || parts[2] == "" {103 return "", "", false104 }105 return parts[1], parts[2], true106}107108func wikiVersionFrom(e event.Event, full bool) wikiVersion {109 d := event.Tag(e, "d")110 v := wikiVersion{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: d, Title: event.Tag(e, "title"), Summary: event.Tag(e, "summary"), Coordinate: strconv.Itoa(kindWikiArticle) + ":" + e.PubKey + ":" + d}111 if v.Title == "" {112 v.Title = d113 }114 for _, tag := range e.Tags {115 if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") || (tag[0] != "a" && tag[0] != "e") {116 continue117 }118 ref := &v.Fork119 if tag[3] == "defer" {120 ref = &v.Defer121 }122 if *ref == nil {123 *ref = &wikiRef{}124 }125 if tag[0] == "a" {126 (*ref).Coordinate = tag[1]127 } else {128 (*ref).Event = tag[1]129 }130 }131 if full {132 v.Content = e.Content133 v.Links = wiki.Links(e.Content)134 }135 return v136}137138// wikiMergeFrom reads a kind 818 request. The proposed version is the e tag139// with the source marker, or a source tag, or the only plain e tag; when two140// plain e tags are present the first is the base and the last the proposal.141func wikiMergeFrom(e event.Event) wikiMerge {142 m := wikiMerge{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, Content: e.Content, Target: event.Tag(e, "a"), wikiAnswer: wikiAnswer{Status: wikiMergeOpen}}143 m.Destination, m.TargetD, _ = wikiCoordinate(m.Target)144 if p := event.Tag(e, "p"); len(p) == 64 {145 m.Destination = p146 }147 var plain []string148 for _, tag := range e.Tags {149 if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 {150 continue151 }152 if len(tag) >= 4 && tag[3] == "source" {153 m.Source = tag[1]154 } else {155 plain = append(plain, tag[1])156 }157 }158 if m.Source == "" {159 if source := event.Tag(e, "source"); len(source) == 64 {160 m.Source = source161 }162 }163 switch {164 case m.Source == "" && len(plain) == 1:165 m.Source = plain[0]166 case m.Source == "" && len(plain) > 1:167 m.Base, m.Source = plain[0], plain[len(plain)-1]168 case len(plain) > 0:169 m.Base = plain[0]170 }171 return m172}173174func wikiRedirectFrom(e event.Event) wikiRedirect {175 r := wikiRedirect{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: event.Tag(e, "d"), Target: event.Tag(e, "a")}176 r.TargetAuthor, r.TargetD, _ = wikiCoordinate(r.Target)177 return r178}179180// wikiMergeStatus reports how the destination author answered a merge181// request: a NIP-25 reaction from that pubkey tagging the request with "+"182// accepts it and "-" rejects it. The newest reaction wins.183func (t *Tenant) wikiMergeStatus(ctx context.Context, mergeRequestID, destination string) (wikiAnswer, error) {184 answers, err := t.wikiMergeStatuses(ctx, map[string]string{mergeRequestID: destination})185 if err != nil {186 return wikiAnswer{}, err187 }188 return answers[mergeRequestID], nil189}190191// wikiMergeStatuses resolves a set of merge requests, keyed by request id192// with the destination pubkey as value, in one query.193func (t *Tenant) wikiMergeStatuses(ctx context.Context, destinations map[string]string) (map[string]wikiAnswer, error) {194 answers := make(map[string]wikiAnswer, len(destinations))195 ids := make([]string, 0, len(destinations))196 authors := make([]string, 0, len(destinations))197 for id, destination := range destinations {198 answers[id] = wikiAnswer{Status: wikiMergeOpen}199 ids = append(ids, id)200 authors = append(authors, destination)201 }202 if len(ids) == 0 {203 return answers, nil204 }205 rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Authors: uniqueStrings(authors), Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})206 if err != nil {207 return nil, err208 }209 reactions := rows.Events210 sortEventsNewestFirst(reactions)211 for _, reaction := range reactions {212 status := wikiReactionStatus(reaction.Content)213 if status == "" {214 continue215 }216 for _, id := range event.TagValues(reaction, "e") {217 if destinations[id] != reaction.PubKey || answers[id].Status != wikiMergeOpen {218 continue219 }220 answers[id] = wikiAnswer{Status: status, AnsweredAt: reaction.CreatedAt, Reaction: reaction.ID}221 }222 }223 return answers, nil224}225226func wikiReactionStatus(content string) string {227 switch strings.TrimSpace(content) {228 case "+", "":229 return wikiMergeAccepted230 case "-":231 return wikiMergeRejected232 }233 return ""234}235236// wikiMemberLikes counts "+" reactions from the owner and members for each237// version, matched by event id or article coordinate.238func (t *Tenant) wikiMemberLikes(ctx context.Context, versions []wikiVersion) (map[string]int, error) {239 likes := make(map[string]int, len(versions))240 if len(versions) == 0 {241 return likes, nil242 }243 ids := make([]string, 0, len(versions))244 coordinates := make([]string, 0, len(versions))245 byCoordinate := make(map[string]string, len(versions))246 for _, v := range versions {247 likes[v.ID] = 0248 ids = append(ids, v.ID)249 coordinates = append(coordinates, v.Coordinate)250 byCoordinate[v.Coordinate] = v.ID251 }252 var reactions []event.Event253 for _, tags := range []map[string][]string{{"e": ids}, {"a": coordinates}} {254 rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: tags}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})255 if err != nil {256 return nil, err257 }258 reactions = append(reactions, rows.Events...)259 }260 members := map[string]bool{t.Policy().Owner: true}261 counted := map[string]bool{}262 for _, reaction := range uniqueEvents(reactions) {263 if wikiReactionStatus(reaction.Content) != wikiMergeAccepted {264 continue265 }266 if _, ok := members[reaction.PubKey]; !ok {267 member, err := t.community.IsMember(ctx, reaction.PubKey)268 if err != nil {269 return nil, err270 }271 members[reaction.PubKey] = member272 }273 if !members[reaction.PubKey] {274 continue275 }276 targets := event.TagValues(reaction, "e")277 for _, coordinate := range event.TagValues(reaction, "a") {278 if id, ok := byCoordinate[coordinate]; ok {279 targets = append(targets, id)280 }281 }282 for _, id := range targets {283 key := reaction.PubKey + "\x00" + id284 if _, known := likes[id]; !known || counted[key] {285 continue286 }287 counted[key] = true288 likes[id]++289 }290 }291 return likes, nil292}293294// wikiPreferred picks the version to show: the requested author, else the295// tenant owner, else the most member likes, else the newest. Versions must296// already be sorted newest first.297func wikiPreferred(versions []wikiVersion, author, owner string) (wikiVersion, string) {298 for _, v := range versions {299 if author != "" && v.Author == author {300 return v, "author"301 }302 }303 for _, v := range versions {304 if v.Author == owner {305 return v, "owner"306 }307 }308 best := versions[0]309 for _, v := range versions[1:] {310 if v.Likes > best.Likes {311 best = v312 }313 }314 if best.Likes > 0 {315 return best, "reactions"316 }317 return versions[0], "newest"318}319320func sortWikiVersions(versions []wikiVersion) {321 sort.Slice(versions, func(i, j int) bool {322 if versions[i].CreatedAt == versions[j].CreatedAt {323 return versions[i].ID < versions[j].ID324 }325 return versions[i].CreatedAt > versions[j].CreatedAt326 })327}328329// wikiNumberRevisions numbers every version in the groups among the330// revisions of the same author and page, oldest first from 1, and sets331// the count on each. The groups together must hold every kept revision332// so the numbers are the same for every viewer.333func wikiNumberRevisions(groups ...[]wikiVersion) {334 pages := map[string][]*wikiVersion{}335 for _, group := range groups {336 for i := range group {337 key := group[i].Author + "\x00" + group[i].D338 pages[key] = append(pages[key], &group[i])339 }340 }341 for _, revisions := range pages {342 sort.SliceStable(revisions, func(i, j int) bool {343 if revisions[i].CreatedAt == revisions[j].CreatedAt {344 return revisions[i].ID < revisions[j].ID345 }346 return revisions[i].CreatedAt < revisions[j].CreatedAt347 })348 for i, v := range revisions {349 v.Revision, v.Revisions = i+1, len(revisions)350 }351 }352}353354// wikiFull reads the content of an event into a version while keeping the355// derived fields of the listed version: likes, revision numbers and the356// proposal state.357func wikiFull(e event.Event, v wikiVersion) wikiVersion {358 full := wikiVersionFrom(e, true)359 full.Likes, full.Revision, full.Revisions, full.SupersededBy = v.Likes, v.Revision, v.Revisions, v.SupersededBy360 full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = v.Proposal, v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy361 return full362}363364// notifyWikiMerge wakes the destination author's devices when a merge365// request for one of their articles arrives from someone else.366func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) {367 if e.Kind != kindWikiMerge || !t.pushDevicesExist(ctx) {368 return369 }370 m := wikiMergeFrom(e)371 if m.Destination == "" || m.Destination == e.PubKey || m.TargetD == "" {372 return373 }374 role, err := t.community.Role(ctx, m.Destination)375 if err != nil || role == "" {376 return377 }378 if !t.pushCoalesced(m.Destination, pushReplies) {379 return380 }381 title := m.TargetD382 rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindWikiArticle}, Authors: []string{m.Destination}, Tags: map[string][]string{"d": {m.TargetD}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})383 if err == nil && len(rows.Events) == 1 {384 title = wikiVersionFrom(rows.Events[0], false).Title385 }386 notice := pushNotice{recipient: m.Destination, category: pushReplies, body: "Merge request for " + excerpt(title), url: t.wikiProposalURL(m.TargetD, e.ID)}387 if err := t.enqueuePushNotice(ctx, notice); err != nil {388 t.app.telemetry.Logger().Debug("wiki merge notification not queued", "error", err)389 }390}