1package daemon23// Device notification triggers. Each stored event that addresses a member4// with registered devices becomes a short summary in one of five categories,5// which the person picks per device: private messages, replies to their own6// posts and repositories, mentions, requests for a decision, and relay7// notices.89import (10 "context"11 "encoding/json"12 "strconv"13 "strings"14 "time"1516 "github.com/FelineStateMachine/tinyrelay/internal/event"17 "github.com/FelineStateMachine/tinyrelay/internal/storage"18)1920const (21 pushMessages = "messages"22 pushReplies = "replies"23 pushMentions = "mentions"24 pushApprovals = "approvals"25 pushRelay = "relay"26 pushCoalesce = 30 * time.Second27 pushExcerptMax = 12028)2930var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushApprovals, pushRelay}3132type pushNotice struct {33 recipient, category, body, url string34 // actions are the answers a device can give from the notification.35 actions []pushAction36}3738// notifyDevices runs after an event is stored. It never fails the publish:39// a device summary is best effort on top of the durable event.40func (t *Tenant) notifyDevices(ctx context.Context, e event.Event) {41 if !t.pushDevicesExist(ctx) {42 return43 }44 for _, notice := range t.pushNotices(ctx, e) {45 if !t.pushCoalesced(notice.recipient, notice.category) {46 continue47 }48 if err := t.enqueuePushNotice(ctx, notice); err != nil {49 t.app.telemetry.Logger().Debug("device notification not queued", "error", err)50 }51 }52}5354func (t *Tenant) pushDevicesExist(ctx context.Context) bool {55 var count int56 return t.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push").Scan(&count) == nil && count > 057}5859// pushCoalesced allows one summary per recipient and category within a short60// window, so a burst of replies produces a single notification.61func (t *Tenant) pushCoalesced(recipient, category string) bool {62 t.pushMu.Lock()63 defer t.pushMu.Unlock()64 if t.pushRecent == nil {65 t.pushRecent = make(map[string]time.Time)66 }67 key := recipient + "\x00" + category68 now := time.Now()69 if last, ok := t.pushRecent[key]; ok && now.Sub(last) < pushCoalesce {70 return false71 }72 for k, at := range t.pushRecent {73 if now.Sub(at) > pushCoalesce {74 delete(t.pushRecent, k)75 }76 }77 t.pushRecent[key] = now78 return true79}8081func (t *Tenant) enqueuePushNotice(ctx context.Context, notice pushNotice) error {82 return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url, Actions: notice.actions})83}8485// pushNotices decides who an event should wake and why.86func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {87 base := strings.TrimRight(t.publicURL, "/")88 var notices []pushNotice89 // A request for a decision wakes the people asked in its own category90 // and nowhere else, so one event yields one notification per device.91 if kind, ok := approvalRequest(e); ok {92 return t.approvalNotices(e, kind)93 }94 // A proposal from an agent asks the repository's maintainers for a95 // decision the same way, and wakes nobody else.96 if notices, ok := t.collaborationProposalNotices(ctx, e); ok {97 return notices98 }99 // A job result, or feedback that needs the requester's attention,100 // reaches the requester as a mention.101 if status, ok := jobNotice(e); ok {102 body := "job " + t.jobRequestKind(ctx, e) + " " + status103 for _, recipient := range pushRecipients(e) {104 notices = append(notices, pushNotice{recipient: recipient, category: pushMentions, body: body, url: base + "/e/" + e.ID})105 }106 return notices107 }108 switch e.Kind {109 case event.KIND_WRAP, 4:110 for _, recipient := range pushRecipients(e) {111 notices = append(notices, pushNotice{recipient: recipient, category: pushMessages, body: "New private message.", url: base + "/inbox"})112 }113 case event.KIND_GIT_ISSUE, event.KIND_GIT_PR:114 owner, identifier, ok := repoCoordinate(event.Tag(e, "a"))115 if !ok || owner == e.PubKey {116 return nil117 }118 label := "New issue"119 view := "issue"120 if e.Kind == event.KIND_GIT_PR {121 label, view = "New pull request", "pr"122 }123 body := label124 if subject := strings.TrimSpace(event.Tag(e, "subject")); subject != "" {125 body += ": " + excerpt(subject)126 }127 notices = append(notices, pushNotice{recipient: owner, category: pushReplies, body: body, url: base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + e.ID})128 case 1, 1111, 30023:129 authored := t.referencedAuthors(ctx, e)130 for _, recipient := range pushRecipients(e) {131 category, body := pushMentions, "Mentioned you: "132 if authored[recipient] {133 category, body = pushReplies, "Replied to you: "134 }135 notices = append(notices, pushNotice{recipient: recipient, category: category, body: body + excerpt(e.Content), url: base + "/e/" + e.ID})136 }137 case event.KIND_CHAT, event.KIND_THREAD, event.KIND_THREAD_REPLY:138 // Room messages name the room so the summary reads as a conversation.139 roomID := event.Tag(e, "h")140 if roomID == "" || e.Kind == event.KIND_MARMOT_GROUP {141 return nil142 }143 room, err := t.community.Room(ctx, roomID)144 if err != nil || !room.Live() {145 return nil146 }147 name := room.Name148 if name == "" {149 name = room.ID150 }151 authored := t.referencedAuthors(ctx, e)152 for _, recipient := range pushRecipients(e) {153 category, body := pushMentions, "Mentioned you in "+name+": "154 if authored[recipient] {155 category, body = pushReplies, "Replied to you in "+name+": "156 }157 notices = append(notices, pushNotice{recipient: recipient, category: category, body: body + excerpt(e.Content), url: base + "/rooms/" + room.ID})158 }159 }160 return notices161}162163// jobNotice reports whether a long task event wakes its requester and with164// which status: every result does, and feedback does when it asks for165// payment or reports an error. Progress and success feedback stay quiet,166// since the result follows.167func jobNotice(e event.Event) (string, bool) {168 if event.IsJobResult(e.Kind) {169 return "result", true170 }171 if e.Kind != event.KIND_JOB_FEEDBACK {172 return "", false173 }174 status := event.Tag(e, "status")175 return status, status == "error" || status == "payment-required"176}177178// jobRequestKind names the request kind an answer belongs to: a result179// says it by its own kind, and feedback by the request the relay holds.180func (t *Tenant) jobRequestKind(ctx context.Context, e event.Event) string {181 if event.IsJobResult(e.Kind) {182 return strconv.Itoa(e.Kind - 1000)183 }184 id := event.Tag(e, "e")185 if len(id) != 64 {186 return strconv.Itoa(e.Kind)187 }188 rows, err := t.store.Query(ctx, event.Filter{IDs: []string{id}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})189 if err != nil || len(rows.Events) == 0 || !event.IsJobRequest(rows.Events[0].Kind) {190 return strconv.Itoa(e.Kind)191 }192 return strconv.Itoa(rows.Events[0].Kind)193}194195// pushRecipients lists the addressed keys other than the author, bounded so196// a broadcast to a large list cannot fan out to every device.197func pushRecipients(e event.Event) []string {198 seen := map[string]struct{}{e.PubKey: {}}199 var recipients []string200 for _, tag := range e.Tags {201 if len(tag) < 2 || (tag[0] != "p" && tag[0] != "P") || len(tag[1]) != 64 {202 continue203 }204 if _, ok := seen[tag[1]]; ok {205 continue206 }207 seen[tag[1]] = struct{}{}208 recipients = append(recipients, tag[1])209 if len(recipients) == 16 {210 break211 }212 }213 return recipients214}215216// referencedAuthors resolves the authors of events this one replies to, so a217// reply to your note counts as a reply rather than a mention.218func (t *Tenant) referencedAuthors(ctx context.Context, e event.Event) map[string]bool {219 authors := map[string]bool{}220 var ids []string221 for _, name := range []string{"e", "E"} {222 for _, id := range event.TagValues(e, name) {223 if len(id) == 64 && len(ids) < 8 {224 ids = append(ids, id)225 }226 }227 }228 if len(ids) == 0 {229 return authors230 }231 rows, err := t.store.Query(ctx, event.Filter{IDs: ids, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: len(ids)})232 if err != nil {233 return authors234 }235 for _, row := range rows.Events {236 authors[row.PubKey] = true237 }238 return authors239}240241func repoCoordinate(value string) (owner, identifier string, ok bool) {242 parts := strings.SplitN(value, ":", 3)243 if len(parts) != 3 || parts[0] != strconv.Itoa(30617) || len(parts[1]) != 64 || parts[2] == "" {244 return "", "", false245 }246 return parts[1], parts[2], true247}248249func excerpt(text string) string {250 text = strings.Join(strings.Fields(text), " ")251 if len(text) > pushExcerptMax {252 cut := pushExcerptMax253 for cut > 0 && cut < len(text) && text[cut]&0xC0 == 0x80 {254 cut--255 }256 text = text[:cut] + "…"257 }258 return text259}260261// inboxSeen records when the person last opened their inbox, which bounds262// the unread count carried on the app badge.263func (t *Tenant) inboxSeen(ctx context.Context, pubkey string) int64 {264 var at int64265 _ = t.store.GetSetting(ctx, "inbox.seen."+pubkey, &at)266 return at267}268269func (t *Tenant) markInboxSeen(ctx context.Context, pubkey string) error {270 return t.store.PutSetting(ctx, "inbox.seen."+pubkey, time.Now().Unix())271}272273// inboxUnread counts events addressed to the key since it last opened the274// inbox, plus every request that still waits for the key's decision. It275// feeds the app badge; the page clears the conversation part on the next276// visit, and an answer clears the request.277func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int {278 since := t.inboxSeen(ctx, pubkey)279 // Count conversation kinds only, so relay records addressed to the280 // owner do not inflate the badge.281 filter := event.Filter{Kinds: []int{1, 4, event.KIND_CHAT, event.KIND_THREAD, event.KIND_THREAD_REPLY, 1111, event.KIND_WRAP, event.KIND_GIT_ISSUE, event.KIND_GIT_PR, 30023}, Tags: map[string][]string{"p": {pubkey}}}282 if since > 0 {283 filter.Since = &since284 }285 count, err := t.store.Count(ctx, []event.Filter{filter}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: []string{pubkey}}})286 if err != nil {287 return 0288 }289 // An open request counts once: those newer than the last visit are290 // already in the conversation count.291 for _, item := range t.openApprovals(ctx, pubkey) {292 if item.CreatedAt < since || !containsInt(filter.Kinds, item.Kind) {293 count++294 }295 }296 if count > 99 {297 return 99298 }299 return int(count)300}301302func decodeCategories(raw string) map[string]bool {303 if raw == "" {304 return nil305 }306 var list []string307 if json.Unmarshal([]byte(raw), &list) != nil {308 return nil309 }310 allowed := map[string]bool{}311 for _, item := range list {312 allowed[item] = true313 }314 return allowed315}