Never send a nudge body off-box when the summary is empty (#368)

Away channels (ntfy, telegram) leave the box, so an empty Summary now sends
a fixed generic line plus the rule name instead of the full Body. The
dispatcher strips detail before any sink sees it, so a sink added later
cannot leak by reading the wrong field. Voice is local and unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
kami
2026-07-31 02:34:54 +04:00
parent abe9b28719
commit 859bbf750f
+38 -11
View File
@@ -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
}