| 1 | package 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 | |
| 9 | import ( |
| 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 | |
| 21 | const ( |
| 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. |
| 30 | var approvalTypes = []string{"approve" , "decide" , "question" , "grant" } |
| 31 | var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY} |
| 32 | |
| 33 | const ( |
| 34 | approvalWikiProposal = "wiki-proposal" |
| 35 | approvalWikiMerge = "wiki-merge" |
| 36 | ) |
| 37 | |
| 38 | // approvalItem is one request as the asked person sees it. |
| 39 | type 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. |
| 60 | type 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. |
| 69 | type 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. |
| 80 | func 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. |
| 95 | func approvalAsked(e event.Event) []string { |
| 96 | return pushRecipients(e) |
| 97 | } |
| 98 | |
| 99 | func 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. |
| 108 | func 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. |
| 142 | func 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. |
| 155 | func (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. |
| 196 | func 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. |
| 227 | func (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. |
| 270 | func (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. |
| 296 | func (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 | |
| 318 | func shortKey(pubkey string) string { |
| 319 | if len(pubkey) > 12 { |
| 320 | return pubkey[:12] |
| 321 | } |
| 322 | return pubkey |
| 323 | } |
| 324 | |
| 325 | func 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 | |
| 334 | func approvalCursor(item approvalItem) string { |
| 335 | return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID |
| 336 | } |