tinyrelayfile
tinyrelay/internal/daemon/wiki_approvals.go/

internal/daemon/wiki_approvals.go raw
1package daemon
2
3// Wiki proposals. A version (kind 30818) whose author holds an agent grant
4// with wiki set to propose is a proposal: it stays pending until the relay
5// owner or a moderator reacts to the version's event id with "+", which
6// approves it, or "-", which rejects it. A newer version from the agent is
7// a new event id and starts pending again, so every edit is decided on its
8// own. A pending or rejected proposal is visible only to the owner,
9// moderators and its author; everyone else sees the agent's newest
10// approved revision in its place, or nothing when none is approved. The
11// state is computed here and carried on every browse result that lists
12// versions, including the archived revisions in a page's history.
13
14import (
15 "context"
16 "net/url"
17 "strings"
18 "time"
19
20 "github.com/FelineStateMachine/tinyrelay/internal/event"
21 "github.com/FelineStateMachine/tinyrelay/internal/storage"
22)
23
24const (
25 wikiProposalPending = "pending"
26 wikiProposalApproved = "approved"
27 wikiProposalRejected = "rejected"
28 wikiGrantPropose = "propose"
29)
30
31// wikiRoles caches the role and grant lookups one browse call makes, since
32// a page's versions and their reactions repeat the same few keys.
33type wikiRoles struct {
34 t *Tenant
35 roles map[string]string
36 proposers map[string]bool
37}
38
39func (t *Tenant) wikiRoles() *wikiRoles {
40 return &wikiRoles{t: t, roles: map[string]string{}, proposers: map[string]bool{}}
41}
42
43func (r *wikiRoles) role(ctx context.Context, pubkey string) (string, error) {
44 if role, ok := r.roles[pubkey]; ok {
45 return role, nil
46 }
47 role, err := r.t.community.Role(ctx, pubkey)
48 if err != nil {
49 return "", err
50 }
51 r.roles[pubkey] = role
52 return role, nil
53}
54
55// decides reports whether a key may approve or reject proposals: the relay
56// owner and moderators.
57func (r *wikiRoles) decides(ctx context.Context, pubkey string) (bool, error) {
58 if pubkey == "" {
59 return false, nil
60 }
61 role, err := r.role(ctx, pubkey)
62 return wikiDecidingRole(role), err
63}
64
65func wikiDecidingRole(role string) bool {
66 return role == "owner" || role == "moderator"
67}
68
69// proposer reports whether versions from a key are proposals: the key holds
70// an agent grant with wiki set to propose and no human standing on the
71// relay. A paused, revoked or expired grant keeps counting, so an agent's
72// pending versions do not surface when its grant ends.
73func (r *wikiRoles) proposer(ctx context.Context, pubkey string) (bool, error) {
74 if known, ok := r.proposers[pubkey]; ok {
75 return known, nil
76 }
77 grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)
78 if err != nil {
79 return false, err
80 }
81 proposer := ok && grant.Scope.Wiki == wikiGrantPropose
82 if proposer {
83 role, err := r.role(ctx, pubkey)
84 if err != nil {
85 return false, err
86 }
87 proposer = role == "" || role == "agent"
88 }
89 r.proposers[pubkey] = proposer
90 return proposer, nil
91}
92
93// wikiProposals marks the proposals among versions and resolves each one's
94// state from the owner's and moderators' reactions to its event id. The
95// newest deciding reaction counts.
96func (t *Tenant) wikiProposals(ctx context.Context, roles *wikiRoles, versions []wikiVersion) error {
97 var ids []string
98 for i := range versions {
99 proposer, err := roles.proposer(ctx, versions[i].Author)
100 if err != nil {
101 return err
102 }
103 if !proposer {
104 continue
105 }
106 versions[i].Proposal = true
107 versions[i].Approval = wikiProposalPending
108 ids = append(ids, versions[i].ID)
109 }
110 if len(ids) == 0 {
111 return nil
112 }
113 rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})
114 if err != nil {
115 return err
116 }
117 reactions := rows.Events
118 sortEventsNewestFirst(reactions)
119 for _, reaction := range reactions {
120 state := wikiProposalState(reaction.Content)
121 if state == "" {
122 continue
123 }
124 decides, err := roles.decides(ctx, reaction.PubKey)
125 if err != nil {
126 return err
127 }
128 if !decides {
129 continue
130 }
131 for _, id := range event.TagValues(reaction, "e") {
132 for i := range versions {
133 v := &versions[i]
134 if v.ID != id || !v.Proposal || v.ApprovalEvent != "" {
135 continue
136 }
137 v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey
138 }
139 }
140 }
141 return nil
142}
143
144func wikiProposalState(content string) string {
145 switch wikiReactionStatus(content) {
146 case wikiMergeAccepted:
147 return wikiProposalApproved
148 case wikiMergeRejected:
149 return wikiProposalRejected
150 }
151 return ""
152}
153
154// wikiSees reports whether an actor may see a version: every version that
155// is not a proposal or is approved, plus pending and rejected proposals
156// for the owner, moderators (decides) and the proposal's author.
157func wikiSees(decides bool, actor string, v wikiVersion) bool {
158 return !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor)
159}
160
161// wikiFallback picks the revision a reader sees in place of a hidden
162// version: the same author's newest approved revision of the page among
163// the archived revisions, which are sorted newest first.
164func wikiFallback(hidden wikiVersion, archived []wikiVersion) (wikiVersion, bool) {
165 for _, a := range archived {
166 if a.Author == hidden.Author && a.D == hidden.D && a.Proposal && a.Approval == wikiProposalApproved {
167 return a, true
168 }
169 }
170 return wikiVersion{}, false
171}
172
173// notifyWikiProposal wakes the owner's and moderators' devices in the
174// approvals category when a proposal arrives. A decision wakes nobody.
175func (t *Tenant) notifyWikiProposal(ctx context.Context, e event.Event) {
176 if e.Kind != kindWikiArticle || !t.pushDevicesExist(ctx) {
177 return
178 }
179 roles := t.wikiRoles()
180 proposer, err := roles.proposer(ctx, e.PubKey)
181 if err != nil || !proposer {
182 return
183 }
184 v := wikiVersionFrom(e, false)
185 if v.D == "" {
186 return
187 }
188 name := shortKey(e.PubKey)
189 if grant, ok, err := t.community.AgentGrant(ctx, e.PubKey); err == nil && ok && strings.TrimSpace(grant.Name) != "" {
190 name = strings.TrimSpace(grant.Name)
191 }
192 moderators, err := t.community.Moderators(ctx)
193 if err != nil {
194 return
195 }
196 notice := pushNotice{category: pushApprovals, body: name + " proposes wiki: " + excerpt(v.Title), url: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(v.D)}
197 queued := 0
198 for _, recipient := range uniqueStrings(append([]string{t.Policy().Owner}, moderators...)) {
199 if recipient == e.PubKey || !t.pushCoalesced(recipient, pushApprovals) {
200 continue
201 }
202 notice.recipient = recipient
203 if err := t.enqueuePushNotice(ctx, notice); err != nil {
204 t.app.telemetry.Logger().Debug("wiki proposal notification not queued", "error", err)
205 continue
206 }
207 queued++
208 }
209 t.app.telemetry.Logger().Debug("wiki proposal notifications queued", "count", queued)
210}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs