FEATURE (notifiers): Add Slack notifier

This commit is contained in:
Rostislav Dugin
2025-06-24 22:58:34 +03:00
parent ccbc6a8039
commit 3ad4adb355
25 changed files with 420 additions and 23 deletions

View File

@@ -6,4 +6,5 @@ const (
NotifierTypeEmail NotifierType = "EMAIL"
NotifierTypeTelegram NotifierType = "TELEGRAM"
NotifierTypeWebhook NotifierType = "WEBHOOK"
NotifierTypeSlack NotifierType = "SLACK"
)

View File

@@ -4,6 +4,7 @@ import (
"errors"
"log/slog"
"postgresus-backend/internal/features/notifiers/models/email_notifier"
slack_notifier "postgresus-backend/internal/features/notifiers/models/slack"
telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram"
webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook"
@@ -21,6 +22,7 @@ type Notifier struct {
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
}
func (n *Notifier) TableName() string {
@@ -56,6 +58,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender {
return n.EmailNotifier
case NotifierTypeWebhook:
return n.WebhookNotifier
case NotifierTypeSlack:
return n.SlackNotifier
default:
panic("unknown notifier type: " + string(n.NotifierType))
}

View File

@@ -0,0 +1,134 @@
package slack_notifier
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
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() 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(logger *slog.Logger, heading, message string) error {
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
)
var (
backoff = defaultBackoff
attempts = 0
)
for {
attempts++
req, err := http.NewRequest(
"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 "+s.BotToken)
resp, err := http.DefaultClient.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: %v 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
}
}

View File

@@ -26,17 +26,21 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
if notifier.WebhookNotifier != nil {
notifier.WebhookNotifier.NotifierID = notifier.ID
}
case NotifierTypeSlack:
if notifier.SlackNotifier != nil {
notifier.SlackNotifier.NotifierID = notifier.ID
}
}
if notifier.ID == uuid.Nil {
if err := tx.Create(notifier).
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier").
Error; err != nil {
return err
}
} else {
if err := tx.Save(notifier).
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier").
Omit("TelegramNotifier", "EmailNotifier", "WebhookNotifier", "SlackNotifier").
Error; err != nil {
return err
}
@@ -64,6 +68,13 @@ func (r *NotifierRepository) Save(notifier *Notifier) error {
return err
}
}
case NotifierTypeSlack:
if notifier.SlackNotifier != nil {
notifier.SlackNotifier.NotifierID = notifier.ID // Ensure ID is set
if err := tx.Save(notifier.SlackNotifier).Error; err != nil {
return err
}
}
}
return nil
@@ -78,6 +89,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
Preload("TelegramNotifier").
Preload("EmailNotifier").
Preload("WebhookNotifier").
Preload("SlackNotifier").
Where("id = ?", id).
First(&notifier).Error; err != nil {
return nil, err
@@ -94,6 +106,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
Preload("TelegramNotifier").
Preload("EmailNotifier").
Preload("WebhookNotifier").
Preload("SlackNotifier").
Where("user_id = ?", userID).
Find(&notifiers).Error; err != nil {
return nil, err
@@ -124,6 +137,12 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error {
return err
}
}
case NotifierTypeSlack:
if notifier.SlackNotifier != nil {
if err := tx.Delete(notifier.SlackNotifier).Error; err != nil {
return err
}
}
}
// Delete the main notifier

View File

