Files
terdut-server/internal/notify/ntfy.go
T
Niklas Ye b114251f45 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
2026-10-12 21:00:40 +02:00

178 lines
4.4 KiB
Go

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"
}
}