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
+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()