Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
This commit is contained in:
@@ -20,7 +20,8 @@
|
||||
//
|
||||
// - reminders are a SEPARATE class — two delivery paths. reminders bypass
|
||||
// the restraint gate ("wake me 7" fires in quiet hours; that's the point).
|
||||
// snooze still applies. voice when present, ntfy when away. fire once.
|
||||
// snooze still applies. voice when present; when away, try ntfy then
|
||||
// telegram as alternatives and stop after the first success. fire once.
|
||||
//
|
||||
// Architecture mirrors the loop's gather/pure split: the routing table is a
|
||||
// PURE function of (severity, presence); the Dispatcher holds the impure Sinks
|
||||
@@ -43,6 +44,12 @@ import (
|
||||
// to import the voice package.
|
||||
var ErrVoiceNoSession = errors.New("delivery: voice: no live session")
|
||||
|
||||
// ErrPermanent is the class of transport failures that waiting cannot repair:
|
||||
// a revoked credential or an endpoint that refuses this sender. Dispatchers
|
||||
// may still try a different reach for the same message, but the failed reach
|
||||
// must not be put on an automatic retry clock until its configuration changes.
|
||||
var ErrPermanent = errors.New("delivery: permanent failure")
|
||||
|
||||
// Channel — one delivery transport. Drop is an explicit no-op (the routing
|
||||
// table chose to suppress, which is a decision, not a failure — "a missed
|
||||
// water nudge is noise"). a nil Sink for a wired channel is a daemon config
|
||||
@@ -91,8 +98,11 @@ func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
|
||||
|
||||
// ChannelsForReminder — reminders are a SEPARATE class that bypasses the gate.
|
||||
// "wake me 7" fires in quiet hours; that's the point. presence still routes
|
||||
// reachability: voice when present, ntfy when away. fires once — no repeat
|
||||
// (repeat-til-ack is a sev4 ops-hard behavior, not a reminder behavior).
|
||||
// reachability: voice when present, then an ordered ntfy→telegram alternative
|
||||
// chain when away. The dispatcher stops after the first successful alternative,
|
||||
// so a reminder still fires once rather than being broadcast on both channels.
|
||||
// There is no repeat (repeat-til-ack is a sev4 ops-hard behavior, not a reminder
|
||||
// behavior).
|
||||
//
|
||||
// reminders don't carry a Severity — they're user-stated future intent, not
|
||||
// loop-derived insistence. the routing is presence-only: reachability without
|
||||
@@ -100,7 +110,7 @@ func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
|
||||
// per-reminder override, not a table entry.
|
||||
func ChannelsForReminder(presence store.Bucket) []Channel {
|
||||
if presence == store.Away {
|
||||
return []Channel{ChannelNtfy}
|
||||
return []Channel{ChannelNtfy, ChannelTelegram}
|
||||
}
|
||||
return []Channel{ChannelVoice}
|
||||
}
|
||||
|
||||
+133
-56
@@ -22,13 +22,25 @@ type NudgeRecorder interface {
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// ReminderCompleter — the seam the store implements. For one-shot reminders:
|
||||
// pending → fired after successful delivery. For recurring reminders (with
|
||||
// cron): reschedule after successful delivery. A failed send does NOT mark or
|
||||
// reschedule it (it stays pending; the next tick re-delivers).
|
||||
// ReminderCompleter — the seam the store implements. Every original represented
|
||||
// by one external delivery is completed in one transaction. That matters for a
|
||||
// collapsed catch-up bundle: partially firing the originals would make the
|
||||
// next tick repeat a presentation that the user already received.
|
||||
type ReminderCompleter interface {
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
RescheduleReminder(ctx context.Context, id int64, now time.Time) error
|
||||
CompleteReminderDelivery(ctx context.Context, originals []store.Reminder, now time.Time) error
|
||||
}
|
||||
|
||||
// DurableReminderCompleter closes the successful outbox attempt and advances
|
||||
// every reminder occurrence in one local transaction. The external send and
|
||||
// local commit cannot be one transaction, but the local half must be: a crash
|
||||
// between `attempt=sent` and `reminder=fired` otherwise strands the reminder in
|
||||
// a permanently suppressed state.
|
||||
type DurableReminderCompleter interface {
|
||||
CompleteSuccessfulReminderAttempt(ctx context.Context, attemptID int64, originals []store.Reminder, now time.Time) error
|
||||
}
|
||||
|
||||
type ReminderBlocker interface {
|
||||
BlockReminderDelivery(ctx context.Context, originals []store.Reminder, now time.Time, reason string) error
|
||||
}
|
||||
|
||||
// Outbox — the durable delivery ledger. Begin is recorded BEFORE the external
|
||||
@@ -39,7 +51,7 @@ type ReminderCompleter interface {
|
||||
// disabled (existing send/record behavior, unchanged — test scenarios that
|
||||
// don't care about crash recovery).
|
||||
type Outbox interface {
|
||||
BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, channel, bodyHash string, now time.Time) (int64, error)
|
||||
BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, bodyHash string, now time.Time) (int64, error)
|
||||
CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error
|
||||
}
|
||||
|
||||
@@ -57,11 +69,11 @@ func bodyHash(channel Channel, body string) string {
|
||||
// on one attempt shouldn't block a nudge/reminder actually reaching the user
|
||||
// — but it does mean this attempt can't be reconciled after a crash, so it's
|
||||
// logged. Returns 0 (no-op id) when unrecorded.
|
||||
func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminderID int64, channel Channel, body string, now time.Time) int64 {
|
||||
func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup string, channel Channel, body string, now time.Time) int64 {
|
||||
if d.cfg.Outbox == nil {
|
||||
return 0
|
||||
}
|
||||
id, err := d.cfg.Outbox.BeginDeliveryAttempt(ctx, kind, rule, reminderID, string(channel), bodyHash(channel, body), now)
|
||||
id, err := d.cfg.Outbox.BeginDeliveryAttempt(ctx, kind, rule, reminderID, deliveryGroup, string(channel), bodyHash(channel, body), now)
|
||||
if err != nil {
|
||||
log.Printf("dispatcher: outbox begin failed (send proceeds untracked): %v", err)
|
||||
return 0
|
||||
@@ -69,16 +81,37 @@ func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminde
|
||||
return id
|
||||
}
|
||||
|
||||
// beginReminderOutbox is stricter than the nudge helper above. A reminder may
|
||||
// be retried indefinitely, so sending it without the durable attempt row would
|
||||
// reopen an unobservable duplicate window after a crash. A configured but
|
||||
// unhealthy outbox therefore blocks this transport attempt; a deliberately nil
|
||||
// outbox still supports small isolated test/development wiring.
|
||||
func (d *Dispatcher) beginReminderOutbox(ctx context.Context, reminderID int64, deliveryGroup string, channel Channel, body string, now time.Time) (int64, error) {
|
||||
if d.cfg.Outbox == nil {
|
||||
return 0, nil
|
||||
}
|
||||
id, err := d.cfg.Outbox.BeginDeliveryAttempt(
|
||||
ctx, "reminder", "", reminderID, deliveryGroup,
|
||||
string(channel), bodyHash(channel, body), now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin reminder delivery attempt: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// completeOutbox records the sink's outcome for a prior beginOutbox call.
|
||||
// id==0 means either tracking is disabled or the begin failed — nothing to
|
||||
// complete either way.
|
||||
func (d *Dispatcher) completeOutbox(ctx context.Context, id int64, status string, now time.Time) {
|
||||
func (d *Dispatcher) completeOutbox(ctx context.Context, id int64, status string, now time.Time) error {
|
||||
if id == 0 || d.cfg.Outbox == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err := d.cfg.Outbox.CompleteDeliveryAttempt(ctx, id, status, now); err != nil {
|
||||
log.Printf("dispatcher: outbox complete failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PhrasedNudge — the phraser module's output for a nudge. the phraser (the
|
||||
@@ -155,7 +188,7 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
// the same afterwards. no nudges row: that table feeds the
|
||||
// ignored_rate signal, and a nudge nobody could see must not
|
||||
// count as ignored.
|
||||
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, pn.Summary, now)
|
||||
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, "", ch, pn.Summary, now)
|
||||
d.completeOutbox(ctx, id, store.DeliveryDropped, now)
|
||||
log.Printf("dispatcher: dropped %s (sev%d, presence=%s) — routing table suppressed it",
|
||||
c.Rule.Name, c.Severity, c.State.Presence)
|
||||
@@ -176,7 +209,7 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
if sink == nil {
|
||||
continue
|
||||
}
|
||||
attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, messageForChannel(s), now)
|
||||
attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, "", ch, messageForChannel(s), now)
|
||||
if err := safeSend(ctx, sink, s); err != nil {
|
||||
if errors.Is(err, ErrSinkPanicked) {
|
||||
// one broken sink must not eat the other channels for this
|
||||
@@ -226,14 +259,17 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
}
|
||||
|
||||
// DispatchReminder — routes a phrased reminder. reminders bypass the gate and
|
||||
// fire once (pending → fired after successful delivery). voice when present,
|
||||
// ntfy when away. no repeat (reminders fire once). marks the reminder fired
|
||||
// only if at least one channel succeeded — a failed send leaves it pending
|
||||
// for the next tick to re-deliver.
|
||||
// fire once (pending → fired after successful delivery). Voice is preferred
|
||||
// when present; if it has no live session, delivery falls back to the ordered
|
||||
// away alternatives. Away delivery tries ntfy, then telegram, and stops after
|
||||
// the first success. A failed or unwired alternative falls through to the next
|
||||
// one. If every selected alternative fails, the reminder stays pending and an
|
||||
// error is returned for the tick's retry path.
|
||||
func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, now time.Time) ([]Dispatch, error) {
|
||||
rd := pr.Decision
|
||||
channels := ChannelsForReminder(rd.State.Presence)
|
||||
var out []Dispatch
|
||||
var failures []error
|
||||
allPermanent := true
|
||||
for i := 0; i < len(channels); i++ {
|
||||
ch := channels[i]
|
||||
s := Sendable{
|
||||
@@ -245,62 +281,103 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
|
||||
Ts: now,
|
||||
}
|
||||
s = minimalForAway(s)
|
||||
sink := d.sinkFor(ch)
|
||||
if sink == nil {
|
||||
reminderID, deliveryGroup := reminderDeliveryIdentity(rd.Reminder)
|
||||
attemptID, err := d.beginReminderOutbox(ctx, reminderID, deliveryGroup, ch, messageForChannel(s), now)
|
||||
if err != nil {
|
||||
allPermanent = false
|
||||
failures = append(failures, err)
|
||||
log.Printf("dispatcher: reminder %d delivery via %s withheld: %v", rd.Reminder.ID, ch, err)
|
||||
continue
|
||||
}
|
||||
sink := d.sinkFor(ch)
|
||||
if sink == nil {
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
err := fmt.Errorf("%s sink is not configured", ch)
|
||||
failures = append(failures, err)
|
||||
log.Printf("dispatcher: reminder %d delivery via %s failed: %v", rd.Reminder.ID, ch, err)
|
||||
if ch == ChannelVoice {
|
||||
channels = ChannelsForReminder(store.Away)
|
||||
failures = nil
|
||||
allPermanent = true
|
||||
i = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
attemptID := d.beginOutbox(ctx, "reminder", "", rd.Reminder.ID, ch, messageForChannel(s), now)
|
||||
if err := safeSend(ctx, sink, s); err != nil {
|
||||
if errors.Is(err, ErrSinkPanicked) {
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
continue
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
failures = append(failures, fmt.Errorf("send %s: %w", ch, err))
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
allPermanent = false
|
||||
}
|
||||
if errors.Is(err, ErrVoiceNoSession) {
|
||||
// presence guess was wrong — reroute reminder to the away
|
||||
// channel (ntfy). voice is the only present channel, so nothing
|
||||
// has been sent yet.
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
// Presence was stale. Voice is the only present alternative, so
|
||||
// nothing has been sent and it is safe to start the away chain.
|
||||
log.Printf("dispatcher: no live voice session for reminder %d, rerouting to away channels", rd.Reminder.ID)
|
||||
channels = ChannelsForReminder(store.Away)
|
||||
failures = nil
|
||||
allPermanent = true
|
||||
i = -1
|
||||
continue
|
||||
}
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
return out, fmt.Errorf("send %s: %w", ch, err)
|
||||
log.Printf("dispatcher: reminder %d delivery via %s failed: %v", rd.Reminder.ID, ch, err)
|
||||
continue
|
||||
}
|
||||
d.completeOutbox(ctx, attemptID, store.DeliverySent, now)
|
||||
out = append(out, Dispatch{Sendable: s})
|
||||
}
|
||||
if d.cfg.Reminders != nil && len(out) > 0 {
|
||||
// ID=0 is a synthetic digest reminder; it's not in the DB. Complete
|
||||
// the collapsed originals it stands in for instead — only now, after
|
||||
// a successful send, so a failed digest leaves them all pending.
|
||||
out := []Dispatch{{Sendable: s}}
|
||||
originals := []store.Reminder{rd.Reminder}
|
||||
if rd.Reminder.ID == 0 {
|
||||
for _, orig := range rd.Reminder.Collapsed {
|
||||
if err := d.completeReminder(ctx, orig, now); err != nil {
|
||||
return out, err
|
||||
}
|
||||
originals = rd.Reminder.Collapsed
|
||||
}
|
||||
if durable, ok := d.cfg.Reminders.(DurableReminderCompleter); ok && attemptID != 0 {
|
||||
if err := durable.CompleteSuccessfulReminderAttempt(ctx, attemptID, originals, now); err != nil {
|
||||
return out, fmt.Errorf("commit successful reminder delivery: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if err := d.completeOutbox(ctx, attemptID, store.DeliverySent, now); err != nil {
|
||||
// The external sink accepted the reminder, but its durable outcome is
|
||||
// ambiguous. Do not complete the reminder row: startup reconciliation
|
||||
// will mark the attempt unknown and DueReminders will hold the exact
|
||||
// occurrence for operator resolution rather than sending a duplicate.
|
||||
return out, fmt.Errorf("record successful reminder delivery: %w", err)
|
||||
}
|
||||
if d.cfg.Reminders != nil {
|
||||
if err := d.cfg.Reminders.CompleteReminderDelivery(ctx, originals, now); err != nil {
|
||||
return out, fmt.Errorf("complete reminder delivery: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
failures = append(failures, errors.New("no delivery alternatives selected"))
|
||||
allPermanent = false
|
||||
}
|
||||
joined := errors.Join(failures...)
|
||||
if allPermanent && d.cfg.Reminders != nil {
|
||||
originals := []store.Reminder{rd.Reminder}
|
||||
if rd.Reminder.ID == 0 {
|
||||
originals = rd.Reminder.Collapsed
|
||||
}
|
||||
if blocker, ok := d.cfg.Reminders.(ReminderBlocker); ok {
|
||||
if err := blocker.BlockReminderDelivery(ctx, originals, now, joined.Error()); err != nil {
|
||||
return nil, fmt.Errorf("block permanently undeliverable reminder %d: %w", rd.Reminder.ID, err)
|
||||
}
|
||||
} else if err := d.completeReminder(ctx, rd.Reminder, now); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return nil, fmt.Errorf("deliver reminder %d: %w", rd.Reminder.ID, joined)
|
||||
}
|
||||
|
||||
// completeReminder — post-delivery bookkeeping for one reminder: recurring
|
||||
// (cron set) reschedules, one-shot marks fired.
|
||||
func (d *Dispatcher) completeReminder(ctx context.Context, r store.Reminder, now time.Time) error {
|
||||
if r.Cron != "" {
|
||||
if err := d.cfg.Reminders.RescheduleReminder(ctx, r.ID, now); err != nil {
|
||||
return fmt.Errorf("reschedule reminder %d: %w", r.ID, err)
|
||||
}
|
||||
return nil
|
||||
// reminderDeliveryIdentity gives the outbox both a human-readable real row id
|
||||
// and the exact occurrence key used for crash suppression. A collapsed digest
|
||||
// has synthetic ID zero, so its first original is the representative; the
|
||||
// shared delivery group still identifies every original atomically.
|
||||
func reminderDeliveryIdentity(r store.Reminder) (int64, string) {
|
||||
if r.ID != 0 {
|
||||
return r.ID, r.DeliveryGroup
|
||||
}
|
||||
if err := d.cfg.Reminders.MarkReminder(ctx, r.ID, "fired"); err != nil {
|
||||
return fmt.Errorf("mark reminder %d fired: %w", r.ID, err)
|
||||
if len(r.Collapsed) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
return nil
|
||||
return r.Collapsed[0].ID, r.Collapsed[0].DeliveryGroup
|
||||
}
|
||||
|
||||
// RepeatUnacked — the daemon calls this each tick to re-send un-acked sev4
|
||||
@@ -340,7 +417,7 @@ func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time.
|
||||
Ts: now,
|
||||
}
|
||||
s = minimalForAway(s)
|
||||
attemptID := d.beginOutbox(ctx, "nudge", key, 0, ChannelTelegram, messageForChannel(s), now)
|
||||
attemptID := d.beginOutbox(ctx, "nudge", key, 0, "", ChannelTelegram, messageForChannel(s), now)
|
||||
if err := safeSend(ctx, d.cfg.Telegram, s); err != nil {
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
if errors.Is(err, ErrSinkPanicked) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package delivery
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -61,9 +62,22 @@ type fakeReminderCompleter struct {
|
||||
status string
|
||||
}
|
||||
rescheduled []int64
|
||||
blocked []int64
|
||||
blockReason string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) BlockReminderDelivery(_ context.Context, originals []store.Reminder, _ time.Time, reason string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
for _, r := range originals {
|
||||
f.blocked = append(f.blocked, r.ID)
|
||||
}
|
||||
f.blockReason = reason
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
@@ -83,6 +97,23 @@ func (f *fakeReminderCompleter) RescheduleReminder(_ context.Context, id int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) CompleteReminderDelivery(_ context.Context, originals []store.Reminder, _ time.Time) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
for _, r := range originals {
|
||||
if r.Cron != "" {
|
||||
f.rescheduled = append(f.rescheduled, r.ID)
|
||||
continue
|
||||
}
|
||||
f.marked = append(f.marked, struct {
|
||||
id int64
|
||||
status string
|
||||
}{r.ID, store.ReminderFired})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAck struct {
|
||||
acked map[string]bool
|
||||
lastSent map[string]time.Time
|
||||
@@ -152,10 +183,10 @@ func TestChannelsForReminderPresentVoice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsForReminderAwayNtfy(t *testing.T) {
|
||||
func TestChannelsForReminderAwayAlternatives(t *testing.T) {
|
||||
got := ChannelsForReminder(store.Away)
|
||||
if len(got) != 1 || got[0] != ChannelNtfy {
|
||||
t.Fatalf("reminder away: want [ntfy], got %v", got)
|
||||
if len(got) != 2 || got[0] != ChannelNtfy || got[1] != ChannelTelegram {
|
||||
t.Fatalf("reminder away: want [ntfy telegram], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,8 +384,9 @@ func TestDispatchReminderPresentVoice(t *testing.T) {
|
||||
|
||||
func TestDispatchReminderAwayNtfy(t *testing.T) {
|
||||
ntfy := &fakeSink{}
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Reminders: rc})
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc})
|
||||
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 7, Status: "pending"},
|
||||
@@ -369,6 +401,9 @@ func TestDispatchReminderAwayNtfy(t *testing.T) {
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
|
||||
t.Fatalf("want 1 ntfy, got %+v", out)
|
||||
}
|
||||
if len(telegram.sends) != 0 {
|
||||
t.Fatalf("ntfy succeeded; telegram must not receive a duplicate, got %d sends", len(telegram.sends))
|
||||
}
|
||||
// away channel gets summary, not body
|
||||
if ntfy.sends[0].Summary != "wake up" {
|
||||
t.Fatalf("ntfy summary: want 'wake up', got %q", ntfy.sends[0].Summary)
|
||||
@@ -378,6 +413,203 @@ func TestDispatchReminderAwayNtfy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAwayNtfyFailureFallsBackToTelegram(t *testing.T) {
|
||||
ntfy := &fakeSink{err: errors.New("ntfy unavailable")}
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 8, Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full medication detail", Summary: "take medication",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
|
||||
t.Fatalf("want one telegram fallback, got out=%+v sends=%d", out, len(telegram.sends))
|
||||
}
|
||||
if got := messageForChannel(telegram.sends[0]); got != "take medication" {
|
||||
t.Fatalf("telegram fallback must retain the minimal away body, got %q", got)
|
||||
}
|
||||
if len(rc.marked) != 1 || rc.marked[0].id != 8 || rc.marked[0].status != "fired" {
|
||||
t.Fatalf("successful fallback must complete the reminder, got %+v", rc.marked)
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[0].channel != "ntfy" || ob.attempts[0].status != store.DeliveryFailed ||
|
||||
ob.attempts[1].channel != "telegram" || ob.attempts[1].status != store.DeliverySent {
|
||||
t.Fatalf("want ntfy failed then telegram sent attempts, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAwayNilNtfyFallsBackVisibly(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 9, Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
|
||||
t.Fatalf("want one telegram fallback, got out=%+v sends=%d", out, len(telegram.sends))
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[0].channel != "ntfy" || ob.attempts[0].status != store.DeliveryFailed ||
|
||||
ob.attempts[1].channel != "telegram" || ob.attempts[1].status != store.DeliverySent {
|
||||
t.Fatalf("nil ntfy must leave a failed row before telegram succeeds, got %+v", ob.attempts)
|
||||
}
|
||||
if len(rc.marked) != 1 || rc.marked[0].id != 9 {
|
||||
t.Fatalf("successful fallback must complete the reminder, got %+v", rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAwayAllAlternativesFailStaysPending(t *testing.T) {
|
||||
ntfy := &fakeSink{err: errors.New("ntfy unavailable")}
|
||||
telegram := &fakeSink{err: errors.New("telegram unavailable")}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 10, Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err == nil {
|
||||
t.Fatal("all alternatives failed: want an error")
|
||||
}
|
||||
if len(out) != 0 || len(rc.marked) != 0 || len(rc.rescheduled) != 0 {
|
||||
t.Fatalf("failed reminder must stay pending, got out=%+v marked=%+v rescheduled=%+v", out, rc.marked, rc.rescheduled)
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[0].status != store.DeliveryFailed || ob.attempts[1].status != store.DeliveryFailed {
|
||||
t.Fatalf("want two failed attempts, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAllPermanentAlternativesBlockGroup(t *testing.T) {
|
||||
permanent := fmt.Errorf("%w: credentials rejected", ErrPermanent)
|
||||
ntfy := &fakeSink{err: permanent}
|
||||
telegram := &fakeSink{err: permanent}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc})
|
||||
now := refNow()
|
||||
originals := []store.Reminder{
|
||||
{ID: 21, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:permanent"},
|
||||
{ID: 22, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:permanent"},
|
||||
}
|
||||
|
||||
_, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 0, Status: store.ReminderPending, Collapsed: originals},
|
||||
State: loop.State{Now: now, Presence: store.Away},
|
||||
}, Body: "details", Summary: "two reminders",
|
||||
}, now)
|
||||
if err == nil || !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("permanent alternatives error = %v", err)
|
||||
}
|
||||
if len(ntfy.sends) != 0 || len(telegram.sends) != 0 {
|
||||
// fakeSink records only successes, so this also asserts neither was
|
||||
// mistaken for a successful delivery.
|
||||
t.Fatalf("permanent failure produced successful sends: ntfy=%d telegram=%d", len(ntfy.sends), len(telegram.sends))
|
||||
}
|
||||
if len(rc.blocked) != 2 || rc.blocked[0] != 21 || rc.blocked[1] != 22 || rc.blockReason == "" {
|
||||
t.Fatalf("permanent bundle was not durably blocked: ids=%v reason=%q", rc.blocked, rc.blockReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderWithUnrecordableOutboxDoesNotSend(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{beginErr: errors.New("database unavailable")}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 11, Status: "pending", DeliveryGroup: "reminder:11"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err == nil {
|
||||
t.Fatal("unrecordable reminder attempt must be withheld")
|
||||
}
|
||||
if len(out) != 0 || len(telegram.sends) != 0 || len(rc.marked) != 0 {
|
||||
t.Fatalf("unrecordable reminder escaped durable boundary: out=%+v sends=%d completed=%+v", out, len(telegram.sends), rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderOutboxCompletionAmbiguityDoesNotCompleteOccurrence(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{completeErr: errors.New("database unavailable after send")}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 12, Status: store.ReminderPending, DeliveryGroup: "reminder:12"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err == nil {
|
||||
t.Fatal("ambiguous outbox completion must surface an error")
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
|
||||
t.Fatalf("the accepted external send must be reported once: out=%+v sends=%d", out, len(telegram.sends))
|
||||
}
|
||||
if len(rc.marked) != 0 || len(rc.rescheduled) != 0 {
|
||||
t.Fatalf("ambiguous accepted send completed the occurrence: marked=%+v rescheduled=%+v", rc.marked, rc.rescheduled)
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[1].status != store.DeliveryPending {
|
||||
t.Fatalf("accepted send should remain pending for startup reconciliation: %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderCollapsedOutboxUsesRealIDAndSharedGroup(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
now := refNow()
|
||||
originals := []store.Reminder{
|
||||
{ID: 81, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:bundle"},
|
||||
{ID: 82, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:bundle"},
|
||||
}
|
||||
|
||||
if _, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 0, Status: store.ReminderPending, Collapsed: originals},
|
||||
State: loop.State{Now: now, Presence: store.Away},
|
||||
},
|
||||
Body: "two reminders", Summary: "two reminders",
|
||||
}, now); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(ob.attempts) != 2 {
|
||||
t.Fatalf("attempts=%d, want nil-ntfy plus telegram", len(ob.attempts))
|
||||
}
|
||||
for _, a := range ob.attempts {
|
||||
if a.reminderID != 81 || a.deliveryGroup != "reminder:bundle" {
|
||||
t.Fatalf("synthetic ID escaped into outbox: %+v", a)
|
||||
}
|
||||
}
|
||||
if len(rc.marked) != 2 || rc.marked[0].id != 81 || rc.marked[1].id != 82 {
|
||||
t.Fatalf("collapsed completion did not cover originals: %+v", rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) {
|
||||
// a failed send must not mark the reminder fired — it stays pending for
|
||||
// the next tick to re-deliver. same instinct as "record after success."
|
||||
@@ -486,11 +718,12 @@ func TestDispatchNudgeVoiceNoSessionSev2Drops(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
|
||||
// present reminder → [voice]. voice has no session → away = ntfy.
|
||||
// present reminder → [voice]. voice has no session → away starts at ntfy.
|
||||
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||
ntfy := &fakeSink{}
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Reminders: rc})
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Reminders: rc})
|
||||
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 99, Status: "pending"},
|
||||
@@ -505,6 +738,9 @@ func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
|
||||
if len(ntfy.sends) != 1 || len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
|
||||
t.Fatalf("reminder voice-no-session: want 1 ntfy, got ntfy=%d out=%+v", len(ntfy.sends), out)
|
||||
}
|
||||
if len(telegram.sends) != 0 {
|
||||
t.Fatalf("successful ntfy fallback must stop before telegram, got %d sends", len(telegram.sends))
|
||||
}
|
||||
if len(rc.marked) != 1 || rc.marked[0].status != "fired" {
|
||||
t.Fatalf("rerouted reminder must be marked fired: %+v", rc.marked)
|
||||
}
|
||||
@@ -641,6 +877,7 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) {
|
||||
type outboxAttempt struct {
|
||||
kind, rule string
|
||||
reminderID int64
|
||||
deliveryGroup string
|
||||
channel, hash string
|
||||
status string
|
||||
begunAt, doneAt time.Time
|
||||
@@ -659,13 +896,13 @@ type fakeOutbox struct {
|
||||
completeErr error
|
||||
}
|
||||
|
||||
func (f *fakeOutbox) BeginDeliveryAttempt(_ context.Context, kind, rule string, reminderID int64, channel, hash string, now time.Time) (int64, error) {
|
||||
func (f *fakeOutbox) BeginDeliveryAttempt(_ context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, hash string, now time.Time) (int64, error) {
|
||||
if f.beginErr != nil {
|
||||
return 0, f.beginErr
|
||||
}
|
||||
f.nextID++
|
||||
f.attempts = append(f.attempts, &outboxAttempt{
|
||||
kind: kind, rule: rule, reminderID: reminderID, channel: channel, hash: hash,
|
||||
kind: kind, rule: rule, reminderID: reminderID, deliveryGroup: deliveryGroup, channel: channel, hash: hash,
|
||||
status: "pending", begunAt: now,
|
||||
})
|
||||
return f.nextID, nil
|
||||
@@ -985,8 +1222,8 @@ func TestPanicInReminderSinkResolvesTheAttempt(t *testing.T) {
|
||||
},
|
||||
Body: "звонок", Summary: "звонок",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("a panicking sink must not fail the dispatch: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("a panicking sole reminder sink must report delivery failure")
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("nothing was delivered, want no dispatches, got %+v", out)
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
|
||||
// the crash: intent recorded, no completion.
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ import (
|
||||
// the credential lives in the daemon's config (or a systemd credential),
|
||||
// never in the binary.
|
||||
type Config struct {
|
||||
// Disabled keeps a written endpoint explicitly dark. This is distinct from
|
||||
// an expanded-empty credential: the latter is a configuration error, while
|
||||
// this flag records an operator decision to use another delivery reach until
|
||||
// credentials are provisioned.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// BaseURL — the ntfy server, no trailing path. Required.
|
||||
BaseURL string `json:"base_url"`
|
||||
|
||||
@@ -71,23 +77,45 @@ type Sink struct {
|
||||
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")
|
||||
// Validate checks one configuration without constructing a client. A disabled
|
||||
// block is the only state in which credentials may be empty. Maven's ntfy
|
||||
// reach is private, and accepting an accidental anonymous configuration turns
|
||||
// a missing environment variable into an endless 403 retry loop.
|
||||
func Validate(cfg Config) error {
|
||||
if cfg.Disabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.BaseURL) == "" {
|
||||
return fmt.Errorf("ntfysink: BaseURL is required while enabled")
|
||||
}
|
||||
if _, err := url.Parse(cfg.BaseURL); err != nil {
|
||||
return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err)
|
||||
return fmt.Errorf("ntfysink: bad BaseURL: %w", err)
|
||||
}
|
||||
if cfg.Topic == "" {
|
||||
return nil, fmt.Errorf("ntfysink: Topic is required")
|
||||
if strings.TrimSpace(cfg.Topic) == "" {
|
||||
return fmt.Errorf("ntfysink: Topic is required while enabled")
|
||||
}
|
||||
// Refuse rather than pick. Two credentials configured means someone
|
||||
// intended one of them, and guessing which would send the other nowhere
|
||||
// and leave a working config that is not the one they wrote.
|
||||
if cfg.Token != "" && cfg.Username != "" {
|
||||
return nil, fmt.Errorf("ntfysink: set Token or Username, not both")
|
||||
if strings.TrimSpace(cfg.Token) != "" && (strings.TrimSpace(cfg.Username) != "" || cfg.Password != "") {
|
||||
return fmt.Errorf("ntfysink: set Token or Username/Password, not both")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
|
||||
return fmt.Errorf("ntfysink: Token or Username/Password is required while enabled")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// New validates the config and builds the sink. Disabled configs belong at the
|
||||
// daemon wiring boundary and cannot accidentally become live sinks.
|
||||
func New(cfg Config) (*Sink, error) {
|
||||
if cfg.Disabled {
|
||||
return nil, fmt.Errorf("ntfysink: config is disabled")
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := cfg.Timeout
|
||||
if to == 0 {
|
||||
@@ -127,6 +155,10 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%w: ntfysink: credentials rejected (%d): %s",
|
||||
delivery.ErrPermanent, resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||
}
|
||||
return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package ntfysink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -81,6 +82,10 @@ func reminderSendable(summary string) delivery.Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func tokenConfig(baseURL string) Config {
|
||||
return Config{BaseURL: baseURL, Topic: "maven", Token: "scoped-write-token"}
|
||||
}
|
||||
|
||||
// ----------------------------- config ---------------------------------------
|
||||
|
||||
func TestNewRejectsEmptyBaseURL(t *testing.T) {
|
||||
@@ -98,7 +103,7 @@ func TestNewRejectsEmptyTopic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewDefaultTimeout(t *testing.T) {
|
||||
s, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
|
||||
s, err := New(tokenConfig("http://localhost:8085"))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -107,6 +112,26 @@ func TestNewDefaultTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsAnonymousPublishing(t *testing.T) {
|
||||
_, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
|
||||
if err == nil {
|
||||
t.Fatal("New accepted a topic with no credential")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required while enabled") {
|
||||
t.Fatalf("error should identify the enabled reach: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsEmptyCredentialOnlyWhenDisabled(t *testing.T) {
|
||||
cfg := Config{Disabled: true, BaseURL: "http://localhost:8085", Topic: "maven"}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate disabled config: %v", err)
|
||||
}
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("New built a live sink from a disabled config")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- send shape ----------------------------------
|
||||
|
||||
func TestSendPostsToTopicPath(t *testing.T) {
|
||||
@@ -114,7 +139,7 @@ func TestSendPostsToTopicPath(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, err := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, err := New(tokenConfig(srv.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -137,7 +162,7 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
@@ -155,7 +180,7 @@ func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
s := nudgeSendable(loop.Sev3, "")
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), s); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
@@ -173,7 +198,7 @@ func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
s := nudgeSendable(loop.Sev3, "")
|
||||
s.Body = ""
|
||||
s.RuleName = ""
|
||||
@@ -209,18 +234,18 @@ func TestSendSetsBasicAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
|
||||
func TestTokenConfigSendsBearerAuth(t *testing.T) {
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
_, _, _, auth, _, _ := rs.snapshot()
|
||||
if auth != "" {
|
||||
t.Fatalf("want no auth header, got %q", auth)
|
||||
if auth != "Bearer scoped-write-token" {
|
||||
t.Fatalf("want bearer auth header, got %q", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +285,7 @@ func TestSendTitleIsMaven(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
@@ -277,7 +302,7 @@ func TestPrioritySev3IsHigh(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert"))
|
||||
_, _, _, _, _, prio := rs.snapshot()
|
||||
if prio != "4" {
|
||||
@@ -290,7 +315,7 @@ func TestPrioritySev4IsMax(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
||||
_, _, _, _, _, prio := rs.snapshot()
|
||||
if prio != "5" {
|
||||
@@ -303,7 +328,7 @@ func TestPriorityReminderIsHigh(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
_ = sink.Send(context.Background(), reminderSendable("wake up"))
|
||||
_, _, _, _, _, prio := rs.snapshot()
|
||||
if prio != "4" {
|
||||
@@ -318,7 +343,7 @@ func TestSendReturnsErrorOnNon2xx(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on 403")
|
||||
@@ -326,6 +351,24 @@ func TestSendReturnsErrorOnNon2xx(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("error should mention status 403, got: %v", err)
|
||||
}
|
||||
if !errors.Is(err, delivery.ErrPermanent) {
|
||||
t.Fatalf("403 = %v; want delivery.ErrPermanent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendServerFailureRemainsRetryable(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusServiceUnavailable, `{"error":"temporarily unavailable"}`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on 503")
|
||||
}
|
||||
if errors.Is(err, delivery.ErrPermanent) {
|
||||
t.Fatalf("503 was marked permanent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendContextCancelReturnsError(t *testing.T) {
|
||||
@@ -333,7 +376,7 @@ func TestSendContextCancelReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
err := sink.Send(ctx, nudgeSendable(loop.Sev3, "down"))
|
||||
@@ -343,7 +386,9 @@ func TestSendContextCancelReturnsError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendConnectionRefusedReturnsError(t *testing.T) {
|
||||
sink, _ := New(Config{BaseURL: "http://127.0.0.1:1", Topic: "maven", Timeout: time.Second})
|
||||
cfg := tokenConfig("http://127.0.0.1:1")
|
||||
cfg.Timeout = time.Second
|
||||
sink, _ := New(cfg)
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on connection refused")
|
||||
|
||||
@@ -51,6 +51,11 @@ const DefaultTimeout = 10 * time.Second
|
||||
// config file; the bot token + chat id live in the daemon's config (or a
|
||||
// systemd credential), never in the binary.
|
||||
type Config struct {
|
||||
// Disabled keeps a written Telegram block explicitly dark. A present block
|
||||
// is otherwise live, so an expanded-empty credential is a configuration
|
||||
// error rather than an implicit opt-out.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// BotToken — the telegram bot token from BotFather. required. sent in the
|
||||
// URL path (the only place telegram accepts it), not in the body.
|
||||
BotToken string `json:"bot_token"`
|
||||
@@ -91,27 +96,47 @@ type Sink struct {
|
||||
base string // resolved BaseURL, no trailing slash
|
||||
}
|
||||
|
||||
// New validates the config and builds the sink. BotToken and ChatID are
|
||||
// required; Proxy and BaseURL are optional.
|
||||
func New(cfg Config) (*Sink, error) {
|
||||
if cfg.BotToken == "" {
|
||||
return nil, fmt.Errorf("telegramsink: BotToken is required")
|
||||
// Validate checks one configuration without constructing a client. A disabled
|
||||
// block is valid and intentionally carries no credentials; every live block
|
||||
// must carry both secrets and valid endpoint URLs.
|
||||
func Validate(cfg Config) error {
|
||||
if cfg.Disabled {
|
||||
return nil
|
||||
}
|
||||
if cfg.ChatID == "" {
|
||||
return nil, fmt.Errorf("telegramsink: ChatID is required")
|
||||
if strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return fmt.Errorf("telegramsink: BotToken is required while enabled")
|
||||
}
|
||||
if strings.TrimSpace(cfg.ChatID) == "" {
|
||||
return fmt.Errorf("telegramsink: ChatID is required while enabled")
|
||||
}
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
if _, err := url.Parse(base); err != nil {
|
||||
return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err)
|
||||
return fmt.Errorf("telegramsink: bad BaseURL: %w", err)
|
||||
}
|
||||
if cfg.Proxy != "" {
|
||||
if _, err := url.Parse(cfg.Proxy); err != nil {
|
||||
return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err)
|
||||
return fmt.Errorf("telegramsink: bad Proxy: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// New validates the config and builds the sink. Disabled configs belong at the
|
||||
// wiring boundary and cannot accidentally become live sinks.
|
||||
func New(cfg Config) (*Sink, error) {
|
||||
if cfg.Disabled {
|
||||
return nil, fmt.Errorf("telegramsink: config is disabled")
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
to := cfg.Timeout
|
||||
if to == 0 {
|
||||
to = DefaultTimeout
|
||||
@@ -192,7 +217,11 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
var tr telegramResp
|
||||
jsonErr := json.Unmarshal(rb, &tr)
|
||||
if jsonErr == nil && !tr.Ok {
|
||||
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||
err := fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||
if tr.ErrorCode == http.StatusUnauthorized || tr.ErrorCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%w: %v", delivery.ErrPermanent, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
|
||||
|
||||
@@ -106,6 +106,16 @@ func TestNewRejectsEmptyChatID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsEmptySecretsOnlyWhenDisabled(t *testing.T) {
|
||||
cfg := Config{Disabled: true}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate disabled config: %v", err)
|
||||
}
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("New built a live sink from a disabled config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultTimeout(t *testing.T) {
|
||||
s, err := New(Config{BotToken: "123:abc", ChatID: "42"})
|
||||
if err != nil {
|
||||
@@ -289,6 +299,9 @@ func TestSendReturnsErrorOnTelegramError(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("error should mention error_code 401, got: %v", err)
|
||||
}
|
||||
if !errors.Is(err, delivery.ErrPermanent) {
|
||||
t.Fatalf("revoked bot credential must be permanent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
|
||||
@@ -525,9 +538,9 @@ func TestSendRoutesThroughProxyWhenConfigured(t *testing.T) {
|
||||
// ----------------------------- reminder same shape --------------------------
|
||||
|
||||
func TestReminderSendUsesSamePath(t *testing.T) {
|
||||
// reminders away route to ntfy, not telegram — but if the daemon ever
|
||||
// routes a reminder via telegram (per-reminder override), the sink must
|
||||
// accept KindReminder undamaged. exercises the kind-agnostic contract.
|
||||
// Telegram is the second away alternative for reminders. If ntfy is
|
||||
// unavailable and the dispatcher falls through, the sink must accept
|
||||
// KindReminder undamaged. exercises the kind-agnostic contract.
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
Reference in New Issue
Block a user