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:
2026-08-13 02:50:59 +04:00
parent da9114b623
commit 35c6ff5a71
67 changed files with 3174 additions and 477 deletions
+14 -4
View File
@@ -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
View File
@@ -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) {
+247 -10
View File
@@ -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)
+2 -2
View File
@@ -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)
}
+42 -10
View File
@@ -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
+61 -16
View File
@@ -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")
+39 -10
View File
@@ -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()