// Package notify provides notification services. package notify import ( "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "time" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/models" ) // HTTP client timeout. const ( httpClientTimeout = 10 * time.Second ) // HTTP status code thresholds. const ( httpStatusClientError = 400 ) // Display constants. const ( shortCommitLength = 12 secondsPerMinute = 60 ) // Sentinel errors for notification failures. var ( // ErrNtfyFailed indicates the ntfy notification request failed. ErrNtfyFailed = errors.New("ntfy notification failed") // ErrSlackFailed indicates the Slack notification request failed. ErrSlackFailed = errors.New("slack notification failed") ) // ServiceParams contains dependencies for Service. type ServiceParams struct { fx.In Logger *logger.Logger } // Service provides notification functionality. type Service struct { log *slog.Logger client *http.Client params *ServiceParams } // New creates a new notify Service. func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) { return &Service{ log: params.Logger.Get(), client: &http.Client{ Timeout: httpClientTimeout, }, params: ¶ms, }, nil } // NotifyBuildStart sends a build started notification. func (svc *Service) NotifyBuildStart( ctx context.Context, app *models.App, deployment *models.Deployment, ) { title := "Build started: " + app.Name message := "Building from branch " + app.Branch if deployment.CommitSHA.Valid { shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))] message += " at " + shortSHA } svc.sendNotifications(ctx, app, title, message, "info") } // NotifyBuildSuccess sends a build success notification. func (svc *Service) NotifyBuildSuccess( ctx context.Context, app *models.App, deployment *models.Deployment, ) { duration := time.Since(deployment.StartedAt) title := "Build success: " + app.Name message := "Image built successfully in " + formatDuration(duration) svc.sendNotifications(ctx, app, title, message, "success") } // NotifyBuildFailed sends a build failed notification. func (svc *Service) NotifyBuildFailed( ctx context.Context, app *models.App, deployment *models.Deployment, buildErr error, ) { duration := time.Since(deployment.StartedAt) title := "Build failed: " + app.Name message := "Build failed after " + formatDuration(duration) + ": " + buildErr.Error() svc.sendNotifications(ctx, app, title, message, "error") } // NotifyDeploySuccess sends a deploy success notification. func (svc *Service) NotifyDeploySuccess( ctx context.Context, app *models.App, deployment *models.Deployment, ) { duration := time.Since(deployment.StartedAt) title := "Deploy success: " + app.Name message := "Successfully deployed in " + formatDuration(duration) if deployment.CommitSHA.Valid { shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))] message += " (commit " + shortSHA + ")" } svc.sendNotifications(ctx, app, title, message, "success") } // NotifyDeployFailed sends a deploy failed notification. func (svc *Service) NotifyDeployFailed( ctx context.Context, app *models.App, deployment *models.Deployment, deployErr error, ) { duration := time.Since(deployment.StartedAt) title := "Deploy failed: " + app.Name message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error() svc.sendNotifications(ctx, app, title, message, "error") } // formatDuration formats a duration for display. func formatDuration(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) } minutes := int(d.Minutes()) seconds := int(d.Seconds()) % secondsPerMinute return fmt.Sprintf("%dm %ds", minutes, seconds) } // minInt returns the smaller of two integers. func minInt(a, b int) int { if a < b { return a } return b } func (svc *Service) sendNotifications( ctx context.Context, app *models.App, title, message, priority string, ) { // Send to ntfy if configured if app.NtfyTopic.Valid && app.NtfyTopic.String != "" { ntfyTopic := app.NtfyTopic.String appName := app.Name go func() { // Use context.WithoutCancel to ensure notification completes // even if the parent context is cancelled. notifyCtx := context.WithoutCancel(ctx) ntfyErr := svc.sendNtfy(notifyCtx, ntfyTopic, title, message, priority) if ntfyErr != nil { svc.log.Error( "failed to send ntfy notification", "error", ntfyErr, "app", appName, ) } }() } // Send to Slack if configured if app.SlackWebhook.Valid && app.SlackWebhook.String != "" { slackWebhook := app.SlackWebhook.String appName := app.Name go func() { // Use context.WithoutCancel to ensure notification completes // even if the parent context is cancelled. notifyCtx := context.WithoutCancel(ctx) slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message, priority) if slackErr != nil { svc.log.Error( "failed to send slack notification", "error", slackErr, "app", appName, ) } }() } } 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("failed to create ntfy request: %w", err) } request.Header.Set("Title", title) request.Header.Set("Priority", svc.ntfyPriority(priority)) resp, err := svc.client.Do(request) if err != nil { return fmt.Errorf("failed to send 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 (svc *Service) ntfyPriority(priority string) string { switch priority { case "error": return "urgent" case "success": return "default" case "info": return "low" default: return "default" } } func (svc *Service) slackColor(priority string) string { switch priority { case "error": return "#dc3545" // red case "success": return "#28a745" // green case "info": return "#17a2b8" // blue default: return "#6c757d" // gray } } // SlackPayload represents a Slack webhook payload. type SlackPayload struct { Text string `json:"text"` Attachments []SlackAttachment `json:"attachments,omitempty"` } // SlackAttachment represents a Slack 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 slack notification", "url", webhookURL, "title", title, ) payload := SlackPayload{ Attachments: []SlackAttachment{ { Color: svc.slackColor(priority), Title: title, Text: message, }, }, } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal slack payload: %w", err) } request, err := http.NewRequestWithContext( ctx, http.MethodPost, webhookURL, bytes.NewBuffer(body), ) if err != nil { return fmt.Errorf("failed to create slack request: %w", err) } request.Header.Set("Content-Type", "application/json") resp, err := svc.client.Do(request) if err != nil { return fmt.Errorf("failed to send slack request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= httpStatusClientError { return fmt.Errorf("%w: status %d", ErrSlackFailed, resp.StatusCode) } return nil }