1package daemon23// Approvals: a request for a decision is an ordinary comment or room message4// that carries a request tag and names the people asked with p tags. The5// asked person answers with a signed reaction (+ approves, - denies) or a6// reply. Nothing here introduces a kind; the asker learns the answer through7// the events it already watches.89import (10 "context"11 "sort"12 "strconv"13 "strings"14 "time"1516 "github.com/FelineStateMachine/tinyrelay/internal/community"17 "github.com/FelineStateMachine/tinyrelay/internal/event"18 "github.com/FelineStateMachine/tinyrelay/internal/storage"19)2021const (22 kindComment = 111123 kindChatMessage = 924 kindThreadRoot = 1125 // approvalScan bounds how many stored events one listing walks.26 approvalScan = 100027)2829// 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}3233const (34 approvalWikiProposal = "wiki-proposal"35 approvalWikiMerge = "wiki-merge"36)3738// 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}5758// approvalAbout is what the request refers to: a repository coordinate, an59// 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}6667// approvalAnswer is the answer that counts: the newest reaction from an asked68// 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}7778// approvalRequest reports whether the event asks anyone for a decision and79// which type it carries.80func approvalRequest(e event.Event) (string, bool) {81 if !containsInt(approvalKinds, e.Kind) {82 return "", false83 }84 kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request")))85 if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {86 return "", false87 }88 if kind == "grant" && !community.IsAgentGrantRequest(e) {89 return "", false90 }91 return kind, true92}9394// approvalAsked lists the keys asked: every p tag other than the asker.95func approvalAsked(e event.Event) []string {96 return pushRecipients(e)97}9899func 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 item103}104105// approvalAboutFrom resolves the request's a or e reference to a page. A106// repository coordinate opens the repository, or its issue or pull request107// 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 = value113 break114 }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 break121 }122 }123 if coordinate == "" && referenced == "" {124 return nil125 }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=" + referenced131 }132 } else if referenced != "" {133 about.URL = base + "/e/" + referenced134 } else {135 about.URL = base + "/a/" + coordinate136 }137 return about138}139140// approvalDecision maps a reaction to the answer it records. An empty141// 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}151152// approvalAnswers finds the answers to the given requests in one query and153// 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, nil159 }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] = true169 }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, err174 }175 grants, err := t.grantApprovalHistory(ctx, items)176 if err != nil {177 return nil, err178 }179 rows.Events = append(rows.Events, grants...)180 sortEventsNewestFirst(rows.Events)181 for _, row := range rows.Events {182 if _, isRequest := approvalRequest(row); isRequest {183 continue184 }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, nil192}193194// 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 return200 }201 var reply *event.Event202 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 return209 }210 } else if row.Kind == kindComment && reply == nil {211 reply = &row212 }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 return218 }219 if item.Expires > 0 && item.Expires <= now {220 item.State = "expired"221 }222}223224// approvalsFor lists every request addressed to the key, newest first,225// settled against its answers. Expired requests are kept so the person sees226// 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, err233 }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 continue239 }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, err245 }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, err253 }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].CreatedAt258 }259 return items[i].ID < items[j].ID260 })261 more := page.More || wikiMore || len(items) > limit262 if len(items) > limit {263 items = items[:limit]264 }265 return items, more, nil266}267268// openApprovals lists the unanswered, unexpired requests addressed to the269// key, which the badge and the panel count.270func (t *Tenant) openApprovals(ctx context.Context, pubkey string) []approvalItem {271 var open []approvalItem272 var cursor *storage.EventCursor273 for scanned := 0; scanned < approvalScan; scanned += 100 {274 items, more, err := t.approvalsFor(ctx, pubkey, cursor, 100)275 if err != nil {276 return open277 }278 for _, item := range items {279 if item.State == "open" {280 open = append(open, item)281 }282 }283 if !more || len(items) == 0 {284 break285 }286 last := items[len(items)-1]287 cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}288 }289 return open290}291292// approvalNotices wakes each asked person in the approvals category. The293// body names the asker and the subject, and the payload carries the actions294// the device can answer with: approve and deny for decisions, reply for295// 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.Content301 }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 []pushNotice312 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 notices316}317318func shortKey(pubkey string) string {319 if len(pubkey) > 12 {320 return pubkey[:12]321 }322 return pubkey323}324325func containsInt(values []int, wanted int) bool {326 for _, value := range values {327 if value == wanted {328 return true329 }330 }331 return false332}333334func approvalCursor(item approvalItem) string {335 return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID336}