feat(notify): Phase 2.5 — Notifier Telegram + Resend email
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
c87145ea0b
commit
46c49d0f2f
2 changed files with 236 additions and 0 deletions
113
internal/notify/notifier.go
Normal file
113
internal/notify/notifier.go
Normal file
|
|
@ -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 <noreply@example.com>",
|
||||||
|
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
|
||||||
|
}
|
||||||
123
internal/notify/notifier_test.go
Normal file
123
internal/notify/notifier_test.go
Normal file
|
|
@ -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", "<p>body</p>"); 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", "<p>something happened</p>"); 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue