// Package notify provides notification delivery to Slack, Mattermost, and ntfy. package notify import ( "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "time" "go.uber.org/fx" "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/logger" ) // HTTP client timeout. const httpClientTimeout = 10 * time.Second // HTTP status code thresholds. const httpStatusClientError = 400 // Sentinel errors for notification failures. var ( // ErrNtfyFailed indicates the ntfy request failed. ErrNtfyFailed = errors.New("ntfy notification failed") // ErrSlackFailed indicates the Slack request failed. ErrSlackFailed = errors.New("slack notification failed") // ErrMattermostFailed indicates the Mattermost request failed. ErrMattermostFailed = errors.New( "mattermost notification failed", ) ) // Params contains dependencies for Service. type Params struct { fx.In Logger *logger.Logger Config *config.Config } // Service provides notification functionality. type Service struct { log *slog.Logger client *http.Client config *config.Config } // New creates a new notify Service. func New( _ fx.Lifecycle, params Params, ) (*Service, error) { return &Service{ log: params.Logger.Get(), client: &http.Client{ Timeout: httpClientTimeout, }, config: params.Config, }, nil } // SendNotification sends a notification to all configured endpoints. func (svc *Service) SendNotification( ctx context.Context, title, message, priority string, ) { if svc.config.NtfyTopic != "" { go func() { notifyCtx := context.WithoutCancel(ctx) err := svc.sendNtfy( notifyCtx, svc.config.NtfyTopic, title, message, priority, ) if err != nil { svc.log.Error( "failed to send ntfy notification", "error", err, ) } }() } if svc.config.SlackWebhook != "" { go func() { notifyCtx := context.WithoutCancel(ctx) err := svc.sendSlack( notifyCtx, svc.config.SlackWebhook, title, message, priority, ) if err != nil { svc.log.Error( "failed to send slack notification", "error", err, ) } }() } if svc.config.MattermostWebhook != "" { go func() { notifyCtx := context.WithoutCancel(ctx) err := svc.sendSlack( notifyCtx, svc.config.MattermostWebhook, title, message, priority, ) if err != nil { svc.log.Error( "failed to send mattermost notification", "error", err, ) } }() } } func (svc *Service) sendNtfy( ctx context.Context, topic, title, message, priority string, ) error { svc.log.Debug( "sending ntfy notification", "topic", topic, "title", title, ) request, err := http.NewRequestWithContext( ctx, http.MethodPost, topic, bytes.NewBufferString(message), ) if err != nil { return fmt.Errorf("creating ntfy request: %w", err) } request.Header.Set("Title", title) request.Header.Set("Priority", ntfyPriority(priority)) resp, err := svc.client.Do(request) if err != nil { return fmt.Errorf("sending ntfy request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= httpStatusClientError { return fmt.Errorf( "%w: status %d", ErrNtfyFailed, resp.StatusCode, ) } return nil } func ntfyPriority(priority string) string { switch priority { case "error": return "urgent" case "warning": return "high" case "success": return "default" case "info": return "low" default: return "default" } } // SlackPayload represents a Slack/Mattermost webhook payload. type SlackPayload struct { Text string `json:"text"` Attachments []SlackAttachment `json:"attachments,omitempty"` } // SlackAttachment represents a Slack/Mattermost attachment. type SlackAttachment struct { Color string `json:"color"` Title string `json:"title"` Text string `json:"text"` } func (svc *Service) sendSlack( ctx context.Context, webhookURL, title, message, priority string, ) error { svc.log.Debug( "sending webhook notification", "url", webhookURL, "title", title, ) payload := SlackPayload{ Attachments: []SlackAttachment{ { Color: slackColor(priority), Title: title, Text: message, }, }, } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling webhook payload: %w", err) } request, err := http.NewRequestWithContext( ctx, http.MethodPost, webhookURL, bytes.NewBuffer(body), ) if err != nil { return fmt.Errorf("creating webhook request: %w", err) } request.Header.Set("Content-Type", "application/json") resp, err := svc.client.Do(request) if err != nil { return fmt.Errorf("sending webhook request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= httpStatusClientError { return fmt.Errorf( "%w: status %d", ErrSlackFailed, resp.StatusCode, ) } return nil } func slackColor(priority string) string { switch priority { case "error": return "#dc3545" case "warning": return "#ffc107" case "success": return "#28a745" case "info": return "#17a2b8" default: return "#6c757d" } }