feat: Implement comprehensive multi-channel notification system with templating and SSL monitoring
- Add robust notification system supporting 7+ communication channels with intelligent message templating, resource monitoring, and SSL certificate alerts. Notification Channels: (Telegram, Discord, Slack, Signal, Email, Google Chat, Webhooks). - This notification system provides enterprise-grade alerting capabilities with extensive customization options and multi-channel redundancy for critical service monitoring.
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SlackService handles Slack notifications
|
||||
type SlackService struct{}
|
||||
|
||||
// NewSlackService creates a new Slack notification service
|
||||
func NewSlackService() *SlackService {
|
||||
return &SlackService{}
|
||||
}
|
||||
|
||||
// SlackPayload represents the payload for Slack webhook
|
||||
type SlackPayload struct {
|
||||
Text string `json:"text"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
||||
Attachments []SlackAttachment `json:"attachments,omitempty"`
|
||||
Blocks []SlackBlock `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// SlackAttachment represents a Slack attachment
|
||||
type SlackAttachment struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Timestamp int64 `json:"ts,omitempty"`
|
||||
Fields []SlackField `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// SlackField represents a field in Slack attachment
|
||||
type SlackField struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short"`
|
||||
}
|
||||
|
||||
// SlackBlock represents a Slack block element
|
||||
type SlackBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *SlackText `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// SlackText represents text in Slack blocks
|
||||
type SlackText struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// SendNotification sends a notification via Slack webhook
|
||||
func (ss *SlackService) SendNotification(config *AlertConfiguration, message string) error {
|
||||
if config.SlackWebhookURL == "" {
|
||||
return fmt.Errorf("slack webhook URL is required")
|
||||
}
|
||||
|
||||
// Parse and format the message for better Slack presentation
|
||||
formattedPayload := ss.createSlackPayload(config, message)
|
||||
|
||||
jsonData, err := json.Marshal(formattedPayload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.Post(config.SlackWebhookURL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("slack webhook error, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendServerNotification sends a server-specific notification via Slack
|
||||
func (ss *SlackService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
|
||||
message := ss.generateServerMessage(payload, template, resourceType)
|
||||
return ss.SendNotification(config, message)
|
||||
}
|
||||
|
||||
// SendServiceNotification sends a service-specific notification via Slack
|
||||
func (ss *SlackService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
|
||||
message := ss.generateServiceMessage(payload, template)
|
||||
return ss.SendNotification(config, message)
|
||||
}
|
||||
|
||||
// createSlackPayload creates a properly formatted Slack payload with rich formatting
|
||||
func (ss *SlackService) createSlackPayload(config *AlertConfiguration, message string) SlackPayload {
|
||||
// Determine color based on message content
|
||||
color := ss.getColorFromMessage(message)
|
||||
|
||||
// Parse message for structured presentation
|
||||
title, fields := ss.parseMessageForSlack(message)
|
||||
|
||||
payload := SlackPayload{
|
||||
Username: config.NotifyName,
|
||||
IconEmoji: ss.getEmojiFromMessage(message),
|
||||
Text: title,
|
||||
}
|
||||
|
||||
// Create attachment for better formatting
|
||||
if len(fields) > 0 {
|
||||
attachment := SlackAttachment{
|
||||
Color: color,
|
||||
Title: title,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Fields: fields,
|
||||
}
|
||||
payload.Attachments = []SlackAttachment{attachment}
|
||||
payload.Text = "" // Clear text when using attachments
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// parseMessageForSlack parses the message to extract title and create structured fields
|
||||
func (ss *SlackService) parseMessageForSlack(message string) (string, []SlackField) {
|
||||
lines := strings.Split(message, "\n")
|
||||
if len(lines) == 0 {
|
||||
return message, nil
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(lines[0])
|
||||
var fields []SlackField
|
||||
|
||||
// Process bullet points and key-value pairs
|
||||
for i, line := range lines {
|
||||
if i == 0 {
|
||||
continue // Skip title
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle bullet points (•, -, *)
|
||||
if strings.HasPrefix(line, "•") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, "*") {
|
||||
// Remove bullet and clean up
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(line, "•"), "-"), "*"))
|
||||
|
||||
// Try to split on colon for key-value pairs
|
||||
if strings.Contains(cleaned, ":") {
|
||||
parts := strings.SplitN(cleaned, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
fields = append(fields, SlackField{
|
||||
Title: key,
|
||||
Value: value,
|
||||
Short: len(value) < 30, // Short fields for compact display
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Add as a single field
|
||||
fields = append(fields, SlackField{
|
||||
Title: "Info",
|
||||
Value: cleaned,
|
||||
Short: false,
|
||||
})
|
||||
}
|
||||
} else if strings.Contains(line, ":") {
|
||||
// Direct key-value pairs
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
fields = append(fields, SlackField{
|
||||
Title: key,
|
||||
Value: value,
|
||||
Short: len(value) < 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return title, fields
|
||||
}
|
||||
|
||||
// getColorFromMessage determines the appropriate color based on message content
|
||||
func (ss *SlackService) getColorFromMessage(message string) string {
|
||||
lowerMsg := strings.ToLower(message)
|
||||
|
||||
// Critical/Error states - Red
|
||||
if strings.Contains(lowerMsg, "down") || strings.Contains(lowerMsg, "failed") ||
|
||||
strings.Contains(lowerMsg, "error") || strings.Contains(lowerMsg, "expired") ||
|
||||
strings.Contains(lowerMsg, "🔴") || strings.Contains(lowerMsg, "🚨") {
|
||||
return "#FF0000" // Red
|
||||
}
|
||||
|
||||
// Warning states - Yellow/Orange
|
||||
if strings.Contains(lowerMsg, "warning") || strings.Contains(lowerMsg, "expiring") ||
|
||||
strings.Contains(lowerMsg, "🟡") || strings.Contains(lowerMsg, "⚠️") {
|
||||
return "#FFA500" // Orange
|
||||
}
|
||||
|
||||
// Success states - Green
|
||||
if strings.Contains(lowerMsg, "up") || strings.Contains(lowerMsg, "restored") ||
|
||||
strings.Contains(lowerMsg, "resolved") || strings.Contains(lowerMsg, "🟢") ||
|
||||
strings.Contains(lowerMsg, "✅") {
|
||||
return "#00FF00" // Green
|
||||
}
|
||||
|
||||
// Maintenance/Info states - Blue
|
||||
if strings.Contains(lowerMsg, "maintenance") || strings.Contains(lowerMsg, "paused") ||
|
||||
strings.Contains(lowerMsg, "🟠") || strings.Contains(lowerMsg, "🔵") {
|
||||
return "#0080FF" // Blue
|
||||
}
|
||||
|
||||
return "#808080" // Default gray
|
||||
}
|
||||
|
||||
// getEmojiFromMessage extracts or determines appropriate emoji for the message
|
||||
func (ss *SlackService) getEmojiFromMessage(message string) string {
|
||||
lowerMsg := strings.ToLower(message)
|
||||
|
||||
// Check for specific service types first
|
||||
if strings.Contains(lowerMsg, "server") {
|
||||
if strings.Contains(lowerMsg, "down") || strings.Contains(lowerMsg, "failed") {
|
||||
return ":red_circle:"
|
||||
} else if strings.Contains(lowerMsg, "warning") {
|
||||
return ":warning:"
|
||||
} else if strings.Contains(lowerMsg, "up") || strings.Contains(lowerMsg, "restored") {
|
||||
return ":white_check_mark:"
|
||||
}
|
||||
return ":desktop_computer:"
|
||||
}
|
||||
|
||||
// SSL Certificate notifications
|
||||
if strings.Contains(lowerMsg, "certificate") || strings.Contains(lowerMsg, "ssl") {
|
||||
if strings.Contains(lowerMsg, "expired") {
|
||||
return ":no_entry:"
|
||||
} else if strings.Contains(lowerMsg, "expiring") {
|
||||
return ":warning:"
|
||||
}
|
||||
return ":lock:"
|
||||
}
|
||||
|
||||
// General status-based emojis
|
||||
if strings.Contains(lowerMsg, "down") || strings.Contains(lowerMsg, "failed") {
|
||||
return ":red_circle:"
|
||||
} else if strings.Contains(lowerMsg, "warning") {
|
||||
return ":warning:"
|
||||
} else if strings.Contains(lowerMsg, "up") || strings.Contains(lowerMsg, "restored") {
|
||||
return ":white_check_mark:"
|
||||
} else if strings.Contains(lowerMsg, "maintenance") {
|
||||
return ":construction:"
|
||||
}
|
||||
|
||||
return ":information_source:" // Default info emoji
|
||||
}
|
||||
|
||||
// generateServerMessage creates a message for server notifications using server template
|
||||
func (ss *SlackService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
|
||||
var templateMessage string
|
||||
|
||||
// Select appropriate template message based on status and resource type
|
||||
switch strings.ToLower(payload.Status) {
|
||||
case "down":
|
||||
templateMessage = template.DownMessage
|
||||
case "up":
|
||||
templateMessage = template.UpMessage
|
||||
case "warning":
|
||||
templateMessage = template.WarningMessage
|
||||
case "paused":
|
||||
templateMessage = template.PausedMessage
|
||||
default:
|
||||
// Handle resource-specific messages
|
||||
switch resourceType {
|
||||
case "cpu":
|
||||
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||
templateMessage = template.RestoreCPUMessage
|
||||
} else {
|
||||
templateMessage = template.CPUMessage
|
||||
}
|
||||
case "ram", "memory":
|
||||
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||
templateMessage = template.RestoreRAMMessage
|
||||
} else {
|
||||
templateMessage = template.RAMMessage
|
||||
}
|
||||
case "disk":
|
||||
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||
templateMessage = template.RestoreDiskMessage
|
||||
} else {
|
||||
templateMessage = template.DiskMessage
|
||||
}
|
||||
case "network":
|
||||
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||
templateMessage = template.RestoreNetworkMessage
|
||||
} else {
|
||||
templateMessage = template.NetworkMessage
|
||||
}
|
||||
case "cpu_temp", "cpu_temperature":
|
||||
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||
templateMessage = template.RestoreCPUTempMessage
|
||||
} else {
|
||||
templateMessage = template.CPUTempMessage
|
||||
}
|
||||
case "disk_io":
|
||||
if strings.Contains(strings.ToLower(payload.Status), "restore") {
|
||||
templateMessage = template.RestoreDiskIOMessage
|
||||
} else {
|
||||
templateMessage = template.DiskIOMessage
|
||||
}
|
||||
default:
|
||||
templateMessage = template.WarningMessage
|
||||
}
|
||||
}
|
||||
|
||||
// If no template message found, use a default
|
||||
if templateMessage == "" {
|
||||
templateMessage = ss.generateDefaultServerMessage(payload, resourceType)
|
||||
}
|
||||
|
||||
return ss.replacePlaceholders(templateMessage, payload)
|
||||
}
|
||||
|
||||
// generateServiceMessage creates a message for service notifications using service template
|
||||
func (ss *SlackService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
|
||||
var templateMessage string
|
||||
|
||||
// Select appropriate template message based on status
|
||||
switch strings.ToLower(payload.Status) {
|
||||
case "up":
|
||||
templateMessage = template.UpMessage
|
||||
case "down":
|
||||
templateMessage = template.DownMessage
|
||||
case "maintenance":
|
||||
templateMessage = template.MaintenanceMessage
|
||||
case "incident":
|
||||
templateMessage = template.IncidentMessage
|
||||
case "resolved":
|
||||
templateMessage = template.ResolvedMessage
|
||||
case "warning":
|
||||
templateMessage = template.WarningMessage
|
||||
default:
|
||||
templateMessage = template.WarningMessage
|
||||
}
|
||||
|
||||
// If no template message found, use a default
|
||||
if templateMessage == "" {
|
||||
templateMessage = ss.generateDefaultUptimeMessage(payload)
|
||||
}
|
||||
|
||||
return ss.replacePlaceholders(templateMessage, payload)
|
||||
}
|
||||
|
||||
// replacePlaceholders replaces all placeholders in the message with actual values
|
||||
func (ss *SlackService) replacePlaceholders(message string, payload *NotificationPayload) string {
|
||||
// Replace basic placeholders
|
||||
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
|
||||
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
|
||||
message = strings.ReplaceAll(message, "${host}", ss.safeString(payload.Host))
|
||||
message = strings.ReplaceAll(message, "${hostname}", ss.safeString(payload.Hostname))
|
||||
|
||||
// Replace URL with fallback to host
|
||||
url := ss.safeString(payload.URL)
|
||||
if url == "N/A" && payload.Host != "" {
|
||||
url = payload.Host
|
||||
}
|
||||
message = strings.ReplaceAll(message, "${url}", url)
|
||||
|
||||
// Replace domain
|
||||
message = strings.ReplaceAll(message, "${domain}", ss.safeString(payload.Domain))
|
||||
|
||||
// Replace service type
|
||||
if payload.ServiceType != "" {
|
||||
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
|
||||
} else {
|
||||
message = strings.ReplaceAll(message, "${service_type}", "N/A")
|
||||
}
|
||||
|
||||
// Replace region and agent info
|
||||
message = strings.ReplaceAll(message, "${region_name}", ss.safeString(payload.RegionName))
|
||||
message = strings.ReplaceAll(message, "${agent_id}", ss.safeString(payload.AgentID))
|
||||
|
||||
// Handle numeric fields safely
|
||||
if payload.Port > 0 {
|
||||
message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port))
|
||||
} else {
|
||||
message = strings.ReplaceAll(message, "${port}", "N/A")
|
||||
}
|
||||
|
||||
if payload.ResponseTime > 0 {
|
||||
message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime))
|
||||
} else {
|
||||
message = strings.ReplaceAll(message, "${response_time}", "N/A")
|
||||
}
|
||||
|
||||
if payload.Uptime > 0 {
|
||||
message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime))
|
||||
} else {
|
||||
message = strings.ReplaceAll(message, "${uptime}", "N/A")
|
||||
}
|
||||
|
||||
// Replace server monitoring fields
|
||||
message = strings.ReplaceAll(message, "${cpu_usage}", ss.safeString(payload.CPUUsage))
|
||||
message = strings.ReplaceAll(message, "${ram_usage}", ss.safeString(payload.RAMUsage))
|
||||
message = strings.ReplaceAll(message, "${disk_usage}", ss.safeString(payload.DiskUsage))
|
||||
message = strings.ReplaceAll(message, "${network_usage}", ss.safeString(payload.NetworkUsage))
|
||||
message = strings.ReplaceAll(message, "${cpu_temp}", ss.safeString(payload.CPUTemp))
|
||||
message = strings.ReplaceAll(message, "${disk_io}", ss.safeString(payload.DiskIO))
|
||||
message = strings.ReplaceAll(message, "${threshold}", ss.safeString(payload.Threshold))
|
||||
|
||||
// Replace SSL certificate fields
|
||||
message = strings.ReplaceAll(message, "${certificate_name}", ss.safeString(payload.CertificateName))
|
||||
message = strings.ReplaceAll(message, "${expiry_date}", ss.safeString(payload.ExpiryDate))
|
||||
message = strings.ReplaceAll(message, "${days_left}", ss.safeString(payload.DaysLeft))
|
||||
message = strings.ReplaceAll(message, "${issuer_cn}", ss.safeString(payload.IssuerCN))
|
||||
message = strings.ReplaceAll(message, "${serial_number}", ss.safeString(payload.SerialNumber))
|
||||
|
||||
// Replace error message - important for uptime services
|
||||
message = strings.ReplaceAll(message, "${error_message}", ss.safeString(payload.ErrorMessage))
|
||||
message = strings.ReplaceAll(message, "${error}", ss.safeString(payload.ErrorMessage))
|
||||
|
||||
// Replace time placeholders
|
||||
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
|
||||
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
|
||||
message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02"))
|
||||
|
||||
// Replace message placeholder
|
||||
if payload.Message != "" {
|
||||
message = strings.ReplaceAll(message, "${message}", payload.Message)
|
||||
} else {
|
||||
message = strings.ReplaceAll(message, "${message}", "N/A")
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// safeString returns the string value or "N/A" if empty
|
||||
func (ss *SlackService) safeString(value string) string {
|
||||
if value == "" {
|
||||
return "N/A"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
|
||||
func (ss *SlackService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
|
||||
// Status emoji mapping
|
||||
statusEmoji := "🔵"
|
||||
switch strings.ToLower(payload.Status) {
|
||||
case "up":
|
||||
statusEmoji = "🟢"
|
||||
case "down":
|
||||
statusEmoji = "🔴"
|
||||
case "warning":
|
||||
statusEmoji = "🟡"
|
||||
case "maintenance", "paused":
|
||||
statusEmoji = "🟠"
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s Service %s is %s", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status))
|
||||
|
||||
// Build formatted details
|
||||
details := []string{}
|
||||
|
||||
// Add URL or host
|
||||
if payload.URL != "" {
|
||||
details = append(details, fmt.Sprintf("• Host URL: %s", payload.URL))
|
||||
} else if payload.Host != "" {
|
||||
details = append(details, fmt.Sprintf("• Host: %s", payload.Host))
|
||||
}
|
||||
|
||||
// Add service type
|
||||
if payload.ServiceType != "" {
|
||||
details = append(details, fmt.Sprintf("• Type: %s", strings.ToUpper(payload.ServiceType)))
|
||||
}
|
||||
|
||||
// Add port if available
|
||||
if payload.Port > 0 {
|
||||
details = append(details, fmt.Sprintf("• Port: %d", payload.Port))
|
||||
}
|
||||
|
||||
// Add domain if available
|
||||
if payload.Domain != "" {
|
||||
details = append(details, fmt.Sprintf("• Domain: %s", payload.Domain))
|
||||
}
|
||||
|
||||
// Add response time
|
||||
if payload.ResponseTime > 0 {
|
||||
details = append(details, fmt.Sprintf("• Response time: %dms", payload.ResponseTime))
|
||||
} else {
|
||||
details = append(details, "• Response time: N/A")
|
||||
}
|
||||
|
||||
// Add region info
|
||||
if payload.RegionName != "" {
|
||||
details = append(details, fmt.Sprintf("• Region: %s", payload.RegionName))
|
||||
}
|
||||
|
||||
// Add agent info
|
||||
if payload.AgentID != "" {
|
||||
details = append(details, fmt.Sprintf("• Agent: %s", payload.AgentID))
|
||||
}
|
||||
|
||||
// Add uptime if available
|
||||
if payload.Uptime > 0 {
|
||||
details = append(details, fmt.Sprintf("• Uptime: %d%%", payload.Uptime))
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
details = append(details, fmt.Sprintf("• Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")))
|
||||
|
||||
// Combine message with details
|
||||
if len(details) > 0 {
|
||||
message += "\n" + strings.Join(details, "\n")
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// generateDefaultServerMessage creates a default server message
|
||||
func (ss *SlackService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
|
||||
statusEmoji := "🔵"
|
||||
switch strings.ToLower(payload.Status) {
|
||||
case "up":
|
||||
statusEmoji = "🟢"
|
||||
case "down":
|
||||
statusEmoji = "🔴"
|
||||
case "warning":
|
||||
statusEmoji = "🟡"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s 🖥️ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status))
|
||||
}
|
||||
Reference in New Issue
Block a user