tinyrelayfile
tinyrelay/internal/daemon/wiki.go/

internal/daemon/wiki.go raw
1package daemon
2
3// NIP-54 wiki rules that sit on top of stored events: how a merge request is
4// 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 on
6// its own; an accepted merge request is applied when the destination author
7// publishes a new version of their article.
8
9import (
10 "context"
11 "net/url"
12 "sort"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/FelineStateMachine/tinyrelay/internal/event"
18 "github.com/FelineStateMachine/tinyrelay/internal/storage"
19 "github.com/FelineStateMachine/tinyrelay/internal/wiki"
20)
21
22const (
23 kindWikiArticle = 30818
24 kindWikiMerge = 818
25 kindWikiRedirect = 30819
26 kindReaction = 7
27 wikiQueryLimit = 2000
28)
29
30const (
31 wikiMergeOpen = "open"
32 wikiMergeAccepted = "accepted"
33 wikiMergeRejected = "rejected"
34)
35
36type wikiRef struct {
37 Coordinate string `json:"a,omitempty"`
38 Event string `json:"e,omitempty"`
39}
40
41type wikiVersion struct {
42 ID string `json:"id"`
43 Author string `json:"author"`
44 CreatedAt int64 `json:"created_at"`
45 D string `json:"d"`
46 Title string `json:"title"`
47 Summary string `json:"summary,omitempty"`
48 Coordinate string `json:"coordinate"`
49 Likes int `json:"likes"`
50 Fork *wikiRef `json:"fork,omitempty"`
51 Defer *wikiRef `json:"defer,omitempty"`
52 Content string `json:"content,omitempty"`
53 Links []string `json:"links,omitempty"`
54 // Revision numbers this event among the author's revisions of the
55 // page, counted from 1 at the oldest kept revision; Revisions is how
56 // many the author has. SupersededBy names the event that replaced an
57 // archived revision and is empty for the author's current version.
58 Revision int `json:"revision,omitempty"`
59 Revisions int `json:"revisions,omitempty"`
60 SupersededBy string `json:"superseded_by,omitempty"`
61 // Proposal marks a version from an agent whose grant says wiki:
62 // propose. Approval is pending, approved or rejected; the other fields
63 // name the deciding reaction, its time and its author once one exists.
64 Proposal bool `json:"proposal,omitempty"`
65 Approval string `json:"approval,omitempty"`
66 ApprovalEvent string `json:"approval_event,omitempty"`
67 ApprovalAt int64 `json:"approval_at,omitempty"`
68 ApprovalBy string `json:"approval_by,omitempty"`
69}
70
71type wikiAnswer struct {
72 Status string `json:"status"`
73 AnsweredAt int64 `json:"answered_at,omitempty"`
74 Reaction string `json:"reaction,omitempty"`
75}
76
77type wikiMerge struct {
78 ID string `json:"id"`
79 Author string `json:"author"`
80 CreatedAt int64 `json:"created_at"`
81 Content string `json:"content"`
82 Target string `json:"target"`
83 TargetD string `json:"target_d"`
84 Destination string `json:"destination"`
85 Base string `json:"base,omitempty"`
86 Source string `json:"source,omitempty"`
87 wikiAnswer
88}
89
90type wikiRedirect struct {
91 ID string `json:"id"`
92 Author string `json:"author"`
93 CreatedAt int64 `json:"created_at"`
94 D string `json:"d"`
95 Target string `json:"target"`
96 TargetD string `json:"target_d"`
97 TargetAuthor string `json:"target_author"`
98}
99
100// wikiCoordinate splits a "30818:<pubkey>:<d>" reference.
101func wikiCoordinate(value string) (author, d string, ok bool) {
102 parts := strings.SplitN(value, ":", 3)
103 if len(parts) != 3 || parts[0] != strconv.Itoa(kindWikiArticle) || len(parts[1]) != 64 || parts[2] == "" {
104 return "", "", false
105 }
106 return parts[1], parts[2], true
107}
108
109func wikiVersionFrom(e event.Event, full bool) wikiVersion {
110 d := event.Tag(e, "d")
111 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}
112 if v.Title == "" {
113 v.Title = d
114 }
115 for _, tag := range e.Tags {
116 if len(tag) < 4 || (tag[3] != "fork" && tag[3] != "defer") || (tag[0] != "a" && tag[0] != "e") {
117 continue
118 }
119 ref := &v.Fork
120 if tag[3] == "defer" {
121 ref = &v.Defer
122 }
123 if *ref == nil {
124 *ref = &wikiRef{}
125 }
126 if tag[0] == "a" {
127 (*ref).Coordinate = tag[1]
128 } else {
129 (*ref).Event = tag[1]
130 }
131 }
132 if full {
133 v.Content = e.Content
134 v.Links = wiki.Links(e.Content)
135 }
136 return v
137}
138
139// wikiMergeFrom reads a kind 818 request. The proposed version is the e tag
140// with the source marker, or a source tag, or the only plain e tag; when two
141// plain e tags are present the first is the base and the last the proposal.
142func wikiMergeFrom(e event.Event) wikiMerge {
143 m := wikiMerge{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, Content: e.Content, Target: event.Tag(e, "a"), wikiAnswer: wikiAnswer{Status: wikiMergeOpen}}
144 m.Destination, m.TargetD, _ = wikiCoordinate(m.Target)
145 if p := event.Tag(e, "p"); len(p) == 64 {
146 m.Destination = p
147 }
148 var plain []string
149 for _, tag := range e.Tags {
150 if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 {
151 continue
152 }
153 if len(tag) >= 4 && tag[3] == "source" {
154 m.Source = tag[1]
155 } else {
156 plain = append(plain, tag[1])
157 }
158 }
159 if m.Source == "" {
160 if source := event.Tag(e, "source"); len(source) == 64 {
161 m.Source = source
162 }
163 }
164 switch {
165 case m.Source == "" && len(plain) == 1:
166 m.Source = plain[0]
167 case m.Source == "" && len(plain) > 1:
168 m.Base, m.Source = plain[0], plain[len(plain)-1]
169 case len(plain) > 0:
170 m.Base = plain[0]
171 }
172 return m
173}
174
175func wikiRedirectFrom(e event.Event) wikiRedirect {
176 r := wikiRedirect{ID: e.ID, Author: e.PubKey, CreatedAt: e.CreatedAt, D: event.Tag(e, "d"), Target: event.Tag(e, "a")}
177 r.TargetAuthor, r.TargetD, _ = wikiCoordinate(r.Target)
178 return r
179}
180
181// wikiMergeStatus reports how the destination author answered a merge
182// request: a NIP-25 reaction from that pubkey tagging the request with "+"
183// accepts it and "-" rejects it. The newest reaction wins.
184func (t *Tenant) wikiMergeStatus(ctx context.Context, mergeRequestID, destination string) (wikiAnswer, error) {
185 answers, err := t.wikiMergeStatuses(ctx, map[string]string{mergeRequestID: destination})
186 if err != nil {
187 return wikiAnswer{}, err
188 }
189 return answers[mergeRequestID], nil
190}
191
192// wikiMergeStatuses resolves a set of merge requests, keyed by request id
193// with the destination pubkey as value, in one query.
194func (t *Tenant) wikiMergeStatuses(ctx context.Context, destinations map[string]string) (map[string]wikiAnswer, error) {
195 answers := make(map[string]wikiAnswer, len(destinations))
196 ids := make([]string, 0, len(destinations))
197 authors := make([]string, 0, len(destinations))
198 for id, destination := range destinations {
199 answers[id] = wikiAnswer{Status: wikiMergeOpen}
200 ids = append(ids, id)
201 authors = append(authors, destination)
202 }
203 if len(ids) == 0 {
204 return answers, nil
205 }
206 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})
207 if err != nil {
208 return nil, err
209 }
210 reactions := rows.Events
211 sortEventsNewestFirst(reactions)
212 for _, reaction := range reactions {
213 status := wikiReactionStatus(reaction.Content)
214 if status == "" {
215 continue
216 }
217 for _, id := range event.TagValues(reaction, "e") {
218 if destinations[id] != reaction.PubKey || answers[id].Status != wikiMergeOpen {
219 continue
220 }
221 answers[id] = wikiAnswer{Status: status, AnsweredAt: reaction.CreatedAt, Reaction: reaction.ID}
222 }
223 }
224 return answers, nil
225}
226
227func wikiReactionStatus(content string) string {
228 switch strings.TrimSpace(content) {
229 case "+", "":
230 return wikiMergeAccepted
231 case "-":
232 return wikiMergeRejected
233 }
234 return ""
235}
236
237// wikiMemberLikes counts "+" reactions from the owner and members for each
238// version, matched by event id or article coordinate.
239func (t *Tenant) wikiMemberLikes(ctx context.Context, versions []wikiVersion) (map[string]int, error) {
240 likes := make(map[string]int, len(versions))
241 if len(versions) == 0 {
242 return likes, nil
243 }
244 ids := make([]string, 0, len(versions))
245 coordinates := make([]string, 0, len(versions))
246 byCoordinate := make(map[string]string, len(versions))
247 for _, v := range versions {
248 likes[v.ID] = 0
249 ids = append(ids, v.ID)
250 coordinates = append(coordinates, v.Coordinate)
251 byCoordinate[v.Coordinate] = v.ID
252 }
253 var reactions []event.Event
254 for _, tags := range []map[string][]string{{"e": ids}, {"a": coordinates}} {
255 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})
256 if err != nil {
257 return nil, err
258 }
259 reactions = append(reactions, rows.Events...)
260 }
261 members := map[string]bool{t.Policy().Owner: true}
262 counted := map[string]bool{}
263 for _, reaction := range uniqueEvents(reactions) {
264 if wikiReactionStatus(reaction.Content) != wikiMergeAccepted {
265 continue
266 }
267 if _, ok := members[reaction.PubKey]; !ok {
268 member, err := t.community.IsMember(ctx, reaction.PubKey)
269 if err != nil {
270 return nil, err
271 }
272 members[reaction.PubKey] = member
273 }
274 if !members[reaction.PubKey] {
275 continue
276 }
277 targets := event.TagValues(reaction, "e")
278 for _, coordinate := range event.TagValues(reaction, "a") {
279 if id, ok := byCoordinate[coordinate]; ok {
280 targets = append(targets, id)
281 }
282 }
283 for _, id := range targets {
284 key := reaction.PubKey + "\x00" + id
285 if _, known := likes[id]; !known || counted[key] {
286 continue
287 }
288 counted[key] = true
289 likes[id]++
290 }
291 }
292 return likes, nil
293}
294
295// wikiPreferred picks the version to show: the requested author, else the
296// tenant owner, else the most member likes, else the newest. Versions must
297// already be sorted newest first.
298func wikiPreferred(versions []wikiVersion, author, owner string) (wikiVersion, string) {
299 for _, v := range versions {
300 if author != "" && v.Author == author {
301 return v, "author"
302 }
303 }
304 for _, v := range versions {
305 if v.Author == owner {
306 return v, "owner"
307 }
308 }
309 best := versions[0]
310 for _, v := range versions[1:] {
311 if v.Likes > best.Likes {
312 best = v
313 }
314 }
315 if best.Likes > 0 {
316 return best, "reactions"
317 }
318 return versions[0], "newest"
319}
320
321func sortWikiVersions(versions []wikiVersion) {
322 sort.Slice(versions, func(i, j int) bool {
323 if versions[i].CreatedAt == versions[j].CreatedAt {
324 return versions[i].ID < versions[j].ID
325 }
326 return versions[i].CreatedAt > versions[j].CreatedAt
327 })
328}
329
330// wikiNumberRevisions numbers every version in the groups among the
331// revisions of the same author and page, oldest first from 1, and sets
332// the count on each. The groups together must hold every kept revision
333// so the numbers are the same for every viewer.
334func wikiNumberRevisions(groups ...[]wikiVersion) {
335 pages := map[string][]*wikiVersion{}
336 for _, group := range groups {
337 for i := range group {
338 key := group[i].Author + "\x00" + group[i].D
339 pages[key] = append(pages[key], &group[i])
340 }
341 }
342 for _, revisions := range pages {
343 sort.SliceStable(revisions, func(i, j int) bool {
344 if revisions[i].CreatedAt == revisions[j].CreatedAt {
345 return revisions[i].ID < revisions[j].ID
346 }
347 return revisions[i].CreatedAt < revisions[j].CreatedAt
348 })
349 for i, v := range revisions {
350 v.Revision, v.Revisions = i+1, len(revisions)
351 }
352 }
353}
354
355// wikiFull reads the content of an event into a version while keeping the
356// derived fields of the listed version: likes, revision numbers and the
357// proposal state.
358func wikiFull(e event.Event, v wikiVersion) wikiVersion {
359 full := wikiVersionFrom(e, true)
360 full.Likes, full.Revision, full.Revisions, full.SupersededBy = v.Likes, v.Revision, v.Revisions, v.SupersededBy
361 full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = v.Proposal, v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy
362 return full
363}
364
365// notifyWikiMerge wakes the destination author's devices when a merge
366// request for one of their articles arrives from someone else.
367func (t *Tenant) notifyWikiMerge(ctx context.Context, e event.Event) {
368 if e.Kind != kindWikiMerge || !t.pushDevicesExist(ctx) {
369 return
370 }
371 m := wikiMergeFrom(e)
372 if m.Destination == "" || m.Destination == e.PubKey || m.TargetD == "" {
373 return
374 }
375 role, err := t.community.Role(ctx, m.Destination)
376 if err != nil || role == "" {
377 return
378 }
379 if !t.pushCoalesced(m.Destination, pushReplies) {
380 return
381 }
382 title := m.TargetD
383 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})
384 if err == nil && len(rows.Events) == 1 {
385 title = wikiVersionFrom(rows.Events[0], false).Title
386 }
387 notice := pushNotice{recipient: m.Destination, category: pushReplies, body: "Merge request for " + excerpt(title), url: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(m.TargetD) + "?merge=" + e.ID}
388 if err := t.enqueuePushNotice(ctx, notice); err != nil {
389 t.app.telemetry.Logger().Debug("wiki merge notification not queued", "error", err)
390 }
391}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs