diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go index 22b7724..d534eb4 100644 --- a/internal/delivery/dispatcher.go +++ b/internal/delivery/dispatcher.go @@ -396,6 +396,13 @@ func messageForChannel(s Sendable) string { if !isAway(s.Channel) { return s.Body } + return AwayMessage(s) +} + +// AwayMessage — the only text an off-box channel may ever carry. Exported so +// the away sinks share this one rule instead of each inventing a fallback: the +// summary if we have one, otherwise a fixed generic line. Never the body. +func AwayMessage(s Sendable) string { if s.Summary != "" { return s.Summary } diff --git a/internal/delivery/ntfysink/ntfysink.go b/internal/delivery/ntfysink/ntfysink.go index dad2f9b..9e7fa3e 100644 --- a/internal/delivery/ntfysink/ntfysink.go +++ b/internal/delivery/ntfysink/ntfysink.go @@ -2,10 +2,10 @@ // // 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 +// 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). voice gets Body; away channels get Summary, enforced at the -// sink so a phraser bug can't exfil. +// 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 @@ -69,18 +69,14 @@ func New(cfg Config) (*Sink, error) { }, 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 +// 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 { - 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) - } + // 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 { diff --git a/internal/delivery/ntfysink/ntfysink_test.go b/internal/delivery/ntfysink/ntfysink_test.go index d106c42..c4edd1a 100644 --- a/internal/delivery/ntfysink/ntfysink_test.go +++ b/internal/delivery/ntfysink/ntfysink_test.go @@ -147,9 +147,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) { } } -func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { - // a terse full message is better than no message; the phraser should - // produce a summary for away-bound severities, but don't silently drop. +func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) { + // #368: this used to fall back to the full body. ntfy leaves the box, so + // an empty summary gets a fixed generic line plus the rule name instead. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -160,12 +160,15 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { t.Fatalf("Send: %v", err) } _, _, body, _, _, _ := rs.snapshot() - if body != s.Body { - t.Fatalf("fallback body: want %q, got %q", s.Body, body) + want := delivery.GenericAwayMessage + ": service_down" + if body != want { + t.Fatalf("body: want %q, got %q", want, body) } } -func TestSendRejectsEmptyMessage(t *testing.T) { +func TestSendNeverSendsAnEmptyMessage(t *testing.T) { + // with nothing at all to say we still send the generic line — an away + // channel can never carry detail, but it also never goes out blank. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -173,9 +176,13 @@ func TestSendRejectsEmptyMessage(t *testing.T) { sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) s := nudgeSendable(loop.Sev3, "") s.Body = "" - err := sink.Send(context.Background(), s) - if err == nil { - t.Fatal("want error for empty message") + s.RuleName = "" + if err := sink.Send(context.Background(), s); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _, _ := rs.snapshot() + if body != delivery.GenericAwayMessage { + t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body) } } diff --git a/internal/delivery/telegramsink/telegramsink.go b/internal/delivery/telegramsink/telegramsink.go index 6358992..f6db2d2 100644 --- a/internal/delivery/telegramsink/telegramsink.go +++ b/internal/delivery/telegramsink/telegramsink.go @@ -2,11 +2,12 @@ // // telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm // at 2am routes to telegram, repeat til ack." 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. additionally, protect_content=true is passed on every send so the -// message can't be forwarded out of the chat — locks the minimal body further. +// 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. additionally, +// protect_content=true is passed on every send so the message can't be +// forwarded out of the chat — locks the minimal body further. // // telegram's bot API is region-restricted for this homesrv — direct egress to // api.telegram.org is unreliable. the spec's "away channels leave the box — @@ -140,18 +141,13 @@ type telegramResp struct { } // Send publishes one message to the configured telegram chat. the body is the -// Sendable's Summary (minimal body); empty Summary falls back to Body (terse -// full message beats no message). protect_content=true so a phraser bug (Body -// leaking detail through Summary) can't be forwarded onward by the user or a -// chat observer — locks the minimal-body rule at the channel's own last mile. +// minimal away message (never the full body). protect_content=true so even +// that can't be forwarded onward by the user or a chat observer — locks the +// minimal-body rule at the channel's own last mile. func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { - body := d.Summary - if body == "" { - body = d.Body - } - if body == "" { - return fmt.Errorf("telegramsink: empty message for %s", d.Channel) - } + // never fall back to d.Body: telegram leaves the box, so an empty summary + // gets a generic line instead of the full detail. + body := delivery.AwayMessage(d) payload := sendMessageReq{ ChatID: s.cfg.ChatID, diff --git a/internal/delivery/telegramsink/telegramsink_test.go b/internal/delivery/telegramsink/telegramsink_test.go index 5c378f4..60b66ff 100644 --- a/internal/delivery/telegramsink/telegramsink_test.go +++ b/internal/delivery/telegramsink/telegramsink_test.go @@ -173,9 +173,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) { } } -func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { - // terse full message beats none; the phraser should produce a summary for - // away-bound severities, but don't silently drop. +func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) { + // #368: this used to fall back to the full body. telegram leaves the box, + // so an empty summary gets a fixed generic line plus the rule name. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -188,12 +188,14 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { _, _, body, _, _ := rs.snapshot() var req sendMessageReq _ = json.Unmarshal([]byte(body), &req) - if req.Text != s.Body { - t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text) + want := delivery.GenericAwayMessage + ": service_down" + if req.Text != want { + t.Fatalf("text: want %q, got %q", want, req.Text) } } -func TestSendRejectsEmptyMessage(t *testing.T) { +func TestSendNeverSendsAnEmptyMessage(t *testing.T) { + // with nothing at all to say we still send the generic line. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -201,9 +203,15 @@ func TestSendRejectsEmptyMessage(t *testing.T) { sink, _ := New(sinkCfg(srv.URL)) s := nudgeSendable(loop.Sev4, "") s.Body = "" - err := sink.Send(context.Background(), s) - if err == nil { - t.Fatal("want error for empty message") + s.RuleName = "" + if err := sink.Send(context.Background(), s); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _ := rs.snapshot() + var req sendMessageReq + _ = json.Unmarshal([]byte(body), &req) + if req.Text != delivery.GenericAwayMessage { + t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text) } }