@@ -0,0 +1,24 @@
-- +goose Up
-- +goose StatementBegin
-- Create slack notifiers table
CREATE TABLE slack_notifiers (
notifier_id UUID PRIMARY KEY,
bot_token TEXT NOT NULL,
target_chat_id TEXT NOT NULL
);
ALTER TABLE slack_notifiers
ADD CONSTRAINT fk_slack_notifiers_notifier
FOREIGN KEY (notifier_id)
REFERENCES notifiers (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS slack_notifiers;
-- +goose StatementEnd

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.5002 14.9996C27.8808 14.9996 29 13.8804 29 12.4998C29 11.1192 27.8807 10 26.5001 10C25.1194 10 24 11.1193 24 12.5V14.9996H26.5002ZM19.5 14.9996C20.8807 14.9996 22 13.8803 22 12.4996V5.5C22 4.11929 20.8807 3 19.5 3C18.1193 3 17 4.11929 17 5.5V12.4996C17 13.8803 18.1193 14.9996 19.5 14.9996Z" fill="#2EB67D"/>
<path d="M5.49979 17.0004C4.11919 17.0004 3 18.1196 3 19.5002C3 20.8808 4.1193 22 5.49989 22C6.8806 22 8 20.8807 8 19.5V17.0004H5.49979ZM12.5 17.0004C11.1193 17.0004 10 18.1197 10 19.5004V26.5C10 27.8807 11.1193 29 12.5 29C13.8807 29 15 27.8807 15 26.5V19.5004C15 18.1197 13.8807 17.0004 12.5 17.0004Z" fill="#E01E5A"/>
<path d="M17.0004 26.5002C17.0004 27.8808 18.1196 29 19.5002 29C20.8808 29 22 27.8807 22 26.5001C22 25.1194 20.8807 24 19.5 24L17.0004 24L17.0004 26.5002ZM17.0004 19.5C17.0004 20.8807 18.1197 22 19.5004 22L26.5 22C27.8807 22 29 20.8807 29 19.5C29 18.1193 27.8807 17 26.5 17L19.5004 17C18.1197 17 17.0004 18.1193 17.0004 19.5Z" fill="#ECB22E"/>
<path d="M14.9996 5.49979C14.9996 4.11919 13.8804 3 12.4998 3C11.1192 3 10 4.1193 10 5.49989C10 6.88061 11.1193 8 12.5 8L14.9996 8L14.9996 5.49979ZM14.9996 12.5C14.9996 11.1193 13.8803 10 12.4996 10L5.5 10C4.11929 10 3 11.1193 3 12.5C3 13.8807 4.11929 15 5.5 15L12.4996 15C13.8803 15 14.9996 13.8807 14.9996 12.5Z" fill="#36C5F0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,7 +1,16 @@
export { notifierApi } from './api/notifierApi';
export type { Notifier } from './models/Notifier';
export type { EmailNotifier } from './models/EmailNotifier';
export type { TelegramNotifier } from './models/TelegramNotifier';
export type { WebhookNotifier } from './models/WebhookNotifier';
export { WebhookMethod } from './models/WebhookMethod';
export { NotifierType } from './models/NotifierType';
export type { EmailNotifier } from './models/email/EmailNotifier';
export { validateEmailNotifier } from './models/email/validateEmailNotifier';
export type { TelegramNotifier } from './models/telegram/TelegramNotifier';
export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier';
export type { WebhookNotifier } from './models/webhook/WebhookNotifier';
export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier';
export { WebhookMethod } from './models/webhook/WebhookMethod';
export type { SlackNotifier } from './models/slack/SlackNotifier';
export { validateSlackNotifier } from './models/slack/validateSlackNotifier';

View File

@@ -1,7 +1,8 @@
import type { EmailNotifier } from './EmailNotifier';
import type { NotifierType } from './NotifierType';
import type { TelegramNotifier } from './TelegramNotifier';
import type { WebhookNotifier } from './WebhookNotifier';
import type { SlackNotifier } from './slack/SlackNotifier';
import type { EmailNotifier } from './email/EmailNotifier';
import type { TelegramNotifier } from './telegram/TelegramNotifier';
import type { WebhookNotifier } from './webhook/WebhookNotifier';
export interface Notifier {
id: string;
@@ -13,4 +14,5 @@ export interface Notifier {
telegramNotifier?: TelegramNotifier;
emailNotifier?: EmailNotifier;
webhookNotifier?: WebhookNotifier;
slackNotifier?: SlackNotifier;
}

View File

@@ -2,4 +2,5 @@ export enum NotifierType {
EMAIL = 'EMAIL',
TELEGRAM = 'TELEGRAM',
WEBHOOK = 'WEBHOOK',
SLACK = 'SLACK',
}

View File

@@ -0,0 +1,25 @@
import type { EmailNotifier } from './EmailNotifier';
export const validateEmailNotifier = (notifier: EmailNotifier): boolean => {
if (!notifier.targetEmail) {
return false;
}
if (!notifier.smtpHost) {
return false;
}
if (!notifier.smtpPort) {
return false;
}
if (!notifier.smtpUser) {
return false;
}
if (!notifier.smtpPassword) {
return false;
}
return true;
};

View File

@@ -8,6 +8,8 @@ export const getNotifierLogoFromType = (type: NotifierType) => {
return '/icons/notifiers/telegram.svg';
case NotifierType.WEBHOOK:
return '/icons/notifiers/webhook.svg';
case NotifierType.SLACK:
return '/icons/notifiers/slack.svg';
default:
return '';
}

View File

@@ -8,6 +8,8 @@ export const getNotifierNameFromType = (type: NotifierType) => {
return 'Telegram';
case NotifierType.WEBHOOK:
return 'Webhook';
case NotifierType.SLACK:
return 'Slack';
default:
return '';
}

View File

@@ -0,0 +1,4 @@
export interface SlackNotifier {
botToken: string;
targetChatId: string;
}

View File

@@ -0,0 +1,13 @@
import type { SlackNotifier } from './SlackNotifier';
export const validateSlackNotifier = (notifier: SlackNotifier): boolean => {
if (!notifier.botToken) {
return false;
}
if (!notifier.targetChatId) {
return false;
}
return true;
};

View File

@@ -0,0 +1,13 @@
import type { TelegramNotifier } from './TelegramNotifier';
export const validateTelegramNotifier = (notifier: TelegramNotifier): boolean => {
if (!notifier.botToken) {
return false;
}
if (!notifier.targetChatId) {
return false;
}
return true;
};

View File

@@ -0,0 +1,9 @@
import type { WebhookNotifier } from './WebhookNotifier';
export const validateWebhookNotifier = (notifier: WebhookNotifier): boolean => {
if (!notifier.webhookUrl) {
return false;
}
return true;
};

View File

@@ -6,10 +6,15 @@ import {
NotifierType,
WebhookMethod,
notifierApi,
validateEmailNotifier,
validateSlackNotifier,
validateTelegramNotifier,
validateWebhookNotifier,
} from '../../../../entity/notifiers';
import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType';
import { ToastHelper } from '../../../../shared/toast';
import { EditEmailNotifierComponent } from './notifiers/EditEmailNotifierComponent';
import { EditSlackNotifierComponent } from './notifiers/EditSlackNotifierComponent';
import { EditTelegramNotifierComponent } from './notifiers/EditTelegramNotifierComponent';
import { EditWebhookNotifierComponent } from './notifiers/EditWebhookNotifierComponent';
@@ -67,6 +72,9 @@ export function EditNotifierComponent({
});
} catch (e) {
alert((e as Error).message);
alert(
'Make sure channel is public or bot is added to the private channel (via @invite) or group. For direct messages use User ID from Slack profile.',
);
}
setIsSendingTestNotification(false);
@@ -102,6 +110,13 @@ export function EditNotifierComponent({
};
}
if (type === NotifierType.SLACK) {
notifier.slackNotifier = {
botToken: '',
targetChatId: '',
};
}
setNotifier(
JSON.parse(
JSON.stringify({
@@ -129,27 +144,28 @@ export function EditNotifierComponent({
);
}, [editingNotifier]);
useEffect(() => {
setIsTestNotificationSuccess(false);
}, [notifier]);
const isAllDataFilled = () => {
if (!notifier) return false;
if (!notifier.name) return false;
if (notifier.notifierType === NotifierType.TELEGRAM) {
return notifier.telegramNotifier?.botToken && notifier.telegramNotifier?.targetChatId;
if (notifier.notifierType === NotifierType.TELEGRAM && notifier.telegramNotifier) {
return validateTelegramNotifier(notifier.telegramNotifier);
}
if (notifier.notifierType === NotifierType.EMAIL) {
return (
notifier.emailNotifier?.targetEmail &&
notifier.emailNotifier?.smtpHost &&
notifier.emailNotifier?.smtpPort &&
notifier.emailNotifier?.smtpUser &&
notifier.emailNotifier?.smtpPassword
);
if (notifier.notifierType === NotifierType.EMAIL && notifier.emailNotifier) {
return validateEmailNotifier(notifier.emailNotifier);
}
if (notifier.notifierType === NotifierType.WEBHOOK) {
return notifier.webhookNotifier?.webhookUrl;
if (notifier.notifierType === NotifierType.WEBHOOK && notifier.webhookNotifier) {
return validateWebhookNotifier(notifier.webhookNotifier);
}
if (notifier.notifierType === NotifierType.SLACK && notifier.slackNotifier) {
return validateSlackNotifier(notifier.slackNotifier);
}
return false;
@@ -185,6 +201,7 @@ export function EditNotifierComponent({
{ label: 'Telegram', value: NotifierType.TELEGRAM },
{ label: 'Email', value: NotifierType.EMAIL },
{ label: 'Webhook', value: NotifierType.WEBHOOK },
{ label: 'Slack', value: NotifierType.SLACK },
]}
onChange={(value) => {
setNotifierType(value);
@@ -223,6 +240,14 @@ export function EditNotifierComponent({
setIsUnsaved={setIsUnsaved}
/>
)}
{notifier?.notifierType === NotifierType.SLACK && (
<EditSlackNotifierComponent
notifier={notifier}
setNotifier={setNotifier}
setIsUnsaved={setIsUnsaved}
/>
)}
</div>
<div className="mt-3 flex">

View File

@@ -0,0 +1,76 @@
import { Input } from 'antd';
import type { Notifier } from '../../../../../entity/notifiers';
interface Props {
notifier: Notifier;
setNotifier: (notifier: Notifier) => void;
setIsUnsaved: (isUnsaved: boolean) => void;
}
export function EditSlackNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) {
return (
<>
<div className="mb-1 ml-[110px] max-w-[200px]" style={{ lineHeight: 1 }}>
<a
className="text-xs !text-blue-600"
href="https://postgresus.com/notifier-slack"
target="_blank"
rel="noreferrer"
>
How to connect Slack (how to get bot token and chat ID)?
</a>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Bot token</div>
<div className="w-[250px]">
<Input
value={notifier?.slackNotifier?.botToken || ''}
onChange={(e) => {
if (!notifier?.slackNotifier) return;
setNotifier({
...notifier,
slackNotifier: {
...notifier.slackNotifier,
botToken: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full"
placeholder="xoxb-..."
/>
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Target chat ID</div>
<div className="w-[250px]">
<Input
value={notifier?.slackNotifier?.targetChatId || ''}
onChange={(e) => {
if (!notifier?.slackNotifier) return;
setNotifier({
...notifier,
slackNotifier: {
...notifier.slackNotifier,
targetChatId: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full"
placeholder="C1234567890"
/>
</div>
</div>
</>
);
}

View File

@@ -2,7 +2,7 @@ import { InfoCircleOutlined } from '@ant-design/icons';
import { Input, Select, Tooltip } from 'antd';
import type { Notifier } from '../../../../../entity/notifiers';
import { WebhookMethod } from '../../../../../entity/notifiers/models/WebhookMethod';
import { WebhookMethod } from '../../../../../entity/notifiers/models/webhook/WebhookMethod';
interface Props {
notifier: Notifier;

View File

@@ -2,6 +2,7 @@ import { type Notifier, NotifierType } from '../../../../entity/notifiers';
import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType';
import { getNotifierNameFromType } from '../../../../entity/notifiers/models/getNotifierNameFromType';
import { ShowEmailNotifierComponent } from './notifier/ShowEmailNotifierComponent';
import { ShowSlackNotifierComponent } from './notifier/ShowSlackNotifierComponent';
import { ShowTelegramNotifierComponent } from './notifier/ShowTelegramNotifierComponent';
import { ShowWebhookNotifierComponent } from './notifier/ShowWebhookNotifierComponent';
@@ -31,6 +32,10 @@ export function ShowNotifierComponent({ notifier }: Props) {
{notifier?.notifierType === NotifierType.WEBHOOK && (
<ShowWebhookNotifierComponent notifier={notifier} />
)}
{notifier?.notifierType === NotifierType.SLACK && (
<ShowSlackNotifierComponent notifier={notifier} />
)}
</div>
</div>
);

View File

@@ -0,0 +1,22 @@
import type { Notifier } from '../../../../../entity/notifiers';
interface Props {
notifier: Notifier;
}
export function ShowSlackNotifierComponent({ notifier }: Props) {
return (
<>
<div className="flex items-center">
<div className="min-w-[110px]">Bot token</div>
<div className="w-[250px]">*********</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Target chat ID</div>
{notifier?.slackNotifier?.targetChatId}
</div>
</>
);
}