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:
1 parent
7994ad492c
commit
955e01333d
4 files changed
+112
-10
No files matched your search
@@ -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.
|
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).
|
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
|
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
|
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.
|
app to be paged on your phone when an escalation policy reaches you.
|
||||||
|
|||||||
+45
-9
@@ -518,11 +518,14 @@ func (s *Server) publishHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
bad(w, err.Error())
|
bad(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
m.Event = "message"
|
||||||
writeJSON(w, 200, m)
|
writeJSON(w, 200, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json).
|
// 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) {
|
func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
topic := r.PathValue("topic")
|
topic := r.PathValue("topic")
|
||||||
if !validTopic(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("Cache-Control", "no-cache")
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
bw := bufio.NewWriter(w)
|
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 {
|
write := func(m Message) bool {
|
||||||
|
m.Event = "message"
|
||||||
b, _ := json.Marshal(m)
|
b, _ := json.Marshal(m)
|
||||||
var err error
|
var err error
|
||||||
if sse {
|
if sse {
|
||||||
@@ -560,21 +570,22 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer unsub()
|
defer unsub()
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
if q := r.URL.Query().Get("since"); q != "" {
|
if q := r.URL.Query().Get("since"); q != "" {
|
||||||
since := int64(0)
|
for _, m := range s.replaySince(topic, q) {
|
||||||
if q != "all" {
|
|
||||||
since, _ = strconv.ParseInt(q, 10, 64)
|
|
||||||
}
|
|
||||||
for _, m := range s.MessagesSince(topic, since, 500) {
|
|
||||||
seen[m.ID] = true
|
seen[m.ID] = true
|
||||||
if !write(m) {
|
if !write(m) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.URL.Query().Get("poll") == "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
if sse {
|
if sse {
|
||||||
fmt.Fprint(bw, ": connected\n\n")
|
fmt.Fprint(bw, ": connected\n\n")
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
fl.Flush()
|
fl.Flush()
|
||||||
|
} else {
|
||||||
|
event("open")
|
||||||
}
|
}
|
||||||
keepalive := time.NewTicker(30 * time.Second)
|
keepalive := time.NewTicker(30 * time.Second)
|
||||||
defer keepalive.Stop()
|
defer keepalive.Stop()
|
||||||
@@ -594,13 +605,38 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
case <-keepalive.C:
|
case <-keepalive.C:
|
||||||
if sse {
|
if sse {
|
||||||
fmt.Fprint(bw, ": keepalive\n\n")
|
fmt.Fprint(bw, ": keepalive\n\n")
|
||||||
} else {
|
|
||||||
fmt.Fprint(bw, "\n")
|
|
||||||
}
|
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
fl.Flush()
|
fl.Flush()
|
||||||
|
} else {
|
||||||
|
event("keepalive")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ----------
|
// ---------- alerts in ----------
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ import (
|
|||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Topic string `json:"topic"`
|
|
||||||
Time int64 `json:"time"`
|
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"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
|
|||||||
@@ -453,6 +453,56 @@ func TestAPIAuthSetupAndTopics(t *testing.T) {
|
|||||||
t.Fatal("subscriber got nothing")
|
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.
|
// Reserved topics are refused.
|
||||||
for _, reserved := range []string{"/api", "/status"} {
|
for _, reserved := range []string{"/api", "/status"} {
|
||||||
r, _ := http.NewRequest("POST", ts.URL+reserved, strings.NewReader("x"))
|
r, _ := http.NewRequest("POST", ts.URL+reserved, strings.NewReader("x"))
|
||||||
|
|||||||
Reference in new issue
Block a user