From 46c49d0f2fee89bc5343b39f9808cda650dba33a Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 14 Apr 2026 20:28:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(notify):=20Phase=202.5=20=E2=80=94=20Notif?= =?UTF-8?q?ier=20Telegram=20+=20Resend=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/notify: Telegram (POST /sendMessage) et Resend email (POST /emails) - Credentials lus depuis env vars (telegramBaseURL/resendBaseURL overridables en test) - No-op gracieux quand token/key absents - 5 tests unitaires avec httptest.Server Co-Authored-By: Claude Sonnet 4.6 --- internal/notify/notifier.go | 113 ++++++++++++++++++++++++++++ internal/notify/notifier_test.go | 123 +++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 internal/notify/notifier.go create mode 100644 internal/notify/notifier_test.go diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go new file mode 100644 index 0000000..6de148e --- /dev/null +++ b/internal/notify/notifier.go @@ -0,0 +1,113 @@ +// Package notify sends operational alerts via Telegram and Resend email. +// Credentials are read exclusively from environment variables — never from +// the config file or source code. +package notify + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "forge.secuaas.ovh/olivier/claude-failover/internal/config" +) + +// Notifier dispatches alerts to configured sinks. +// All methods are safe to call when credentials are absent — they become no-ops. +type Notifier struct { + telegramToken string + telegramChatID string + resendAPIKey string + notifyEmail string + httpClient *http.Client + telegramBaseURL string // override in tests + resendBaseURL string // override in tests +} + +// New creates a Notifier, reading credentials from the environment variables +// named in cfg.Notifications. +func New(cfg *config.Config) *Notifier { + n := &Notifier{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + telegramBaseURL: "https://api.telegram.org", + resendBaseURL: "https://api.resend.com", + } + if cfg.Notifications.TelegramTokenEnv != "" { + n.telegramToken = os.Getenv(cfg.Notifications.TelegramTokenEnv) + } + if cfg.Notifications.TelegramChatIDEnv != "" { + n.telegramChatID = os.Getenv(cfg.Notifications.TelegramChatIDEnv) + } + if cfg.Notifications.ResendAPIKeyEnv != "" { + n.resendAPIKey = os.Getenv(cfg.Notifications.ResendAPIKeyEnv) + } + n.notifyEmail = "admin@example.com" + if cfg.Notifications.NotifyEmailEnv != "" { + if v := os.Getenv(cfg.Notifications.NotifyEmailEnv); v != "" { + n.notifyEmail = v + } + } + return n +} + +// Telegram sends msg to the configured Telegram chat. +// Returns nil (no-op) when token or chat ID is not configured. +func (n *Notifier) Telegram(msg string) error { + if n.telegramToken == "" || n.telegramChatID == "" { + return nil + } + url := fmt.Sprintf("%s/bot%s/sendMessage", n.telegramBaseURL, n.telegramToken) + body := map[string]string{ + "chat_id": n.telegramChatID, + "text": msg, + "parse_mode": "HTML", + } + data, _ := json.Marshal(body) + resp, err := n.httpClient.Post(url, "application/json", bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("telegram: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("telegram: HTTP %d", resp.StatusCode) + } + return nil +} + +// Email sends an HTML email via the Resend API. +// Returns nil (no-op) when the API key is not configured. +func (n *Notifier) Email(subject, htmlBody string) error { + if n.resendAPIKey == "" { + return nil + } + type emailReq struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html"` + } + payload := emailReq{ + From: "Orchestrator ", + To: []string{n.notifyEmail}, + Subject: subject, + HTML: htmlBody, + } + data, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, n.resendBaseURL+"/emails", bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("email: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+n.resendAPIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := n.httpClient.Do(req) + if err != nil { + return fmt.Errorf("email: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("email: HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/internal/notify/notifier_test.go b/internal/notify/notifier_test.go new file mode 100644 index 0000000..9b9a8c1 --- /dev/null +++ b/internal/notify/notifier_test.go @@ -0,0 +1,123 @@ +package notify + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.secuaas.ovh/olivier/claude-failover/internal/config" +) + +// TestTelegramNoop verifies that an unconfigured notifier is a safe no-op. +func TestTelegramNoop(t *testing.T) { + n := &Notifier{} + if err := n.Telegram("hello"); err != nil { + t.Errorf("expected nil error for empty token, got %v", err) + } +} + +// TestEmailNoop verifies that an unconfigured notifier is a safe no-op. +func TestEmailNoop(t *testing.T) { + n := &Notifier{} + if err := n.Email("subject", "

body

"); err != nil { + t.Errorf("expected nil error for empty API key, got %v", err) + } +} + +// TestNewFromConfig verifies that New reads credentials from env vars. +func TestNewFromConfig(t *testing.T) { + t.Setenv("TEST_TG_TOKEN", "my-token") + t.Setenv("TEST_TG_CHAT", "9999") + t.Setenv("TEST_EMAIL", "ops@example.com") + + cfg := &config.Config{ + Notifications: config.NotificationsConfig{ + TelegramTokenEnv: "TEST_TG_TOKEN", + TelegramChatIDEnv: "TEST_TG_CHAT", + NotifyEmailEnv: "TEST_EMAIL", + }, + } + n := New(cfg) + if n.telegramToken != "my-token" { + t.Errorf("expected token my-token, got %q", n.telegramToken) + } + if n.telegramChatID != "9999" { + t.Errorf("expected chatID 9999, got %q", n.telegramChatID) + } + if n.notifyEmail != "ops@example.com" { + t.Errorf("expected email ops@example.com, got %q", n.notifyEmail) + } +} + +// TestTelegramHTTP verifies the actual HTTP body sent to Telegram. +func TestTelegramHTTP(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(200) + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + n := &Notifier{ + telegramToken: "tok", + telegramChatID: "42", + httpClient: srv.Client(), + telegramBaseURL: srv.URL, + } + if err := n.Telegram("test message"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody["text"] != "test message" { + t.Errorf("expected text 'test message', got %q", gotBody["text"]) + } + if gotBody["chat_id"] != "42" { + t.Errorf("expected chat_id 42, got %q", gotBody["chat_id"]) + } +} + +// TestEmailHTTP verifies the HTTP body sent to Resend. +func TestEmailHTTP(t *testing.T) { + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&gotBody) + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("unexpected Authorization header: %q", r.Header.Get("Authorization")) + } + w.WriteHeader(201) + w.Write([]byte(`{"id":"msg-123"}`)) + })) + defer srv.Close() + + n := &Notifier{ + resendAPIKey: "test-key", + notifyEmail: "user@example.com", + httpClient: srv.Client(), + resendBaseURL: srv.URL, + } + if err := n.Email("Alert", "

something happened

"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody["subject"] != "Alert" { + t.Errorf("expected subject Alert, got %v", gotBody["subject"]) + } +} + +// TestTelegramHTTPError propagates non-2xx status as error. +func TestTelegramHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(429) + })) + defer srv.Close() + + n := &Notifier{ + telegramToken: "tok", + telegramChatID: "1", + httpClient: srv.Client(), + telegramBaseURL: srv.URL, + } + if err := n.Telegram("msg"); err == nil { + t.Error("expected error on HTTP 429, got nil") + } +}