Make the topic feeds work with the ntfy apps

The JSON stream now carries the event field the apps look for, sends open and keepalive
events, and supports ?poll=1 and ?since= with a duration or a message id, which is how the
apps catch up. Adds a walkthrough for the phone app to the README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 04:22:53 -06:00
1 parent 7994ad492c
commit 955e01333d
4 files changed
+112 -10

No files matched your search

+15
View File
@@ -136,6 +136,21 @@ A topic is 1 to 64 letters, digits, `-` or `_`. Publishing and subscribing both
API key (create one on the Account page). Clients that use basic auth can send the key as the password.
Messages are kept for `SANDBAR_MESSAGE_DAYS` (default 30).
`?since=` also takes unix seconds, a duration such as `10m`, or the id of the last message you have, and
`?poll=1` returns just that replay and closes, which is how the ntfy apps catch up.
### Phone app (ntfy)
1. Create an API key on the Account page and copy it.
2. In the ntfy app, open Settings, then Default server, and enter your Sandbar address
(`https://sandbar.example.com`). Or tick "Use another server" when you add each subscription.
3. Add a subscription. For a page when an escalation reaches you, use your personal topic from the Account page
(`u-<id>`). For everything, use `alerts` and `incidents`.
4. Under Manage users, add a user for the server. The username can be anything and the password is the API key.
The Android app keeps a connection open, so messages arrive at once. The iPhone app depends on ntfy.sh's push
relay, which Sandbar does not use, so there messages arrive only when the app polls.
Two topics are filled for you: `alerts` gets every alert and its recovery, and `incidents` gets every incident
update. Each person also has a personal topic (`u-<id>`, shown on the Account page). Subscribe to it in the ntfy
app to be paged on your phone when an escalation policy reaches you.
+45 -9
View File
@@ -518,11 +518,14 @@ func (s *Server) publishHTTP(w http.ResponseWriter, r *http.Request) {
bad(w, err.Error())
return
}
m.Event = "message"
writeJSON(w, 200, m)
}
// subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json).
// ?since=<unix seconds>|all replays stored messages first.
// ?since= replays stored messages first: "all", unix seconds, a duration such as 10m, or the id
// of the last message the client saw. ?poll=1 sends only that replay and then ends, which is
// how the ntfy apps catch up.
func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !validTopic(topic) {
@@ -543,7 +546,14 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
bw := bufio.NewWriter(w)
event := func(kind string) {
b, _ := json.Marshal(map[string]any{"id": newID(), "time": s.now().Unix(), "event": kind, "topic": topic})
fmt.Fprintf(bw, "%s\n", b)
bw.Flush()
fl.Flush()
}
write := func(m Message) bool {
m.Event = "message"
b, _ := json.Marshal(m)
var err error
if sse {
@@ -560,21 +570,22 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
defer unsub()
seen := map[string]bool{}
if q := r.URL.Query().Get("since"); q != "" {
since := int64(0)
if q != "all" {
since, _ = strconv.ParseInt(q, 10, 64)
}
for _, m := range s.MessagesSince(topic, since, 500) {
for _, m := range s.replaySince(topic, q) {
seen[m.ID] = true
if !write(m) {
return
}
}
}
if r.URL.Query().Get("poll") == "1" {
return
}
if sse {
fmt.Fprint(bw, ": connected\n\n")
bw.Flush()
fl.Flush()
} else {
event("open")
}
keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop()
@@ -594,15 +605,40 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
case <-keepalive.C:
if sse {
fmt.Fprint(bw, ": keepalive\n\n")
bw.Flush()
fl.Flush()
} else {
fmt.Fprint(bw, "\n")
event("keepalive")
}
bw.Flush()
fl.Flush()
}
}
}
// replaySince returns the stored messages a subscriber should see first for a ?since= value.
func (s *Server) replaySince(topic, q string) []Message {
if q == "all" {
return s.MessagesSince(topic, 0, 500)
}
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
return s.MessagesSince(topic, n, 500)
}
if d, err := time.ParseDuration(q); err == nil {
return s.MessagesSince(topic, s.now().Add(-d).Unix(), 500)
}
// Otherwise it is the id of the last message the client has: send what came after it.
var at int64
if s.st.db.QueryRow(`SELECT time FROM messages WHERE topic=? AND id=?`, topic, q).Scan(&at) != nil {
return nil
}
all := s.MessagesSince(topic, at, 500)
for i, m := range all {
if m.ID == q {
return all[i+1:]
}
}
return all
}
// ---------- alerts in ----------
func (s *Server) inboundAlert(w http.ResponseWriter, r *http.Request) {
+2 -1
View File
@@ -25,8 +25,9 @@ import (
type Message struct {
ID string `json:"id"`
Topic string `json:"topic"`
Time int64 `json:"time"`
Event string `json:"event"` // always "message"; the ntfy apps ignore lines without it
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message"`
Priority int `json:"priority"`
+50
View File
@@ -453,6 +453,56 @@ func TestAPIAuthSetupAndTopics(t *testing.T) {
t.Fatal("subscriber got nothing")
}
// The ntfy apps catch up with ?poll=1&since=<last id> and ignore lines without "event".
var ids []string
for _, body := range []string{"one", "two", "three"} {
r, _ := http.NewRequest("POST", ts.URL+"/phone", strings.NewReader(body))
r.Header.Set("Authorization", "Bearer "+key["token"])
resp, err := http.DefaultClient.Do(r)
if err != nil || resp.StatusCode != 200 {
t.Fatalf("publish %s: %v %v", body, err, resp)
}
var pm Message
json.NewDecoder(resp.Body).Decode(&pm)
resp.Body.Close()
if pm.Event != "message" {
t.Fatalf("publish response event: %q", pm.Event)
}
ids = append(ids, pm.ID)
}
pollLines := func(query string) []Message {
req, _ := http.NewRequest("GET", ts.URL+"/phone/json?poll=1&"+query, nil)
req.Header.Set("Authorization", "Bearer "+key["token"])
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var got []Message
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
var m Message
json.Unmarshal(sc.Bytes(), &m)
if m.Event != "message" {
t.Fatalf("poll line without event: %s", sc.Text())
}
got = append(got, m)
}
return got
}
if got := pollLines("since=all"); len(got) != 3 {
t.Fatalf("poll since=all: %d messages", len(got))
}
if got := pollLines("since=" + ids[0]); len(got) != 2 || got[0].Message != "two" {
t.Fatalf("poll since=<id>: %+v", got)
}
if got := pollLines("since=" + ids[2]); len(got) != 0 {
t.Fatalf("poll since=<last id>: %+v", got)
}
if got := pollLines("since=10m"); len(got) != 3 {
t.Fatalf("poll since=10m: %d messages", len(got))
}
// Reserved topics are refused.
for _, reserved := range []string{"/api", "/status"} {
r, _ := http.NewRequest("POST", ts.URL+reserved, strings.NewReader("x"))