1package daemon23// Wiki proposals. A version (kind 30818) whose author holds an agent grant4// with wiki set to propose is a proposal: it stays pending until the relay5// owner or a moderator reacts to the version's event id with "+", which6// approves it, or "-", which rejects it. A newer version from the agent is7// a new event id and starts pending again, so every edit is decided on its8// own. A pending or rejected proposal is visible only to the owner,9// moderators and its author; everyone else sees the agent's newest10// approved revision in its place, or nothing when none is approved. The11// state is computed here and carried on every browse result that lists12// versions, including the archived revisions in a page's history.1314import (15 "context"16 "net/url"17 "strings"18 "time"1920 "github.com/FelineStateMachine/tinyrelay/internal/event"21 "github.com/FelineStateMachine/tinyrelay/internal/storage"22)2324const (25 wikiProposalPending = "pending"26 wikiProposalApproved = "approved"27 wikiProposalRejected = "rejected"28 wikiGrantPropose = "propose"29)3031// wikiRoles caches the role and grant lookups one browse call makes, since32// a page's versions and their reactions repeat the same few keys.33type wikiRoles struct {34 t *Tenant35 roles map[string]string36 proposers map[string]bool37}3839func (t *Tenant) wikiRoles() *wikiRoles {40 return &wikiRoles{t: t, roles: map[string]string{}, proposers: map[string]bool{}}41}4243func (r *wikiRoles) role(ctx context.Context, pubkey string) (string, error) {44 if role, ok := r.roles[pubkey]; ok {45 return role, nil46 }47 role, err := r.t.community.Role(ctx, pubkey)48 if err != nil {49 return "", err50 }51 r.roles[pubkey] = role52 return role, nil53}5455// decides reports whether a key may approve or reject proposals: the relay56// owner and moderators.57func (r *wikiRoles) decides(ctx context.Context, pubkey string) (bool, error) {58 if pubkey == "" {59 return false, nil60 }61 role, err := r.role(ctx, pubkey)62 return wikiDecidingRole(role), err63}6465func wikiDecidingRole(role string) bool {66 return role == "owner" || role == "moderator"67}6869// proposer reports whether versions from a key are proposals: the key holds70// an agent grant with wiki set to propose and no human standing on the71// relay. A paused, revoked or expired grant keeps counting, so an agent's72// pending versions do not surface when its grant ends.73func (r *wikiRoles) proposer(ctx context.Context, pubkey string) (bool, error) {74 if known, ok := r.proposers[pubkey]; ok {75 return known, nil76 }77 grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)78 if err != nil {79 return false, err80 }81 proposer := ok && grant.Scope.Wiki == wikiGrantPropose82 if proposer {83 role, err := r.role(ctx, pubkey)84 if err != nil {85 return false, err86 }87 proposer = role == "" || role == "agent"88 }89 r.proposers[pubkey] = proposer90 return proposer, nil91}9293// wikiProposals marks the proposals among versions and resolves each one's94// state from the owner's and moderators' reactions to its event id. The95// newest deciding reaction counts.96func (t *Tenant) wikiProposals(ctx context.Context, roles *wikiRoles, versions []wikiVersion) error {97 var ids []string98 for i := range versions {99 proposer, err := roles.proposer(ctx, versions[i].Author)100 if err != nil {101 return err102 }103 if !proposer {104 continue105 }106 versions[i].Proposal = true107 versions[i].Approval = wikiProposalPending108 ids = append(ids, versions[i].ID)109 }110 if len(ids) == 0 {111 return nil112 }113 rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})114 if err != nil {115 return err116 }117 reactions := rows.Events118 sortEventsNewestFirst(reactions)119 for _, reaction := range reactions {120 state := wikiProposalState(reaction.Content)121 if state == "" {122 continue123 }124 decides, err := roles.decides(ctx, reaction.PubKey)125 if err != nil {126 return err127 }128 if !decides {129 continue130 }131 for _, id := range event.TagValues(reaction, "e") {132 for i := range versions {133 v := &versions[i]134 if v.ID != id || !v.Proposal || v.ApprovalEvent != "" {135 continue136 }137 v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey138 }139 }140 }141 return nil142}143144func wikiProposalState(content string) string {145 switch wikiReactionStatus(content) {146 case wikiMergeAccepted:147 return wikiProposalApproved148 case wikiMergeRejected:149 return wikiProposalRejected150 }151 return ""152}153154// wikiSees reports whether an actor may see a version: every version that155// is not a proposal or is approved, plus pending and rejected proposals156// for the owner, moderators (decides) and the proposal's author.157func wikiSees(decides bool, actor string, v wikiVersion) bool {158 return !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor)159}160161// wikiFallback picks the revision a reader sees in place of a hidden162// version: the same author's newest approved revision of the page among163// the archived revisions, which are sorted newest first.164func wikiFallback(hidden wikiVersion, archived []wikiVersion) (wikiVersion, bool) {165 for _, a := range archived {166 if a.Author == hidden.Author && a.D == hidden.D && a.Proposal && a.Approval == wikiProposalApproved {167 return a, true168 }169 }170 return wikiVersion{}, false171}172173// notifyWikiProposal wakes the owner's and moderators' devices in the174// approvals category when a proposal arrives. A decision wakes nobody.175func (t *Tenant) notifyWikiProposal(ctx context.Context, e event.Event) {176 if e.Kind != kindWikiArticle || !t.pushDevicesExist(ctx) {177 return178 }179 roles := t.wikiRoles()180 proposer, err := roles.proposer(ctx, e.PubKey)181 if err != nil || !proposer {182 return183 }184 v := wikiVersionFrom(e, false)185 if v.D == "" {186 return187 }188 name := shortKey(e.PubKey)189 if grant, ok, err := t.community.AgentGrant(ctx, e.PubKey); err == nil && ok && strings.TrimSpace(grant.Name) != "" {190 name = strings.TrimSpace(grant.Name)191 }192 moderators, err := t.community.Moderators(ctx)193 if err != nil {194 return195 }196 notice := pushNotice{category: pushApprovals, body: name + " proposes wiki: " + excerpt(v.Title), url: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(v.D)}197 queued := 0198 for _, recipient := range uniqueStrings(append([]string{t.Policy().Owner}, moderators...)) {199 if recipient == e.PubKey || !t.pushCoalesced(recipient, pushApprovals) {200 continue201 }202 notice.recipient = recipient203 if err := t.enqueuePushNotice(ctx, notice); err != nil {204 t.app.telemetry.Logger().Debug("wiki proposal notification not queued", "error", err)205 continue206 }207 queued++208 }209 t.app.telemetry.Logger().Debug("wiki proposal notifications queued", "count", queued)210}