b114251f45
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
178 lines
4.4 KiB
Go
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"
|
|
}
|
|
}
|