Skip to content

Commit 3ad4adb

Browse files
FEATURE (notifiers): Add Slack notifier
1 parent ccbc6a8 commit 3ad4adb

25 files changed

+420
-23
lines changed

backend/internal/features/notifiers/enums.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ const (
66
NotifierTypeEmail NotifierType = "EMAIL"
77
NotifierTypeTelegram NotifierType = "TELEGRAM"
88
NotifierTypeWebhook NotifierType = "WEBHOOK"
9+
NotifierTypeSlack NotifierType = "SLACK"
910
)

backend/internal/features/notifiers/model.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"log/slog"
66
"postgresus-backend/internal/features/notifiers/models/email_notifier"
7+
slack_notifier "postgresus-backend/internal/features/notifiers/models/slack"
78
telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram"
89
webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook"
910

@@ -21,6 +22,7 @@ type Notifier struct {
2122
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
2223
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
2324
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
25+
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
2426
}
2527

2628
func (n *Notifier) TableName() string {
@@ -56,6 +58,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender {
5658
return n.EmailNotifier
5759
case NotifierTypeWebhook:
5860
return n.WebhookNotifier
61+
case NotifierTypeSlack:
62+
return n.SlackNotifier
5963
default:
6064
panic("unknown notifier type: " + string(n.NotifierType))
6165
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package slack_notifier
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"log/slog"
10+
"net/http"
11+
"strconv"
12+
"strings"
13+
"time"
14+
15+
"github.com/google/uuid"
16+
)
17+
18+
type SlackNotifier struct {
19+
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
20+
BotToken string `json:"botToken" gorm:"not null;column:bot_token"`
21+
TargetChatID string `json:"targetChatId" gorm:"not null;column:target_chat_id"`
22+
}
23+
24+
func (s *SlackNotifier) TableName() string { return "slack_notifiers" }
25+
26+
func (s *SlackNotifier) Validate() error {
27+
if s.BotToken == "" {
28+
return errors.New("bot token is required")
29+
}
30+
31+
if s.TargetChatID == "" {
32+
return errors.New("target channel ID is required")
33+
}
34+
35+
if !strings.HasPrefix(s.TargetChatID, "C") && !strings.HasPrefix(s.TargetChatID, "G") &&
36+
!strings.HasPrefix(s.TargetChatID, "D") &&
37+
!strings.HasPrefix(s.TargetChatID, "U") {
38+
return errors.New(
39+
"target channel ID must be a valid Slack channel ID (starts with C, G, D) or User ID (starts with U)",
40+
)
41+
}
42+
43+
return nil
44+
}
45+
46+
func (s *SlackNotifier) Send(logger *slog.Logger, heading, message string) error {
47+
full := fmt.Sprintf("*%s*", heading)
48+
49+
if message != "" {
50+
full = fmt.Sprintf("%s\n\n%s", full, message)
51+
}
52+
53+
payload, _ := json.Marshal(map[string]any{
54+
"channel": s.TargetChatID,
55+
"text": full,
56+
"mrkdwn": true,
57+
})
58+
59+
const (
60+
maxAttempts = 5
61+
defaultBackoff = 2 * time.Second // when Retry-After header missing
62+
backoffMultiplier = 1.5 // use exponential growth
63+
)
64+
65+
var (
66+
backoff = defaultBackoff
67+
attempts = 0
68+
)
69+
70+
for {
71+
attempts++
72+
73+
req, err := http.NewRequest(
74+
"POST",
75+
"https://slack.com/api/chat.postMessage",
76+
bytes.NewReader(payload),
77+
)
78+
if err != nil {
79+
return fmt.Errorf("create request: %w", err)
80+
}
81+
82+
req.Header.Set("Content-Type", "application/json; charset=utf-8")
83+
req.Header.Set("Authorization", "Bearer "+s.BotToken)
84+
85+
resp, err := http.DefaultClient.Do(req)
86+
if err != nil {
87+
return fmt.Errorf("send slack message: %w", err)
88+
}
89+
90+
defer func() {
91+
if err := resp.Body.Close(); err != nil {
92+
logger.Warn("Failed to close response body", "error", err)
93+
}
94+
}()
95+
96+
if resp.StatusCode == http.StatusTooManyRequests { // 429
97+
retryAfter := backoff
98+
if h := resp.Header.Get("Retry-After"); h != "" {
99+
if seconds, _ := strconv.Atoi(h); seconds > 0 {
100+
retryAfter = time.Duration(seconds) * time.Second
101+
}
102+
}
103+
104+
if attempts >= maxAttempts {
105+
return fmt.Errorf("rate-limited after %d attempts, giving up", attempts)
106+
}
107+
108+
logger.Warn("Slack rate-limited, retrying", "after", retryAfter, "attempt", attempts)
109+
time.Sleep(retryAfter)
110+
backoff = time.Duration(float64(backoff) * backoffMultiplier)
111+
112+
continue
113+
}
114+
115+
// Slack always returns 200 for logical errors, so decode body
116+
var respBody struct {
117+
OK bool `json:"ok"`
118+
Error string `json:"error,omitempty"`
119+
}
120+
121+
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
122+
raw, _ := io.ReadAll(resp.Body)
123+
return fmt.Errorf("decode response: %v – raw: %s", err, raw)
124+
}
125+
126+
if !respBody.OK {
127+
return fmt.Errorf("slack API error: %s", respBody.Error)
128+
}
129+
130+
logger.Info("Slack message sent", "channel", s.TargetChatID, "attempts", attempts)
131+
132+
return nil
133+
}
134+
}

