tinyrelaycommit

Commit f9f2a9a9c040

browse files at this commit | history

1diff --git a/internal/daemon/approvals.go b/internal/daemon/approvals.go
2new file mode 100644
3index 0000000..0b4cf80
4--- /dev/null
5+++ b/internal/daemon/approvals.go
6@@ -0,0 +1,295 @@
7+package daemon
8+
9+// Approvals: a request for a decision is an ordinary comment or room message
10+// that carries a request tag and names the people asked with p tags. The
11+// asked person answers with a signed reaction (+ approves, - denies) or a
12+// reply. Nothing here introduces a kind; the asker learns the answer through
13+// the events it already watches.
14+
15+import (
16+ "context"
17+ "strconv"
18+ "strings"
19+ "time"
20+
21+ "github.com/FelineStateMachine/tinyrelay/internal/event"
22+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
23+)
24+
25+const (
26+ kindComment = 1111
27+ kindChatMessage = 9
28+ kindThreadRoot = 11
29+ // approvalScan bounds how many stored events one listing walks.
30+ approvalScan = 1000
31+)
32+
33+// approvalTypes is the bounded vocabulary of the request tag.
34+var approvalTypes = []string{"approve", "decide", "question"}
35+var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot}
36+
37+// approvalItem is one request as the asked person sees it.
38+type approvalItem struct {
39+ ID string `json:"id"`
40+ Kind int `json:"kind"`
41+ Type string `json:"type"`
42+ Asker string `json:"asker"`
43+ Asked []string `json:"asked"`
44+ Subject string `json:"subject,omitempty"`
45+ Content string `json:"content"`
46+ CreatedAt int64 `json:"created_at"`
47+ Expires int64 `json:"expires,omitempty"`
48+ State string `json:"state"`
49+ Room string `json:"room,omitempty"`
50+ About *approvalAbout `json:"about,omitempty"`
51+ Answer *approvalAnswer `json:"answer,omitempty"`
52+ Event event.Event `json:"event"`
53+}
54+
55+// approvalAbout is what the request refers to: a repository coordinate, an
56+// event, or both, with the page that opens it.
57+type approvalAbout struct {
58+ Coordinate string `json:"coordinate,omitempty"`
59+ Event string `json:"event,omitempty"`
60+ Kind string `json:"kind,omitempty"`
61+ URL string `json:"url"`
62+}
63+
64+// approvalAnswer is the answer that counts: the newest reaction from an asked
65+// key, or the newest reply when nobody reacted.
66+type approvalAnswer struct {
67+ ID string `json:"id"`
68+ Kind int `json:"kind"`
69+ Author string `json:"author"`
70+ Decision string `json:"decision"`
71+ Content string `json:"content"`
72+ CreatedAt int64 `json:"created_at"`
73+}
74+
75+// approvalRequest reports whether the event asks anyone for a decision and
76+// which type it carries.
77+func approvalRequest(e event.Event) (string, bool) {
78+ if !containsInt(approvalKinds, e.Kind) {
79+ return "", false
80+ }
81+ kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request")))
82+ if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {
83+ return "", false
84+ }
85+ return kind, true
86+}
87+
88+// approvalAsked lists the keys asked: every p tag other than the asker.
89+func approvalAsked(e event.Event) []string {
90+ return pushRecipients(e)
91+}
92+
93+func approvalItemFrom(e event.Event, kind string, base string) approvalItem {
94+ 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}
95+ item.About = approvalAboutFrom(e, base)
96+ return item
97+}
98+
99+// approvalAboutFrom resolves the request's a or e reference to a page. A
100+// repository coordinate opens the repository, or its issue or pull request
101+// when the root is one; any other event opens the event page.
102+func approvalAboutFrom(e event.Event, base string) *approvalAbout {
103+ coordinate := ""
104+ for _, name := range []string{"a", "A"} {
105+ if value := event.Tag(e, name); value != "" {
106+ coordinate = value
107+ break
108+ }
109+ }
110+ referenced, kind := "", ""
111+ for _, ref := range [][2]string{{"E", "K"}, {"e", "k"}} {
112+ if value := event.Tag(e, ref[0]); len(value) == 64 {
113+ referenced, kind = value, event.Tag(e, ref[1])
114+ break
115+ }
116+ }
117+ if coordinate == "" && referenced == "" {
118+ return nil
119+ }
120+ about := &approvalAbout{Coordinate: coordinate, Event: referenced, Kind: kind}
121+ if owner, identifier, ok := repoCoordinate(coordinate); ok {
122+ about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=home"
123+ if view := map[string]string{"1621": "issue", "1618": "pr"}[kind]; view != "" && referenced != "" {
124+ about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + referenced
125+ }
126+ } else if referenced != "" {
127+ about.URL = base + "/e/" + referenced
128+ } else {
129+ about.URL = base + "/a/" + coordinate
130+ }
131+ return about
132+}
133+
134+// approvalDecision maps a reaction to the answer it records. An empty
135+// reaction counts as approval, as NIP-25 reads it.
136+func approvalDecision(content string) string {
137+ switch strings.TrimSpace(content) {
138+ case "+", "":
139+ return "approved"
140+ case "-":
141+ return "denied"
142+ }
143+ return ""
144+}
145+
146+// approvalAnswers finds the answers to the given requests in one query and
147+// settles each request's state. The newest reaction from an asked key wins;
148+// a reply from an asked key answers a request that has no reaction.
149+func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now int64) (map[string][]event.Event, error) {
150+ answers := make(map[string][]event.Event, len(items))
151+ if len(items) == 0 {
152+ return answers, nil
153+ }
154+ ids := make([]string, 0, len(items))
155+ authors := []string{}
156+ asked := make(map[string]map[string]bool, len(items))
157+ for _, item := range items {
158+ ids = append(ids, item.ID)
159+ authors = append(authors, item.Asked...)
160+ asked[item.ID] = map[string]bool{}
161+ for _, key := range item.Asked {
162+ asked[item.ID][key] = true
163+ }
164+ }
165+ 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})
166+ if err != nil {
167+ return nil, err
168+ }
169+ sortEventsNewestFirst(rows.Events)
170+ for _, row := range rows.Events {
171+ if _, isRequest := approvalRequest(row); isRequest {
172+ continue
173+ }
174+ for _, id := range event.TagValues(row, "e") {
175+ if asked[id] != nil && asked[id][row.PubKey] {
176+ answers[id] = append(answers[id], row)
177+ }
178+ }
179+ }
180+ return answers, nil
181+}
182+
183+// approvalSettle fills the answer and state of one request from its answers,
184+// which arrive newest first.
185+func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
186+ var reply *event.Event
187+ for i := range rows {
188+ row := rows[i]
189+ if row.Kind == kindReaction {
190+ if decision := approvalDecision(row.Content); decision != "" {
191+ item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: decision, Content: row.Content, CreatedAt: row.CreatedAt}
192+ item.State = "answered"
193+ return
194+ }
195+ } else if reply == nil {
196+ reply = &row
197+ }
198+ }
199+ if reply != nil {
200+ item.Answer = &approvalAnswer{ID: reply.ID, Kind: reply.Kind, Author: reply.PubKey, Decision: "replied", Content: reply.Content, CreatedAt: reply.CreatedAt}
201+ item.State = "answered"
202+ return
203+ }
204+ if item.Expires > 0 && item.Expires <= now {
205+ item.State = "expired"
206+ }
207+}
208+
209+// approvalsFor lists every request addressed to the key, newest first,
210+// settled against its answers. Expired requests are kept so the person sees
211+// what lapsed until maintenance removes them.
212+func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {
213+ now := time.Now().Unix()
214+ base := strings.TrimRight(t.publicURL, "/")
215+ 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})
216+ if err != nil {
217+ return nil, false, err
218+ }
219+ items := make([]approvalItem, 0, len(page.Events))
220+ for _, row := range page.Events {
221+ kind, ok := approvalRequest(row)
222+ if !ok || row.PubKey == pubkey {
223+ continue
224+ }
225+ items = append(items, approvalItemFrom(row, kind, base))
226+ }
227+ answers, err := t.approvalAnswers(ctx, items, now)
228+ if err != nil {
229+ return nil, false, err
230+ }
231+ for i := range items {
232+ approvalSettle(&items[i], answers[items[i].ID], now)
233+ }
234+ return items, page.More, nil
235+}
236+
237+// openApprovals lists the unanswered, unexpired requests addressed to the
238+// key, which the badge and the panel count.
239+func (t *Tenant) openApprovals(ctx context.Context, pubkey string) []approvalItem {
240+ var open []approvalItem
241+ var cursor *storage.EventCursor
242+ for scanned := 0; scanned < approvalScan; scanned += 100 {
243+ items, more, err := t.approvalsFor(ctx, pubkey, cursor, 100)
244+ if err != nil {
245+ return open
246+ }
247+ for _, item := range items {
248+ if item.State == "open" {
249+ open = append(open, item)
250+ }
251+ }
252+ if !more || len(items) == 0 {
253+ break
254+ }
255+ last := items[len(items)-1]
256+ cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}
257+ }
258+ return open
259+}
260+
261+// approvalNotices wakes each asked person in the approvals category. The
262+// body names the asker and the subject, and the payload carries the actions
263+// the device can answer with: approve and deny for decisions, reply for
264+// every type.
265+func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {
266+ base := strings.TrimRight(t.publicURL, "/")
267+ subject := strings.TrimSpace(event.Tag(e, "subject"))
268+ if subject == "" {
269+ subject = e.Content
270+ }
271+ body := shortKey(e.PubKey) + " asks: " + excerpt(subject)
272+ actions := []pushAction{{Action: "reply", Title: "Reply"}}
273+ if kind != "question" {
274+ actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}}
275+ }
276+ var notices []pushNotice
277+ for _, recipient := range approvalAsked(e) {
278+ notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})
279+ }
280+ return notices
281+}
282+
283+func shortKey(pubkey string) string {
284+ if len(pubkey) > 12 {
285+ return pubkey[:12]
286+ }
287+ return pubkey
288+}
289+
290+func containsInt(values []int, wanted int) bool {
291+ for _, value := range values {
292+ if value == wanted {
293+ return true
294+ }
295+ }
296+ return false
297+}
298+
299+func approvalCursor(item approvalItem) string {
300+ return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID
301+}
302diff --git a/internal/daemon/approvals_browse.go b/internal/daemon/approvals_browse.go
303new file mode 100644
304index 0000000..d83659d
305--- /dev/null
306+++ b/internal/daemon/approvals_browse.go
307@@ -0,0 +1,182 @@
308+package daemon
309+
310+// Approval browsing: the requests addressed to the caller with their state
311+// and counts, and one request with every answer it received. Both read
312+// through the tenant gate with the caller's session, like the other browse
313+// methods.
314+
315+import (
316+ "context"
317+ "encoding/json"
318+ "errors"
319+ "fmt"
320+ "strings"
321+ "time"
322+
323+ "github.com/FelineStateMachine/tinyrelay/internal/event"
324+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
325+)
326+
327+type approvalBrowseRequest struct {
328+ Limit int `json:"limit"`
329+ Cursor string `json:"cursor"`
330+ State string `json:"state"`
331+ ID string `json:"id"`
332+ Event string `json:"event"`
333+}
334+
335+// approvalDevice is one of the caller's registered devices, as a fact for
336+// the panel: when it was registered and which categories it wakes for.
337+type approvalDevice struct {
338+ CreatedAt int64 `json:"created_at"`
339+ Categories []string `json:"categories"`
340+}
341+
342+func approvalBrowseMethod(method string) bool {
343+ return method == "browseapprovals" || method == "browseapproval"
344+}
345+
346+func (t *Tenant) executeApprovals(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
347+ if actor == "" {
348+ return nil, errors.New("auth-required: sign in to see requests addressed to you")
349+ }
350+ q := approvalBrowseRequest{}
351+ if len(params) > 0 {
352+ if err := json.Unmarshal(params[0], &q); err != nil {
353+ return nil, fmt.Errorf("invalid: approval parameters: %w", err)
354+ }
355+ }
356+ if q.Limit <= 0 || q.Limit > 100 {
357+ q.Limit = 50
358+ }
359+ switch method {
360+ case "browseapprovals":
361+ return t.browseApprovals(ctx, actor, q)
362+ case "browseapproval":
363+ if q.ID == "" {
364+ q.ID = q.Event
365+ }
366+ return t.browseApproval(ctx, actor, q.ID)
367+ }
368+ return nil, errors.New("unsupported: browse operation")
369+}
370+
371+// browseApprovals lists the requests addressed to the caller. The counts
372+// cover every request the walk saw, so the panel can say how many wait even
373+// when the page shows only one state.
374+func (t *Tenant) browseApprovals(ctx context.Context, actor string, q approvalBrowseRequest) (any, error) {
375+ if q.State == "" {
376+ q.State = "all"
377+ }
378+ if !containsString([]string{"open", "answered", "expired", "all"}, q.State) {
379+ return nil, errors.New("invalid: approval state")
380+ }
381+ cursor, err := parseCollaborationCursor(q.Cursor)
382+ if err != nil {
383+ return nil, errors.New("invalid: approval cursor")
384+ }
385+ session := browseSession(t, actor)
386+ items := make([]approvalItem, 0, q.Limit)
387+ counts := map[string]int{"open": 0, "answered": 0, "expired": 0}
388+ var oldest int64
389+ next := ""
390+ for scanned := 0; scanned < approvalScan; scanned += 100 {
391+ page, more, err := t.approvalsFor(ctx, actor, cursor, 100)
392+ if err != nil {
393+ return nil, err
394+ }
395+ for _, item := range page {
396+ cursor = &storage.EventCursor{CreatedAt: item.CreatedAt, ID: item.ID}
397+ if !t.gate.CanSee(ctx, item.Event, session, nil) {
398+ continue
399+ }
400+ counts[item.State]++
401+ if item.State == "open" && (oldest == 0 || item.CreatedAt < oldest) {
402+ oldest = item.CreatedAt
403+ }
404+ if q.State != "all" && q.State != item.State {
405+ continue
406+ }
407+ if len(items) < q.Limit {
408+ items = append(items, item)
409+ } else if next == "" {
410+ next = approvalCursor(items[len(items)-1])
411+ }
412+ }
413+ if !more || len(page) == 0 {
414+ break
415+ }
416+ if next == "" && len(items) == q.Limit {
417+ next = approvalCursor(items[len(items)-1])
418+ }
419+ }
420+ devices, err := t.approvalDevices(ctx, actor)
421+ if err != nil {
422+ return nil, err
423+ }
424+ return map[string]any{"items": items, "next_cursor": next, "counts": counts, "oldest_open": oldest, "devices": devices}, nil
425+}
426+
427+// browseApproval returns one request with every reaction and reply from the
428+// asked keys, newest first. The asked people and the asker may read it.
429+func (t *Tenant) browseApproval(ctx context.Context, actor, id string) (any, error) {
430+ if len(id) != 64 {
431+ return nil, errors.New("invalid: approval id")
432+ }
433+ session := browseSession(t, actor)
434+ rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Kinds: approvalKinds, Limit: intPtr(1)}}, session)
435+ if err != nil {
436+ return nil, err
437+ }
438+ if len(rows) != 1 {
439+ return nil, errors.New("not found: request")
440+ }
441+ kind, ok := approvalRequest(rows[0])
442+ if !ok {
443+ return nil, errors.New("not found: request")
444+ }
445+ item := approvalItemFrom(rows[0], kind, strings.TrimRight(t.publicURL, "/"))
446+ if item.Asker != actor && !containsString(item.Asked, actor) {
447+ return nil, errors.New("restricted: this request is not addressed to you")
448+ }
449+ now := time.Now().Unix()
450+ answers, err := t.approvalAnswers(ctx, []approvalItem{item}, now)
451+ if err != nil {
452+ return nil, err
453+ }
454+ approvalSettle(&item, answers[item.ID], now)
455+ history := answers[item.ID]
456+ if history == nil {
457+ history = []event.Event{}
458+ }
459+ return map[string]any{"item": item, "answers": history}, nil
460+}
461+
462+// approvalDevices lists the caller's registered devices with the categories
463+// each one chose. Endpoints and keys stay out of the result.
464+func (t *Tenant) approvalDevices(ctx context.Context, pubkey string) ([]approvalDevice, error) {
465+ rows, err := t.store.DB().QueryContext(ctx, "SELECT created_at, categories FROM web_push WHERE pubkey=? ORDER BY created_at", pubkey)
466+ if err != nil {
467+ return nil, err
468+ }
469+ defer rows.Close()
470+ devices := []approvalDevice{}
471+ for rows.Next() {
472+ var device approvalDevice
473+ var raw string
474+ if err := rows.Scan(&device.CreatedAt, &raw); err != nil {
475+ return nil, err
476+ }
477+ device.Categories = pushCategories
478+ if allowed := decodeCategories(raw); allowed != nil {
479+ device.Categories = []string{}
480+ for _, category := range pushCategories {
481+ if allowed[category] {
482+ device.Categories = append(device.Categories, category)
483+ }
484+ }
485+ }
486+ devices = append(devices, device)
487+ }
488+ return devices, rows.Err()
489+}
490diff --git a/internal/daemon/approvals_test.go b/internal/daemon/approvals_test.go
491new file mode 100644
492index 0000000..ce6d633
493--- /dev/null
494+++ b/internal/daemon/approvals_test.go
495@@ -0,0 +1,242 @@
496+package daemon
497+
498+import (
499+ "context"
500+ "encoding/json"
501+ "strconv"
502+ "strings"
503+ "testing"
504+ "time"
505+
506+ "github.com/FelineStateMachine/tinyrelay/internal/event"
507+)
508+
509+const (
510+ approvalAskerSecret = "5555555555555555555555555555555555555555555555555555555555555555"
511+ approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666"
512+)
513+
514+func approvalCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) (map[string]any, error) {
515+ t.Helper()
516+ raw, err := json.Marshal(params)
517+ if err != nil {
518+ t.Fatal(err)
519+ }
520+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw})
521+ if err != nil {
522+ return nil, err
523+ }
524+ encoded, err := json.Marshal(value)
525+ if err != nil {
526+ t.Fatal(err)
527+ }
528+ var result map[string]any
529+ if err := json.Unmarshal(encoded, &result); err != nil {
530+ t.Fatal(err)
531+ }
532+ return result, nil
533+}
534+
535+func approvalByID(items []map[string]any, id string) map[string]any {
536+ for _, item := range items {
537+ if item["id"] == id {
538+ return item
539+ }
540+ }
541+ return nil
542+}
543+
544+func TestBrowseApprovalsStatesAndAccess(t *testing.T) {
545+ _, tenant := testTenant(t)
546+ ctx := context.Background()
547+ owner := tenant.Policy().Owner
548+ asker, other := wikiKey(t, approvalAskerSecret), wikiKey(t, approvalOtherSecret)
549+ now := time.Now().Unix()
550+
551+ open := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-400, "Publish release notes 1.4 as drafted?", []string{"request", "approve"}, []string{"p", owner}, []string{"subject", "Publish release notes 1.4"}, []string{"a", "30617:" + owner + ":notes"}, []string{"expiration", strconv.FormatInt(now+3600, 10)})
552+ decided := wikiPublish(t, tenant, approvalAskerSecret, kindThreadRoot, now-300, "Mark pull request 23 merged?", []string{"request", "decide"}, []string{"p", owner}, []string{"h", tenant.meta.Name})
553+ // An older denial, a newer approval and a stranger's reaction: the
554+ // newest reaction from an asked key wins and the stranger is ignored.
555+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-250, "-", []string{"e", decided.ID}, []string{"p", asker})
556+ approval := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", decided.ID}, []string{"p", asker})
557+ wikiPublish(t, tenant, approvalOtherSecret, kindReaction, now-100, "-", []string{"e", decided.ID}, []string{"p", asker})
558+ question := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-200, "Should the edge be mentioned?", []string{"request", "question"}, []string{"p", owner}, []string{"E", decided.ID, "", asker}, []string{"K", "11"}, []string{"P", asker})
559+ reply := wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-150, "Leave it out.", []string{"E", question.ID, "", asker}, []string{"K", "1111"}, []string{"P", asker}, []string{"e", question.ID, "", asker}, []string{"k", "1111"}, []string{"p", asker})
560+ // An expired request is saved directly, as it would have been before it
561+ // lapsed, and shows as expired until maintenance removes it.
562+ expired := event.Event{Kind: kindComment, CreatedAt: now - 7200, Content: "Rotate the key tonight?", Tags: [][]string{{"request", "approve"}, {"p", owner}, {"expiration", strconv.FormatInt(now-3600, 10)}}}
563+ if err := event.Sign(&expired, approvalAskerSecret); err != nil {
564+ t.Fatal(err)
565+ }
566+ if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil {
567+ t.Fatal(err)
568+ }
569+ elsewhere := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-50, "Only for someone else", []string{"request", "approve"}, []string{"p", other})
570+ // Ordinary comments and unknown request types are not requests.
571+ wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-40, "Plain mention", []string{"p", owner})
572+ wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-30, "Unknown type", []string{"request", "veto"}, []string{"p", owner})
573+
574+ result, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{})
575+ if err != nil {
576+ t.Fatal(err)
577+ }
578+ items := wikiItems(result, "items")
579+ if len(items) != 4 || approvalByID(items, elsewhere.ID) != nil {
580+ t.Fatalf("owner sees %d items: %v", len(items), items)
581+ }
582+ counts := result["counts"].(map[string]any)
583+ if counts["open"] != 1.0 || counts["answered"] != 2.0 || counts["expired"] != 1.0 {
584+ t.Fatalf("counts %v", counts)
585+ }
586+ if result["oldest_open"] != float64(open.CreatedAt) {
587+ t.Fatalf("oldest open %v", result["oldest_open"])
588+ }
589+ item := approvalByID(items, open.ID)
590+ if item["state"] != "open" || item["type"] != "approve" || item["subject"] != "Publish release notes 1.4" || item["asker"] != asker || item["expires"] != float64(now+3600) || item["answer"] != nil {
591+ t.Fatalf("open item %v", item)
592+ }
593+ if about := item["about"].(map[string]any); about["coordinate"] != "30617:"+owner+":notes" || !strings.HasSuffix(about["url"].(string), "/repo?owner="+owner+"&repo=notes&view=home") {
594+ t.Fatalf("about %v", about)
595+ }
596+ item = approvalByID(items, decided.ID)
597+ answer, _ := item["answer"].(map[string]any)
598+ if item["state"] != "answered" || item["room"] != tenant.meta.Name || answer == nil || answer["decision"] != "approved" || answer["id"] != approval.ID {
599+ t.Fatalf("decided item %v", item)
600+ }
601+ item = approvalByID(items, question.ID)
602+ answer, _ = item["answer"].(map[string]any)
603+ if item["state"] != "answered" || answer == nil || answer["decision"] != "replied" || answer["id"] != reply.ID || answer["content"] != "Leave it out." {
604+ t.Fatalf("question item %v", item)
605+ }
606+ if about := item["about"].(map[string]any); about["event"] != decided.ID || about["kind"] != "11" || !strings.HasSuffix(about["url"].(string), "/e/"+decided.ID) {
607+ t.Fatalf("question about %v", about)
608+ }
609+ if item = approvalByID(items, expired.ID); item["state"] != "expired" {
610+ t.Fatalf("expired item %v", item)
611+ }
612+
613+ filtered, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "open"})
614+ if err != nil {
615+ t.Fatal(err)
616+ }
617+ if items := wikiItems(filtered, "items"); len(items) != 1 || items[0]["id"] != open.ID {
618+ t.Fatalf("open filter %v", items)
619+ }
620+ if _, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {
621+ t.Fatalf("bogus state %v", err)
622+ }
623+ paged, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2})
624+ if err != nil {
625+ t.Fatal(err)
626+ }
627+ if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" {
628+ t.Fatalf("paged %v", paged)
629+ }
630+ rest, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2, "cursor": paged["next_cursor"]})
631+ if err != nil {
632+ t.Fatal(err)
633+ }
634+ if items := wikiItems(rest, "items"); len(items) != 2 || items[0]["id"] == wikiItems(paged, "items")[0]["id"] {
635+ t.Fatalf("second page %v", rest)
636+ }
637+
638+ // The other key sees only its own request; guests see nothing.
639+ theirs, err := approvalCall(t, tenant, other, "browseapprovals", map[string]any{})
640+ if err != nil {
641+ t.Fatal(err)
642+ }
643+ if items := wikiItems(theirs, "items"); len(items) != 1 || items[0]["id"] != elsewhere.ID {
644+ t.Fatalf("other sees %v", items)
645+ }
646+ if _, err := approvalCall(t, tenant, "", "browseapprovals", map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {
647+ t.Fatalf("guest %v", err)
648+ }
649+
650+ // One request with its history: the asked person and the asker may
651+ // read it, nobody else.
652+ detail, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": decided.ID})
653+ if err != nil {
654+ t.Fatal(err)
655+ }
656+ if got := detail["item"].(map[string]any); got["state"] != "answered" || got["answer"].(map[string]any)["decision"] != "approved" {
657+ t.Fatalf("detail %v", detail)
658+ }
659+ if answers := detail["answers"].([]any); len(answers) != 2 || answers[0].(map[string]any)["id"] != approval.ID {
660+ t.Fatalf("answers %v", answers)
661+ }
662+ if _, err := approvalCall(t, tenant, asker, "browseapproval", map[string]any{"id": decided.ID}); err != nil {
663+ t.Fatalf("asker detail %v", err)
664+ }
665+ if _, err := approvalCall(t, tenant, other, "browseapproval", map[string]any{"id": decided.ID}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
666+ t.Fatalf("stranger detail %v", err)
667+ }
668+ if _, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": reply.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
669+ t.Fatalf("reply as request %v", err)
670+ }
671+}
672+
673+func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {
674+ _, tenant := testTenant(t)
675+ ctx := context.Background()
676+ owner := tenant.Policy().Owner
677+ asker := wikiKey(t, approvalAskerSecret)
678+ now := time.Now().Unix()
679+
680+ request := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "decide"}, {"p", owner}, {"subject", "Publish release notes"}, {"expiration", strconv.FormatInt(now+3600, 10)}}, Content: "Long body"}
681+ notices := tenant.pushNotices(ctx, request)
682+ if len(notices) != 1 || notices[0].category != pushApprovals || notices[0].recipient != owner || notices[0].body != asker[:12]+" asks: Publish release notes" || !strings.HasSuffix(notices[0].url, "/approvals?id="+request.ID) {
683+ t.Fatalf("decision notices %+v", notices)
684+ }
685+ if actions := notices[0].actions; len(actions) != 3 || actions[0].Action != "approve" || actions[1].Action != "deny" || actions[2].Action != "reply" || actions[0].Title != "Approve" {
686+ t.Fatalf("decision actions %+v", notices[0].actions)
687+ }
688+ question := event.Event{Kind: kindChatMessage, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "question"}, {"p", owner}, {"h", "build"}}, Content: "Mention the edge?"}
689+ notices = tenant.pushNotices(ctx, question)
690+ if len(notices) != 1 || notices[0].body != asker[:12]+" asks: Mention the edge?" || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "reply" {
691+ t.Fatalf("question notices %+v", notices)
692+ }
693+ // A comment that asks nobody keeps its mention notice.
694+ plain := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"p", owner}}, Content: "Hello"}
695+ if notices := tenant.pushNotices(ctx, plain); len(notices) != 1 || notices[0].category != pushMentions {
696+ t.Fatalf("plain notices %+v", notices)
697+ }
698+ if !containsString(pushCategories, pushApprovals) {
699+ t.Fatal("approvals is not a device category")
700+ }
701+
702+ // The actions travel through the payload to the device message.
703+ message := tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushApprovals, Text: notices[0].body, URL: notices[0].url, Actions: notices[0].actions})
704+ encoded, err := json.Marshal(message)
705+ if err != nil {
706+ t.Fatal(err)
707+ }
708+ if !strings.Contains(string(encoded), `"actions":[{"action":"reply","title":"Reply"}]`) || !strings.Contains(string(encoded), `"tag":"tiny-approvals"`) {
709+ t.Fatalf("message %s", encoded)
710+ }
711+ if encoded, _ := json.Marshal(tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushMentions, Text: "hi"})); strings.Contains(string(encoded), "actions") {
712+ t.Fatalf("mention message carries actions: %s", encoded)
713+ }
714+
715+ // The badge counts open requests once, whether they arrived before or
716+ // after the last inbox visit, and drops them once answered.
717+ if err := tenant.markInboxSeen(ctx, owner); err != nil {
718+ t.Fatal(err)
719+ }
720+ seen := tenant.inboxSeen(ctx, owner)
721+ older := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen-100, "Older request", []string{"request", "approve"}, []string{"p", owner})
722+ if got := tenant.inboxUnread(ctx, owner); got != 1 {
723+ t.Fatalf("unread with an older open request = %d", got)
724+ }
725+ newer := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen+100, "Newer request", []string{"request", "approve"}, []string{"p", owner})
726+ wikiPublish(t, tenant, approvalAskerSecret, kindChatMessage, seen+100, "Room request", []string{"request", "question"}, []string{"p", owner}, []string{"h", tenant.meta.Name})
727+ if got := tenant.inboxUnread(ctx, owner); got != 3 {
728+ t.Fatalf("unread with three open requests = %d", got)
729+ }
730+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "+", []string{"e", older.ID}, []string{"p", asker})
731+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "-", []string{"e", newer.ID}, []string{"p", asker})
732+ // The newer request still counts as a conversation event since the
733+ // visit; the older one is answered and gone from the badge.
734+ if got := tenant.inboxUnread(ctx, owner); got != 2 {
735+ t.Fatalf("unread after answering = %d", got)
736+ }
737+}
738diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
739index fc2e26a..d67a87a 100644
740--- a/internal/daemon/client_browse.go
741+++ b/internal/daemon/client_browse.go
742@@ -34,7 +34,7 @@ type clientBrowseRequest struct {
743 }
744
745 func clientBrowseMethod(method string) bool {
746- return wikiBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method)
747+ return wikiBrowseMethod(method) || approvalBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method)
748 }
749
750 func (t *Tenant) browseRead(ctx context.Context, actor string) error {
751@@ -61,6 +61,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
752 if wikiBrowseMethod(method) {
753 return t.executeWiki(ctx, actor, method, params)
754 }
755+ if approvalBrowseMethod(method) {
756+ return t.executeApprovals(ctx, actor, method, params)
757+ }
758 q := clientBrowseRequest{}
759 if len(params) > 0 {
760 if err := json.Unmarshal(params[0], &q); err != nil {
761diff --git a/internal/daemon/webpush.go b/internal/daemon/webpush.go
762index 93fe7a0..30d41be 100644
763--- a/internal/daemon/webpush.go
764+++ b/internal/daemon/webpush.go
765@@ -41,15 +41,25 @@ type pushPayload struct {
766 Subject string `json:"subject"`
767 Text string `json:"text"`
768 URL string `json:"url,omitempty"`
769+ // Actions are the answers the device offers on the notification.
770+ Actions []pushAction `json:"actions,omitempty"`
771+}
772+
773+// pushAction is one button on a notification. The service worker opens the
774+// notification's url with answer=<action> when it is pressed.
775+type pushAction struct {
776+ Action string `json:"action"`
777+ Title string `json:"title"`
778 }
779
780 // pushMessage is what the service worker receives after decryption.
781 type pushMessage struct {
782- Title string `json:"title"`
783- Body string `json:"body"`
784- URL string `json:"url"`
785- Tag string `json:"tag"`
786- Badge int `json:"badge"`
787+ Title string `json:"title"`
788+ Body string `json:"body"`
789+ URL string `json:"url"`
790+ Tag string `json:"tag"`
791+ Badge int `json:"badge"`
792+ Actions []pushAction `json:"actions,omitempty"`
793 }
794
795 // pushRegistration is the browser's subscription with its chosen categories.
796@@ -374,5 +384,5 @@ func (t *Tenant) pushMessage(ctx context.Context, payload pushPayload) pushMessa
797 if url == "" {
798 url = strings.TrimRight(t.publicURL, "/") + "/inbox"
799 }
800- return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient)}
801+ return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient), Actions: payload.Actions}
802 }
803diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go
804index cf99037..db8dec2 100644
805--- a/internal/daemon/webpush_triggers.go
806+++ b/internal/daemon/webpush_triggers.go
807@@ -1,9 +1,10 @@
808 package daemon
809
810 // Device notification triggers. Each stored event that addresses a member
811-// with registered devices becomes a short summary in one of four categories,
812+// with registered devices becomes a short summary in one of five categories,
813 // which the person picks per device: private messages, replies to their own
814-// posts and repositories, mentions, and relay notices.
815+// posts and repositories, mentions, requests for a decision, and relay
816+// notices.
817
818 import (
819 "context"
820@@ -20,15 +21,18 @@ const (
821 pushMessages = "messages"
822 pushReplies = "replies"
823 pushMentions = "mentions"
824+ pushApprovals = "approvals"
825 pushRelay = "relay"
826 pushCoalesce = 30 * time.Second
827 pushExcerptMax = 120
828 )
829
830-var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushRelay}
831+var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushApprovals, pushRelay}
832
833 type pushNotice struct {
834 recipient, category, body, url string
835+ // actions are the answers a device can give from the notification.
836+ actions []pushAction
837 }
838
839 // notifyDevices runs after an event is stored. It never fails the publish:
840@@ -75,13 +79,18 @@ func (t *Tenant) pushCoalesced(recipient, category string) bool {
841 }
842
843 func (t *Tenant) enqueuePushNotice(ctx context.Context, notice pushNotice) error {
844- return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url})
845+ return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url, Actions: notice.actions})
846 }
847
848 // pushNotices decides who an event should wake and why.
849 func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {
850 base := strings.TrimRight(t.publicURL, "/")
851 var notices []pushNotice
852+ // A request for a decision wakes the people asked in its own category
853+ // and nowhere else, so one event yields one notification per device.
854+ if kind, ok := approvalRequest(e); ok {
855+ return t.approvalNotices(e, kind)
856+ }
857 switch e.Kind {
858 case event.KIND_WRAP, 4:
859 for _, recipient := range pushRecipients(e) {
860@@ -216,7 +225,9 @@ func (t *Tenant) markInboxSeen(ctx context.Context, pubkey string) error {
861 }
862
863 // inboxUnread counts events addressed to the key since it last opened the
864-// inbox. It feeds the app badge; the page clears it on the next visit.
865+// inbox, plus every request that still waits for the key's decision. It
866+// feeds the app badge; the page clears the conversation part on the next
867+// visit, and an answer clears the request.
868 func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int {
869 since := t.inboxSeen(ctx, pubkey)
870 // Count conversation kinds only, so relay records addressed to the
871@@ -229,6 +240,13 @@ func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int {
872 if err != nil {
873 return 0
874 }
875+ // An open request counts once: those newer than the last visit are
876+ // already in the conversation count.
877+ for _, item := range t.openApprovals(ctx, pubkey) {
878+ if item.CreatedAt < since || !containsInt(filter.Kinds, item.Kind) {
879+ count++
880+ }
881+ }
882 if count > 99 {
883 return 99
884 }

About

ownerc01503e8035f
visibilitypublic
headf9f2a9a9c040

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs