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 "io"13 "sort"14 "strconv"15 "strings"16 "time"1718 "github.com/FelineStateMachine/tinyrelay/internal/community"19 "github.com/FelineStateMachine/tinyrelay/internal/event"20 "github.com/FelineStateMachine/tinyrelay/internal/storage"21)2223const (24 kindComment = 111125 kindChatMessage = 926 kindThreadRoot = 1127 // approvalScan bounds how many stored events one listing walks.28 approvalScan = 100029 nativeMaxOptions = 1230)3132type nativeOption struct {33 ID string `json:"id"`34 Label string `json:"label"`35}3637func nativeInteraction(e event.Event) ([]nativeOption, string, bool) {38 interaction := event.Tag(e, "interaction")39 if event.Tag(e, "tinyagent") != "1" || (interaction != "question" && interaction != "approval" && interaction != "confirmation") {40 return nil, "", false41 }42 selection := event.Tag(e, "selection")43 if selection != "single" && selection != "multiple" && selection != "text" {44 return nil, "", false45 }46 freeform := event.Tag(e, "freeform") == "true"47 if (interaction == "approval" || interaction == "confirmation") && (selection == "text" || freeform) {48 return nil, "", false49 }50 if (interaction == "approval" || interaction == "confirmation") && selection != "single" {51 return nil, "", false52 }53 options := make([]nativeOption, 0, nativeMaxOptions)54 for _, tag := range e.Tags {55 if len(tag) != 3 || tag[0] != "option" || len(tag[1]) == 0 || len(tag[1]) > 64 || len(tag[2]) == 0 || len(tag[2]) > 200 {56 if len(tag) > 0 && tag[0] == "option" {57 return nil, "", false58 }59 continue60 }61 for _, option := range options {62 if option.ID == tag[1] {63 return nil, "", false64 }65 }66 options = append(options, nativeOption{ID: tag[1], Label: tag[2]})67 if len(options) > nativeMaxOptions {68 return nil, "", false69 }70 }71 if selection == "text" {72 return nil, selection, len(options) == 073 }74 return options, selection, len(options) > 075}7677// approvalTypes is the bounded vocabulary of the request tag.78var approvalTypes = []string{"approve", "decide", "question", "grant"}79var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY}8081const (82 approvalWikiProposal = "wiki-proposal"83 approvalWikiMerge = "wiki-merge"84)8586// approvalItem is one request as the asked person sees it.87type approvalItem struct {88 ID string `json:"id"`89 Kind int `json:"kind"`90 Type string `json:"type"`91 Asker string `json:"asker"`92 Asked []string `json:"asked"`93 Subject string `json:"subject,omitempty"`94 Content string `json:"content"`95 CreatedAt int64 `json:"created_at"`96 Expires int64 `json:"expires,omitempty"`97 State string `json:"state"`98 Room string `json:"room,omitempty"`99 About *approvalAbout `json:"about,omitempty"`100 Answer *approvalAnswer `json:"answer,omitempty"`101 Event event.Event `json:"event"`102 Grant *community.AgentGrantReview `json:"grant,omitempty"`103 GrantError string `json:"grant_error,omitempty"`104 Options []nativeOption `json:"options,omitempty"`105 Selection string `json:"selection,omitempty"`106 Freeform bool `json:"freeform,omitempty"`107 Interaction string `json:"interaction,omitempty"`108}109110// approvalAbout is what the request refers to: a repository coordinate, an111// event, or both, with the page that opens it.112type approvalAbout struct {113 Coordinate string `json:"coordinate,omitempty"`114 Event string `json:"event,omitempty"`115 Kind string `json:"kind,omitempty"`116 URL string `json:"url"`117}118119// approvalAnswer is the answer that counts: the newest reaction from an asked120// key, or the newest reply when nobody reacted.121type approvalAnswer struct {122 ID string `json:"id"`123 Kind int `json:"kind"`124 Author string `json:"author"`125 Decision string `json:"decision"`126 Content string `json:"content"`127 CreatedAt int64 `json:"created_at"`128}129130// approvalRequest reports whether the event asks anyone for a decision and131// which type it carries.132func approvalRequest(e event.Event) (string, bool) {133 if !containsInt(approvalKinds, e.Kind) {134 return "", false135 }136 if event.Tag(e, "tinyagent") != "" {137 if _, _, ok := nativeInteraction(e); !ok {138 return "", false139 }140 }141 kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request")))142 if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {143 return "", false144 }145 if kind == "grant" && !community.IsAgentGrantRequest(e) {146 return "", false147 }148 return kind, true149}150151// approvalAsked lists the keys asked: every p tag other than the asker.152func approvalAsked(e event.Event) []string {153 return pushRecipients(e)154}155156func (t *Tenant) approvalItemFrom(ctx context.Context, e event.Event, kind string) approvalItem {157 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}158 item.Options, item.Selection, _ = nativeInteraction(e)159 item.Freeform = event.Tag(e, "freeform") == "true"160 item.Interaction = event.Tag(e, "interaction")161 item.About = t.approvalAboutFrom(ctx, e)162 return item163}164165// approvalAboutFrom resolves the request's a or e reference to a page. A166// repository coordinate opens the repository, or its issue or pull request167// when the root is one; any other event opens the event page.168func (t *Tenant) approvalAboutFrom(ctx context.Context, e event.Event) *approvalAbout {169 base := strings.TrimRight(t.publicURL, "/")170 coordinate := ""171 for _, name := range []string{"a", "A"} {172 if value := event.Tag(e, name); value != "" {173 coordinate = value174 break175 }176 }177 referenced, kind := "", ""178 for _, ref := range [][2]string{{"E", "K"}, {"e", "k"}} {179 if value := event.Tag(e, ref[0]); len(value) == 64 {180 referenced, kind = value, event.Tag(e, ref[1])181 break182 }183 }184 if coordinate == "" && referenced == "" {185 return nil186 }187 about := &approvalAbout{Coordinate: coordinate, Event: referenced, Kind: kind}188 if owner, identifier, ok := repoCoordinate(coordinate); ok {189 about.URL = t.repoPageURL(ctx, owner, identifier)190 if section := map[string]string{"1621": "issues", "1618": "prs"}[kind]; section != "" && referenced != "" {191 about.URL = t.repoItemURL(ctx, owner, identifier, section, referenced)192 }193 } else if referenced != "" {194 about.URL = base + "/e/" + referenced195 } else {196 about.URL = base + "/a/" + coordinate197 }198 return about199}200201// approvalDecision maps a reaction to the answer it records. An empty202// reaction counts as approval, as NIP-25 reads it.203func approvalDecision(content string) string {204 switch strings.TrimSpace(content) {205 case "+", "":206 return "approved"207 case "-":208 return "denied"209 }210 return ""211}212213// approvalAnswers finds the answers to the given requests in one query and214// settles each request's state. The newest reaction from an asked key wins;215// a reply from an asked key answers a request that has no reaction.216func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now int64) (map[string][]event.Event, error) {217 answers := make(map[string][]event.Event, len(items))218 if len(items) == 0 {219 return answers, nil220 }221 ids := make([]string, 0, len(items))222 authors := []string{}223 asked := make(map[string]map[string]bool, len(items))224 for _, item := range items {225 ids = append(ids, item.ID)226 authors = append(authors, item.Asked...)227 asked[item.ID] = map[string]bool{}228 for _, key := range item.Asked {229 asked[item.ID][key] = true230 }231 }232 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})233 if err != nil {234 return nil, err235 }236 grants, err := t.grantApprovalHistory(ctx, items)237 if err != nil {238 return nil, err239 }240 rows.Events = append(rows.Events, grants...)241 sortEventsNewestFirst(rows.Events)242 for _, row := range rows.Events {243 if _, isRequest := approvalRequest(row); isRequest {244 continue245 }246 for _, id := range event.TagValues(row, "e") {247 if asked[id] != nil && asked[id][row.PubKey] {248 if item := approvalItemByID(items, id); item != nil && !nativeAnswerValid(*item, row) {249 continue250 }251 answers[id] = append(answers[id], row)252 }253 }254 }255 return answers, nil256}257258func approvalItemByID(items []approvalItem, id string) *approvalItem {259 for i := range items {260 if items[i].ID == id {261 return &items[i]262 }263 }264 return nil265}266267func nativeAnswerValid(item approvalItem, answer event.Event) bool {268 options, selection, ok := nativeInteraction(item.Event)269 if !ok {270 return true271 }272 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 {273 return false274 }275 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) {276 return false277 }278 if len([]rune(answer.Content)) > 8000 {279 return false280 }281 if selection != "text" && item.Freeform && strings.HasPrefix(strings.TrimSpace(answer.Content), "{") {282 if nativeCombinedAnswer(answer.Content, options, selection) {283 return true284 }285 }286 if selection != "text" && item.Freeform && strings.HasPrefix(strings.TrimSpace(answer.Content), "{") {287 fields, valid := nativeJSONFields(answer.Content, map[string]bool{"text": true})288 var text string289 if !valid || len(fields) != 1 || json.Unmarshal(fields["text"], &text) != nil {290 return false291 }292 return len([]rune(strings.TrimSpace(text))) > 0 && len([]rune(text)) <= 8000293 }294 if selection == "text" {295 return len([]rune(strings.TrimSpace(answer.Content))) > 0 && len([]rune(answer.Content)) <= 8000296 }297 if selection == "single" {298 for _, option := range options {299 if strings.TrimSpace(answer.Content) == option.ID {300 return true301 }302 }303 return false304 }305 var ids []string306 if json.Unmarshal([]byte(answer.Content), &ids) != nil {307 return false308 }309 if len(ids) == 0 || len(ids) > len(options) {310 return false311 }312 seen := map[string]bool{}313 for _, id := range ids {314 if seen[id] {315 return false316 }317 seen[id] = true318 found := false319 for _, option := range options {320 if option.ID == id {321 found = true322 break323 }324 }325 if !found {326 return false327 }328 }329 return true330}331332func nativeCombinedAnswer(content string, options []nativeOption, selection string) bool {333 fields, valid := nativeJSONFields(content, map[string]bool{"choices": true, "text": true})334 if !valid || len(fields) != 2 {335 return false336 }337 var choices []string338 var text string339 if json.Unmarshal(fields["choices"], &choices) != nil || json.Unmarshal(fields["text"], &text) != nil {340 return false341 }342 text = strings.TrimSpace(text)343 if len([]rune(text)) == 0 || len([]rune(text)) > 8000 {344 return false345 }346 if selection == "single" && len(choices) != 1 || selection == "multiple" && (len(choices) == 0 || len(choices) > len(options)) {347 return false348 }349 seen := make(map[string]bool, len(choices))350 for _, id := range choices {351 if seen[id] {352 return false353 }354 seen[id] = true355 found := false356 for _, option := range options {357 if option.ID == id {358 found = true359 break360 }361 }362 if !found {363 return false364 }365 }366 return true367}368369func nativeJSONFields(content string, allowed map[string]bool) (map[string]json.RawMessage, bool) {370 decoder := json.NewDecoder(strings.NewReader(content))371 token, err := decoder.Token()372 if err != nil || token != json.Delim('{') {373 return nil, false374 }375 fields := make(map[string]json.RawMessage, len(allowed))376 for decoder.More() {377 token, err = decoder.Token()378 key, ok := token.(string)379 if err != nil || !ok || !allowed[key] || fields[key] != nil {380 return nil, false381 }382 var value json.RawMessage383 if err := decoder.Decode(&value); err != nil {384 return nil, false385 }386 fields[key] = value387 }388 if _, err = decoder.Token(); err != nil {389 return nil, false390 }391 var extra any392 if err := decoder.Decode(&extra); err != io.EOF {393 return nil, false394 }395 return fields, true396}397398// approvalSettle fills the answer and state of one request from its answers,399// which arrive newest first.400func approvalSettle(item *approvalItem, rows []event.Event, now int64) {401 if item.Type == "grant" {402 settleGrantApproval(item, rows, now)403 return404 }405 var reply *event.Event406 for i := range rows {407 row := rows[i]408 if row.Kind == kindReaction {409 if decision := approvalDecision(row.Content); decision != "" {410 item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: decision, Content: row.Content, CreatedAt: row.CreatedAt}411 item.State = "answered"412 return413 }414 } else if row.Kind == kindComment && reply == nil {415 reply = &row416 }417 }418 if reply != nil {419 item.Answer = &approvalAnswer{ID: reply.ID, Kind: reply.Kind, Author: reply.PubKey, Decision: "replied", Content: reply.Content, CreatedAt: reply.CreatedAt}420 item.State = "answered"421 return422 }423 if item.Expires > 0 && item.Expires <= now {424 item.State = "expired"425 }426}427428// approvalsFor lists every request addressed to the key, newest first,429// settled against its answers. Expired requests are kept so the person sees430// what lapsed until maintenance removes them.431func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {432 now := time.Now().Unix()433 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})434 if err != nil {435 return nil, false, err436 }437 items := make([]approvalItem, 0, len(page.Events))438 for _, row := range page.Events {439 kind, ok := approvalRequest(row)440 if !ok || row.PubKey == pubkey {441 continue442 }443 items = append(items, t.approvalItemFrom(ctx, row, kind))444 }445 answers, err := t.approvalAnswers(ctx, items, now)446 if err != nil {447 return nil, false, err448 }449 for i := range items {450 approvalSettle(&items[i], answers[items[i].ID], now)451 t.reviewGrantApproval(ctx, &items[i], now)452 }453 wikiItems, wikiMore, err := t.wikiApprovalItems(ctx, pubkey, before, limit)454 if err != nil {455 return nil, false, err456 }457 items = append(items, wikiItems...)458 sort.SliceStable(items, func(i, j int) bool {459 if items[i].CreatedAt != items[j].CreatedAt {460 return items[i].CreatedAt > items[j].CreatedAt461 }462 return items[i].ID < items[j].ID463 })464 more := page.More || wikiMore || len(items) > limit465 if len(items) > limit {466 items = items[:limit]467 }468 return items, more, nil469}470471// openApprovals lists the unanswered, unexpired requests addressed to the472// key, which the badge and the panel count.473func (t *Tenant) openApprovals(ctx context.Context, pubkey string) []approvalItem {474 var open []approvalItem475 var cursor *storage.EventCursor476 for scanned := 0; scanned < approvalScan; scanned += 100 {477 items, more, err := t.approvalsFor(ctx, pubkey, cursor, 100)478 if err != nil {479 return open480 }481 for _, item := range items {482 if item.State == "open" {483 open = append(open, item)484 }485 }486 if !more || len(items) == 0 {487 break488 }489 last := items[len(items)-1]490 cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}491 }492 return open493}494495// approvalNotices wakes each asked person in the approvals category. The496// body names the asker and the subject, and the payload carries the actions497// the device can answer with: approve and deny for decisions, reply for498// every type.499func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {500 base := strings.TrimRight(t.publicURL, "/")501 subject := strings.TrimSpace(event.Tag(e, "subject"))502 if subject == "" {503 subject = e.Content504 }505 body := shortKey(e.PubKey) + " asks: " + excerpt(subject)506 actions := []pushAction{{Action: "reply", Title: "Reply"}}507 if kind != "question" {508 actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}}509 }510 if kind == "grant" {511 body = shortKey(e.PubKey) + " requests an access change: " + excerpt(e.Content)512 actions = []pushAction{{Action: "review", Title: "Review access"}}513 }514 var notices []pushNotice515 _, _, native := nativeInteraction(e)516 for _, recipient := range approvalAsked(e) {517 if native && !containsString(event.TagValues(e, "mention"), recipient) {518 continue519 }520 notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals/" + e.ID, actions: actions})521 }522 return notices523}524525func shortKey(pubkey string) string {526 if len(pubkey) > 12 {527 return pubkey[:12]528 }529 return pubkey530}531532func containsInt(values []int, wanted int) bool {533 for _, value := range values {534 if value == wanted {535 return true536 }537 }538 return false539}540541func approvalCursor(item approvalItem) string {542 return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID543}