package main // 🤖 Changelog announcements to the space's admins — the server-side half of // retiring the Announcer bot. On startup (which is when a new binary can first // speak for itself) the server diffs its embedded changelog against the ids it // has already announced and pushes anything new to every role-admin, full text // in the banner. The words are branding-class plaintext (the changelog ships // in the binary and is announced publicly), never user content, so — uniquely // among pushes — the payload may carry them in the clear; nothing here touches // a key. The in-app half is the Admin app's update stream (admin.note events), // which materialises the same notes into the thread when an admin next opens // it; this push covers the app-closed case, with no sidecar in the loop. // // Web Push only for now; the native banner (relay/NSE) is the follow-up PR. import ( "encoding/json" "log" "regexp" "strings" ) // The announced-ids watermark lives in config, playing the role of the retired // Announcer's .state.json: first run baselines silently (a fresh space // isn't greeted by the whole backlog), later runs announce only the diff. const changelogAnnouncedKey = "changelog_announced" var ( changelogMarkerRe = regexp.MustCompile(`^\s*(?:🤖|👨|👤|🧑|👩)\s+`) changelogParaRe = regexp.MustCompile(`\n[ \t]*\n`) changelogWrapRe = regexp.MustCompile(`[ \t]*\n[ \t]*`) ) // changelogClean formats one embedded release note for a notification body: // drops the leading authorship marker (🤖/👨/… — repo metadata, not for // members) and collapses the ~72-column hard wraps back into flowing // paragraphs. Mirrors changelogParagraphs in web/static/format.js — the two // sides share test cases; keep them in sync. func changelogClean(text string) string { clean := changelogMarkerRe.ReplaceAllString(text, "") var out []string for _, p := range changelogParaRe.Split(clean, -1) { p = strings.TrimSpace(changelogWrapRe.ReplaceAllString(p, " ")) if p != "" { out = append(out, p) } } return strings.Join(out, "\n\n") } // announceChangelog runs once at startup: baseline or announce-the-diff, then // advance the watermark. Returns (fresh, sent) — entries newly announced and // push sends that succeeded — for tests; the caller ignores them. func (s *Server) announceChangelog() (fresh, sent int) { raw, hadMark, _ := s.db.getConfig(changelogAnnouncedKey) seen := map[string]bool{} if hadMark { var ids []string if err := json.Unmarshal([]byte(raw), &ids); err != nil { log.Printf("changelog: bad %s config (%v) — re-baselining", changelogAnnouncedKey, err) hadMark = false } for _, id := range ids { seen[id] = true } } ids := make([]string, 0, len(s.changelog)) var texts []string for _, e := range s.changelog { ids = append(ids, e.ID) if hadMark && !seen[e.ID] { if t := changelogClean(e.Text); t != "" { texts = append(texts, t) } } } // Advance the watermark before pushing (at-most-once): a crash mid-announce // must not replay the same entries at every restart. Recorded even with no // admins subscribed — old news by the time someone subscribes, same rule the // Announcer applied to empty groups. b, _ := json.Marshal(ids) if err := s.db.setConfig(changelogAnnouncedKey, string(b)); err != nil { log.Printf("changelog: save watermark: %v", err) } if !hadMark { log.Printf("changelog: baselined at %d entries", len(ids)) return 0, 0 } if len(texts) == 0 { return 0, 0 } admins, err := s.db.adminUserIDs() if err != nil || len(admins) == 0 { log.Printf("changelog: %d new entr(y/ies), no admins to notify (err=%v)", len(texts), err) return len(texts), 0 } recipients := make(map[string]bool, len(admins)) for _, id := range admins { recipients[id] = true } title := "What's new in " + s.db.getGroupName() body := strings.Join(texts, "\n\n") // Web Push payloads cap around 4KB; the OS truncates banners far sooner. if r := []rune(body); len(r) > 1500 { body = string(r[:1500]) + "…" } payload, _ := json.Marshal(map[string]any{ "changelog": true, "title": title, "body": body, }) // skipActive=false: a deploy just restarted the server, so presence is empty // anyway — and this banner is the one signal an admin gets while the Admin // app is closed. exceptToken="": there is no sender to exclude. sent = s.sendToRecipients(payload, "changelog", "", false, recipients) // The same banner over APNs to the admins' iPhones (apnsAnnounce truncates // tighter for the OS cap; no-op when APNs isn't configured). Plaintext by // the same branding-class argument as the Web Push payload above. sent += s.apnsAnnounce(title, body, recipients) log.Printf("changelog: announced %d entr(y/ies) to %d admin subscription(s)", len(texts), sent) return len(texts), sent }