Files
databasus/backend/internal/features/notifiers/models/slack/model.go
2026-03-13 18:50:57 +03:00

176 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.NotifierID, 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.NotifierID, s.BotToken)
if err != nil {
return fmt.Errorf("failed to encrypt bot token: %w", err)
}
s.BotToken = encrypted
}
return nil
}