Add notification channels and a provider abstraction

Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 6292ad6708
commit b114251f45
42 changed files with 2200 additions and 611 deletions
+138
View File
@@ -0,0 +1,138 @@
// Package notify is how Terdut reaches a person: a Provider per way of doing it
// (ntfy today), and a Registry of the ones this server has been configured with.
//
// The package knows nothing about incidents. The notifier renders a Message from
// an incident and hands it to whichever Provider the recipient's channel names,
// so a new way of paging someone is one new Provider and nothing else.
package notify
import (
"context"
"sort"
)
// Message kinds. Providers may use them to shape a message (ntfy sends a
// "resolved" quietly), but a Provider that ignores them is correct.
const (
KindTriggered = "triggered"
KindReminder = "reminder"
KindEscalated = "escalated"
KindResolved = "resolved"
KindTest = "test"
)
// Message is one notification, in no provider's format.
type Message struct {
// Address is where this provider delivers it: an ntfy topic, later an email
// address. Opaque to everything except the Provider that owns it.
Address string
Kind string
Title string
Body string
// Level is the incident's severity on a 0-4 scale: 0 unknown, 1 info,
// 2 warning, 3 error, 4 critical. A provider with a notion of urgency maps it.
Level int
// ClickURL opens the incident in the web UI. Empty when the server does not
// know its own public URL.
ClickURL string
// AckURL acknowledges the incident when POSTed. Only set for a provider whose
// Capabilities say it can render an action; for the rest the notifier puts
// the link in Body instead.
AckURL string
}
// Capabilities says what a Provider can render, so the notifier does not have to
// know which provider it is talking to.
type Capabilities struct {
// ActionButtons is whether the provider can show a button that POSTs to a URL
// without opening anything, which is what the Acknowledge button is.
ActionButtons bool
}
// Provider is one way of delivering a Message.
type Provider interface {
// Type is the stable identifier stored in a channel row and shown in the
// API: "ntfy". Lower case, never changes once released.
Type() string
// Label is the name a person sees: "ntfy".
Label() string
// AddressLabel and AddressHint describe the address field for the UI: a
// "Topic" labelled field with a sentence on what to put in it.
AddressLabel() string
AddressHint() string
Capabilities() Capabilities
// Validate checks an address a user typed and returns it normalised. An error
// is shown to that user as is.
Validate(address string) (string, error)
// Send delivers one message. It must honour ctx, and an error means the
// message did not land, so the notifier will retry.
Send(ctx context.Context, msg Message) error
}
// Target names a provider and an address without belonging to a user: the
// fallback for an incident nobody is on call for.
type Target struct {
Type string `json:"type"`
Address string `json:"address"`
}
// IsZero reports whether no target is set.
func (t Target) IsZero() bool { return t.Type == "" && t.Address == "" }
// Registry is the providers a server was configured with. The zero value and
// nil are both an empty registry, so a server with nothing configured needs no
// special case at its call sites.
type Registry struct {
byType map[string]Provider
}
// NewRegistry registers providers by Type. A later provider of the same type
// replaces the earlier one.
func NewRegistry(providers ...Provider) *Registry {
r := &Registry{byType: make(map[string]Provider, len(providers))}
for _, p := range providers {
r.byType[p.Type()] = p
}
return r
}
// Get returns the provider for a type.
func (r *Registry) Get(typ string) (Provider, bool) {
if r == nil {
return nil, false
}
p, ok := r.byType[typ]
return p, ok
}
// Has reports whether a provider of this type is configured.
func (r *Registry) Has(typ string) bool {
_, ok := r.Get(typ)
return ok
}
// Empty reports whether no provider is configured, which disables notifications.
func (r *Registry) Empty() bool { return r == nil || len(r.byType) == 0 }
// List returns the providers ordered by type, so the UI and the tests see them
// in a stable order.
func (r *Registry) List() []Provider {
if r == nil {
return nil
}
out := make([]Provider, 0, len(r.byType))
for _, p := range r.byType {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Type() < out[j].Type() })
return out
}
+111
View File
@@ -0,0 +1,111 @@
package notify_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
func TestRegistry_NilAndEmptyAreEmpty(t *testing.T) {
var nilReg *notify.Registry
for name, r := range map[string]*notify.Registry{"nil": nilReg, "empty": notify.NewRegistry()} {
if !r.Empty() || r.Has("ntfy") || len(r.List()) != 0 {
t.Errorf("%s registry should be empty", name)
}
if _, ok := r.Get("ntfy"); ok {
t.Errorf("%s registry should have no ntfy", name)
}
}
}
func TestRegistry_ListIsOrderedByType(t *testing.T) {
r := notify.NewRegistry(notify.NewNtfy("http://x", ""), fakeProvider{"email"})
list := r.List()
if len(list) != 2 || list[0].Type() != "email" || list[1].Type() != "ntfy" {
t.Errorf("expected providers ordered by type, got %v", list)
}
}
type fakeProvider struct{ typ string }
func (f fakeProvider) Type() string { return f.typ }
func (f fakeProvider) Label() string { return f.typ }
func (f fakeProvider) AddressLabel() string { return "" }
func (f fakeProvider) AddressHint() string { return "" }
func (f fakeProvider) Capabilities() notify.Capabilities { return notify.Capabilities{} }
func (f fakeProvider) Validate(a string) (string, error) { return a, nil }
func (f fakeProvider) Send(context.Context, notify.Message) error { return nil }
func TestNtfy_ValidateTopic(t *testing.T) {
n := notify.NewNtfy("http://x", "")
for _, bad := range []string{"", " ", "a b", "a/b", "a?b", "a#b"} {
if _, err := n.Validate(bad); err == nil {
t.Errorf("topic %q should be rejected", bad)
}
}
if got, err := n.Validate(" my-topic "); err != nil || got != "my-topic" {
t.Errorf("a good topic should be trimmed and accepted, got %q, %v", got, err)
}
}
func TestNtfy_SendEncodesTheMessage(t *testing.T) {
var got map[string]any
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&got)
}))
defer srv.Close()
n := notify.NewNtfy(srv.URL+"/", "tok")
err := n.Send(context.Background(), notify.Message{
Address: "t", Kind: notify.KindTriggered, Title: "T", Body: "B", Level: 4,
ClickURL: "https://x/incidents/1", AckURL: "https://x/ack",
})
if err != nil {
t.Fatal(err)
}
if auth != "Bearer tok" {
t.Errorf("expected the bearer token, got %q", auth)
}
if got["topic"] != "t" || got["title"] != "T" || got["message"] != "B" || got["priority"] != float64(5) {
t.Errorf("unexpected body %v", got)
}
actions, _ := got["actions"].([]any)
if len(actions) != 1 || actions[0].(map[string]any)["url"] != "https://x/ack" {
t.Errorf("expected the Acknowledge action, got %v", got["actions"])
}
}
func TestNtfy_ResolvedIsQuietAndTestHasNoPriority(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = nil
_ = json.NewDecoder(r.Body).Decode(&got)
}))
defer srv.Close()
n := notify.NewNtfy(srv.URL, "")
_ = n.Send(context.Background(), notify.Message{Address: "t", Kind: notify.KindResolved, Level: 4})
if got["priority"] != float64(2) {
t.Errorf("a resolved page should be low priority whatever the severity, got %v", got["priority"])
}
_ = n.Send(context.Background(), notify.Message{Address: "t", Kind: notify.KindTest})
if _, ok := got["priority"]; ok {
t.Errorf("a test should carry no priority, got %v", got["priority"])
}
}
func TestNtfy_SendReportsARejection(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
if err := notify.NewNtfy(srv.URL, "").Send(context.Background(), notify.Message{Address: "t"}); err == nil {
t.Error("a 403 from ntfy must be an error so the notifier retries")
}
}
+177
View File
@@ -0,0 +1,177 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// TypeNtfy is the Type of the ntfy provider.
const TypeNtfy = "ntfy"
// ntfy's priority scale. Max is the one that overrides the phone's quiet
// settings, which is the whole point of paging on critical.
const (
ntfyPriorityLow = 2
ntfyPriorityDefault = 3
ntfyPriorityHigh = 4
ntfyPriorityMax = 5
)
// Ntfy publishes to an ntfy server. One server per install, from its
// configuration; only the topic is per person.
type Ntfy struct {
baseURL string
token string
client *http.Client
}
// NewNtfy builds the provider. token is an optional bearer token for an
// access-controlled server. The client's timeout is what keeps a hung ntfy from
// stalling a delivery pass: a page is small and infrequent.
func NewNtfy(baseURL, token string) *Ntfy {
return &Ntfy{
baseURL: strings.TrimSuffix(baseURL, "/"),
token: token,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (n *Ntfy) Type() string { return TypeNtfy }
func (n *Ntfy) Label() string { return "ntfy" }
func (n *Ntfy) AddressLabel() string { return "Topic" }
func (n *Ntfy) AddressHint() string {
return "Subscribe to this topic in the ntfy app. A topic is a shared secret: anyone who knows it can read " +
"your pages and publish to it, so pick one that is hard to guess."
}
// Capabilities: ntfy renders an http action as a button on the notification.
func (n *Ntfy) Capabilities() Capabilities { return Capabilities{ActionButtons: true} }
func (n *Ntfy) Validate(address string) (string, error) {
topic := strings.TrimSpace(address)
if topic == "" {
return "", errors.New("a topic is required")
}
if strings.ContainsAny(topic, " \t\r\n/?#") {
return "", errors.New("a topic cannot contain spaces, slashes, '?' or '#'")
}
return topic, nil
}
// ntfyMessage is ntfy's JSON publish format. Using it rather than the X-Actions
// header avoids that header's comma and quote escaping rules, which are easy to
// break with a title that happens to contain a comma.
type ntfyMessage struct {
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Actions []ntfyAction `json:"actions,omitempty"`
}
type ntfyAction struct {
Action string `json:"action"`
Label string `json:"label"`
URL string `json:"url"`
Method string `json:"method,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// Send POSTs one message to ntfy.
func (n *Ntfy) Send(ctx context.Context, msg Message) error {
body, err := json.Marshal(n.encode(msg))
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.baseURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if n.token != "" {
req.Header.Set("Authorization", "Bearer "+n.token)
}
resp, err := n.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy returned %s", resp.Status)
}
return nil
}
func (n *Ntfy) encode(msg Message) ntfyMessage {
out := ntfyMessage{
Topic: msg.Address,
Title: msg.Title,
Message: msg.Body,
Click: msg.ClickURL,
}
switch msg.Kind {
case KindResolved:
out.Priority = ntfyPriorityLow
out.Tags = []string{"white_check_mark"}
case KindTest:
out.Tags = []string{"white_check_mark"}
default:
out.Priority = ntfyPriority(msg.Level)
out.Tags = []string{ntfyTag(msg.Level)}
}
if msg.AckURL != "" {
out.Actions = append(out.Actions, ntfyAction{
Action: "http",
Label: "Acknowledge",
URL: msg.AckURL,
Method: "POST",
Clear: true,
})
}
return out
}
// ntfyPriority maps a severity level onto ntfy's scale. An unrecognised level
// gets the default rather than being silenced.
func ntfyPriority(level int) int {
switch level {
case 4:
return ntfyPriorityMax
case 3:
return ntfyPriorityHigh
case 2:
return ntfyPriorityDefault
case 1:
return ntfyPriorityLow
default:
return ntfyPriorityDefault
}
}
func ntfyTag(level int) string {
switch level {
case 4:
return "rotating_light"
case 3:
return "red_circle"
case 2:
return "warning"
case 1:
return "information_source"
default:
return "bell"
}
}