| 1 | package main |
| 2 | |
| 3 | // π€ Changelog announcements to the space's admins β the server-side half of |
| 4 | // retiring the Announcer bot. On startup (which is when a new binary can first |
| 5 | // speak for itself) the server diffs its embedded changelog against the ids it |
| 6 | // has already announced and pushes anything new to every role-admin, full text |
| 7 | // in the banner. The words are branding-class plaintext (the changelog ships |
| 8 | // in the binary and is announced publicly), never user content, so β uniquely |
| 9 | // among pushes β the payload may carry them in the clear; nothing here touches |
| 10 | // a key. The in-app half is the Admin app's update stream (admin.note events), |
| 11 | // which materialises the same notes into the thread when an admin next opens |
| 12 | // it; this push covers the app-closed case, with no sidecar in the loop. |
| 13 | // |
| 14 | // Web Push only for now; the native banner (relay/NSE) is the follow-up PR. |
| 15 | |
| 16 | import ( |
| 17 | "encoding/json" |
| 18 | "log" |
| 19 | "regexp" |
| 20 | "strings" |
| 21 | ) |
| 22 | |
| 23 | // The announced-ids watermark lives in config, playing the role of the retired |
| 24 | // Announcer's <session>.state.json: first run baselines silently (a fresh space |
| 25 | // isn't greeted by the whole backlog), later runs announce only the diff. |
| 26 | const changelogAnnouncedKey = "changelog_announced" |
| 27 | |
| 28 | var ( |
| 29 | changelogMarkerRe = regexp.MustCompile(`^\s*(?:π€|π¨|π€|π§|π©)\s+`) |
| 30 | changelogParaRe = regexp.MustCompile(`\n[ \t]*\n`) |
| 31 | changelogWrapRe = regexp.MustCompile(`[ \t]*\n[ \t]*`) |
| 32 | ) |
| 33 | |
| 34 | // changelogClean formats one embedded release note for a notification body: |
| 35 | // drops the leading authorship marker (π€/π¨/β¦ β repo metadata, not for |
| 36 | // members) and collapses the ~72-column hard wraps back into flowing |
| 37 | // paragraphs. Mirrors changelogParagraphs in web/static/format.js β the two |
| 38 | // sides share test cases; keep them in sync. |
| 39 | func changelogClean(text string) string { |
| 40 | clean := changelogMarkerRe.ReplaceAllString(text, "") |
| 41 | var out []string |
| 42 | for _, p := range changelogParaRe.Split(clean, -1) { |
| 43 | p = strings.TrimSpace(changelogWrapRe.ReplaceAllString(p, " ")) |
| 44 | if p != "" { |
| 45 | out = append(out, p) |
| 46 | } |
| 47 | } |
| 48 | return strings.Join(out, "\n\n") |
| 49 | } |
| 50 | |
| 51 | // announceChangelog runs once at startup: baseline or announce-the-diff, then |
| 52 | // advance the watermark. Returns (fresh, sent) β entries newly announced and |
| 53 | // push sends that succeeded β for tests; the caller ignores them. |
| 54 | func (s *Server) announceChangelog() (fresh, sent int) { |
| 55 | raw, hadMark, _ := s.db.getConfig(changelogAnnouncedKey) |
| 56 | seen := map[string]bool{} |
| 57 | if hadMark { |
| 58 | var ids []string |
| 59 | if err := json.Unmarshal([]byte(raw), &ids); err != nil { |
| 60 | log.Printf("changelog: bad %s config (%v) β re-baselining", changelogAnnouncedKey, err) |
| 61 | hadMark = false |
| 62 | } |
| 63 | for _, id := range ids { |
| 64 | seen[id] = true |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | ids := make([]string, 0, len(s.changelog)) |
| 69 | var texts []string |
| 70 | for _, e := range s.changelog { |
| 71 | ids = append(ids, e.ID) |
| 72 | if hadMark && !seen[e.ID] { |
| 73 | if t := changelogClean(e.Text); t != "" { |
| 74 | texts = append(texts, t) |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | // Advance the watermark before pushing (at-most-once): a crash mid-announce |
| 79 | // must not replay the same entries at every restart. Recorded even with no |
| 80 | // admins subscribed β old news by the time someone subscribes, same rule the |
| 81 | // Announcer applied to empty groups. |
| 82 | b, _ := json.Marshal(ids) |
| 83 | if err := s.db.setConfig(changelogAnnouncedKey, string(b)); err != nil { |
| 84 | log.Printf("changelog: save watermark: %v", err) |
| 85 | } |
| 86 | if !hadMark { |
| 87 | log.Printf("changelog: baselined at %d entries", len(ids)) |
| 88 | return 0, 0 |
| 89 | } |
| 90 | if len(texts) == 0 { |
| 91 | return 0, 0 |
| 92 | } |
| 93 | |
| 94 | admins, err := s.db.adminUserIDs() |
| 95 | if err != nil || len(admins) == 0 { |
| 96 | log.Printf("changelog: %d new entr(y/ies), no admins to notify (err=%v)", len(texts), err) |
| 97 | return len(texts), 0 |
| 98 | } |
| 99 | recipients := make(map[string]bool, len(admins)) |
| 100 | for _, id := range admins { |
| 101 | recipients[id] = true |
| 102 | } |
| 103 | |
| 104 | title := "What's new in " + s.db.getGroupName() |
| 105 | body := strings.Join(texts, "\n\n") |
| 106 | // Web Push payloads cap around 4KB; the OS truncates banners far sooner. |
| 107 | if r := []rune(body); len(r) > 1500 { |
| 108 | body = string(r[:1500]) + "β¦" |
| 109 | } |
| 110 | payload, _ := json.Marshal(map[string]any{ |
| 111 | "changelog": true, |
| 112 | "title": title, |
| 113 | "body": body, |
| 114 | }) |
| 115 | // skipActive=false: a deploy just restarted the server, so presence is empty |
| 116 | // anyway β and this banner is the one signal an admin gets while the Admin |
| 117 | // app is closed. exceptToken="": there is no sender to exclude. |
| 118 | sent = s.sendToRecipients(payload, "changelog", "", false, recipients) |
| 119 | // The same banner over APNs to the admins' iPhones (apnsAnnounce truncates |
| 120 | // tighter for the OS cap; no-op when APNs isn't configured). Plaintext by |
| 121 | // the same branding-class argument as the Web Push payload above. |
| 122 | sent += s.apnsAnnounce(title, body, recipients) |
| 123 | log.Printf("changelog: announced %d entr(y/ies) to %d admin subscription(s)", len(texts), sent) |
| 124 | return len(texts), sent |
| 125 | } |
| 126 | |