// Package ntfysink implements delivery.Sink for the ntfy push channel. // // ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard) // nudges when present (alongside voice), and reminders when away. the // message body is the Sendable's Summary — the minimal-body rule from the // spec ("disk low on homesrv," not detail; no shoulder-surf exfil through // the relay). voice gets Body; away channels get Summary, enforced at the // sink so a phraser bug can't exfil. // // ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes // with a dedicated user (write-only to maven-* topics) — the credential is a // delivery-config secret, not a db key; a popped ntfy sink can push spam to // your phone, nothing else. matches the module key-isolation invariant: the // sink never holds the sqlcipher key. package ntfysink import ( "context" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/loop" ) // Config — ntfy publish config. the daemon wires this from its config file; // the credential lives in the daemon's config (or a systemd credential), // never in the binary. type Config struct { BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path) Topic string // e.g. maven (all maven notifications land here) Username string // basic auth; empty = anonymous (won't work with deny-all) Password string // basic auth Timeout time.Duration // per-request; 0 = DefaultTimeout } const DefaultTimeout = 10 * time.Second // Sink — implements delivery.Sink via ntfy HTTP publish. one POST per Send. // no retry (the dispatcher + daemon decide retry policy); no streaming. type Sink struct { cfg Config hc *http.Client } // New validates the config and builds the sink. BaseURL and Topic are // required; auth is optional (but deny-all servers reject unauthed publishes). func New(cfg Config) (*Sink, error) { if cfg.BaseURL == "" { return nil, fmt.Errorf("ntfysink: BaseURL is required") } if _, err := url.Parse(cfg.BaseURL); err != nil { return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err) } if cfg.Topic == "" { return nil, fmt.Errorf("ntfysink: Topic is required") } to := cfg.Timeout if to == 0 { to = DefaultTimeout } return &Sink{ cfg: cfg, hc: &http.Client{Timeout: to}, }, nil } // Send publishes one notification to ntfy. the body is the Sendable's Summary // (minimal body); Title is "maven" (consistent sender identity on the lock // screen — the content is in the body). Priority maps from severity/kind so // the phone client can ring differently for an alarm vs a soft ops nudge. func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { body := d.Summary if body == "" { body = d.Body // terse full message beats no message } if body == "" { return fmt.Errorf("ntfysink: empty message for %s", d.Channel) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body)) if err != nil { return fmt.Errorf("ntfysink: build request: %w", err) } req.Header.Set("Title", "maven") req.Header.Set("Priority", priorityFor(d).String()) if s.cfg.Username != "" { req.SetBasicAuth(s.cfg.Username, s.cfg.Password) } resp, err := s.hc.Do(req) if err != nil { return fmt.Errorf("ntfysink: publish: %w", err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb))) } return nil } func (s *Sink) topicURL() string { return strings.TrimRight(s.cfg.BaseURL, "/") + "/" + s.cfg.Topic } // ntfyPriority — ntfy's 1–5 priority scale. type ntfyPriority int const ( prioMin ntfyPriority = 1 prioLow ntfyPriority = 2 prioDefault ntfyPriority = 3 prioHigh ntfyPriority = 4 prioMax ntfyPriority = 5 ) func (p ntfyPriority) String() string { return fmt.Sprintf("%d", int(p)) } // priorityFor — maps maven's (kind, severity) to ntfy's 1–5. // // reminders are user-stated intent ("wake me 7") → high (4); they bypassed the // gate to reach you, make them ring. sev4 (ops hard, present — away goes to // telegram) → max (5): a disk-fire alarm that also pushes to your watch. sev3 // (ops soft, away) → high (4): it held through away for a reason. care nudges // never reach ntfy (they drop on away), so no sev1–2 mapping here. func priorityFor(d delivery.Sendable) ntfyPriority { if d.Kind == delivery.KindReminder { return prioHigh } if d.Severity >= loop.Sev4 { return prioMax } if d.Severity == loop.Sev3 { return prioHigh } return prioDefault }