backend/internal/features/notifiers/repository.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,21 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
2626
if notifier.WebhookNotifier != nil {
2727
notifier.WebhookNotifier.NotifierID = notifier.ID
2828
}
29+
case NotifierTypeSlack:
30+
if notifier.SlackNotifier != nil {
31+
notifier.SlackNotifier.NotifierID = notifier.ID
32+
}
2933
}
3034

3135
if notifier.ID == uuid.Nil {
3236
if err := tx.Create(notifier).
33-
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
37+
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier").
3438
Error; err != nil {
3539
return err
3640
}
3741
} else {
3842
if err := tx.Save(notifier).
39-
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
43+
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier").
4044
Error; err != nil {
4145
return err
4246
}
@@ -64,6 +68,13 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
6468
return err
6569
}
6670
}
71+
case NotifierTypeSlack:
72+
if notifier.SlackNotifier != nil {
73+
notifier.SlackNotifier.NotifierID = notifier.ID // Ensure ID is set
74+
if err := tx.Save(notifier.SlackNotifier).Error; err != nil {
75+
return err
76+
}
77+
}
6778
}
6879

6980
return nil
@@ -78,6 +89,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
7889
Preload("TelegramNotifier").
7990
Preload("EmailNotifier").
8091
Preload("WebhookNotifier").
92+
Preload("SlackNotifier").
8193
Where("id = ?", id).
8294
First(&notifier).Error; err != nil {
8395
return nil, err
@@ -94,6 +106,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
94106
Preload("TelegramNotifier").
95107
Preload("EmailNotifier").
96108
Preload("WebhookNotifier").
109+
Preload("SlackNotifier").
97110
Where("user_id = ?", userID).
98111
Find(&notifiers).Error; err != nil {
99112
return nil, err
@@ -124,6 +137,12 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error {
124137
return err
125138
}
126139
}
140+
case NotifierTypeSlack:
141+
if notifier.SlackNotifier != nil {
142+
if err := tx.Delete(notifier.SlackNotifier).Error; err != nil {
143+
return err
144+
}
145+
}
127146
}
128147

129148
// Delete the main notifier
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
4+
-- Create slack notifiers table
5+
CREATE TABLE slack_notifiers (
6+
notifier_id UUID PRIMARY KEY,
7+
bot_token TEXT NOT NULL,
8+
target_chat_id TEXT NOT NULL
9+
);
10+
11+
ALTER TABLE slack_notifiers
12+
ADD CONSTRAINT fk_slack_notifiers_notifier
13+
FOREIGN KEY (notifier_id)
14+
REFERENCES notifiers (id)
15+
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
16+
17+
-- +goose StatementEnd
18+
19+
-- +goose Down
20+
-- +goose StatementBegin
21+
22+
DROP TABLE IF EXISTS slack_notifiers;
23+
24+
-- +goose StatementEnd
Lines changed: 7 additions & 0 deletions
Loading
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
export { notifierApi } from './api/notifierApi';
22
export type { Notifier } from './models/Notifier';
3-
export type { EmailNotifier } from './models/EmailNotifier';
4-
export type { TelegramNotifier } from './models/TelegramNotifier';
5-
export type { WebhookNotifier } from './models/WebhookNotifier';
6-
export { WebhookMethod } from './models/WebhookMethod';
73
export { NotifierType } from './models/NotifierType';
4+
5+
export type { EmailNotifier } from './models/email/EmailNotifier';
6+
export { validateEmailNotifier } from './models/email/validateEmailNotifier';
7+
8+
export type { TelegramNotifier } from './models/telegram/TelegramNotifier';
9+
export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier';
10+
11+
export type { WebhookNotifier } from './models/webhook/WebhookNotifier';
12+
export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier';
13+
export { WebhookMethod } from './models/webhook/WebhookMethod';
14+
15+
export type { SlackNotifier } from './models/slack/SlackNotifier';
16+
export { validateSlackNotifier } from './models/slack/validateSlackNotifier';

frontend/src/entity/notifiers/models/Notifier.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { EmailNotifier } from './EmailNotifier';
21
import type { NotifierType } from './NotifierType';
3-
import type { TelegramNotifier } from './TelegramNotifier';
4-
import type { WebhookNotifier } from './WebhookNotifier';
2+
import type { SlackNotifier } from './slack/SlackNotifier';
3+
import type { EmailNotifier } from './email/EmailNotifier';
4+
import type { TelegramNotifier } from './telegram/TelegramNotifier';
5+
import type { WebhookNotifier } from './webhook/WebhookNotifier';
56

67
export interface Notifier {
78
id: string;
@@ -13,4 +14,5 @@ export interface Notifier {
1314
telegramNotifier?: TelegramNotifier;
1415
emailNotifier?: EmailNotifier;
1516
webhookNotifier?: WebhookNotifier;
17+
slackNotifier?: SlackNotifier;
1618
}

frontend/src/entity/notifiers/models/NotifierType.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export enum NotifierType {
22
EMAIL = 'EMAIL',
33
TELEGRAM = 'TELEGRAM',
44
WEBHOOK = 'WEBHOOK',
5+
SLACK = 'SLACK',
56
}

frontend/src/entity/notifiers/models/EmailNotifier.ts renamed to frontend/src/entity/notifiers/models/email/EmailNotifier.ts

File renamed without changes.

0 commit comments

Comments
 (0)