tinyrelayfile
tinyrelay/internal/daemon/approvals.go/

internal/daemon/approvals.go raw
1package daemon
2
3// Approvals: a request for a decision is an ordinary comment or room message
4// that carries a request tag and names the people asked with p tags. The
5// asked person answers with a signed reaction (+ approves, - denies) or a
6// reply. Nothing here introduces a kind; the asker learns the answer through
7// the events it already watches.
8
9import (
10 "context"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/FelineStateMachine/tinyrelay/internal/community"
17 "github.com/FelineStateMachine/tinyrelay/internal/event"
18 "github.com/FelineStateMachine/tinyrelay/internal/storage"
19)
20
21const (
22 kindComment = 1111
23 kindChatMessage = 9
24 kindThreadRoot = 11
25 // approvalScan bounds how many stored events one listing walks.
26 approvalScan = 1000
27)
28
29// approvalTypes is the bounded vocabulary of the request tag.
30var approvalTypes = []string{"approve", "decide", "question", "grant"}
31var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY}
32
33const (
34 approvalWikiProposal = "wiki-proposal"
35 approvalWikiMerge = "wiki-merge"
36)
37
38// approvalItem is one request as the asked person sees it.
39type approvalItem struct {
40 ID string `json:"id"`
41 Kind int `json:"kind"`
42 Type string `json:"type"`
43 Asker string `json:"asker"`
44 Asked []string `json:"asked"`
45 Subject string `json:"subject,omitempty"`
46 Content string `json:"content"`
47 CreatedAt int64 `json:"created_at"`
48 Expires int64 `json:"expires,omitempty"`
49 State string `json:"state"`
50 Room string `json:"room,omitempty"`
51 About *approvalAbout `json:"about,omitempty"`
52 Answer *approvalAnswer `json:"answer,omitempty"`
53 Event event.Event `json:"event"`
54 Grant *community.AgentGrantReview `json:"grant,omitempty"`
55 GrantError string `json:"grant_error,omitempty"`
56}
57
58// approvalAbout is what the request refers to: a repository coordinate, an
59// event, or both, with the page that opens it.
60type approvalAbout struct {
61 Coordinate string `json:"coordinate,omitempty"`
62 Event string `json:"event,omitempty"`
63 Kind string `json:"kind,omitempty"`
64 URL string `json:"url"`
65}
66
67// approvalAnswer is the answer that counts: the newest reaction from an asked
68// key, or the newest reply when nobody reacted.
69type approvalAnswer struct {
70 ID string `json:"id"`
71 Kind int `json:"kind"`
72 Author string `json:"author"`
73 Decision string `json:"decision"`
74 Content string `json:"content"`
75 CreatedAt int64 `json:"created_at"`
76}
77
78// approvalRequest reports whether the event asks anyone for a decision and
79// which type it carries.
80func approvalRequest(e event.Event) (string, bool) {
81 if !containsInt(approvalKinds, e.Kind) {
82 return "", false
83 }
84 kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request")))
85 if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {
86 return "", false
87 }
88 if kind == "grant" && !community.IsAgentGrantRequest(e) {
89 return "", false
90 }
91 return kind, true
92}
93
94// approvalAsked lists the keys asked: every p tag other than the asker.
95func approvalAsked(e event.Event) []string {
96 return pushRecipients(e)
97}
98
99func approvalItemFrom(e event.Event, kind string, base string) approvalItem {
100 item := approvalItem{ID: e.ID, Kind: e.Kind, Type: kind, Asker: e.PubKey, Asked: approvalAsked(e), Subject: strings.TrimSpace(event.Tag(e, "subject")), Content: e.Content, CreatedAt: e.CreatedAt, Expires: event.Expiration(e), State: "open", Room: event.Tag(e, "h"), Event: e}
101 item.About = approvalAboutFrom(e, base)
102 return item
103}
104
105// approvalAboutFrom resolves the request's a or e reference to a page. A
106// repository coordinate opens the repository, or its issue or pull request
107// when the root is one; any other event opens the event page.
108func approvalAboutFrom(e event.Event, base string) *approvalAbout {
109 coordinate := ""
110 for _, name := range []string{"a", "A"} {
111 if value := event.Tag(e, name); value != "" {
112 coordinate = value
113 break
114 }
115 }
116 referenced, kind := "", ""
117 for _, ref := range [][2]string{{"E", "K"}, {"e", "k"}} {
118 if value := event.Tag(e, ref[0]); len(value) == 64 {
119 referenced, kind = value, event.Tag(e, ref[1])
120 break
121 }
122 }
123 if coordinate == "" && referenced == "" {
124 return nil
125 }
126 about := &approvalAbout{Coordinate: coordinate, Event: referenced, Kind: kind}
127 if owner, identifier, ok := repoCoordinate(coordinate); ok {
128 about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=home"
129 if view := map[string]string{"1621": "issue", "1618": "pr"}[kind]; view != "" && referenced != "" {
130 about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + referenced
131 }
132 } else if referenced != "" {
133 about.URL = base + "/e/" + referenced
134 } else {
135 about.URL = base + "/a/" + coordinate
136 }
137 return about
138}
139
140// approvalDecision maps a reaction to the answer it records. An empty
141// reaction counts as approval, as NIP-25 reads it.
142func approvalDecision(content string) string {
143 switch strings.TrimSpace(content) {
144 case "+", "":
145 return "approved"
146 case "-":
147 return "denied"
148 }
149 return ""
150}
151
152// approvalAnswers finds the answers to the given requests in one query and
153// settles each request's state. The newest reaction from an asked key wins;
154// a reply from an asked key answers a request that has no reaction.
155func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now int64) (map[string][]event.Event, error) {
156 answers := make(map[string][]event.Event, len(items))
157 if len(items) == 0 {
158 return answers, nil
159 }
160 ids := make([]string, 0, len(items))
161 authors := []string{}
162 asked := make(map[string]map[string]bool, len(items))
163 for _, item := range items {
164 ids = append(ids, item.ID)
165 authors = append(authors, item.Asked...)
166 asked[item.ID] = map[string]bool{}
167 for _, key := range item.Asked {
168 asked[item.ID][key] = true
169 }
170 }
171 rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction, kindComment}, Authors: uniqueStrings(authors), Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: approvalScan})
172 if err != nil {
173 return nil, err
174 }
175 grants, err := t.grantApprovalHistory(ctx, items)
176 if err != nil {
177 return nil, err
178 }
179 rows.Events = append(rows.Events, grants...)
180 sortEventsNewestFirst(rows.Events)
181 for _, row := range rows.Events {
182 if _, isRequest := approvalRequest(row); isRequest {
183 continue
184 }
185 for _, id := range event.TagValues(row, "e") {
186 if asked[id] != nil && asked[id][row.PubKey] {
187 answers[id] = append(answers[id], row)
188 }
189 }
190 }
191 return answers, nil
192}
193
194// approvalSettle fills the answer and state of one request from its answers,
195// which arrive newest first.
196func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
197 if item.Type == "grant" {
198 settleGrantApproval(item, rows, now)
199 return
200 }
201 var reply *event.Event
202 for i := range rows {
203 row := rows[i]
204 if row.Kind == kindReaction {
205 if decision := approvalDecision(row.Content); decision != "" {
206 item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: decision, Content: row.Content, CreatedAt: row.CreatedAt}
207 item.State = "answered"
208 return
209 }
210 } else if row.Kind == kindComment && reply == nil {
211 reply = &row
212 }
213 }
214 if reply != nil {
215 item.Answer = &approvalAnswer{ID: reply.ID, Kind: reply.Kind, Author: reply.PubKey, Decision: "replied", Content: reply.Content, CreatedAt: reply.CreatedAt}
216 item.State = "answered"
217 return
218 }
219 if item.Expires > 0 && item.Expires <= now {
220 item.State = "expired"
221 }
222}
223
224// approvalsFor lists every request addressed to the key, newest first,
225// settled against its answers. Expired requests are kept so the person sees
226// what lapsed until maintenance removes them.
227func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {
228 now := time.Now().Unix()
229 base := strings.TrimRight(t.publicURL, "/")
230 page, err := t.store.Query(ctx, event.Filter{Kinds: approvalKinds, Tags: map[string][]string{"p": {pubkey}}}, storage.QueryOptions{Access: storage.Access{PubKeys: []string{pubkey}}, Limit: limit, Before: before})
231 if err != nil {
232 return nil, false, err
233 }
234 items := make([]approvalItem, 0, len(page.Events))
235 for _, row := range page.Events {
236 kind, ok := approvalRequest(row)
237 if !ok || row.PubKey == pubkey {
238 continue
239 }
240 items = append(items, approvalItemFrom(row, kind, base))
241 }
242 answers, err := t.approvalAnswers(ctx, items, now)
243 if err != nil {
244 return nil, false, err
245 }
246 for i := range items {
247 approvalSettle(&items[i], answers[items[i].ID], now)
248 t.reviewGrantApproval(ctx, &items[i], now)
249 }
250 wikiItems, wikiMore, err := t.wikiApprovalItems(ctx, pubkey, before, limit)
251 if err != nil {
252 return nil, false, err
253 }
254 items = append(items, wikiItems...)
255 sort.SliceStable(items, func(i, j int) bool {
256 if items[i].CreatedAt != items[j].CreatedAt {
257 return items[i].CreatedAt > items[j].CreatedAt
258 }
259 return items[i].ID < items[j].ID
260 })
261 more := page.More || wikiMore || len(items) > limit
262 if len(items) > limit {
263 items = items[:limit]
264 }
265 return items, more, nil
266}
267
268// openApprovals lists the unanswered, unexpired requests addressed to the
269// key, which the badge and the panel count.
270func (t *Tenant) openApprovals(ctx context.Context, pubkey string) []approvalItem {
271 var open []approvalItem
272 var cursor *storage.EventCursor
273 for scanned := 0; scanned < approvalScan; scanned += 100 {
274 items, more, err := t.approvalsFor(ctx, pubkey, cursor, 100)
275 if err != nil {
276 return open
277 }
278 for _, item := range items {
279 if item.State == "open" {
280 open = append(open, item)
281 }
282 }
283 if !more || len(items) == 0 {
284 break
285 }
286 last := items[len(items)-1]
287 cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}
288 }
289 return open
290}
291
292// approvalNotices wakes each asked person in the approvals category. The
293// body names the asker and the subject, and the payload carries the actions
294// the device can answer with: approve and deny for decisions, reply for
295// every type.
296func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {
297 base := strings.TrimRight(t.publicURL, "/")
298 subject := strings.TrimSpace(event.Tag(e, "subject"))
299 if subject == "" {
300 subject = e.Content
301 }
302 body := shortKey(e.PubKey) + " asks: " + excerpt(subject)
303 actions := []pushAction{{Action: "reply", Title: "Reply"}}
304 if kind != "question" {
305 actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}}
306 }
307 if kind == "grant" {
308 body = shortKey(e.PubKey) + " requests an access change: " + excerpt(e.Content)
309 actions = []pushAction{{Action: "review", Title: "Review access"}}
310 }
311 var notices []pushNotice
312 for _, recipient := range approvalAsked(e) {
313 notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})
314 }
315 return notices
316}
317
318func shortKey(pubkey string) string {
319 if len(pubkey) > 12 {
320 return pubkey[:12]
321 }
322 return pubkey
323}
324
325func containsInt(values []int, wanted int) bool {
326 for _, value := range values {
327 if value == wanted {
328 return true
329 }
330 }
331 return false
332}
333
334func approvalCursor(item approvalItem) string {
335 return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID
336}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs