package notify import ( "context" "fmt" "io" "net/http" "strings" "time" ) // NtfyNotifier posts to ntfy.sh (or a self-hosted ntfy server). Message // body is the plain text body; title and URL are passed via X-Title and // X-Click headers so ntfy renders them as the push title + deep link. type NtfyNotifier struct { NameStr string Server string // e.g. "https://ntfy.sh" or self-hosted Topic string HTTP *http.Client } func NewNtfy(name, server, topic string) *NtfyNotifier { if server == "" { server = "https://ntfy.sh" } return &NtfyNotifier{ NameStr: name, Server: strings.TrimRight(server, "/"), Topic: topic, HTTP: &http.Client{Timeout: 10 * time.Second}, } } func (n *NtfyNotifier) Name() string { return n.NameStr } func (n *NtfyNotifier) Send(ctx context.Context, ev Event) error { if n.Topic == "" { return fmt.Errorf("ntfy: no topic configured") } url := n.Server + "/" + n.Topic req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(ev.Body)) if err != nil { return err } if ev.Title != "" { req.Header.Set("X-Title", ev.Title) } if ev.URL != "" { req.Header.Set("X-Click", ev.URL) } req.Header.Set("X-Priority", priorityForSeverity(ev.Severity)) req.Header.Set("X-Tags", ntfyTag(ev.Kind, ev.Severity)) resp, err := n.HTTP.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("ntfy: %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } return nil } // priorityForSeverity maps our severities to ntfy's 1–5 scale. "info" // → 3 (default), warning → 4, critical → 5. func priorityForSeverity(s Severity) string { switch s { case SeverityCritical: return "5" case SeverityWarning: return "4" default: return "3" } } func ntfyTag(k Kind, s Severity) string { switch { case s == SeverityCritical: return "rotating_light," + string(k) case k == KindRunCompleted: return "white_check_mark," + string(k) case k == KindHoldingOpened: return "construction," + string(k) default: return string(k) } }