clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / internal / daemon / approvals.go
approvals.go 15.5K
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 "encoding/json"12 "sort"13 "strconv"14 "strings"15 "time"1617 "github.com/FelineStateMachine/tinyrelay/internal/community"18 "github.com/FelineStateMachine/tinyrelay/internal/event"19 "github.com/FelineStateMachine/tinyrelay/internal/storage"20)2122const (23 kindComment = 111124 kindChatMessage = 925 kindThreadRoot = 1126 // approvalScan bounds how many stored events one listing walks.27 approvalScan = 100028 nativeMaxOptions = 1229)3031type nativeOption struct {32 ID string `json:"id"`33 Label string `json:"label"`34}3536func nativeInteraction(e event.Event) ([]nativeOption, string, bool) {37 interaction := event.Tag(e, "interaction")38 if event.Tag(e, "tinyagent") != "1" || (interaction != "question" && interaction != "approval" && interaction != "confirmation") {39 return nil, "", false40 }41 selection := event.Tag(e, "selection")42 if selection != "single" && selection != "multiple" && selection != "text" {43 return nil, "", false44 }45 freeform := event.Tag(e, "freeform") == "true"46 if (interaction == "approval" || interaction == "confirmation") && (selection == "text" || freeform) {47 return nil, "", false48 }49 if (interaction == "approval" || interaction == "confirmation") && selection != "single" {50 return nil, "", false51 }52 options := make([]nativeOption, 0, nativeMaxOptions)53 for _, tag := range e.Tags {54 if len(tag) != 3 || tag[0] != "option" || len(tag[1]) == 0 || len(tag[1]) > 64 || len(tag[2]) == 0 || len(tag[2]) > 200 {55 if len(tag) > 0 && tag[0] == "option" {56 return nil, "", false57 }58 continue59 }60 for _, option := range options {61 if option.ID == tag[1] {62 return nil, "", false63 }64 }65 options = append(options, nativeOption{ID: tag[1], Label: tag[2]})66 if len(options) > nativeMaxOptions {67 return nil, "", false68 }69 }70 if selection == "text" {71 return nil, selection, len(options) == 072 }73 return options, selection, len(options) > 074}7576// approvalTypes is the bounded vocabulary of the request tag.77var approvalTypes = []string{"approve", "decide", "question", "grant"}78var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY}7980const (81 approvalWikiProposal = "wiki-proposal"82 approvalWikiMerge = "wiki-merge"83)8485// approvalItem is one request as the asked person sees it.86type approvalItem struct {87 ID string `json:"id"`88 Kind int `json:"kind"`89 Type string `json:"type"`90 Asker string `json:"asker"`91 Asked []string `json:"asked"`92 Subject string `json:"subject,omitempty"`93 Content string `json:"content"`94 CreatedAt int64 `json:"created_at"`95 Expires int64 `json:"expires,omitempty"`96 State string `json:"state"`97 Room string `json:"room,omitempty"`98 About *approvalAbout `json:"about,omitempty"`99 Answer *approvalAnswer `json:"answer,omitempty"`100 Event event.Event `json:"event"`101 Grant *community.AgentGrantReview `json:"grant,omitempty"`102 GrantError string `json:"grant_error,omitempty"`103 Options []nativeOption `json:"options,omitempty"`104 Selection string `json:"selection,omitempty"`105 Freeform bool `json:"freeform,omitempty"`106 Interaction string `json:"interaction,omitempty"`107}108109// approvalAbout is what the request refers to: a repository coordinate, an110// event, or both, with the page that opens it.111type approvalAbout struct {112 Coordinate string `json:"coordinate,omitempty"`113 Event string `json:"event,omitempty"`114 Kind string `json:"kind,omitempty"`115 URL string `json:"url"`116}117118// approvalAnswer is the answer that counts: the newest reaction from an asked119// key, or the newest reply when nobody reacted.120type approvalAnswer struct {121 ID string `json:"id"`122 Kind int `json:"kind"`123 Author string `json:"author"`124 Decision string `json:"decision"`125 Content string `json:"content"`126 CreatedAt int64 `json:"created_at"`127}128129// approvalRequest reports whether the event asks anyone for a decision and130// which type it carries.131func approvalRequest(e event.Event) (string, bool) {132 if !containsInt(approvalKinds, e.Kind) {133 return "", false134 }135 if event.Tag(e, "tinyagent") != "" {136 if _, _, ok := nativeInteraction(e); !ok {137 return "", false138 }139 }140 kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request")))141 if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {142 return "", false143 }144 if kind == "grant" && !community.IsAgentGrantRequest(e) {145 return "", false146 }147 return kind, true148}149150// approvalAsked lists the keys asked: every p tag other than the asker.151func approvalAsked(e event.Event) []string {152 return pushRecipients(e)153}154155func approvalItemFrom(e event.Event, kind string, base string) approvalItem {156 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}157 item.Options, item.Selection, _ = nativeInteraction(e)158 item.Freeform = event.Tag(e, "freeform") == "true"159 item.Interaction = event.Tag(e, "interaction")160 item.About = approvalAboutFrom(e, base)161 return item162}163164// approvalAboutFrom resolves the request's a or e reference to a page. A165// repository coordinate opens the repository, or its issue or pull request166// when the root is one; any other event opens the event page.167func approvalAboutFrom(e event.Event, base string) *approvalAbout {168 coordinate := ""169 for _, name := range []string{"a", "A"} {170 if value := event.Tag(e, name); value != "" {171 coordinate = value172 break173 }174 }175 referenced, kind := "", ""176 for _, ref := range [][2]string{{"E", "K"}, {"e", "k"}} {177 if value := event.Tag(e, ref[0]); len(value) == 64 {178 referenced, kind = value, event.Tag(e, ref[1])179 break180 }181 }182 if coordinate == "" && referenced == "" {183 return nil184 }185 about := &approvalAbout{Coordinate: coordinate, Event: referenced, Kind: kind}186 if owner, identifier, ok := repoCoordinate(coordinate); ok {187 about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=home"188 if view := map[string]string{"1621": "issue", "1618": "pr"}[kind]; view != "" && referenced != "" {189 about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + referenced190 }191 } else if referenced != "" {192 about.URL = base + "/e/" + referenced193 } else {194 about.URL = base + "/a/" + coordinate195 }196 return about197}198199// approvalDecision maps a reaction to the answer it records. An empty200// reaction counts as approval, as NIP-25 reads it.201func approvalDecision(content string) string {202 switch strings.TrimSpace(content) {203 case "+", "":204 return "approved"205 case "-":206 return "denied"207 }208 return ""209}210211// approvalAnswers finds the answers to the given requests in one query and212// settles each request's state. The newest reaction from an asked key wins;213// a reply from an asked key answers a request that has no reaction.214func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now int64) (map[string][]event.Event, error) {215 answers := make(map[string][]event.Event, len(items))216 if len(items) == 0 {217 return answers, nil218 }219 ids := make([]string, 0, len(items))220 authors := []string{}221 asked := make(map[string]map[string]bool, len(items))222 for _, item := range items {223 ids = append(ids, item.ID)224 authors = append(authors, item.Asked...)225 asked[item.ID] = map[string]bool{}226 for _, key := range item.Asked {227 asked[item.ID][key] = true228 }229 }230 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})231 if err != nil {232 return nil, err233 }234 grants, err := t.grantApprovalHistory(ctx, items)235 if err != nil {236 return nil, err237 }238 rows.Events = append(rows.Events, grants...)239 sortEventsNewestFirst(rows.Events)240 for _, row := range rows.Events {241 if _, isRequest := approvalRequest(row); isRequest {242 continue243 }244 for _, id := range event.TagValues(row, "e") {245 if asked[id] != nil && asked[id][row.PubKey] {246 if item := approvalItemByID(items, id); item != nil && !nativeAnswerValid(*item, row) {247 continue248 }249 answers[id] = append(answers[id], row)250 }251 }252 }253 return answers, nil254}255256func approvalItemByID(items []approvalItem, id string) *approvalItem {257 for i := range items {258 if items[i].ID == id {259 return &items[i]260 }261 }262 return nil263}264265func nativeAnswerValid(item approvalItem, answer event.Event) bool {266 options, selection, ok := nativeInteraction(item.Event)267 if !ok {268 return true269 }270 if answer.Kind != kindComment || answer.CreatedAt < item.CreatedAt || item.Expires > 0 && answer.CreatedAt >= item.Expires || event.Tag(answer, "h") != event.Tag(item.Event, "h") || event.Tag(answer, "e") != item.ID || event.Tag(answer, "p") != item.Asker {271 return false272 }273 if event.Tag(answer, "E") != item.ID || event.Tag(answer, "P") != item.Asker || event.Tag(answer, "K") != strconv.Itoa(item.Kind) || event.Tag(answer, "k") != strconv.Itoa(item.Kind) {274 return false275 }276 if selection != "text" && item.Freeform && strings.HasPrefix(strings.TrimSpace(answer.Content), "{") {277 var custom map[string]any278 if json.Unmarshal([]byte(answer.Content), &custom) != nil || len(custom) != 1 {279 return false280 }281 text, ok := custom["text"].(string)282 return ok && len([]rune(strings.TrimSpace(text))) > 0 && len([]rune(text)) <= 8000283 }284 if selection == "text" {285 return len([]rune(strings.TrimSpace(answer.Content))) > 0 && len([]rune(answer.Content)) <= 8000286 }287 if selection == "single" {288 for _, option := range options {289 if strings.TrimSpace(answer.Content) == option.ID {290 return true291 }292 }293 return false294 }295 var ids []string296 if json.Unmarshal([]byte(answer.Content), &ids) != nil {297 return false298 }299 if len(ids) == 0 || len(ids) > len(options) {300 return false301 }302 seen := map[string]bool{}303 for _, id := range ids {304 if seen[id] {305 return false306 }307 seen[id] = true308 found := false309 for _, option := range options {310 if option.ID == id {311 found = true312 break313 }314 }315 if !found {316 return false317 }318 }319 return true320}321322// approvalSettle fills the answer and state of one request from its answers,323// which arrive newest first.324func approvalSettle(item *approvalItem, rows []event.Event, now int64) {325 if item.Type == "grant" {326 settleGrantApproval(item, rows, now)327 return328 }329 var reply *event.Event330 for i := range rows {331 row := rows[i]332 if row.Kind == kindReaction {333 if decision := approvalDecision(row.Content); decision != "" {334 item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: decision, Content: row.Content, CreatedAt: row.CreatedAt}335 item.State = "answered"336 return337 }338 } else if row.Kind == kindComment && reply == nil {339 reply = &row340 }341 }342 if reply != nil {343 item.Answer = &approvalAnswer{ID: reply.ID, Kind: reply.Kind, Author: reply.PubKey, Decision: "replied", Content: reply.Content, CreatedAt: reply.CreatedAt}344 item.State = "answered"345 return346 }347 if item.Expires > 0 && item.Expires <= now {348 item.State = "expired"349 }350}351352// approvalsFor lists every request addressed to the key, newest first,353// settled against its answers. Expired requests are kept so the person sees354// what lapsed until maintenance removes them.355func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {356 now := time.Now().Unix()357 base := strings.TrimRight(t.publicURL, "/")358 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})359 if err != nil {360 return nil, false, err361 }362 items := make([]approvalItem, 0, len(page.Events))363 for _, row := range page.Events {364 kind, ok := approvalRequest(row)365 if !ok || row.PubKey == pubkey {366 continue367 }368 items = append(items, approvalItemFrom(row, kind, base))369 }370 answers, err := t.approvalAnswers(ctx, items, now)371 if err != nil {372 return nil, false, err373 }374 for i := range items {375 approvalSettle(&items[i], answers[items[i].ID], now)376 t.reviewGrantApproval(ctx, &items[i], now)377 }378 wikiItems, wikiMore, err := t.wikiApprovalItems(ctx, pubkey, before, limit)379 if err != nil {380 return nil, false, err381 }382 items = append(items, wikiItems...)383 sort.SliceStable(items, func(i, j int) bool {384 if items[i].CreatedAt != items[j].CreatedAt {385 return items[i].CreatedAt > items[j].CreatedAt386 }387 return items[i].ID < items[j].ID388 })389 more := page.More || wikiMore || len(items) > limit390 if len(items) > limit {391 items = items[:limit]392 }393 return items, more, nil394}395396// openApprovals lists the unanswered, unexpired requests addressed to the397// key, which the badge and the panel count.398func (t *Tenant) openApprovals(ctx context.Context, pubkey string) []approvalItem {399 var open []approvalItem400 var cursor *storage.EventCursor401 for scanned := 0; scanned < approvalScan; scanned += 100 {402 items, more, err := t.approvalsFor(ctx, pubkey, cursor, 100)403 if err != nil {404 return open405 }406 for _, item := range items {407 if item.State == "open" {408 open = append(open, item)409 }410 }411 if !more || len(items) == 0 {412 break413 }414 last := items[len(items)-1]415 cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}416 }417 return open418}419420// approvalNotices wakes each asked person in the approvals category. The421// body names the asker and the subject, and the payload carries the actions422// the device can answer with: approve and deny for decisions, reply for423// every type.424func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {425 base := strings.TrimRight(t.publicURL, "/")426 subject := strings.TrimSpace(event.Tag(e, "subject"))427 if subject == "" {428 subject = e.Content429 }430 body := shortKey(e.PubKey) + " asks: " + excerpt(subject)431 actions := []pushAction{{Action: "reply", Title: "Reply"}}432 if kind != "question" {433 actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}}434 }435 if kind == "grant" {436 body = shortKey(e.PubKey) + " requests an access change: " + excerpt(e.Content)437 actions = []pushAction{{Action: "review", Title: "Review access"}}438 }439 var notices []pushNotice440 _, _, native := nativeInteraction(e)441 for _, recipient := range approvalAsked(e) {442 if native && !containsString(event.TagValues(e, "mention"), recipient) {443 continue444 }445 notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})446 }447 return notices448}449450func shortKey(pubkey string) string {451 if len(pubkey) > 12 {452 return pubkey[:12]453 }454 return pubkey455}456457func containsInt(values []int, wanted int) bool {458 for _, value := range values {459 if value == wanted {460 return true461 }462 }463 return false464}465466func approvalCursor(item approvalItem) string {467 return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID468}