- #1: remove mock alert events that poisoned production responses - #2: add Notifier interface + Send implementations for all channels - #3/#4: notification goroutines now carry a 30s context timeout and retry up to 3x with exponential backoff - #5: schedule email digest in poller via DIGEST_INTERVAL_HOURS (default 24h) - #6: SystemStatus queries real checkpoint data; returns starting/active/idle with latestBlock and lag - #7: Ethereum RPC client retries on network errors, 429, and 5xx with 1s/2s backoff - #8: alert_events dedup index + ON CONFLICT DO NOTHING to prevent duplicate events on poller restart - #9: PATCH /v1/addresses/{id} for label editing; frontend address list gains inline edit and remove buttons
129 lines
3.1 KiB
Go
129 lines
3.1 KiB
Go
package notifications
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// SlackNotifier sends alert notifications via a Slack webhook.
|
|
type SlackNotifier struct {
|
|
WebhookURL string
|
|
}
|
|
|
|
// Send implements Notifier for Slack.
|
|
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
|
_, err := SendSlackNotification(s.WebhookURL, message, meta)
|
|
return err
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|