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:
@@ -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