e9ff2c4912
The away sinks fell back to the whole Body when Summary was empty. ntfy and telegram leave the box, and the 0.8B phraser drops fields regularly, so that fallback could push full detail off the machine. The dispatcher already strips detail from away sendables. This exports that one rule as delivery.AwayMessage and has both sinks use it, so a sink can't leak the body on its own either: empty Summary means a generic line plus the rule name, never the body. The two sink tests named TestSendFallsBackToBodyWhenSummaryEmpty asserted the old, wrong behaviour, so they are rewritten to assert the generic line. TestSendRejectsEmptyMessage is likewise replaced: an away message can no longer be empty, so the sink has nothing left to reject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
139 lines
4.6 KiB
Go
139 lines
4.6 KiB
Go
// 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 delivery.AwayMessage — the minimal-body rule from the
|
||
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
|
||
// the relay). the dispatcher already strips detail off away sendables; the
|
||
// sink uses the same helper so it can't leak the body on its own either.
|
||
//
|
||
// 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 minimal away
|
||
// message (never the full 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 {
|
||
// never fall back to d.Body: ntfy leaves the box, so an empty summary gets
|
||
// a generic line instead of the full detail.
|
||
body := delivery.AwayMessage(d)
|
||
|
||
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
|
||
}
|