Building out notification update channels
Some checks are pending
check / check (push) Waiting to run
Some checks are pending
check / check (push) Waiting to run
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ Prompts/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
.claude
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ POLL_INTERVAL_MS=60000
|
|||||||
|
|
||||||
# Firebase
|
# Firebase
|
||||||
FIREBASE_PROJECT_ID=koin-ping
|
FIREBASE_PROJECT_ID=koin-ping
|
||||||
|
|
||||||
|
# Email notifications (Resend — https://resend.com)
|
||||||
|
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||||
|
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -48,10 +49,15 @@ func main() {
|
|||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
|
|
||||||
|
emailDigestSvc := services.NewEmailDigestService(
|
||||||
|
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
||||||
|
)
|
||||||
|
|
||||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
addressHandler := handlers.NewAddressHandler(addressModel)
|
||||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
||||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
||||||
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
b := cfg.APIBasePath // e.g. "/v1"
|
b := cfg.APIBasePath // e.g. "/v1"
|
||||||
@@ -89,6 +95,14 @@ func main() {
|
|||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||||
mux.Handle("DELETE "+b+"/notification-config",
|
mux.Handle("DELETE "+b+"/notification-config",
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||||
|
mux.Handle("POST "+b+"/notification-config/test",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||||
|
|
||||||
|
// Authenticated routes — email digest
|
||||||
|
mux.Handle("POST "+b+"/email/setup",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||||
|
mux.Handle("POST "+b+"/email/digest",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||||
|
|
||||||
handler := corsMiddleware(mux)
|
handler := corsMiddleware(mux)
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ func main() {
|
|||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
|
|
||||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||||
evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel)
|
evaluator := services.NewEvaluatorService(
|
||||||
|
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
||||||
|
cfg.ResendAPIKey, cfg.EmailFrom,
|
||||||
|
)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
2
backend-go/infra/migrations/003_add_slack_webhook.sql
Normal file
2
backend-go/infra/migrations/003_add_slack_webhook.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE user_notification_configs
|
||||||
|
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;
|
||||||
@@ -54,8 +54,9 @@ CREATE TABLE user_notification_configs (
|
|||||||
user_id VARCHAR(128) PRIMARY KEY,
|
user_id VARCHAR(128) PRIMARY KEY,
|
||||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||||
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
||||||
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use)
|
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
||||||
email VARCHAR(255), -- Email for notifications (nullable)
|
email VARCHAR(255), -- Email for notifications (nullable)
|
||||||
|
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
|
||||||
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type Config struct {
|
|||||||
EthRPCURL string
|
EthRPCURL string
|
||||||
PollIntervalMS int
|
PollIntervalMS int
|
||||||
NodeEnv string
|
NodeEnv string
|
||||||
|
ResendAPIKey string
|
||||||
|
EmailFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and returns a Config.
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
@@ -45,6 +47,8 @@ func Load() (*Config, error) {
|
|||||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||||
|
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||||
|
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ type NotificationConfig struct {
|
|||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||||
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
|
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
|
||||||
TelegramBotToken *string `json:"telegram_bot_token,omitempty"` //nolint:tagliatelle
|
TelegramBotToken *string `json:"telegram_bot_token"` //nolint:tagliatelle
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
|
SlackWebhookURL *string `json:"slack_webhook_url"` //nolint:tagliatelle
|
||||||
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
|
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
|
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
|
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
|
||||||
|
|||||||
101
backend-go/internal/handlers/email_digest.go
Normal file
101
backend-go/internal/handlers/email_digest.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailDigestHandler struct {
|
||||||
|
digestSvc *services.EmailDigestService
|
||||||
|
configs *models.NotificationConfigModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailDigestHandler(
|
||||||
|
digestSvc *services.EmailDigestService,
|
||||||
|
configs *models.NotificationConfigModel,
|
||||||
|
) *EmailDigestHandler {
|
||||||
|
return &EmailDigestHandler{digestSvc: digestSvc, configs: configs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmail reads the user's email from their notification config and sends
|
||||||
|
// a confirmation message via Resend to verify the integration works.
|
||||||
|
func (h *EmailDigestHandler) SetupEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if !h.digestSvc.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||||
|
"Email service is not configured on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for email setup: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||||
|
"Failed to load notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"Save an email address in notification settings first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.digestSvc.SetupEmail(*cfg.Email); err != nil {
|
||||||
|
log.Printf("Email setup failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusBadGateway, "EMAIL_SEND_FAILED",
|
||||||
|
"Failed to send confirmation email — check server email config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Email setup confirmation sent to user %s (%s)", userID, *cfg.Email)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"email": *cfg.Email,
|
||||||
|
"message": "Confirmation email sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigest compiles and sends a digest of recent alerts to the user's email.
|
||||||
|
func (h *EmailDigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if !h.digestSvc.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||||
|
"Email service is not configured on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for digest: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||||
|
"Failed to load notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"No email address configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.digestSvc.SendDigest(r.Context(), userID, *cfg.Email); err != nil {
|
||||||
|
log.Printf("Digest send failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusBadGateway, "DIGEST_SEND_FAILED",
|
||||||
|
"Failed to send digest email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Digest sent to user %s (%s)", userID, *cfg.Email)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"email": *cfg.Email,
|
||||||
|
"message": "Digest email sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,19 +7,22 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
|
|
||||||
type NotificationConfigHandler struct {
|
type NotificationConfigHandler struct {
|
||||||
configs *models.NotificationConfigModel
|
configs *models.NotificationConfigModel
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler {
|
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
|
||||||
return &NotificationConfigHandler{configs: configs}
|
return &NotificationConfigHandler{configs: configs, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -51,7 +54,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
var body struct {
|
var body struct {
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
||||||
TelegramChatID *string `json:"telegram_chat_id"`
|
TelegramChatID *string `json:"telegram_chat_id"`
|
||||||
|
TelegramBotToken *string `json:"telegram_bot_token"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
|
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||||
NotificationEnabled *bool `json:"notification_enabled"`
|
NotificationEnabled *bool `json:"notification_enabled"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
@@ -63,7 +68,8 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
log.Printf("User %s updating notification config", userID)
|
log.Printf("User %s updating notification config", userID)
|
||||||
|
|
||||||
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
|
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
|
||||||
body.Email == nil && body.NotificationEnabled == nil {
|
body.TelegramBotToken == nil && body.Email == nil &&
|
||||||
|
body.SlackWebhookURL == nil && body.NotificationEnabled == nil {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"At least one configuration field must be provided")
|
"At least one configuration field must be provided")
|
||||||
return
|
return
|
||||||
@@ -76,6 +82,13 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if body.SlackWebhookURL != nil && *body.SlackWebhookURL != "" &&
|
||||||
|
!strings.HasPrefix(*body.SlackWebhookURL, "https://hooks.slack.com/") {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"Invalid Slack webhook URL format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
|
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"Invalid email address format")
|
"Invalid email address format")
|
||||||
@@ -90,7 +103,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
cfg := domain.NotificationConfig{
|
cfg := domain.NotificationConfig{
|
||||||
DiscordWebhookURL: body.DiscordWebhookURL,
|
DiscordWebhookURL: body.DiscordWebhookURL,
|
||||||
TelegramChatID: body.TelegramChatID,
|
TelegramChatID: body.TelegramChatID,
|
||||||
|
TelegramBotToken: body.TelegramBotToken,
|
||||||
Email: body.Email,
|
Email: body.Email,
|
||||||
|
SlackWebhookURL: body.SlackWebhookURL,
|
||||||
NotificationEnabled: enabled,
|
NotificationEnabled: enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,3 +141,76 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
|
|||||||
log.Println("Notification config deleted")
|
log.Println("Notification config deleted")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestChannels sends a test message to all configured notification channels.
|
||||||
|
func (h *NotificationConfigHandler) TestChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
log.Printf("User %s testing notification channels", userID)
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for test: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "No notification configuration found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelResult struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []channelResult
|
||||||
|
|
||||||
|
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
|
ok, testErr := notifications.TestDiscordWebhook(*cfg.DiscordWebhookURL)
|
||||||
|
res := channelResult{Channel: "discord", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
|
ok, testErr := notifications.TestTelegramWebhook(*cfg.TelegramBotToken, *cfg.TelegramChatID)
|
||||||
|
res := channelResult{Channel: "telegram", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||||
|
ok, testErr := notifications.TestSlackWebhook(*cfg.SlackWebhookURL)
|
||||||
|
res := channelResult{Channel: "slack", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Email != nil && *cfg.Email != "" {
|
||||||
|
ok, testErr := notifications.TestEmailNotification(
|
||||||
|
h.cfg.ResendAPIKey, h.cfg.EmailFrom, *cfg.Email,
|
||||||
|
)
|
||||||
|
res := channelResult{Channel: "email", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "NO_CHANNELS",
|
||||||
|
"No notification channels are configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string)
|
|||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
email, notification_enabled, created_at, updated_at
|
email, slack_webhook_url, notification_enabled, created_at, updated_at
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs
|
||||||
WHERE user_id = $1`,
|
WHERE user_id = $1`,
|
||||||
userID,
|
userID,
|
||||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -40,22 +40,24 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
|
|||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO user_notification_configs
|
`INSERT INTO user_notification_configs
|
||||||
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, email, notification_enabled, updated_at)
|
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
email, slack_webhook_url, notification_enabled, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
ON CONFLICT (user_id)
|
ON CONFLICT (user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
||||||
telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id),
|
telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id),
|
||||||
telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token),
|
telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token),
|
||||||
email = COALESCE($5, user_notification_configs.email),
|
email = COALESCE($5, user_notification_configs.email),
|
||||||
notification_enabled = $6,
|
slack_webhook_url = COALESCE($6, user_notification_configs.slack_webhook_url),
|
||||||
|
notification_enabled = $7,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
email, notification_enabled, created_at, updated_at`,
|
email, slack_webhook_url, notification_enabled, created_at, updated_at`,
|
||||||
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
|
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
|
||||||
cfg.Email, cfg.NotificationEnabled,
|
cfg.Email, cfg.SlackWebhookURL, cfg.NotificationEnabled,
|
||||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -75,7 +77,8 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
|||||||
|
|
||||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, email
|
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
|
email, slack_webhook_url
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs
|
||||||
WHERE notification_enabled = TRUE`,
|
WHERE notification_enabled = TRUE`,
|
||||||
)
|
)
|
||||||
@@ -87,7 +90,8 @@ func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.Not
|
|||||||
var configs []domain.NotificationConfig
|
var configs []domain.NotificationConfig
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.Email); err != nil {
|
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID,
|
||||||
|
&c.TelegramBotToken, &c.Email, &c.SlackWebhookURL); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.NotificationEnabled = true
|
c.NotificationEnabled = true
|
||||||
|
|||||||
142
backend-go/internal/notifications/email.go
Normal file
142
backend-go/internal/notifications/email.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const emailHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: emailHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type resendPayload struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta AlertMetadata) (bool, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Printf("Skipping email notification: RESEND_API_KEY not configured")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("Koin Ping Alert: %s", alertTypeLabel(meta.AlertType))
|
||||||
|
|
||||||
|
txLink := ""
|
||||||
|
if meta.TxHash != "" {
|
||||||
|
txLink = fmt.Sprintf(
|
||||||
|
`<p><a href="https://etherscan.io/tx/%s">View on Etherscan</a></p>`,
|
||||||
|
meta.TxHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Koin Ping Alert</h2>
|
||||||
|
<p style="font-size: 16px;">%s</p>
|
||||||
|
<table style="margin: 16px 0; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0; color: #666;">Address</td>
|
||||||
|
<td style="padding: 4px 0;">%s</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0; color: #666;">Blockchain</td>
|
||||||
|
<td style="padding: 4px 0; font-family: monospace; font-size: 13px;">%s</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
%s
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||||
|
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||||
|
</div>`,
|
||||||
|
message, meta.AddressLabel, meta.Address, txLink)
|
||||||
|
|
||||||
|
payload := resendPayload{
|
||||||
|
From: fromAddress,
|
||||||
|
To: toAddress,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: html,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("marshal email payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("create email request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := emailHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send email notification: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailNotification(apiKey, fromAddress, toAddress string) (bool, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return false, fmt.Errorf("email not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := resendPayload{
|
||||||
|
From: fromAddress,
|
||||||
|
To: toAddress,
|
||||||
|
Subject: "Koin Ping — Test Notification",
|
||||||
|
HTML: `<p>Your email alerts are configured correctly!</p><p style="font-size:12px;color:#999;">Sent by Koin Ping</p>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := emailHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Email test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertTypeLabel(alertType string) string {
|
||||||
|
switch alertType {
|
||||||
|
case "incoming_tx":
|
||||||
|
return "Incoming Transaction"
|
||||||
|
case "outgoing_tx":
|
||||||
|
return "Outgoing Transaction"
|
||||||
|
case "large_transfer":
|
||||||
|
return "Large Transfer"
|
||||||
|
case "balance_below":
|
||||||
|
return "Balance Below Threshold"
|
||||||
|
default:
|
||||||
|
return "Alert"
|
||||||
|
}
|
||||||
|
}
|
||||||
116
backend-go/internal/notifications/slack.go
Normal file
116
backend-go/internal/notifications/slack.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const slackHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: slackHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackAttachment struct {
|
||||||
|
Color string `json:"color"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Fields []slackField `json:"fields"`
|
||||||
|
Footer string `json:"footer"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackField struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackPayload struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Attachments []slackAttachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
|
||||||
|
fields := []slackField{
|
||||||
|
{Title: "Address", Value: meta.AddressLabel, Short: true},
|
||||||
|
{Title: "Blockchain Address", Value: fmt.Sprintf("`%s`", meta.Address), Short: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.TxHash != "" {
|
||||||
|
fields = append(fields, slackField{
|
||||||
|
Title: "Transaction",
|
||||||
|
Value: fmt.Sprintf("<https://etherscan.io/tx/%s|View on Etherscan>", meta.TxHash),
|
||||||
|
Short: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := slackPayload{
|
||||||
|
Attachments: []slackAttachment{
|
||||||
|
{
|
||||||
|
Color: slackColorForAlertType(meta.AlertType),
|
||||||
|
Title: "Koin Ping Alert",
|
||||||
|
Text: message,
|
||||||
|
Fields: fields,
|
||||||
|
Footer: "Koin Ping",
|
||||||
|
Ts: time.Now().Unix(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("marshal slack payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send Slack notification: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlackWebhook(webhookURL string) (bool, error) {
|
||||||
|
payload := slackPayload{
|
||||||
|
Text: "Koin Ping test notification — Your Slack alerts are configured correctly!",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Slack webhook test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func slackColorForAlertType(alertType string) string {
|
||||||
|
switch alertType {
|
||||||
|
case "incoming_tx":
|
||||||
|
return "#00ff00"
|
||||||
|
case "outgoing_tx":
|
||||||
|
return "#ff9900"
|
||||||
|
case "large_transfer", "balance_below":
|
||||||
|
return "#ff0000"
|
||||||
|
default:
|
||||||
|
return "#0099ff"
|
||||||
|
}
|
||||||
|
}
|
||||||
109
backend-go/internal/notifications/telegram.go
Normal file
109
backend-go/internal/notifications/telegram.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const telegramHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: telegramHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramPayload struct {
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ParseMode string `json:"parse_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendTelegramNotification(botToken, chatID, message string, meta AlertMetadata) (bool, error) {
|
||||||
|
text := fmt.Sprintf("*Koin Ping Alert*\n\n%s\n\n*Address:* %s\n`%s`",
|
||||||
|
escapeMarkdown(message), escapeMarkdown(meta.AddressLabel), meta.Address)
|
||||||
|
|
||||||
|
if meta.TxHash != "" {
|
||||||
|
text += fmt.Sprintf("\n\n[View on Etherscan](https://etherscan.io/tx/%s)", meta.TxHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := telegramPayload{
|
||||||
|
ChatID: chatID,
|
||||||
|
Text: text,
|
||||||
|
ParseMode: "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("marshal telegram payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||||
|
|
||||||
|
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send Telegram notification: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramWebhook(botToken, chatID string) (bool, error) {
|
||||||
|
payload := telegramPayload{
|
||||||
|
ChatID: chatID,
|
||||||
|
Text: "Koin Ping test notification — Your Telegram alerts are configured correctly!",
|
||||||
|
ParseMode: "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||||
|
|
||||||
|
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Telegram test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMarkdown(s string) string {
|
||||||
|
replacer := []struct{ old, new string }{
|
||||||
|
{"_", "\\_"}, {"*", "\\*"}, {"[", "\\["}, {"]", "\\]"},
|
||||||
|
{"(", "\\("}, {")", "\\)"}, {"~", "\\~"}, {"`", "\\`"},
|
||||||
|
{">", "\\>"}, {"#", "\\#"}, {"+", "\\+"}, {"-", "\\-"},
|
||||||
|
{"=", "\\="}, {"|", "\\|"}, {"{", "\\{"}, {"}", "\\}"},
|
||||||
|
{".", "\\."}, {"!", "\\!"},
|
||||||
|
}
|
||||||
|
result := s
|
||||||
|
for _, r := range replacer {
|
||||||
|
result = replaceAll(result, r.old, r.new)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceAll(s, old, new string) string {
|
||||||
|
out := ""
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if string(s[i]) == old {
|
||||||
|
out += new
|
||||||
|
} else {
|
||||||
|
out += string(s[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
199
backend-go/internal/services/email_digest.go
Normal file
199
backend-go/internal/services/email_digest.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
resendAPIURL = "https://api.resend.com/emails"
|
||||||
|
emailHTTPTimeout = 10 * time.Second
|
||||||
|
defaultDigestMaxItems = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// EmailDigestService handles email setup and digest sending via Resend.
|
||||||
|
type EmailDigestService struct {
|
||||||
|
apiKey string
|
||||||
|
fromAddress string
|
||||||
|
alertEvents *models.AlertEventModel
|
||||||
|
notifCfgs *models.NotificationConfigModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailDigestService(
|
||||||
|
apiKey, fromAddress string,
|
||||||
|
alertEvents *models.AlertEventModel,
|
||||||
|
notifCfgs *models.NotificationConfigModel,
|
||||||
|
) *EmailDigestService {
|
||||||
|
return &EmailDigestService{
|
||||||
|
apiKey: apiKey,
|
||||||
|
fromAddress: fromAddress,
|
||||||
|
alertEvents: alertEvents,
|
||||||
|
notifCfgs: notifCfgs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured returns true when the Resend API key is present.
|
||||||
|
func (s *EmailDigestService) Configured() bool {
|
||||||
|
return s.apiKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmail validates the email works by sending a welcome/confirmation
|
||||||
|
// message via Resend. Called when a user saves their email in notification settings.
|
||||||
|
func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
html := `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Welcome to Koin Ping Email Alerts</h2>
|
||||||
|
<p>Your email has been successfully configured for alert notifications.</p>
|
||||||
|
<p>You will receive alert digests at this address when events are triggered
|
||||||
|
on your watched addresses.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||||
|
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigest compiles recent alert events for a user and sends a digest email.
|
||||||
|
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := s.alertEvents.ListRecentByUser(ctx, userID, defaultDigestMaxItems)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch alert events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) == 0 {
|
||||||
|
log.Printf("No recent alerts for user %s — skipping digest", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows string
|
||||||
|
for _, e := range events {
|
||||||
|
label := "—"
|
||||||
|
if e.AddressLabel != nil {
|
||||||
|
label = *e.AddressLabel
|
||||||
|
}
|
||||||
|
txLink := "—"
|
||||||
|
if e.TxHash != nil {
|
||||||
|
txLink = fmt.Sprintf(
|
||||||
|
`<a href="https://etherscan.io/tx/%s" style="color:#0066cc;">%s…</a>`,
|
||||||
|
*e.TxHash, (*e.TxHash)[:10],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows += fmt.Sprintf(`
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee; font-size:12px; color:#666;">%s</td>
|
||||||
|
</tr>`,
|
||||||
|
label, e.Message, txLink,
|
||||||
|
e.Timestamp.Format("Jan 2 15:04 UTC"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<div style="font-family: sans-serif; max-width: 700px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Koin Ping — Alert Digest</h2>
|
||||||
|
<p>Here are your recent alerts (%d total):</p>
|
||||||
|
<table style="width:100%%; border-collapse:collapse; font-size:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f5f5f5;">
|
||||||
|
<th style="padding:8px; text-align:left;">Address</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Alert</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Tx</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>%s</tbody>
|
||||||
|
</table>
|
||||||
|
<hr style="border:none; border-top:1px solid #eee; margin:24px 0;" />
|
||||||
|
<p style="font-size:12px; color:#999;">Sent by Koin Ping</p>
|
||||||
|
</div>`, len(events), rows)
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("Koin Ping Digest — %d alerts", len(events))
|
||||||
|
|
||||||
|
return s.send(toAddress, subject, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigestsForAllUsers sends a digest email to every user that has
|
||||||
|
// notifications enabled and an email configured.
|
||||||
|
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||||
|
if !s.Configured() {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := s.notifCfgs.ListEnabled(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list enabled configs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := 0
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.SendDigest(ctx, cfg.UserID, *cfg.Email); err != nil {
|
||||||
|
log.Printf("Failed to send digest to user %s: %v", cfg.UserID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type resendEmailPayload struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailDigestService) send(to, subject, html string) error {
|
||||||
|
payload := resendEmailPayload{
|
||||||
|
From: s.fromAddress,
|
||||||
|
To: to,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: html,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal email payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, resendAPIURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||||||
|
|
||||||
|
resp, err := digestHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send email via Resend: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("Resend API returned HTTP %d", resp.StatusCode) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ type EvaluatorService struct {
|
|||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
|
resendAPIKey string
|
||||||
|
emailFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvaluatorService(
|
func NewEvaluatorService(
|
||||||
@@ -26,6 +28,8 @@ func NewEvaluatorService(
|
|||||||
alertEvents *models.AlertEventModel,
|
alertEvents *models.AlertEventModel,
|
||||||
addresses *models.AddressModel,
|
addresses *models.AddressModel,
|
||||||
notifConfigs *models.NotificationConfigModel,
|
notifConfigs *models.NotificationConfigModel,
|
||||||
|
resendAPIKey string,
|
||||||
|
emailFrom string,
|
||||||
) *EvaluatorService {
|
) *EvaluatorService {
|
||||||
return &EvaluatorService{
|
return &EvaluatorService{
|
||||||
eth: eth,
|
eth: eth,
|
||||||
@@ -33,6 +37,8 @@ func NewEvaluatorService(
|
|||||||
alertEvents: alertEvents,
|
alertEvents: alertEvents,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
|
resendAPIKey: resendAPIKey,
|
||||||
|
emailFrom: emailFrom,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,28 +193,59 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil {
|
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sent, err := notifications.SendDiscordNotification(
|
meta := notifications.AlertMetadata{
|
||||||
*notifConfig.DiscordWebhookURL,
|
|
||||||
message,
|
|
||||||
notifications.AlertMetadata{
|
|
||||||
TxHash: obs.Hash,
|
TxHash: obs.Hash,
|
||||||
AddressLabel: addressLabel,
|
AddressLabel: addressLabel,
|
||||||
AlertType: string(rule.Type),
|
AlertType: string(rule.Type),
|
||||||
Address: address,
|
Address: address,
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil || !sent {
|
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
||||||
log.Printf("Discord notification failed for user %s: %v", userID, err)
|
sent, sendErr := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
||||||
|
if sendErr != nil || !sent {
|
||||||
|
log.Printf("Discord notification failed for user %s: %v", userID, sendErr)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Discord notification sent to user %s", userID)
|
log.Printf("Discord notification sent to user %s", userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if notifConfig.TelegramBotToken != nil && *notifConfig.TelegramBotToken != "" &&
|
||||||
|
notifConfig.TelegramChatID != nil && *notifConfig.TelegramChatID != "" {
|
||||||
|
sent, sendErr := notifications.SendTelegramNotification(
|
||||||
|
*notifConfig.TelegramBotToken, *notifConfig.TelegramChatID, message, meta,
|
||||||
|
)
|
||||||
|
if sendErr != nil || !sent {
|
||||||
|
log.Printf("Telegram notification failed for user %s: %v", userID, sendErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("Telegram notification sent to user %s", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
|
||||||
|
sent, sendErr := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
|
||||||
|
if sendErr != nil || !sent {
|
||||||
|
log.Printf("Slack notification failed for user %s: %v", userID, sendErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("Slack notification sent to user %s", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if notifConfig.Email != nil && *notifConfig.Email != "" {
|
||||||
|
sent, sendErr := notifications.SendEmailNotification(
|
||||||
|
s.resendAPIKey, s.emailFrom, *notifConfig.Email, message, meta,
|
||||||
|
)
|
||||||
|
if sendErr != nil || !sent {
|
||||||
|
log.Printf("Email notification failed for user %s: %v", userID, sendErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("Email notification sent to user %s", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case domain.AlertIncomingTx:
|
case domain.AlertIncomingTx:
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// API client for notification configuration
|
|
||||||
|
|
||||||
import { getAuthHeaders } from "./authHeaders";
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
import { API_BASE } from "./config";
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
@@ -38,11 +36,13 @@ export async function getNotificationConfig() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update notification configuration
|
* Update notification configuration
|
||||||
* @param {Object} config - Configuration to update
|
* @param {Object} config
|
||||||
* @param {string} [config.discord_webhook_url] - Discord webhook URL
|
* @param {string} [config.discord_webhook_url]
|
||||||
* @param {string} [config.telegram_chat_id] - Telegram chat ID
|
* @param {string} [config.telegram_chat_id]
|
||||||
* @param {string} [config.email] - Email address
|
* @param {string} [config.telegram_bot_token]
|
||||||
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
|
* @param {string} [config.email]
|
||||||
|
* @param {string} [config.slack_webhook_url]
|
||||||
|
* @param {boolean} [config.notification_enabled]
|
||||||
* @returns {Promise<Object>} Updated config
|
* @returns {Promise<Object>} Updated config
|
||||||
*/
|
*/
|
||||||
export async function updateNotificationConfig(config) {
|
export async function updateNotificationConfig(config) {
|
||||||
@@ -77,27 +77,107 @@ export async function updateNotificationConfig(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test a Discord webhook URL
|
* Set up email notifications via Resend.
|
||||||
* Sends a test message to verify the webhook works
|
* Reads the user's saved email from their config and sends a confirmation.
|
||||||
* @param {string} webhookUrl - Discord webhook URL to test
|
* @returns {Promise<Object>} Setup result
|
||||||
* @returns {Promise<boolean>} True if test successful
|
|
||||||
*/
|
*/
|
||||||
export async function testDiscordWebhook(webhookUrl) {
|
export async function setupEmail() {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const headers = await getAuthHeaders();
|
||||||
content:
|
const response = await fetch(`${API_BASE}/email/setup`, {
|
||||||
"Koin Ping test notification - Your Discord webhook is configured correctly!",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: headers,
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.ok;
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to set up email";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Discord webhook test failed:", error);
|
if (error.message.includes("fetch")) {
|
||||||
return false;
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an alert digest email to the user's configured email address.
|
||||||
|
* @returns {Promise<Object>} Digest send result
|
||||||
|
*/
|
||||||
|
export async function sendEmailDigest() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(`${API_BASE}/email/digest`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to send digest";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test all configured notification channels via the backend
|
||||||
|
* @returns {Promise<Object>} Results per channel
|
||||||
|
*/
|
||||||
|
export async function testNotificationChannels() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/notification-config/test`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to test notification channels";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AlertForm from "../components/AlertForm";
|
import AlertForm from "../components/AlertForm";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
import Input from "../components/Input";
|
|
||||||
import { getAddresses } from "../api/addresses";
|
import { getAddresses } from "../api/addresses";
|
||||||
import {
|
import {
|
||||||
getAlerts,
|
getAlerts,
|
||||||
@@ -12,9 +11,40 @@ import {
|
|||||||
import {
|
import {
|
||||||
getNotificationConfig,
|
getNotificationConfig,
|
||||||
updateNotificationConfig,
|
updateNotificationConfig,
|
||||||
testDiscordWebhook,
|
testNotificationChannels,
|
||||||
|
setupEmail,
|
||||||
|
sendEmailDigest,
|
||||||
} from "../api/notificationConfig";
|
} from "../api/notificationConfig";
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#ccc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const helpLinkStyle = { color: "#0066cc", fontSize: "0.85rem" };
|
||||||
|
|
||||||
|
const sectionStyle = {
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
padding: "1rem",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #3a3a3a",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Alerts() {
|
export default function Alerts() {
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||||
@@ -23,34 +53,39 @@ export default function Alerts() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Notification config state
|
// Notification config state
|
||||||
const [notificationConfig, setNotificationConfig] = useState(null);
|
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
const [telegramBotToken, setTelegramBotToken] = useState("");
|
||||||
|
const [telegramChatId, setTelegramChatId] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
||||||
const [notificationLoading, setNotificationLoading] = useState(false);
|
const [notificationLoading, setNotificationLoading] = useState(false);
|
||||||
const [notificationError, setNotificationError] = useState(null);
|
const [notificationError, setNotificationError] = useState(null);
|
||||||
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
||||||
const [testingWebhook, setTestingWebhook] = useState(false);
|
const [testingChannels, setTestingChannels] = useState(false);
|
||||||
|
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
||||||
|
const [sendingDigest, setSendingDigest] = useState(false);
|
||||||
|
|
||||||
// Load addresses and notification config on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Fetch addresses
|
|
||||||
const addressData = await getAddresses();
|
const addressData = await getAddresses();
|
||||||
setAddresses(addressData);
|
setAddresses(addressData);
|
||||||
if (addressData.length > 0) {
|
if (addressData.length > 0) {
|
||||||
setSelectedAddressId(addressData[0].id);
|
setSelectedAddressId(addressData[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch notification config
|
|
||||||
const configData = await getNotificationConfig();
|
const configData = await getNotificationConfig();
|
||||||
setNotificationConfig(configData);
|
|
||||||
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
|
||||||
setNotificationEnabled(
|
setNotificationEnabled(
|
||||||
configData.notification_enabled !== false,
|
configData.notification_enabled !== false,
|
||||||
);
|
);
|
||||||
|
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
||||||
|
setTelegramBotToken(configData.telegram_bot_token || "");
|
||||||
|
setTelegramChatId(configData.telegram_chat_id || "");
|
||||||
|
setEmail(configData.email || "");
|
||||||
|
setSlackWebhookUrl(configData.slack_webhook_url || "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to fetch data:", err);
|
console.error("Failed to fetch data:", err);
|
||||||
@@ -62,7 +97,6 @@ export default function Alerts() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load alerts when address is selected
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAddressId) {
|
if (!selectedAddressId) {
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
@@ -73,7 +107,7 @@ export default function Alerts() {
|
|||||||
try {
|
try {
|
||||||
const data = await getAlerts(selectedAddressId);
|
const data = await getAlerts(selectedAddressId);
|
||||||
setAlerts(data);
|
setAlerts(data);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to fetch alerts:", err);
|
console.error("Failed to fetch alerts:", err);
|
||||||
@@ -83,47 +117,43 @@ export default function Alerts() {
|
|||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
}, [selectedAddressId]);
|
}, [selectedAddressId]);
|
||||||
|
|
||||||
// Handle new alert submission
|
|
||||||
async function handleAlertSubmit(data) {
|
async function handleAlertSubmit(data) {
|
||||||
if (!selectedAddressId) return;
|
if (!selectedAddressId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newAlert = await createAlert(selectedAddressId, data);
|
const newAlert = await createAlert(selectedAddressId, data);
|
||||||
setAlerts((prev) => [...prev, newAlert]);
|
setAlerts((prev) => [...prev, newAlert]);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to create alert:", err);
|
console.error("Failed to create alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle alert enabled/disabled
|
|
||||||
async function handleToggleAlert(alertId, currentStatus) {
|
async function handleToggleAlert(alertId, currentStatus) {
|
||||||
try {
|
try {
|
||||||
const updated = await updateAlertStatus(alertId, !currentStatus);
|
const updated = await updateAlertStatus(alertId, !currentStatus);
|
||||||
setAlerts((prev) =>
|
setAlerts((prev) =>
|
||||||
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
||||||
);
|
);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to update alert:", err);
|
console.error("Failed to update alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete alert
|
|
||||||
async function handleDeleteAlert(alertId) {
|
async function handleDeleteAlert(alertId) {
|
||||||
try {
|
try {
|
||||||
await deleteAlert(alertId);
|
await deleteAlert(alertId);
|
||||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to delete alert:", err);
|
console.error("Failed to delete alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save notification config
|
|
||||||
async function handleSaveNotificationConfig() {
|
async function handleSaveNotificationConfig() {
|
||||||
try {
|
try {
|
||||||
setNotificationLoading(true);
|
setNotificationLoading(true);
|
||||||
@@ -131,15 +161,16 @@ export default function Alerts() {
|
|||||||
setNotificationSuccess(null);
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
discord_webhook_url: discordWebhookUrl || null,
|
|
||||||
notification_enabled: notificationEnabled,
|
notification_enabled: notificationEnabled,
|
||||||
|
discord_webhook_url: discordWebhookUrl || null,
|
||||||
|
telegram_bot_token: telegramBotToken || null,
|
||||||
|
telegram_chat_id: telegramChatId || null,
|
||||||
|
email: email || null,
|
||||||
|
slack_webhook_url: slackWebhookUrl || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updated = await updateNotificationConfig(config);
|
await updateNotificationConfig(config);
|
||||||
setNotificationConfig(updated);
|
|
||||||
setNotificationSuccess("Notification settings saved!");
|
setNotificationSuccess("Notification settings saved!");
|
||||||
|
|
||||||
// Clear success message after 3 seconds
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
setTimeout(() => setNotificationSuccess(null), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotificationError(err.message);
|
setNotificationError(err.message);
|
||||||
@@ -149,32 +180,76 @@ export default function Alerts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test Discord webhook
|
async function handleTestChannels() {
|
||||||
async function handleTestWebhook() {
|
|
||||||
if (!discordWebhookUrl) {
|
|
||||||
setNotificationError("Please enter a Discord webhook URL first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTestingWebhook(true);
|
setTestingChannels(true);
|
||||||
setNotificationError(null);
|
setNotificationError(null);
|
||||||
setNotificationSuccess(null);
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
const success = await testDiscordWebhook(discordWebhookUrl);
|
const data = await testNotificationChannels();
|
||||||
|
const results = data.results || [];
|
||||||
|
|
||||||
if (success) {
|
const failed = results.filter((r) => !r.success);
|
||||||
|
const succeeded = results.filter((r) => r.success);
|
||||||
|
|
||||||
|
if (failed.length === 0 && succeeded.length > 0) {
|
||||||
setNotificationSuccess(
|
setNotificationSuccess(
|
||||||
"Test notification sent! Check your Discord channel.",
|
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
|
||||||
|
);
|
||||||
|
} else if (failed.length > 0 && succeeded.length > 0) {
|
||||||
|
setNotificationSuccess(
|
||||||
|
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||||
|
);
|
||||||
|
} else if (failed.length > 0) {
|
||||||
|
setNotificationError(
|
||||||
|
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
setNotificationError(null);
|
||||||
|
}, 6000);
|
||||||
|
} catch (err) {
|
||||||
|
setNotificationError(err.message);
|
||||||
|
} finally {
|
||||||
|
setTestingChannels(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetupEmail() {
|
||||||
|
try {
|
||||||
|
setSettingUpEmail(true);
|
||||||
|
setNotificationError(null);
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
|
await handleSaveNotificationConfig();
|
||||||
|
|
||||||
|
const result = await setupEmail();
|
||||||
|
setNotificationSuccess(
|
||||||
|
result.message || "Confirmation email sent!",
|
||||||
);
|
);
|
||||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||||
} else {
|
|
||||||
setNotificationError("Test failed. Check your webhook URL.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotificationError("Test failed: " + err.message);
|
setNotificationError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingWebhook(false);
|
setSettingUpEmail(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendDigest() {
|
||||||
|
try {
|
||||||
|
setSendingDigest(true);
|
||||||
|
setNotificationError(null);
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
|
const result = await sendEmailDigest();
|
||||||
|
setNotificationSuccess(result.message || "Digest email sent!");
|
||||||
|
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||||
|
} catch (err) {
|
||||||
|
setNotificationError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSendingDigest(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,19 +276,18 @@ export default function Alerts() {
|
|||||||
Alert Rules & Notifications
|
Alert Rules & Notifications
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "2rem",
|
gap: "2rem",
|
||||||
|
alignItems: "start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* LEFT COLUMN: Alert Rules */}
|
{/* LEFT COLUMN: Alert Rules */}
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
||||||
|
|
||||||
{/* Address selector */}
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
<label
|
<label
|
||||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
style={{ display: "block", marginBottom: "0.5rem" }}
|
||||||
@@ -245,7 +319,6 @@ export default function Alerts() {
|
|||||||
|
|
||||||
{selectedAddress && (
|
{selectedAddress && (
|
||||||
<>
|
<>
|
||||||
{/* Current address info */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
@@ -282,13 +355,11 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert creation form */}
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
<h3>Create New Alert</h3>
|
<h3>Create New Alert</h3>
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Existing alerts list */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Active Alert Rules</h3>
|
<h3>Active Alert Rules</h3>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -449,7 +520,7 @@ export default function Alerts() {
|
|||||||
{/* Master toggle */}
|
{/* Master toggle */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "2rem",
|
marginBottom: "1.5rem",
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
backgroundColor: "#333",
|
backgroundColor: "#333",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
@@ -490,17 +561,146 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discord Section */}
|
{/* All channel settings — hidden when master toggle is off */}
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div
|
||||||
<h3 style={{ marginBottom: "1rem" }}>Discord</h3>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: notificationEnabled ? "block" : "none",
|
||||||
marginBottom: "0.5rem",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Telegram */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
|
Telegram
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>Bot Token</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={telegramBotToken}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTelegramBotToken(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "0.5rem" }}>
|
||||||
|
<label style={labelStyle}>Chat ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={telegramChatId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTelegramChatId(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="-1001234567890"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={helpLinkStyle}
|
||||||
|
>
|
||||||
|
How to create a Telegram bot & get your Chat ID
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
|
Email
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<label style={labelStyle}>Email Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "#808080",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alert notifications and digests will be sent to
|
||||||
|
this address
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleSetupEmail}
|
||||||
|
disabled={settingUpEmail || !email}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
backgroundColor:
|
||||||
|
settingUpEmail || !email
|
||||||
|
? "#333"
|
||||||
|
: "#0066cc",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor:
|
||||||
|
settingUpEmail || !email
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settingUpEmail
|
||||||
|
? "Setting up..."
|
||||||
|
: "Verify Email"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSendDigest}
|
||||||
|
disabled={sendingDigest || !email}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
backgroundColor:
|
||||||
|
sendingDigest || !email
|
||||||
|
? "#333"
|
||||||
|
: "#6c757d",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor:
|
||||||
|
sendingDigest || !email
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sendingDigest
|
||||||
|
? "Sending..."
|
||||||
|
: "Send Digest Now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discord */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
|
Discord
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "0.5rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
Discord Webhook URL
|
Discord Webhook URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -510,37 +710,59 @@ export default function Alerts() {
|
|||||||
setDiscordWebhookUrl(e.target.value)
|
setDiscordWebhookUrl(e.target.value)
|
||||||
}
|
}
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
style={{
|
style={inputStyle}
|
||||||
width: "100%",
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
border: "1px solid #444",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "white",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div
|
</div>
|
||||||
style={{
|
|
||||||
fontSize: "0.978rem",
|
|
||||||
color: "#b3b3b3",
|
|
||||||
marginTop: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ color: "#0066cc" }}
|
style={helpLinkStyle}
|
||||||
>
|
>
|
||||||
How to get a Discord webhook URL
|
How to get a Discord webhook URL
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Slack */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
|
Slack
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "0.5rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
Slack Webhook URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slackWebhookUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSlackWebhookUrl(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<a
|
||||||
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={helpLinkStyle}
|
||||||
|
>
|
||||||
|
How to set up Slack Incoming Webhooks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save & Test buttons at the bottom */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveNotificationConfig}
|
onClick={handleSaveNotificationConfig}
|
||||||
disabled={notificationLoading}
|
disabled={notificationLoading}
|
||||||
@@ -564,67 +786,34 @@ export default function Alerts() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleTestWebhook}
|
onClick={handleTestChannels}
|
||||||
disabled={testingWebhook || !discordWebhookUrl}
|
disabled={testingChannels}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem 1.5rem",
|
padding: "0.75rem 1.5rem",
|
||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
backgroundColor:
|
backgroundColor: testingChannels
|
||||||
testingWebhook || !discordWebhookUrl
|
|
||||||
? "#333"
|
? "#333"
|
||||||
: "#28a745",
|
: "#28a745",
|
||||||
color: "white",
|
color: "white",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
cursor:
|
cursor: testingChannels
|
||||||
testingWebhook || !discordWebhookUrl
|
|
||||||
? "not-allowed"
|
? "not-allowed"
|
||||||
: "pointer",
|
: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{testingWebhook ? "Testing..." : "Test Webhook"}
|
{testingChannels
|
||||||
|
? "Testing..."
|
||||||
|
: "Test All Channels"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telegram Section (Coming Soon) */}
|
|
||||||
<div style={{ marginBottom: "2rem", opacity: 0.5 }}>
|
|
||||||
<h3 style={{ marginBottom: "1rem" }}>Telegram</h3>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#999",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email Section (Coming Soon) */}
|
|
||||||
<div style={{ opacity: 0.5 }}>
|
|
||||||
<h3 style={{ marginBottom: "1rem" }}>Email</h3>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#999",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format alert type for display
|
|
||||||
function formatAlertType(type) {
|
function formatAlertType(type) {
|
||||||
const labels = {
|
const labels = {
|
||||||
incoming_tx: "Incoming transaction",
|
incoming_tx: "Incoming transaction",
|
||||||
|
|||||||
Reference in New Issue
Block a user