diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go index 93fc47d..f3ad35d 100644 --- a/internal/delivery/dispatcher.go +++ b/internal/delivery/dispatcher.go @@ -160,6 +160,7 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim RepeatUntilAck: ch == ChannelTelegram && c.Severity >= loop.Sev4, Ts: now, } + s = minimalForAway(s) sink := d.sinkFor(ch) if sink == nil { continue @@ -225,6 +226,7 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n Summary: pr.Summary, Ts: now, } + s = minimalForAway(s) sink := d.sinkFor(ch) if sink == nil { continue @@ -315,6 +317,7 @@ func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time. RepeatUntilAck: true, Ts: now, } + s = minimalForAway(s) attemptID := d.beginOutbox(ctx, "nudge", key, 0, ChannelTelegram, messageForChannel(s), now) if err := d.cfg.Telegram.Send(ctx, s); err != nil { d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now) @@ -342,20 +345,44 @@ func (d *Dispatcher) sinkFor(ch Channel) Sink { } } +// GenericAwayMessage — what an away channel gets when the phraser gave us no +// summary. no gendered forms, so it stays right whoever reads it. +const GenericAwayMessage = "что-то требует внимания" + +// isAway — this channel leaves the box, so it only ever gets a minimal body. +func isAway(ch Channel) bool { + return ch == ChannelNtfy || ch == ChannelTelegram +} + // messageForChannel — away channels get the minimal summary (no shoulder-surf // exfil — "disk low on homesrv," not detail); voice gets the full body (local). -// a missing summary falls back to body — a terse full message is better than -// no message, and the phraser should have produced a summary for away-bound -// severities. this is the "minimal body" rule from the spec, enforced at the -// last mile so a phraser bug can't accidentally exfil via the relay. +// an empty summary must NOT fall back to the body: the resident model is small +// and drops fields often, and the away path crosses the "never phones home" +// boundary. so we send a fixed generic line plus the rule name instead. voice +// is local, so it keeps the full body. func messageForChannel(s Sendable) string { - switch s.Channel { - case ChannelNtfy, ChannelTelegram: - if s.Summary != "" { - return s.Summary - } - return s.Body - default: + if !isAway(s.Channel) { return s.Body } + if s.Summary != "" { + return s.Summary + } + if s.RuleName != "" { + return GenericAwayMessage + ": " + s.RuleName + } + return GenericAwayMessage +} + +// minimalForAway — strips detail from a Sendable bound for an away channel +// before any sink sees it. the sinks pick Summary themselves too, but this is +// where the boundary actually is: a sink added later must not be able to leak +// the full body just by reading the wrong field. +func minimalForAway(s Sendable) Sendable { + if !isAway(s.Channel) { + return s + } + msg := messageForChannel(s) + s.Body = msg + s.Summary = msg + return s }