package slack_notifier import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "time" "github.com/google/uuid" "databasus-backend/internal/util/encryption" ) type SlackNotifier struct { NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"` BotToken string `json:"botToken" gorm:"not null;column:bot_token"` TargetChatID string `json:"targetChatId" gorm:"not null;column:target_chat_id"` } func (s *SlackNotifier) TableName() string { return "slack_notifiers" } func (s *SlackNotifier) Validate(encryptor encryption.FieldEncryptor) error { if s.BotToken == "" { return errors.New("bot token is required") } if s.TargetChatID == "" { return errors.New("target channel ID is required") } if !strings.HasPrefix(s.TargetChatID, "C") && !strings.HasPrefix(s.TargetChatID, "G") && !strings.HasPrefix(s.TargetChatID, "D") && !strings.HasPrefix(s.TargetChatID, "U") { return errors.New( "target channel ID must be a valid Slack channel ID (starts with C, G, D) or User ID (starts with U)", ) } return nil } func (s *SlackNotifier) Send( encryptor encryption.FieldEncryptor, logger *slog.Logger, heading, message string, ) error { botToken, err := encryptor.Decrypt(s.BotToken) if err != nil { return fmt.Errorf("failed to decrypt bot token: %w", err) } full := fmt.Sprintf("*%s*", heading) if message != "" { full = fmt.Sprintf("%s\n\n%s", full, message) } payload, _ := json.Marshal(map[string]any{ "channel": s.TargetChatID, "text": full, "mrkdwn": true, }) const ( maxAttempts = 5 defaultBackoff = 2 * time.Second // when Retry-After header missing backoffMultiplier = 1.5 // use exponential growth requestTimeout = 30 * time.Second ) var ( backoff = defaultBackoff attempts = 0 ) client := &http.Client{ Timeout: requestTimeout, } for { attempts++ req, err := http.NewRequestWithContext( context.Background(), "POST", "https://slack.com/api/chat.postMessage", bytes.NewReader(payload), ) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Authorization", "Bearer "+botToken) resp, err := client.Do(req) if err != nil { return fmt.Errorf("send slack message: %w", err) } defer func() { if err := resp.Body.Close(); err != nil { logger.Warn("Failed to close response body", "error", err) } }() if resp.StatusCode == http.StatusTooManyRequests { // 429 retryAfter := backoff if h := resp.Header.Get("Retry-After"); h != "" { if seconds, _ := strconv.Atoi(h); seconds > 0 { retryAfter = time.Duration(seconds) * time.Second } } if attempts >= maxAttempts { return fmt.Errorf("rate-limited after %d attempts, giving up", attempts) } logger.Warn("Slack rate-limited, retrying", "after", retryAfter, "attempt", attempts) time.Sleep(retryAfter) backoff = time.Duration(float64(backoff) * backoffMultiplier) continue } // Slack always returns 200 for logical errors, so decode body var respBody struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` } if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { raw, _ := io.ReadAll(resp.Body) return fmt.Errorf("decode response: %w – raw: %s", err, raw) } if !respBody.OK { return fmt.Errorf("slack API error: %s", respBody.Error) } logger.Info("Slack message sent", "channel", s.TargetChatID, "attempts", attempts) return nil } } func (s *SlackNotifier) HideSensitiveData() { s.BotToken = "" } func (s *SlackNotifier) Update(incoming *SlackNotifier) { s.TargetChatID = incoming.TargetChatID if incoming.BotToken != "" { s.BotToken = incoming.BotToken } } func (s *SlackNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error { if s.BotToken != "" { encrypted, err := encryptor.Encrypt(s.BotToken) if err != nil { return fmt.Errorf("failed to encrypt bot token: %w", err) } s.BotToken = encrypted } return nil }