eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
16import (
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.
26const changelogAnnouncedKey = "changelog_announced"
27
28var (
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.
39func 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.
54func (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