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:
Tola Leng
2025-08-14 20:50:31 +07:00
parent 58242c33f8
commit 933e70a3f6
40 changed files with 7159 additions and 97 deletions
@@ -0,0 +1,214 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// DiscordService handles Discord notifications
type DiscordService struct{}
// NewDiscordService creates a new Discord notification service
func NewDiscordService() *DiscordService {
return &DiscordService{}
}
// DiscordPayload represents the payload for Discord webhook
type DiscordPayload struct {
Content string `json:"content,omitempty"`
Embeds []DiscordEmbed `json:"embeds,omitempty"`
}
// DiscordEmbed represents a Discord embed
type DiscordEmbed struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Color int `json:"color,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Fields []DiscordEmbedField `json:"fields,omitempty"`
Footer *DiscordEmbedFooter `json:"footer,omitempty"`
}
// DiscordEmbedField represents a field in Discord embed
type DiscordEmbedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline,omitempty"`
}
// DiscordEmbedFooter represents footer in Discord embed
type DiscordEmbedFooter struct {
Text string `json:"text"`
}
// SendNotification sends a notification via Discord webhook
func (ds *DiscordService) SendNotification(config *AlertConfiguration, message string) error {
if config.DiscordWebhookURL == "" {
return fmt.Errorf("discord webhook URL is required")
}
// Create rich embed for better formatting
embed := ds.createDiscordEmbed(message)
payload := DiscordPayload{
Embeds: []DiscordEmbed{embed},
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal Discord payload: %v", err)
}
resp, err := http.Post(config.DiscordWebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("discord webhook request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("discord webhook error, status: %d", resp.StatusCode)
}
return nil
}
// createDiscordEmbed creates a formatted Discord embed from the message
func (ds *DiscordService) createDiscordEmbed(message string) DiscordEmbed {
// Parse the message to extract structured information
lines := strings.Split(message, "\n")
if len(lines) == 0 {
return DiscordEmbed{
Description: message,
Color: ds.getDefaultColor(),
Timestamp: time.Now().Format(time.RFC3339),
}
}
// Extract title from first line and determine status
title := strings.TrimSpace(lines[0])
color := ds.determineColorFromMessage(title)
// Create fields from the remaining lines
var fields []DiscordEmbedField
for i, line := range lines {
if i == 0 {
continue // Skip title line
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Parse field from "• Key: Value" format
if strings.HasPrefix(line, "•") || strings.HasPrefix(line, "-") {
line = strings.TrimPrefix(line, "•")
line = strings.TrimPrefix(line, "-")
line = strings.TrimSpace(line)
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
fieldName := strings.TrimSpace(parts[0])
fieldValue := strings.TrimSpace(parts[1])
if fieldName != "" && fieldValue != "" {
fields = append(fields, DiscordEmbedField{
Name: fieldName,
Value: fieldValue,
Inline: true,
})
}
} else {
// If not in key:value format, add as description field
fields = append(fields, DiscordEmbedField{
Name: "Details",
Value: line,
Inline: false,
})
}
}
}
// If no structured fields found, use the entire message as description
if len(fields) == 0 {
// Remove the first line from description since it's used as title
description := ""
if len(lines) > 1 {
description = strings.Join(lines[1:], "\n")
}
return DiscordEmbed{
Title: title,
Description: description,
Color: color,
Timestamp: time.Now().Format(time.RFC3339),
Footer: &DiscordEmbedFooter{
Text: "CheckCle System Alert",
},
}
}
return DiscordEmbed{
Title: title,
Color: color,
Timestamp: time.Now().Format(time.RFC3339),
Fields: fields,
Footer: &DiscordEmbedFooter{
Text: "CheckCle System Alert",
},
}
}
// determineColorFromMessage determines Discord embed color based on message content
func (ds *DiscordService) determineColorFromMessage(message string) int {
messageLower := strings.ToLower(message)
// Red for critical/error states
if strings.Contains(messageLower, "expired") ||
strings.Contains(messageLower, "down") ||
strings.Contains(messageLower, "failed") ||
strings.Contains(messageLower, "error") ||
strings.Contains(messageLower, "critical") ||
strings.Contains(messageLower, "🚨") ||
strings.Contains(messageLower, "🔴") {
return 15158332 // Red (#E74C3C)
}
// Orange for warnings
if strings.Contains(messageLower, "expiring_soon") ||
strings.Contains(messageLower, "expiring soon") ||
strings.Contains(messageLower, "warning") ||
strings.Contains(messageLower, "maintenance") ||
strings.Contains(messageLower, "paused") ||
strings.Contains(messageLower, "⚠️") ||
strings.Contains(messageLower, "🟡") ||
strings.Contains(messageLower, "🟠") {
return 15105570 // Orange (#E67E22)
}
// Green for success/up states
if strings.Contains(messageLower, "up") ||
strings.Contains(messageLower, "resolved") ||
strings.Contains(messageLower, "success") ||
strings.Contains(messageLower, "restored") ||
strings.Contains(messageLower, "valid") ||
strings.Contains(messageLower, "🟢") ||
strings.Contains(messageLower, "✅") {
return 3066993 // Green (#2ECC71)
}
// Blue for info/default
return 3447003 // Blue (#3498DB)
}
// getDefaultColor returns the default Discord embed color (blue)
func (ds *DiscordService) getDefaultColor() int {
return 3447003 // Blue (#3498DB)
}
@@ -0,0 +1,532 @@
package notification
import (
"crypto/tls"
"fmt"
"net/smtp"
"strconv"
"strings"
"time"
)
// EmailService handles email notifications
type EmailService struct{}
// NewEmailService creates a new email notification service
func NewEmailService() *EmailService {
// log.Printf("✅ Email notification service initialized")
return &EmailService{}
}
// SendNotification sends a notification via email
func (es *EmailService) SendNotification(config *AlertConfiguration, message string) error {
// log.Printf("📧 === SENDING EMAIL NOTIFICATION ===")
// log.Printf("🔔 Email notification request received")
// log.Printf("📊 Email Configuration:")
// log.Printf(" - Email Address: %s", config.EmailAddress)
// log.Printf(" - Sender Name: %s", config.EmailSenderName)
// log.Printf(" - SMTP Server: %s", config.SMTPServer)
// log.Printf(" - SMTP Port: %s", config.SMTPPort)
// log.Printf(" - SMTP Password present: %t", config.SMTPPassword != "")
// log.Printf(" - Message Length: %d chars", len(message))
// Validate email configuration
if config.EmailAddress == "" || config.SMTPServer == "" || config.SMTPPort == "" {
// log.Printf("❌ EMAIL CONFIGURATION ERROR: Missing required fields")
// log.Printf(" - Email Address present: %t", config.EmailAddress != "")
// log.Printf(" - SMTP Server present: %t", config.SMTPServer != "")
// log.Printf(" - SMTP Port present: %t", config.SMTPPort != "")
return fmt.Errorf("email configuration is incomplete")
}
if config.SMTPPassword == "" {
// log.Printf("⚠️ WARNING: SMTP password not provided - authentication may fail")
}
port, err := strconv.Atoi(config.SMTPPort)
if err != nil {
// log.Printf("❌ SMTP PORT ERROR: Invalid port '%s': %v", config.SMTPPort, err)
return fmt.Errorf("invalid SMTP port: %v", err)
}
// log.Printf("✅ Email configuration validation passed")
// log.Printf("🔧 Parsed SMTP Port: %d", port)
// Determine alert severity and get appropriate emoji/color
severity, emoji := es.parseMessageSeverity(message)
// log.Printf("📋 Message Analysis:")
// log.Printf(" - Detected Severity: %s", severity)
// log.Printf(" - Emoji: %s", emoji)
// Create enhanced email content
subject := es.createEmailSubject(config.EmailSenderName, severity)
htmlBody := es.createHTMLEmailBody(message, severity, emoji)
plainBody := es.createPlainEmailBody(message, severity, emoji)
// log.Printf("📝 Email Content Created:")
// log.Printf(" - Subject: %s", subject)
// log.Printf(" - HTML Body Length: %d chars", len(htmlBody))
// log.Printf(" - Plain Body Length: %d chars", len(plainBody))
// Prepare email message with both HTML and plain text
emailMessage := es.createMIMEMessage(config.EmailSenderName, config.EmailAddress, subject, htmlBody, plainBody)
// log.Printf("📤 Preparing to send email...")
// log.Printf(" - SMTP Server: %s:%d", config.SMTPServer, port)
// log.Printf(" - From: %s", config.EmailSenderName)
// log.Printf(" - To: %s", config.EmailAddress)
// Send email with enhanced SMTP handling
err = es.sendSMTPEmail(config.SMTPServer, port, config.EmailSenderName, config.EmailAddress, config.SMTPPassword, emailMessage)
if err != nil {
// log.Printf("❌ EMAIL SENDING FAILED: %v", err)
// log.Printf("❌ Error Details:")
// log.Printf(" - SMTP Server: %s:%d", config.SMTPServer, port)
// log.Printf(" - Error Type: %T", err)
// log.Printf(" - Error Message: %s", err.Error())
return fmt.Errorf("failed to send email: %v", err)
}
// log.Printf("✅ EMAIL SENT SUCCESSFULLY")
// log.Printf("✅ Email Details:")
// log.Printf(" - Recipient: %s", config.EmailAddress)
// log.Printf(" - Subject: %s", subject)
// log.Printf(" - Severity: %s", severity)
// log.Printf(" - Timestamp: %s", time.Now().Format("2006-01-02 15:04:05"))
// log.Printf("=== EMAIL NOTIFICATION COMPLETE ===")
return nil
}
// parseMessageSeverity analyzes the message to determine severity level
func (es *EmailService) parseMessageSeverity(message string) (string, string) {
messageUpper := strings.ToUpper(message)
// Check for critical indicators
if strings.Contains(messageUpper, "🚨") || strings.Contains(messageUpper, "CRITICAL") ||
strings.Contains(messageUpper, "DOWN") || strings.Contains(messageUpper, "ERROR") ||
strings.Contains(messageUpper, "FAILED") || strings.Contains(messageUpper, "🔴") {
return "CRITICAL", "🚨"
}
// Check for warning indicators
if strings.Contains(messageUpper, "⚠️") || strings.Contains(messageUpper, "WARNING") ||
strings.Contains(messageUpper, "🟡") || strings.Contains(messageUpper, "INCIDENT") {
return "WARNING", "⚠️"
}
// Check for success indicators
if strings.Contains(messageUpper, "🟢") || strings.Contains(messageUpper, "UP") ||
strings.Contains(messageUpper, "SUCCESS") || strings.Contains(messageUpper, "RESOLVED") ||
strings.Contains(messageUpper, "RESTORED") {
return "SUCCESS", "✅"
}
// Check for maintenance indicators
if strings.Contains(messageUpper, "🟠") || strings.Contains(messageUpper, "MAINTENANCE") ||
strings.Contains(messageUpper, "PAUSED") {
return "MAINTENANCE", "🔧"
}
// Default to info
return "INFO", "️"
}
// createEmailSubject creates an appropriate email subject
func (es *EmailService) createEmailSubject(senderName, severity string) string {
prefix := "Service Alert"
if senderName != "" {
prefix = fmt.Sprintf("%s - Service Alert", senderName)
}
switch severity {
case "CRITICAL":
return fmt.Sprintf("🚨 [CRITICAL] %s", prefix)
case "WARNING":
return fmt.Sprintf("⚠️ [WARNING] %s", prefix)
case "SUCCESS":
return fmt.Sprintf("✅ [RESOLVED] %s", prefix)
case "MAINTENANCE":
return fmt.Sprintf("🔧 [MAINTENANCE] %s", prefix)
default:
return fmt.Sprintf("️ [INFO] %s", prefix)
}
}
// createHTMLEmailBody creates a rich HTML email body
func (es *EmailService) createHTMLEmailBody(message, severity, emoji string) string {
// Get color scheme based on severity
var bgColor, borderColor, textColor string
switch severity {
case "CRITICAL":
bgColor = "#fee2e2"
borderColor = "#dc2626"
textColor = "#991b1b"
case "WARNING":
bgColor = "#fef3c7"
borderColor = "#d97706"
textColor = "#92400e"
case "SUCCESS":
bgColor = "#dcfce7"
borderColor = "#16a34a"
textColor = "#15803d"
case "MAINTENANCE":
bgColor = "#fed7aa"
borderColor = "#ea580c"
textColor = "#c2410c"
default:
bgColor = "#dbeafe"
borderColor = "#2563eb"
textColor = "#1d4ed8"
}
// Format message content for HTML
htmlMessage := strings.ReplaceAll(message, "\n", "<br>")
// Enhance bullet points and formatting
htmlMessage = strings.ReplaceAll(htmlMessage, " - ", "<br>&nbsp;&nbsp;• ")
htmlMessage = strings.ReplaceAll(htmlMessage, "Service:", "<strong>Service:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Status:", "<strong>Status:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Host:", "<strong>Host:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Type:", "<strong>Type:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Response time:", "<strong>Response time:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Time:", "<strong>Time:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Domain:", "<strong>Domain:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Days Remaining:", "<strong>Days Remaining:</strong>")
htmlMessage = strings.ReplaceAll(htmlMessage, "Expiration Date:", "<strong>Expiration Date:</strong>")
return fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Alert Notification</title>
</head>
<body style="margin: 0; padding: 20px; font-family: Arial, sans-serif; background-color: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background-color: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="background-color: %s; color: %s; padding: 20px; border-radius: 8px 8px 0 0; border-left: 4px solid %s;">
<h2 style="margin: 0; font-size: 18px;">
%s Service Alert Notification
</h2>
</div>
<!-- Content -->
<div style="padding: 20px;">
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 3px solid %s;">
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #333;">
%s
</p>
</div>
<!-- Footer -->
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 12px; color: #6b7280; text-align: center;">
This is an automated notification from your monitoring system.<br>
Generated at: %s
</p>
</div>
</div>
</div>
</body>
</html>`,
bgColor, textColor, borderColor, emoji, borderColor, htmlMessage, time.Now().Format("2006-01-02 15:04:05 MST"))
}
// createPlainEmailBody creates a plain text email body
func (es *EmailService) createPlainEmailBody(message, severity, emoji string) string {
separator := strings.Repeat("=", 50)
return fmt.Sprintf(`%s
%s SERVICE ALERT NOTIFICATION
%s
%s
%s
This is an automated notification from your monitoring system.
Generated at: %s
%s`,
separator,
emoji+" "+severity,
separator,
message,
separator,
time.Now().Format("2006-01-02 15:04:05 MST"),
separator)
}
// createMIMEMessage creates a MIME message with both HTML and plain text
func (es *EmailService) createMIMEMessage(fromName, toEmail, subject, htmlBody, plainBody string) string {
boundary := fmt.Sprintf("boundary_%d", time.Now().Unix())
headers := fmt.Sprintf(`From: %s
To: %s
Subject: %s
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="%s"
`, fromName, toEmail, subject, boundary)
body := fmt.Sprintf(`--%s
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
--%s
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
%s
--%s--
`, boundary, plainBody, boundary, htmlBody, boundary)
return headers + body
}
// sendSMTPEmail sends the email using SMTP with proper authentication
func (es *EmailService) sendSMTPEmail(smtpServer string, port int, fromEmail, toEmail, password, message string) error {
addr := fmt.Sprintf("%s:%d", smtpServer, port)
// log.Printf("🔌 Connecting to SMTP server: %s", addr)
// Extract hostname from SMTP server for proper HELO
hostname := smtpServer
if strings.Contains(hostname, ".") {
// Use the SMTP server hostname for HELO
hostname = smtpServer
}
// log.Printf("🔧 Using hostname for HELO: %s", hostname)
// For port 587 (STARTTLS) - most common for authenticated SMTP
if port == 587 {
// log.Printf("🔐 Attempting STARTTLS connection with authentication...")
return es.sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message)
}
// For port 465 (SSL/TLS)
if port == 465 {
// log.Printf("🔒 Attempting SSL connection with authentication...")
return es.sendWithSSLAuth(addr, hostname, fromEmail, toEmail, password, message)
}
// For port 25 (Plain SMTP with optional STARTTLS)
if port == 25 {
// log.Printf("📧 Attempting plain SMTP with optional STARTTLS...")
return es.sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message)
}
// Fallback to STARTTLS for any other port
// log.Printf("📧 Using STARTTLS with auth fallback for port %d...", port)
return es.sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message)
}
// sendWithSTARTTLSAuth sends email with STARTTLS and authentication
func (es *EmailService) sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message string) error {
// log.Printf("🔐 Establishing STARTTLS connection to %s", addr)
// Connect to server
client, err := smtp.Dial(addr)
if err != nil {
// log.Printf("❌ Failed to connect to SMTP server: %v", err)
return fmt.Errorf("failed to connect to SMTP server: %v", err)
}
defer client.Close()
// Send EHLO with proper hostname
// log.Printf("👋 Sending EHLO with hostname: %s", hostname)
if err = client.Hello(hostname); err != nil {
// log.Printf("❌ EHLO failed: %v", err)
return fmt.Errorf("EHLO failed: %v", err)
}
// Check if STARTTLS is supported and use it
if ok, _ := client.Extension("STARTTLS"); ok {
// log.Printf("🔐 STARTTLS supported, initiating TLS...")
tlsConfig := &tls.Config{
ServerName: strings.Split(addr, ":")[0],
InsecureSkipVerify: false,
}
if err = client.StartTLS(tlsConfig); err != nil {
// log.Printf("❌ STARTTLS failed: %v", err)
return fmt.Errorf("STARTTLS failed: %v", err)
}
// log.Printf("✅ TLS connection established")
} else {
// log.Printf("⚠️ STARTTLS not supported by server, continuing with plain connection")
}
// Check for AUTH support and authenticate if available
if ok, mechanisms := client.Extension("AUTH"); ok {
// Suppress unused variable warning
_ = mechanisms
// log.Printf("🔑 AUTH extension supported with mechanisms: %s", mechanisms)
// Use the provided credentials for authentication
username := fromEmail
// log.Printf("🔐 Attempting authentication for user: %s", username)
// log.Printf("🔑 Password provided: %t", password != "")
if password != "" {
// Create auth mechanism - try PLAIN first as it's most common
auth := smtp.PlainAuth("", username, password, strings.Split(addr, ":")[0])
if err := client.Auth(auth); err != nil {
// log.Printf("❌ Authentication failed: %v", err)
return fmt.Errorf("SMTP authentication failed: %v", err)
} else {
// log.Printf("✅ Authentication successful")
}
} else {
// log.Printf("⚠️ No password provided, skipping authentication")
return fmt.Errorf("SMTP password is required for authentication")
}
} else {
// log.Printf("️ AUTH extension not available, proceeding without authentication")
}
// Set sender
// log.Printf("📤 Setting sender: %s", fromEmail)
if err = client.Mail(fromEmail); err != nil {
// log.Printf("❌ Failed to set sender: %v", err)
return fmt.Errorf("failed to set sender: %v", err)
}
// Set recipient
// log.Printf("📥 Setting recipient: %s", toEmail)
if err = client.Rcpt(toEmail); err != nil {
// log.Printf("❌ Failed to set recipient: %v", err)
return fmt.Errorf("failed to set recipient: %v", err)
}
// Send message
// log.Printf("📝 Sending message data...")
w, err := client.Data()
if err != nil {
// log.Printf("❌ Failed to initiate data transfer: %v", err)
return fmt.Errorf("failed to initiate data transfer: %v", err)
}
_, err = w.Write([]byte(message))
if err != nil {
// log.Printf("❌ Failed to write message data: %v", err)
return fmt.Errorf("failed to write message data: %v", err)
}
err = w.Close()
if err != nil {
// log.Printf("❌ Failed to close data writer: %v", err)
return fmt.Errorf("failed to close data writer: %v", err)
}
// Quit gracefully
err = client.Quit()
if err != nil {
// log.Printf("⚠️ Warning during QUIT: %v", err)
// Don't return error for QUIT issues as email might have been sent
}
// log.Printf("✅ Email sent successfully via STARTTLS")
return nil
}
// sendWithSSLAuth sends email with SSL/TLS and authentication (for port 465)
func (es *EmailService) sendWithSSLAuth(addr, hostname, fromEmail, toEmail, password, message string) error {
// log.Printf("🔒 Establishing SSL/TLS connection to %s", addr)
// Create TLS configuration
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: strings.Split(addr, ":")[0],
}
// Connect with TLS
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
// log.Printf("❌ Failed to establish TLS connection: %v", err)
return fmt.Errorf("failed to establish TLS connection: %v", err)
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, strings.Split(addr, ":")[0])
if err != nil {
// log.Printf("❌ Failed to create SMTP client: %v", err)
return fmt.Errorf("failed to create SMTP client: %v", err)
}
defer client.Quit()
// Send EHLO with proper hostname
// log.Printf("👋 Sending EHLO with hostname: %s", hostname)
if err = client.Hello(hostname); err != nil {
// log.Printf("❌ EHLO failed: %v", err)
return fmt.Errorf("EHLO failed: %v", err)
}
// Authenticate if AUTH is supported and password is provided
if ok, mechanisms := client.Extension("AUTH"); ok {
// Suppress unused variable warning
_ = mechanisms
// log.Printf("🔑 AUTH extension supported with mechanisms: %s", mechanisms)
username := fromEmail
// log.Printf("🔐 Attempting authentication for user: %s", username)
// log.Printf("🔑 Password provided: %t", password != "")
if password != "" {
auth := smtp.PlainAuth("", username, password, strings.Split(addr, ":")[0])
if err := client.Auth(auth); err != nil {
// log.Printf("❌ Authentication failed: %v", err)
return fmt.Errorf("SMTP authentication failed: %v", err)
} else {
// log.Printf("✅ Authentication successful")
}
} else {
// log.Printf("⚠️ No password provided, skipping authentication")
return fmt.Errorf("SMTP password is required for authentication")
}
}
// Set sender and recipient
// log.Printf("📤 Setting sender: %s", fromEmail)
if err := client.Mail(fromEmail); err != nil {
// log.Printf("❌ Failed to set sender: %v", err)
return fmt.Errorf("failed to set sender: %v", err)
}
// log.Printf("📥 Setting recipient: %s", toEmail)
if err := client.Rcpt(toEmail); err != nil {
// log.Printf("❌ Failed to set recipient: %v", err)
return fmt.Errorf("failed to set recipient: %v", err)
}
// Send message
// log.Printf("📝 Sending message data...")
w, err := client.Data()
if err != nil {
// log.Printf("❌ Failed to initiate data transfer: %v", err)
return fmt.Errorf("failed to initiate data transfer: %v", err)
}
_, err = w.Write([]byte(message))
if err != nil {
// log.Printf("❌ Failed to write message data: %v", err)
return fmt.Errorf("failed to write message data: %v", err)
}
err = w.Close()
if err != nil {
// log.Printf("❌ Failed to close data writer: %v", err)
return fmt.Errorf("failed to close data writer: %v", err)
}
// log.Printf("✅ Email sent successfully via SSL/TLS")
return nil
}
@@ -0,0 +1,336 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// GoogleChatService handles Google Chat notifications
type GoogleChatService struct{}
// NewGoogleChatService creates a new Google Chat notification service
func NewGoogleChatService() *GoogleChatService {
return &GoogleChatService{}
}
// GoogleChatPayload represents the payload for Google Chat webhook
type GoogleChatPayload struct {
Text string `json:"text"`
}
// SendNotification sends a notification via Google Chat webhook
func (gcs *GoogleChatService) SendNotification(config *AlertConfiguration, message string) error {
// fmt.Printf("💬 [GOOGLE_CHAT] Attempting to send notification...\n")
// fmt.Printf("💬 [GOOGLE_CHAT] Config - Webhook URL present: %v\n", config.GoogleChatWebhookURL != "")
// fmt.Printf("💬 [GOOGLE_CHAT] Message: %s\n", message)
if config.GoogleChatWebhookURL == "" {
return fmt.Errorf("google chat webhook URL is required")
}
payload := GoogleChatPayload{
Text: message,
}
jsonData, err := json.Marshal(payload)
if err != nil {
// fmt.Printf("❌ [GOOGLE_CHAT] JSON marshal error: %v\n", err)
return err
}
// fmt.Printf("💬 [GOOGLE_CHAT] Sending POST request to webhook...\n")
resp, err := http.Post(config.GoogleChatWebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
// fmt.Printf("❌ [GOOGLE_CHAT] HTTP error: %v\n", err)
return err
}
defer resp.Body.Close()
// fmt.Printf("💬 [GOOGLE_CHAT] Response status: %d\n", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
// fmt.Printf("❌ [GOOGLE_CHAT] Webhook error, status: %d\n", resp.StatusCode)
return fmt.Errorf("google chat webhook error, status: %d", resp.StatusCode)
}
// fmt.Printf("✅ [GOOGLE_CHAT] Message sent successfully!\n")
return nil
}
// SendServerNotification sends a server-specific notification via Google Chat
func (gcs *GoogleChatService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
message := gcs.generateServerMessage(payload, template, resourceType)
return gcs.SendNotification(config, message)
}
// SendServiceNotification sends a service-specific notification via Google Chat
func (gcs *GoogleChatService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := gcs.generateServiceMessage(payload, template)
return gcs.SendNotification(config, message)
}
// generateServerMessage creates a message for server notifications using server template
func (gcs *GoogleChatService) 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 = gcs.generateDefaultServerMessage(payload, resourceType)
}
return gcs.replacePlaceholders(templateMessage, payload)
}
// generateServiceMessage creates a message for service notifications using service template
func (gcs *GoogleChatService) 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 = gcs.generateDefaultUptimeMessage(payload)
}
return gcs.replacePlaceholders(templateMessage, payload)
}
// replacePlaceholders replaces all placeholders in the message with actual values
func (gcs *GoogleChatService) 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}", gcs.safeString(payload.Host))
message = strings.ReplaceAll(message, "${hostname}", gcs.safeString(payload.Hostname))
// Replace URL with fallback to host
url := gcs.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}", gcs.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}", gcs.safeString(payload.RegionName))
message = strings.ReplaceAll(message, "${agent_id}", gcs.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}", gcs.safeString(payload.CPUUsage))
message = strings.ReplaceAll(message, "${ram_usage}", gcs.safeString(payload.RAMUsage))
message = strings.ReplaceAll(message, "${disk_usage}", gcs.safeString(payload.DiskUsage))
message = strings.ReplaceAll(message, "${network_usage}", gcs.safeString(payload.NetworkUsage))
message = strings.ReplaceAll(message, "${cpu_temp}", gcs.safeString(payload.CPUTemp))
message = strings.ReplaceAll(message, "${disk_io}", gcs.safeString(payload.DiskIO))
message = strings.ReplaceAll(message, "${threshold}", gcs.safeString(payload.Threshold))
// Replace error message - important for uptime services
message = strings.ReplaceAll(message, "${error_message}", gcs.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${error}", gcs.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"))
return message
}
// safeString returns the string value or "N/A" if empty
func (gcs *GoogleChatService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
func (gcs *GoogleChatService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
// Status emoji mapping for Google Chat
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 (gcs *GoogleChatService) 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))
}
@@ -0,0 +1,61 @@
package notification
import (
"log"
"time"
"service-operation/pocketbase"
"service-operation/types"
)
// ServiceNotifier provides an easy interface to send notifications for service events
type ServiceNotifier struct {
manager *NotificationManager
}
// NewServiceNotifier creates a new service notifier
func NewServiceNotifier(pbClient *pocketbase.PocketBaseClient) *ServiceNotifier {
return &ServiceNotifier{
manager: NewNotificationManager(pbClient),
}
}
// NotifyServiceStatus sends a notification for service status change
func (sn *ServiceNotifier) NotifyServiceStatus(service pocketbase.Service, result *types.OperationResult) {
// Check if service has notification configured
if service.NotificationID == "" {
return
}
// Create notification payload
payload := &NotificationPayload{
ServiceName: service.Name,
Status: service.Status,
Host: service.Host,
Port: service.Port,
ServiceType: service.ServiceType,
ResponseTime: result.ResponseTime.Milliseconds(),
Timestamp: time.Now(),
ErrorMessage: result.Error,
}
// Send notification
if err := sn.manager.SendServiceNotification(payload, service.NotificationID, service.TemplateID); err != nil {
log.Printf("Failed to send notification for service %s: %v", service.Name, err)
} else {
log.Printf("Notification sent successfully for service %s", service.Name)
}
}
// NotifyCustom sends a custom notification
func (sn *ServiceNotifier) NotifyCustom(notificationID, templateID, serviceName, status, message string) error {
payload := &NotificationPayload{
ServiceName: serviceName,
Status: status,
Message: message,
Timestamp: time.Now(),
}
return sn.manager.SendServiceNotification(payload, notificationID, templateID)
}
@@ -0,0 +1,67 @@
package notification
import (
// "log"
"service-operation/pocketbase"
)
// NotificationManager handles all notification operations
type NotificationManager struct {
pbClient *pocketbase.PocketBaseClient
services map[string]NotificationService
serverManager *ServerNotificationManager
uptimeManager *UptimeNotificationManager
sslManager *SSLNotificationManager
}
// NewNotificationManager creates a new notification manager
func NewNotificationManager(pbClient *pocketbase.PocketBaseClient) *NotificationManager {
// Initialize notification services
services := make(map[string]NotificationService)
// log.Printf("🔧 Initializing notification services...")
services["telegram"] = NewTelegramService()
services["signal"] = NewSignalService()
services["discord"] = NewDiscordService()
services["slack"] = NewSlackService()
services["google_chat"] = NewGoogleChatService()
services["email"] = NewEmailService()
services["webhook"] = NewWebhookService()
// log.Printf("✅ Notification services initialized: %v", getKeys(services))
// Create specialized managers
serverManager := NewServerNotificationManager(pbClient, services)
uptimeManager := NewUptimeNotificationManager(pbClient, services)
sslManager := NewSSLNotificationManager(pbClient, services)
return &NotificationManager{
pbClient: pbClient,
services: services,
serverManager: serverManager,
uptimeManager: uptimeManager,
sslManager: sslManager,
}
}
// SendServiceNotification sends notification for a service based on its configuration
func (nm *NotificationManager) SendServiceNotification(payload *NotificationPayload, notificationID, templateID string) error {
return nm.serverManager.SendServiceNotification(payload, notificationID, templateID)
}
// SendResourceNotification sends notification for specific resource alerts (CPU, RAM, Disk, etc.)
func (nm *NotificationManager) SendResourceNotification(payload *NotificationPayload, notificationID, templateID, resourceType string) error {
return nm.serverManager.SendResourceNotification(payload, notificationID, templateID, resourceType)
}
// SendUptimeServiceNotification sends notification for uptime services using service templates
func (nm *NotificationManager) SendUptimeServiceNotification(payload *NotificationPayload, notificationID, templateID string) error {
return nm.uptimeManager.SendUptimeServiceNotification(payload, notificationID, templateID)
}
// SendSSLNotification sends notification for SSL certificates using SSL templates
func (nm *NotificationManager) SendSSLNotification(payload *NotificationPayload, notificationID, templateID string) error {
return nm.sslManager.SendSSLNotification(payload, notificationID, templateID)
}
@@ -0,0 +1,480 @@
package notification
import (
"fmt"
"strings"
"service-operation/pocketbase"
)
// ServerNotificationManager handles server-specific notifications
type ServerNotificationManager struct {
pbClient *pocketbase.PocketBaseClient
services map[string]NotificationService
}
// NewServerNotificationManager creates a new server notification manager
func NewServerNotificationManager(pbClient *pocketbase.PocketBaseClient, services map[string]NotificationService) *ServerNotificationManager {
return &ServerNotificationManager{
pbClient: pbClient,
services: services,
}
}
// SendServiceNotification sends notification for a service based on its configuration
func (snm *ServerNotificationManager) SendServiceNotification(payload *NotificationPayload, notificationID, templateID string) error {
// log.Printf("📨 SendServiceNotification called with notification_id: %s, template_id: %s", notificationID, templateID)
// log.Printf("📨 Payload: %+v", payload)
if notificationID == "" {
// log.Printf("❌ Notification ID is required but was empty")
return fmt.Errorf("notification ID is required")
}
// Parse multiple notification IDs
notificationIDs := parseNotificationIDs(notificationID)
if len(notificationIDs) == 0 {
// log.Printf("❌ No valid notification IDs found")
return fmt.Errorf("no valid notification IDs found")
}
var errors []string
successCount := 0
// Send notification to each channel
for _, id := range notificationIDs {
// log.Printf("📤 Processing notification ID: %s", id)
// Check if notification is enabled
if !isNotificationEnabled(snm.pbClient, id) {
// log.Printf("⚠️ Notification %s is disabled, skipping", id)
continue
}
// Get alert configuration
alertConfig, err := getAlertConfiguration(snm.pbClient, id)
if err != nil {
// log.Printf("❌ Failed to get alert configuration for %s: %v", id, err)
errors = append(errors, fmt.Sprintf("failed to get config for %s: %v", id, err))
continue
}
// Get template if provided
var template *ServerNotificationTemplate
if templateID != "" {
template, err = getNotificationTemplate(snm.pbClient, templateID)
if err != nil {
// log.Printf("⚠️ Warning: failed to get template %s for notification %s: %v", templateID, id, err)
}
}
// Generate message from template or use default
message := snm.generateMessage(payload, template)
// log.Printf("📝 Generated message for %s: %s", id, message)
// Send notification using appropriate service
service, exists := snm.services[alertConfig.NotificationType]
if !exists {
// log.Printf("❌ Unsupported notification type for %s: %s", id, alertConfig.NotificationType)
errors = append(errors, fmt.Sprintf("unsupported notification type for %s: %s", id, alertConfig.NotificationType))
continue
}
err = service.SendNotification(alertConfig, message)
if err != nil {
// log.Printf("❌ Failed to send notification via %s for %s: %v", alertConfig.NotificationType, id, err)
errors = append(errors, fmt.Sprintf("failed to send via %s for %s: %v", alertConfig.NotificationType, id, err))
} else {
// log.Printf("✅ Successfully sent notification via %s for %s", alertConfig.NotificationType, id)
successCount++
}
}
// Report results
if successCount > 0 {
// log.Printf("✅ Successfully sent %d out of %d notifications", successCount, len(notificationIDs))
}
if len(errors) > 0 {
// log.Printf("❌ Errors occurred: %v", errors)
if successCount == 0 {
return fmt.Errorf("all notifications failed: %v", errors)
}
// If some succeeded, just log errors but don't return error
// log.Printf("⚠️ Some notifications failed but %d succeeded", successCount)
}
return nil
}
// SendResourceNotification sends notification for specific resource alerts (CPU, RAM, Disk, etc.)
func (snm *ServerNotificationManager) SendResourceNotification(payload *NotificationPayload, notificationID, templateID, resourceType string) error {
// log.Printf("📨 SendResourceNotification called for resource: %s", resourceType)
// log.Printf("📨 Notification ID: %s, Template ID: %s", notificationID, templateID)
// log.Printf("📨 Payload: %+v", payload)
if notificationID == "" {
// log.Printf("❌ Notification ID is required but was empty")
return fmt.Errorf("notification ID is required")
}
// Parse multiple notification IDs
notificationIDs := parseNotificationIDs(notificationID)
if len(notificationIDs) == 0 {
// log.Printf("❌ No valid notification IDs found")
return fmt.Errorf("no valid notification IDs found")
}
var errors []string
successCount := 0
// Send notification to each channel
for _, id := range notificationIDs {
// log.Printf("📤 Processing resource notification ID: %s", id)
// Check if notification is enabled
if !isNotificationEnabled(snm.pbClient, id) {
// log.Printf("⚠️ Notification %s is disabled, skipping", id)
continue
}
// Get alert configuration
alertConfig, err := getAlertConfiguration(snm.pbClient, id)
if err != nil {
// log.Printf("❌ Failed to get alert configuration for %s: %v", id, err)
errors = append(errors, fmt.Sprintf("failed to get config for %s: %v", id, err))
continue
}
// Get template if provided
var template *ServerNotificationTemplate
if templateID != "" {
template, err = getNotificationTemplate(snm.pbClient, templateID)
if err != nil {
// log.Printf("⚠️ Warning: failed to get template %s for notification %s: %v", templateID, id, err)
}
}
// Generate resource-specific message
message := snm.generateResourceMessage(payload, template, resourceType)
// log.Printf("📝 Generated resource message for %s: %s", id, message)
// Send notification
service, exists := snm.services[alertConfig.NotificationType]
if !exists {
// log.Printf("❌ Unsupported notification type for %s: %s", id, alertConfig.NotificationType)
errors = append(errors, fmt.Sprintf("unsupported notification type for %s: %s", id, alertConfig.NotificationType))
continue
}
err = service.SendNotification(alertConfig, message)
if err != nil {
// log.Printf("❌ Failed to send resource notification via %s for %s: %v", alertConfig.NotificationType, id, err)
errors = append(errors, fmt.Sprintf("failed to send via %s for %s: %v", alertConfig.NotificationType, id, err))
} else {
// log.Printf("✅ Successfully sent resource notification via %s for %s", alertConfig.NotificationType, id)
successCount++
}
}
// Report results
if successCount > 0 {
// log.Printf("✅ Successfully sent %d out of %d resource notifications", successCount, len(notificationIDs))
}
if len(errors) > 0 {
// log.Printf("❌ Resource notification errors occurred: %v", errors)
if successCount == 0 {
return fmt.Errorf("all resource notifications failed: %v", errors)
}
// If some succeeded, just log errors but don't return error
// log.Printf("⚠️ Some resource notifications failed but %d succeeded", successCount)
}
return nil
}
// generateResourceMessage creates notification message for specific resource alerts
func (snm *ServerNotificationManager) generateResourceMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
var baseMessage string
// Select appropriate resource message from template based on status
if template != nil {
// log.Printf("🔧 Using template for resource type: %s with status: %s", resourceType, payload.Status)
// Check if this is a recovery/restore notification
if payload.Status == "up" {
// Use restore messages for recovery notifications
switch strings.ToLower(resourceType) {
case "cpu":
baseMessage = template.RestoreCPUMessage
case "ram", "memory":
baseMessage = template.RestoreRAMMessage
case "disk":
baseMessage = template.RestoreDiskMessage
case "network":
baseMessage = template.RestoreNetworkMessage
case "cpu_temp", "cpu_temperature":
baseMessage = template.RestoreCPUTempMessage
case "disk_io":
baseMessage = template.RestoreDiskIOMessage
default:
baseMessage = template.UpMessage // fallback to general up message
}
} else {
// Use regular alert messages for warning/down notifications
switch strings.ToLower(resourceType) {
case "cpu":
baseMessage = template.CPUMessage
case "ram", "memory":
baseMessage = template.RAMMessage
case "disk":
baseMessage = template.DiskMessage
case "network":
baseMessage = template.NetworkMessage
case "cpu_temp", "cpu_temperature":
baseMessage = template.CPUTempMessage
case "disk_io":
baseMessage = template.DiskIOMessage
default:
baseMessage = template.WarningMessage // fallback to warning message
}
}
// log.Printf("📝 Template message selected for %s (status: %s): %s", resourceType, payload.Status, baseMessage)
}
// If no template or empty message, use default
if baseMessage == "" {
// log.Printf("🔧 Using default message for resource %s", resourceType)
baseMessage = snm.getDefaultResourceMessage(payload, resourceType)
}
// Replace placeholders with actual values
message := snm.replacePlaceholders(baseMessage, payload)
// log.Printf("📝 Final resource message after placeholder replacement: %s", message)
return message
}
// getDefaultResourceMessage provides default messages for resource alerts
func (snm *ServerNotificationManager) getDefaultResourceMessage(payload *NotificationPayload, resourceType string) string {
statusEmoji := "⚠️"
statusText := "Alert"
if payload.Status == "up" {
statusEmoji = "✅"
statusText = "Recovery"
} else if payload.Status == "down" {
statusEmoji = "❌"
statusText = "Critical"
}
switch strings.ToLower(resourceType) {
case "cpu":
if payload.Status == "up" {
return fmt.Sprintf("%s CPU %s: Server %s CPU usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.CPUUsage)
}
return fmt.Sprintf("%s CPU %s: Server %s CPU usage is %s", statusEmoji, statusText, payload.ServiceName, payload.CPUUsage)
case "ram", "memory":
if payload.Status == "up" {
return fmt.Sprintf("%s RAM %s: Server %s RAM usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.RAMUsage)
}
return fmt.Sprintf("%s RAM %s: Server %s RAM usage is %s", statusEmoji, statusText, payload.ServiceName, payload.RAMUsage)
case "disk":
if payload.Status == "up" {
return fmt.Sprintf("%s Disk %s: Server %s disk usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.DiskUsage)
}
return fmt.Sprintf("%s Disk %s: Server %s disk usage is %s", statusEmoji, statusText, payload.ServiceName, payload.DiskUsage)
case "network":
if payload.Status == "up" {
return fmt.Sprintf("%s Network %s: Server %s network usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.NetworkUsage)
}
return fmt.Sprintf("%s Network %s: Server %s network usage is %s", statusEmoji, statusText, payload.ServiceName, payload.NetworkUsage)
case "cpu_temp", "cpu_temperature":
if payload.Status == "up" {
return fmt.Sprintf("%s CPU Temperature %s: Server %s CPU temperature has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.CPUTemp)
}
return fmt.Sprintf("%s CPU Temperature %s: Server %s CPU temperature is %s", statusEmoji, statusText, payload.ServiceName, payload.CPUTemp)
case "disk_io":
if payload.Status == "up" {
return fmt.Sprintf("%s Disk I/O %s: Server %s disk I/O has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.DiskIO)
}
return fmt.Sprintf("%s Disk I/O %s: Server %s disk I/O is %s", statusEmoji, statusText, payload.ServiceName, payload.DiskIO)
default:
if payload.Status == "up" {
return fmt.Sprintf("%s Resource %s: Server %s %s has recovered", statusEmoji, statusText, payload.ServiceName, payload.Message)
}
return fmt.Sprintf("%s Resource %s: Server %s %s", statusEmoji, statusText, payload.ServiceName, payload.Message)
}
}
// generateMessage creates notification message from template or default
func (snm *ServerNotificationManager) generateMessage(payload *NotificationPayload, template *ServerNotificationTemplate) string {
var baseMessage string
// Select appropriate message based on status and template
if template != nil {
// log.Printf("🔧 Using template for status: %s", strings.ToLower(payload.Status))
switch strings.ToLower(payload.Status) {
case "up":
baseMessage = template.UpMessage
case "down":
baseMessage = template.DownMessage
case "warning":
baseMessage = template.WarningMessage
case "paused":
baseMessage = template.PausedMessage
default:
baseMessage = template.UpMessage // fallback
}
// log.Printf("📝 Template message selected: %s", baseMessage)
}
// If no template or empty message, use default
if baseMessage == "" {
// log.Printf("🔧 Using default message (no template or empty template message)")
baseMessage = snm.getDefaultMessage(payload)
}
// Replace placeholders with actual values
message := snm.replacePlaceholders(baseMessage, payload)
// log.Printf("📝 Final message after placeholder replacement: %s", message)
return message
}
// replacePlaceholders replaces all placeholders in the message with actual values
func (snm *ServerNotificationManager) replacePlaceholders(message string, payload *NotificationPayload) string {
// Service/Server name placeholders
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // For server notifications
// Status placeholder
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
// Host/IP placeholders
message = strings.ReplaceAll(message, "${host}", payload.Host)
message = strings.ReplaceAll(message, "${ip}", payload.Host) // Alternative placeholder
message = strings.ReplaceAll(message, "${ip_address}", payload.Host) // New server monitoring placeholder
// Hostname placeholder
message = strings.ReplaceAll(message, "${hostname}", payload.Hostname)
// Port placeholder
if payload.Port > 0 {
message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port))
} else {
message = strings.ReplaceAll(message, "${port}", "N/A")
}
// Response time placeholder
if payload.ResponseTime > 0 {
message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime))
} else {
message = strings.ReplaceAll(message, "${response_time}", "N/A")
}
// Timestamp placeholders
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02"))
// Service type placeholder
message = strings.ReplaceAll(message, "${service_type}", payload.ServiceType)
// Error message placeholder
if payload.ErrorMessage != "" {
message = strings.ReplaceAll(message, "${error}", payload.ErrorMessage)
message = strings.ReplaceAll(message, "${error_message}", payload.ErrorMessage)
} else {
message = strings.ReplaceAll(message, "${error}", "")
message = strings.ReplaceAll(message, "${error_message}", "")
}
// Custom message placeholder
if payload.Message != "" {
message = strings.ReplaceAll(message, "${message}", payload.Message)
}
// Server monitoring specific placeholders
if payload.CPUUsage != "" {
message = strings.ReplaceAll(message, "${cpu_usage}", payload.CPUUsage)
} else {
message = strings.ReplaceAll(message, "${cpu_usage}", "N/A")
}
if payload.RAMUsage != "" {
message = strings.ReplaceAll(message, "${ram_usage}", payload.RAMUsage)
} else {
message = strings.ReplaceAll(message, "${ram_usage}", "N/A")
}
if payload.DiskUsage != "" {
message = strings.ReplaceAll(message, "${disk_usage}", payload.DiskUsage)
} else {
message = strings.ReplaceAll(message, "${disk_usage}", "N/A")
}
if payload.NetworkUsage != "" {
message = strings.ReplaceAll(message, "${network_usage}", payload.NetworkUsage)
} else {
message = strings.ReplaceAll(message, "${network_usage}", "N/A")
}
if payload.CPUTemp != "" {
message = strings.ReplaceAll(message, "${cpu_temp}", payload.CPUTemp)
} else {
message = strings.ReplaceAll(message, "${cpu_temp}", "N/A")
}
if payload.DiskIO != "" {
message = strings.ReplaceAll(message, "${disk_io}", payload.DiskIO)
} else {
message = strings.ReplaceAll(message, "${disk_io}", "N/A")
}
if payload.Threshold != "" {
message = strings.ReplaceAll(message, "${threshold}", payload.Threshold)
} else {
message = strings.ReplaceAll(message, "${threshold}", "N/A")
}
return message
}
// getDefaultMessage provides a default notification message
func (snm *ServerNotificationManager) getDefaultMessage(payload *NotificationPayload) string {
statusEmoji := "✅"
if payload.Status == "down" {
statusEmoji = "❌"
} else if payload.Status == "warning" {
statusEmoji = "⚠️"
}
message := fmt.Sprintf("%s Server Alert\n\n", statusEmoji)
message += fmt.Sprintf("Server: %s\n", payload.ServiceName)
message += fmt.Sprintf("Status: %s\n", strings.ToUpper(payload.Status))
message += fmt.Sprintf("IP Address: %s\n", payload.Host)
if payload.CPUUsage != "" {
message += fmt.Sprintf("CPU Usage: %s\n", payload.CPUUsage)
}
if payload.RAMUsage != "" {
message += fmt.Sprintf("RAM Usage: %s\n", payload.RAMUsage)
}
if payload.DiskUsage != "" {
message += fmt.Sprintf("Disk Usage: %s\n", payload.DiskUsage)
}
message += fmt.Sprintf("Time: %s\n", payload.Timestamp.Format("2006-01-02 15:04:05"))
if payload.ErrorMessage != "" {
message += fmt.Sprintf("Error: %s\n", payload.ErrorMessage)
}
return message
}
@@ -0,0 +1,141 @@
package notification
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"service-operation/pocketbase"
)
// parseNotificationIDs parses comma-separated notification IDs
func parseNotificationIDs(notificationID string) []string {
if notificationID == "" {
return []string{}
}
// Split by comma and trim whitespace
ids := strings.Split(notificationID, ",")
var cleanIDs []string
for _, id := range ids {
cleanID := strings.TrimSpace(id)
if cleanID != "" {
cleanIDs = append(cleanIDs, cleanID)
}
}
//log.Printf("📋 Parsed notification IDs: %v", cleanIDs)
return cleanIDs
}
// isNotificationEnabled checks if the notification is enabled
func isNotificationEnabled(pbClient *pocketbase.PocketBaseClient, notificationID string) bool {
config, err := getAlertConfiguration(pbClient, notificationID)
if err != nil {
//log.Printf("❌ Error getting alert configuration for enabled check: %v", err)
return false
}
enabled, err := strconv.ParseBool(config.Enabled)
if err != nil {
//log.Printf("❌ Error parsing enabled field: %v", err)
return false
}
//log.Printf("️ Notification %s enabled status: %t", notificationID, enabled)
return enabled
}
// getAlertConfiguration fetches alert configuration from PocketBase
func getAlertConfiguration(pbClient *pocketbase.PocketBaseClient, notificationID string) (*AlertConfiguration, error) {
url := fmt.Sprintf("%s/api/collections/alert_configurations/records/%s", pbClient.GetBaseURL(), notificationID)
//log.Printf("🌐 Fetching alert configuration from: %s", url)
resp, err := http.Get(url)
if err != nil {
//log.Printf("❌ HTTP error fetching alert configuration: %v", err)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("❌ Failed to fetch alert configuration, status: %d", resp.StatusCode)
return nil, fmt.Errorf("failed to fetch alert configuration, status: %d", resp.StatusCode)
}
var config AlertConfiguration
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
//log.Printf("❌ Error decoding alert configuration JSON: %v", err)
return nil, err
}
//log.Printf("✅ Successfully fetched alert configuration: %+v", config)
return &config, nil
}
// getNotificationTemplate fetches server notification template from PocketBase
func getNotificationTemplate(pbClient *pocketbase.PocketBaseClient, templateID string) (*ServerNotificationTemplate, error) {
url := fmt.Sprintf("%s/api/collections/server_notification_templates/records/%s", pbClient.GetBaseURL(), templateID)
//log.Printf("🌐 Fetching notification template from: %s", url)
resp, err := http.Get(url)
if err != nil {
//log.Printf("❌ HTTP error fetching notification template: %v", err)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("❌ Failed to fetch notification template, status: %d", resp.StatusCode)
return nil, fmt.Errorf("failed to fetch notification template, status: %d", resp.StatusCode)
}
var template ServerNotificationTemplate
if err := json.NewDecoder(resp.Body).Decode(&template); err != nil {
//log.Printf("❌ Error decoding notification template JSON: %v", err)
return nil, err
}
//log.Printf("✅ Successfully fetched notification template: %+v", template)
return &template, nil
}
// getServiceNotificationTemplate fetches service notification template from PocketBase
func getServiceNotificationTemplate(pbClient *pocketbase.PocketBaseClient, templateID string) (*ServiceNotificationTemplate, error) {
url := fmt.Sprintf("%s/api/collections/service_notification_templates/records/%s", pbClient.GetBaseURL(), templateID)
//log.Printf("🌐 Fetching service notification template from: %s", url)
resp, err := http.Get(url)
if err != nil {
//log.Printf("❌ HTTP error fetching service notification template: %v", err)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
//log.Printf("❌ Failed to fetch service notification template, status: %d", resp.StatusCode)
return nil, fmt.Errorf("failed to fetch service notification template, status: %d", resp.StatusCode)
}
var template ServiceNotificationTemplate
if err := json.NewDecoder(resp.Body).Decode(&template); err != nil {
//log.Printf("❌ Error decoding service notification template JSON: %v", err)
return nil, err
}
//log.Printf("✅ Successfully fetched service notification template: %+v", template)
return &template, nil
}
// Helper function to get map keys
func getKeys(m map[string]NotificationService) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
@@ -0,0 +1,371 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// SignalService handles Signal notifications
type SignalService struct{}
// NewSignalService creates a new Signal notification service
func NewSignalService() *SignalService {
return &SignalService{}
}
// SignalPayload represents the payload for Signal REST API
type SignalPayload struct {
Number string `json:"number"`
Recipients []string `json:"recipients"`
Message string `json:"message"`
}
// SendNotification sends a notification via Signal REST API
func (ss *SignalService) SendNotification(config *AlertConfiguration, message string) error {
// fmt.Printf("📱 [SIGNAL] Attempting to send notification...\n")
// fmt.Printf("📱 [SIGNAL] Config - Phone Number: %s, API Endpoint: %s\n", config.SignalNumber, config.SignalAPIEndpoint)
// fmt.Printf("📱 [SIGNAL] Message: %s\n", message)
if config.SignalNumber == "" {
return fmt.Errorf("signal phone number is required")
}
if config.SignalAPIEndpoint == "" {
return fmt.Errorf("signal API endpoint is required")
}
// Use the configured endpoint directly (it should already include the full path like /v2/send)
apiURL := config.SignalAPIEndpoint
// fmt.Printf("📱 [SIGNAL] API URL: %s\n", apiURL)
// Prepare payload for Signal REST API
payload := SignalPayload{
Number: config.SignalNumber, // This should be the sender's number (registered with signal-cli)
Recipients: []string{config.SignalNumber}, // Send to the same number for now
Message: message,
}
jsonData, err := json.Marshal(payload)
if err != nil {
// fmt.Printf("❌ [SIGNAL] JSON marshal error: %v\n", err)
return fmt.Errorf("failed to marshal signal payload: %v", err)
}
// fmt.Printf("📱 [SIGNAL] Payload: %s\n", string(jsonData))
// fmt.Printf("📱 [SIGNAL] Sending POST request...\n")
// Send POST request to Signal REST API
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
// fmt.Printf("❌ [SIGNAL] HTTP error: %v\n", err)
return fmt.Errorf("failed to send signal request: %v", err)
}
defer resp.Body.Close()
// fmt.Printf("📱 [SIGNAL] Response Status: %d\n", resp.StatusCode)
// Check if the request was successful
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
// fmt.Printf("❌ [SIGNAL] HTTP Status error: %d\n", resp.StatusCode)
// Try to read error response
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err == nil {
// fmt.Printf("❌ [SIGNAL] Error response: %+v\n", errorResponse)
return fmt.Errorf("signal API error (status %d): %v", resp.StatusCode, errorResponse)
}
return fmt.Errorf("signal API returned status %d", resp.StatusCode)
}
// Parse response
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// fmt.Printf("❌ [SIGNAL] JSON decode error: %v\n", err)
// Don't fail if we can't decode response but got success status
// fmt.Printf("✅ [SIGNAL] Message sent successfully (response decode failed but status was OK)!\n")
return nil
}
// fmt.Printf("📱 [SIGNAL] Response: %+v\n", result)
// fmt.Printf("✅ [SIGNAL] Message sent successfully!\n")
return nil
}
// SendServerNotification sends a server-specific notification via Signal
func (ss *SignalService) 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 Signal
func (ss *SignalService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := ss.generateServiceMessage(payload, template)
return ss.SendNotification(config, message)
}
// generateServerMessage creates a message for server notifications using server template
func (ss *SignalService) 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 *SignalService) 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 *SignalService) 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 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"))
return message
}
// safeString returns the string value or "N/A" if empty
func (ss *SignalService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
func (ss *SignalService) 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 *SignalService) 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))
}
@@ -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))
}
@@ -0,0 +1,285 @@
package notification
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"service-operation/pocketbase"
)
// SSLNotificationTemplate represents an SSL notification template
type SSLNotificationTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
Expired string `json:"expired"`
ExpiringSoon string `json:"exiring_soon"`
Warning string `json:"warning"`
Placeholder string `json:"placeholder"`
}
// SSLNotificationManager handles SSL certificate notifications
type SSLNotificationManager struct {
pbClient *pocketbase.PocketBaseClient
services map[string]NotificationService
}
// NewSSLNotificationManager creates a new SSL notification manager
func NewSSLNotificationManager(pbClient *pocketbase.PocketBaseClient, services map[string]NotificationService) *SSLNotificationManager {
return &SSLNotificationManager{
pbClient: pbClient,
services: services,
}
}
// SendSSLNotification sends notification for SSL certificate status
func (snm *SSLNotificationManager) SendSSLNotification(payload *NotificationPayload, notificationID, templateID string) error {
// log.Printf("📨 [SSL-MANAGER] IMMEDIATE send for certificate: %s, status: %s", payload.Domain, payload.Status)
// log.Printf("📨 [SSL-MANAGER] Notification ID: %s, Template ID: %s", notificationID, templateID)
if notificationID == "" {
return fmt.Errorf("notification ID required for SSL certificate: %s", payload.Domain)
}
// Parse notification IDs
notificationIDs := parseNotificationIDs(notificationID)
if len(notificationIDs) == 0 {
return fmt.Errorf("no valid notification IDs for SSL certificate: %s", payload.Domain)
}
var errors []string
successCount := 0
// Send to each notification channel IMMEDIATELY
for _, id := range notificationIDs {
// log.Printf("📤 [SSL-SEND] Processing notification ID: %s for certificate %s", id, payload.Domain)
// Check if enabled
if !isNotificationEnabled(snm.pbClient, id) {
// log.Printf("⚠️ Notification %s disabled for SSL certificate %s, skipping", id, payload.Domain)
_ = id
continue
}
// Get alert configuration
alertConfig, err := getAlertConfiguration(snm.pbClient, id)
if err != nil {
// log.Printf("❌ Failed to get alert config for %s (certificate: %s): %v", id, payload.Domain, err)
errors = append(errors, fmt.Sprintf("config error %s: %v", id, err))
continue
}
// log.Printf("📋 [SSL-CONFIG] Alert config for %s: Type=%s, ChatID=%s, Token present=%v",
// id, alertConfig.NotificationType, alertConfig.TelegramChatID, alertConfig.BotToken != "")
// Get SSL template
var sslTemplate *SSLNotificationTemplate
if templateID != "" {
sslTemplate, err = snm.getSSLNotificationTemplate(templateID)
if err != nil {
// log.Printf("⚠️ Template error for %s (certificate: %s): %v", templateID, payload.Domain, err)
_ = err
} else {
// log.Printf("📄 [SSL-TEMPLATE] Retrieved template: Expired=%s, ExpiringSoon=%s, Warning=%s",
// sslTemplate.Expired, sslTemplate.ExpiringSoon, sslTemplate.Warning)
_ = sslTemplate
}
}
// Generate message
message := snm.generateSSLMessage(payload, sslTemplate)
// log.Printf("📝 [SSL-MESSAGE] Generated for %s (%s): %s", payload.Domain, id, message)
// Get notification service
service, exists := snm.services[alertConfig.NotificationType]
if !exists {
// log.Printf("❌ Unsupported notification type for SSL: %s", alertConfig.NotificationType)
errors = append(errors, fmt.Sprintf("unsupported type %s", alertConfig.NotificationType))
continue
}
// SEND IMMEDIATELY - NO DELAYS
// log.Printf("⚡ [SSL-TELEGRAM] Sending via %s for SSL certificate %s", alertConfig.NotificationType, payload.Domain)
err = service.SendNotification(alertConfig, message)
if err != nil {
// log.Printf("❌ [SSL-FAILED] Failed to send via %s for %s: %v", alertConfig.NotificationType, payload.Domain, err)
errors = append(errors, fmt.Sprintf("send failed %s: %v", alertConfig.NotificationType, err))
} else {
// log.Printf("✅ [SSL-SUCCESS] Successfully sent via %s for %s", alertConfig.NotificationType, payload.Domain)
successCount++
}
_ = alertConfig
_ = message
}
// Report results
if successCount > 0 {
// log.Printf("✅ [SSL-FINAL] Sent %d/%d SSL notifications for %s", successCount, len(notificationIDs), payload.Domain)
_ = successCount
_ = notificationIDs
}
if len(errors) > 0 && successCount == 0 {
return fmt.Errorf("all SSL notifications failed for %s: %v", payload.Domain, errors)
}
return nil
}
// getSSLNotificationTemplate fetches SSL notification template from PocketBase
func (snm *SSLNotificationManager) getSSLNotificationTemplate(templateID string) (*SSLNotificationTemplate, error) {
url := fmt.Sprintf("%s/api/collections/ssl_notification_templates/records/%s", snm.pbClient.GetBaseURL(), templateID)
// log.Printf("🌐 Fetching SSL notification template from: %s", url)
resp, err := http.Get(url)
if err != nil {
// log.Printf("❌ HTTP error fetching SSL notification template: %v", err)
_ = err
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// log.Printf("❌ Failed to fetch SSL notification template, status: %d", resp.StatusCode)
return nil, fmt.Errorf("failed to fetch SSL notification template, status: %d", resp.StatusCode)
}
var template SSLNotificationTemplate
if err := json.NewDecoder(resp.Body).Decode(&template); err != nil {
// log.Printf("❌ Error decoding SSL notification template JSON: %v", err)
_ = err
return nil, err
}
// log.Printf("✅ Successfully fetched SSL notification template: %+v", template)
_ = url
_ = template
return &template, nil
}
// generateSSLMessage creates notification message for SSL certificates
func (snm *SSLNotificationManager) generateSSLMessage(payload *NotificationPayload, template *SSLNotificationTemplate) string {
var baseMessage string
// Use template if available
if template != nil {
// log.Printf("🔧 Using SSL template for status: %s", strings.ToLower(payload.Status))
// log.Printf("🔧 [SSL-TEMPLATE-DEBUG] Available templates - Expired: '%s', ExpiringSoon: '%s', Warning: '%s'",
// template.Expired, template.ExpiringSoon, template.Warning)
switch strings.ToLower(payload.Status) {
case "expired":
baseMessage = template.Expired
// log.Printf("🔧 [SSL-EXPIRED] Selected expired template: '%s'", baseMessage)
case "expiring_soon":
baseMessage = template.ExpiringSoon
// log.Printf("🔧 [SSL-EXPIRING] Selected expiring soon template: '%s'", baseMessage)
case "warning":
baseMessage = template.Warning
// log.Printf("🔧 [SSL-WARNING] Selected warning template: '%s'", baseMessage)
default:
baseMessage = template.Warning
// log.Printf("🔧 [SSL-DEFAULT] Using warning template for status '%s': '%s'", payload.Status, baseMessage)
}
_ = baseMessage
_ = template
}
// Use default if no template or template message is empty
if baseMessage == "" {
// log.Printf("🔧 Using default SSL message (no template or empty template)")
baseMessage = snm.getDefaultSSLMessage(payload)
}
// Replace placeholders
message := snm.replaceSSLPlaceholders(baseMessage, payload)
// log.Printf("📝 Final SSL message: %s", message)
_ = message
return message
}
// replaceSSLPlaceholders replaces all placeholders in the SSL message
func (snm *SSLNotificationManager) replaceSSLPlaceholders(message string, payload *NotificationPayload) string {
// log.Printf("🔄 [SSL-REPLACE] Before replacement: %s", message)
// log.Printf("🔄 [SSL-DATA] Payload data - Domain: %s, ExpiryDate: %s, DaysLeft: %s, IssuerCN: %s",
// payload.Domain, payload.ExpiryDate, payload.DaysLeft, payload.IssuerCN)
// SSL Certificate specific placeholders - ensure all are replaced
message = strings.ReplaceAll(message, "${domain}", snm.safeString(payload.Domain))
message = strings.ReplaceAll(message, "${certificate_name}", snm.safeString(payload.CertificateName))
message = strings.ReplaceAll(message, "${expiry_date}", snm.safeString(payload.ExpiryDate))
message = strings.ReplaceAll(message, "${days_left}", snm.safeString(payload.DaysLeft))
message = strings.ReplaceAll(message, "${issuer_cn}", snm.safeString(payload.IssuerCN))
message = strings.ReplaceAll(message, "${serial_number}", snm.safeString(payload.SerialNumber))
// Basic placeholders
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
message = strings.ReplaceAll(message, "${service_name}", snm.safeString(payload.ServiceName))
message = strings.ReplaceAll(message, "${host}", snm.safeString(payload.Host))
// Time placeholders
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("15:04:05"))
message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02"))
// Message placeholder
if payload.Message != "" {
message = strings.ReplaceAll(message, "${message}", payload.Message)
} else {
message = strings.ReplaceAll(message, "${message}", "N/A")
}
// log.Printf("🔄 [SSL-REPLACE] After replacement: %s", message)
_ = payload
return message
}
// safeString returns the string value or "N/A" if empty
func (snm *SSLNotificationManager) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// getDefaultSSLMessage provides a default notification message for SSL certificates
func (snm *SSLNotificationManager) getDefaultSSLMessage(payload *NotificationPayload) string {
statusEmoji := "🔒"
if payload.Status == "expired" {
statusEmoji = "🚨"
} else if payload.Status == "expiring_soon" {
statusEmoji = "⚠️"
} else if payload.Status == "warning" {
statusEmoji = "🔔"
}
// Create the default message with all SSL details
message := fmt.Sprintf("%s SSL certificate for %s has %s", statusEmoji, payload.Domain, strings.ToUpper(payload.Status))
// Add certificate details if available
if payload.CertificateName != "" && payload.CertificateName != payload.Domain {
message += fmt.Sprintf("\n • Certs Name: %s", payload.CertificateName)
}
if payload.ExpiryDate != "" {
message += fmt.Sprintf("\n • Expiry Date: %s", payload.ExpiryDate)
}
if payload.DaysLeft != "" {
message += fmt.Sprintf("\n • Days Left: %s", payload.DaysLeft)
}
if payload.IssuerCN != "" {
message += fmt.Sprintf("\n • Issuer: %s", payload.IssuerCN)
}
// Add timestamp
message += fmt.Sprintf("\n • Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))
return message
}
@@ -0,0 +1,347 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// TelegramService handles Telegram notifications
type TelegramService struct{}
// NewTelegramService creates a new Telegram notification service
func NewTelegramService() *TelegramService {
return &TelegramService{}
}
// TelegramPayload represents the payload for Telegram API
type TelegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
}
// SendNotification sends a notification via Telegram
func (ts *TelegramService) SendNotification(config *AlertConfiguration, message string) error {
// fmt.Printf("📱 [TELEGRAM] Attempting to send notification...\n")
// fmt.Printf("📱 [TELEGRAM] Config - Chat ID: %s, Bot Token present: %v\n", config.TelegramChatID, config.BotToken != "")
// fmt.Printf("📱 [TELEGRAM] Message: %s\n", message)
if config.BotToken == "" || config.TelegramChatID == "" {
return fmt.Errorf("telegram bot token and chat ID are required")
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", config.BotToken)
// fmt.Printf("📱 [TELEGRAM] API URL: %s\n", strings.Replace(url, config.BotToken, "[REDACTED]", 1))
payload := TelegramPayload{
ChatID: config.TelegramChatID,
Text: message,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return err
}
// fmt.Printf("📱 [TELEGRAM] Sending POST request...\n")
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
// fmt.Printf("❌ [TELEGRAM] HTTP error: %v\n", err)
return err
}
defer resp.Body.Close()
var result NotificationResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// fmt.Printf("❌ [TELEGRAM] JSON decode error: %v\n", err)
return err
}
// fmt.Printf("📱 [TELEGRAM] Response - OK: %v, Description: %s\n", result.OK, result.Description)
if !result.OK {
// fmt.Printf("❌ [TELEGRAM] API error: %s\n", result.Description)
return fmt.Errorf("telegram API error: %s", result.Description)
}
// fmt.Printf("✅ [TELEGRAM] Message sent successfully!\n")
return nil
}
// SendServerNotification sends a server-specific notification via Telegram
func (ts *TelegramService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
message := ts.generateServerMessage(payload, template, resourceType)
return ts.SendNotification(config, message)
}
// SendServiceNotification sends a service-specific notification via Telegram
func (ts *TelegramService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := ts.generateServiceMessage(payload, template)
return ts.SendNotification(config, message)
}
// generateServerMessage creates a message for server notifications using server template
func (ts *TelegramService) 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 = ts.generateDefaultServerMessage(payload, resourceType)
}
return ts.replacePlaceholders(templateMessage, payload)
}
// generateServiceMessage creates a message for service notifications using service template
func (ts *TelegramService) 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 = ts.generateDefaultUptimeMessage(payload)
}
return ts.replacePlaceholders(templateMessage, payload)
}
// replacePlaceholders replaces all placeholders in the message with actual values
func (ts *TelegramService) 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}", ts.safeString(payload.Host))
message = strings.ReplaceAll(message, "${hostname}", ts.safeString(payload.Hostname))
// Replace URL with fallback to host
url := ts.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}", ts.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}", ts.safeString(payload.RegionName))
message = strings.ReplaceAll(message, "${agent_id}", ts.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}", ts.safeString(payload.CPUUsage))
message = strings.ReplaceAll(message, "${ram_usage}", ts.safeString(payload.RAMUsage))
message = strings.ReplaceAll(message, "${disk_usage}", ts.safeString(payload.DiskUsage))
message = strings.ReplaceAll(message, "${network_usage}", ts.safeString(payload.NetworkUsage))
message = strings.ReplaceAll(message, "${cpu_temp}", ts.safeString(payload.CPUTemp))
message = strings.ReplaceAll(message, "${disk_io}", ts.safeString(payload.DiskIO))
message = strings.ReplaceAll(message, "${threshold}", ts.safeString(payload.Threshold))
// Replace error message - important for uptime services
message = strings.ReplaceAll(message, "${error_message}", ts.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${error}", ts.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"))
return message
}
// safeString returns the string value or "N/A" if empty
func (ts *TelegramService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
func (ts *TelegramService) 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("%sService %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 (ts *TelegramService) 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))
}
@@ -0,0 +1,117 @@
package notification
import "time"
// NotificationPayload represents the data sent in notifications
type NotificationPayload struct {
ServiceName string `json:"service_name"`
Status string `json:"status"`
Host string `json:"host"`
Hostname string `json:"hostname"`
Port int `json:"port"`
ServiceType string `json:"service_type"`
ResponseTime int64 `json:"response_time"`
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
ErrorMessage string `json:"error_message,omitempty"`
// Service-specific fields
URL string `json:"url,omitempty"`
Domain string `json:"domain,omitempty"`
RegionName string `json:"region_name,omitempty"`
AgentID string `json:"agent_id,omitempty"`
Uptime int `json:"uptime,omitempty"`
// Server monitoring specific fields
CPUUsage string `json:"cpu_usage,omitempty"`
RAMUsage string `json:"ram_usage,omitempty"`
DiskUsage string `json:"disk_usage,omitempty"`
NetworkUsage string `json:"network_usage,omitempty"`
CPUTemp string `json:"cpu_temp,omitempty"`
DiskIO string `json:"disk_io,omitempty"`
Threshold string `json:"threshold,omitempty"`
// SSL Certificate specific fields
CertificateName string `json:"certificate_name,omitempty"`
ExpiryDate string `json:"expiry_date,omitempty"`
DaysLeft string `json:"days_left,omitempty"`
IssuerCN string `json:"issuer_cn,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
}
// AlertConfiguration represents an alert configuration from PocketBase
type AlertConfiguration struct {
ID string `json:"id"`
NotificationType string `json:"notification_type"`
TelegramChatID string `json:"telegram_chat_id"`
DiscordWebhookURL string `json:"discord_webhook_url"`
SignalNumber string `json:"signal_number"`
SignalAPIEndpoint string `json:"signal_api_endpoint"`
NotifyName string `json:"notify_name"`
BotToken string `json:"bot_token"`
TemplateID string `json:"template_id"`
SlackWebhookURL string `json:"slack_webhook_url"`
GoogleChatWebhookURL string `json:"google_chat_webhook_url"`
Enabled string `json:"enabled"` // String because PocketBase returns it as string
EmailAddress string `json:"email_address"`
EmailSenderName string `json:"email_sender_name"`
SMTPServer string `json:"smtp_server"`
SMTPPassword string `json:"smtp_password,omitempty"`
SMTPPort string `json:"smtp_port"`
WebhookID string `json:"webhook_id"`
ChannelID string `json:"channel_id"`
// New webhook fields
WebhookURL string `json:"webhook_url"`
WebhookPayloadTemplate string `json:"webhook_payload_template"`
}
// ServerNotificationTemplate represents a server notification template
type ServerNotificationTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
UpMessage string `json:"up_message"`
DownMessage string `json:"down_message"`
WarningMessage string `json:"warning_message"`
PausedMessage string `json:"paused_message"`
// Resource-specific messages
RAMMessage string `json:"ram_message"`
CPUMessage string `json:"cpu_message"`
DiskMessage string `json:"disk_message"`
NetworkMessage string `json:"network_message"`
CPUTempMessage string `json:"cpu_temp_message"`
DiskIOMessage string `json:"disk_io_message"`
// Resource restore messages
RestoreRAMMessage string `json:"restore_ram_message"`
RestoreCPUMessage string `json:"restore_cpu_message"`
RestoreDiskMessage string `json:"restore_disk_message"`
RestoreNetworkMessage string `json:"restore_network_message"`
RestoreCPUTempMessage string `json:"restore_cpu_temp_message"`
RestoreDiskIOMessage string `json:"restore_disk_io_message"`
Placeholder string `json:"placeholder"`
}
// ServiceNotificationTemplate represents a service notification template
type ServiceNotificationTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
UpMessage string `json:"up_message"`
DownMessage string `json:"down_message"`
MaintenanceMessage string `json:"maintenance_message"`
IncidentMessage string `json:"incident_message"`
ResolvedMessage string `json:"resolved_message"`
WarningMessage string `json:"warning_message"`
Placeholder string `json:"placeholder"`
}
// NotificationResponse represents the response from notification APIs (like Telegram)
type NotificationResponse struct {
OK bool `json:"ok"`
Description string `json:"description,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
Result interface{} `json:"result,omitempty"`
}
// NotificationService interface for different notification services
type NotificationService interface {
SendNotification(config *AlertConfiguration, message string) error
}
@@ -0,0 +1,244 @@
package notification
import (
"fmt"
// "log"
"strings"
"service-operation/pocketbase"
)
// UptimeNotificationManager handles uptime service notifications with IMMEDIATE sending
type UptimeNotificationManager struct {
pbClient *pocketbase.PocketBaseClient
services map[string]NotificationService
}
// NewUptimeNotificationManager creates a new uptime notification manager
func NewUptimeNotificationManager(pbClient *pocketbase.PocketBaseClient, services map[string]NotificationService) *UptimeNotificationManager {
return &UptimeNotificationManager{
pbClient: pbClient,
services: services,
}
}
// SendUptimeServiceNotification sends notification IMMEDIATELY - NO QUEUING
func (unm *UptimeNotificationManager) SendUptimeServiceNotification(payload *NotificationPayload, notificationID, templateID string) error {
// log.Printf("📨 [UPTIME-MANAGER] IMMEDIATE send for service: %s, status: %s", payload.ServiceName, payload.Status)
// log.Printf("📨 [UPTIME-MANAGER] Notification ID: %s, Template ID: %s", notificationID, templateID)
if notificationID == "" {
return fmt.Errorf("notification ID required for uptime service: %s", payload.ServiceName)
}
// Parse notification IDs
notificationIDs := parseNotificationIDs(notificationID)
if len(notificationIDs) == 0 {
return fmt.Errorf("no valid notification IDs for uptime service: %s", payload.ServiceName)
}
var errors []string
successCount := 0
// Send to each notification channel IMMEDIATELY
for _, id := range notificationIDs {
// log.Printf("📤 [UPTIME-SEND] Processing notification ID: %s for service %s", id, payload.ServiceName)
// Check if enabled
if !isNotificationEnabled(unm.pbClient, id) {
// log.Printf("⚠️ Notification %s disabled for uptime service %s, skipping", id, payload.ServiceName)
continue
}
// Get alert configuration
alertConfig, err := getAlertConfiguration(unm.pbClient, id)
if err != nil {
// log.Printf("❌ Failed to get alert config for %s (service: %s): %v", id, payload.ServiceName, err)
errors = append(errors, fmt.Sprintf("config error %s: %v", id, err))
continue
}
// log.Printf("📋 [UPTIME-CONFIG] Alert config for %s: Type=%s, ChatID=%s, Token present=%v",
// id, alertConfig.NotificationType, alertConfig.TelegramChatID, alertConfig.BotToken != "")
// Get service template
var serviceTemplate *ServiceNotificationTemplate
if templateID != "" {
serviceTemplate, err = getServiceNotificationTemplate(unm.pbClient, templateID)
if err != nil {
// log.Printf("⚠️ Template error for %s (service: %s): %v", templateID, payload.ServiceName, err)
}
}
// Generate message
message := unm.generateUptimeMessage(payload, serviceTemplate)
// log.Printf("📝 [UPTIME-MESSAGE] Generated for %s (%s): %s", payload.ServiceName, id, message)
// Get notification service
service, exists := unm.services[alertConfig.NotificationType]
if !exists {
// log.Printf("❌ Unsupported notification type for uptime: %s", alertConfig.NotificationType)
errors = append(errors, fmt.Sprintf("unsupported type %s", alertConfig.NotificationType))
continue
}
// SEND IMMEDIATELY - NO DELAYS
// log.Printf("⚡ [UPTIME-TELEGRAM] Sending via %s for uptime service %s", alertConfig.NotificationType, payload.ServiceName)
err = service.SendNotification(alertConfig, message)
if err != nil {
// log.Printf("❌ [UPTIME-FAILED] Failed to send via %s for %s: %v", alertConfig.NotificationType, payload.ServiceName, err)
errors = append(errors, fmt.Sprintf("send failed %s: %v", alertConfig.NotificationType, err))
} else {
// log.Printf("✅ [UPTIME-SUCCESS] Successfully sent via %s for %s", alertConfig.NotificationType, payload.ServiceName)
successCount++
}
}
// Report results
if successCount > 0 {
// log.Printf("✅ [UPTIME-FINAL] Sent %d/%d uptime notifications for %s", successCount, len(notificationIDs), payload.ServiceName)
}
if len(errors) > 0 && successCount == 0 {
return fmt.Errorf("all uptime notifications failed for %s: %v", payload.ServiceName, errors)
}
return nil
}
// generateUptimeMessage creates notification message for uptime services
func (unm *UptimeNotificationManager) generateUptimeMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
var baseMessage string
// Use template if available
if template != nil {
// log.Printf("🔧 Using uptime template for status: %s", strings.ToLower(payload.Status))
switch strings.ToLower(payload.Status) {
case "up":
baseMessage = template.UpMessage
case "down":
baseMessage = template.DownMessage
case "maintenance":
baseMessage = template.MaintenanceMessage
case "incident":
baseMessage = template.IncidentMessage
case "resolved":
baseMessage = template.ResolvedMessage
case "warning":
baseMessage = template.WarningMessage
default:
baseMessage = template.WarningMessage
}
}
// Use default if no template
if baseMessage == "" {
// log.Printf("🔧 Using default uptime message (no template)")
baseMessage = unm.getDefaultUptimeMessage(payload)
}
// Replace placeholders
message := unm.replacePlaceholders(baseMessage, payload)
// log.Printf("📝 Final uptime message: %s", message)
return message
}
// replacePlaceholders replaces all placeholders in the message
func (unm *UptimeNotificationManager) replacePlaceholders(message string, payload *NotificationPayload) string {
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
message = strings.ReplaceAll(message, "${host}", payload.Host)
message = strings.ReplaceAll(message, "${ip}", payload.Host)
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.URL != "" {
message = strings.ReplaceAll(message, "${url}", payload.URL)
} else {
message = strings.ReplaceAll(message, "${url}", "N/A")
}
if payload.Domain != "" {
message = strings.ReplaceAll(message, "${domain}", payload.Domain)
} else {
message = strings.ReplaceAll(message, "${domain}", "N/A")
}
if payload.ServiceType != "" {
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
} else {
message = strings.ReplaceAll(message, "${service_type}", "N/A")
}
if payload.RegionName != "" {
message = strings.ReplaceAll(message, "${region_name}", payload.RegionName)
} else {
message = strings.ReplaceAll(message, "${region_name}", "N/A")
}
if payload.AgentID != "" {
message = strings.ReplaceAll(message, "${agent_id}", payload.AgentID)
} else {
message = strings.ReplaceAll(message, "${agent_id}", "N/A")
}
if payload.Uptime > 0 {
message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime))
} else {
message = strings.ReplaceAll(message, "${uptime}", "N/A")
}
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02"))
// Handle error message placeholder - critical for uptime services
if payload.ErrorMessage != "" {
message = strings.ReplaceAll(message, "${error}", payload.ErrorMessage)
message = strings.ReplaceAll(message, "${error_message}", payload.ErrorMessage)
} else {
message = strings.ReplaceAll(message, "${error}", "")
message = strings.ReplaceAll(message, "${error_message}", "")
}
if payload.Message != "" {
message = strings.ReplaceAll(message, "${message}", payload.Message)
}
return message
}
// getDefaultUptimeMessage provides a default notification message for uptime services
func (unm *UptimeNotificationManager) getDefaultUptimeMessage(payload *NotificationPayload) string {
statusEmoji := "✅"
if payload.Status == "down" {
statusEmoji = "❌"
} else if payload.Status == "warning" {
statusEmoji = "⚠️"
}
message := fmt.Sprintf("%s [UPTIME] %s is %s", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status))
if payload.Host != "" {
message += fmt.Sprintf(" | Host: %s", payload.Host)
}
if payload.ResponseTime > 0 {
message += fmt.Sprintf(" | %dms", payload.ResponseTime)
}
message += fmt.Sprintf(" | %s", payload.Timestamp.Format("15:04:05"))
return message
}
@@ -0,0 +1,440 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"text/template"
"time"
)
// WebhookService handles webhook notifications
type WebhookService struct{}
// NewWebhookService creates a new webhook notification service
func NewWebhookService() *WebhookService {
return &WebhookService{}
}
// WebhookPayload represents the payload for webhook
type WebhookPayload struct {
Message string `json:"message"`
NotifyName string `json:"notify_name"`
Timestamp string `json:"timestamp"`
ServiceName string `json:"service_name,omitempty"`
Status string `json:"status,omitempty"`
Host string `json:"host,omitempty"`
URL string `json:"url,omitempty"`
Port int `json:"port,omitempty"`
ServiceType string `json:"service_type,omitempty"`
ResponseTime int64 `json:"response_time,omitempty"`
// Server monitoring fields
CPUUsage string `json:"cpu_usage,omitempty"`
RAMUsage string `json:"ram_usage,omitempty"`
DiskUsage string `json:"disk_usage,omitempty"`
NetworkUsage string `json:"network_usage,omitempty"`
CPUTemp string `json:"cpu_temp,omitempty"`
DiskIO string `json:"disk_io,omitempty"`
Threshold string `json:"threshold,omitempty"`
// SSL fields
CertificateName string `json:"certificate_name,omitempty"`
ExpiryDate string `json:"expiry_date,omitempty"`
DaysLeft string `json:"days_left,omitempty"`
IssuerCN string `json:"issuer_cn,omitempty"`
}
// DiscordWebhookPayload represents Discord-specific webhook payload
type DiscordWebhookPayload struct {
Content string `json:"content"`
}
// SendNotification sends a notification via webhook
func (ws *WebhookService) SendNotification(config *AlertConfiguration, message string) error {
// fmt.Printf("📡 [WEBHOOK] Attempting to send notification...\n")
// fmt.Printf("📡 [WEBHOOK] Config - URL: %s, Notify Name: %s\n", config.WebhookURL, config.NotifyName)
// fmt.Printf("📡 [WEBHOOK] Message: %s\n", message)
// Use the webhook_url field from alert_configurations
if config.WebhookURL == "" {
return fmt.Errorf("webhook URL is required")
}
// Create payload using webhook_payload_template if available
var jsonData []byte
var err error
if config.WebhookPayloadTemplate != "" && strings.TrimSpace(config.WebhookPayloadTemplate) != "" {
// Use custom payload template
jsonData, err = ws.generateCustomPayload(config, message)
if err != nil {
// fmt.Printf("❌ [WEBHOOK] Custom payload generation error: %v\n", err)
return err
}
} else {
// Detect Discord webhook and use appropriate payload
if strings.Contains(strings.ToLower(config.WebhookURL), "discord") {
jsonData, err = ws.generateDiscordPayload(message)
} else {
// Use default payload structure for other webhooks
jsonData, err = ws.generateDefaultPayload(config, message)
}
if err != nil {
// fmt.Printf("❌ [WEBHOOK] JSON marshal error: %v\n", err)
return err
}
}
// fmt.Printf("📡 [WEBHOOK] Payload: %s\n", string(jsonData))
// fmt.Printf("📡 [WEBHOOK] Sending POST request...\n")
resp, err := http.Post(config.WebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
// fmt.Printf("❌ [WEBHOOK] HTTP error: %v\n", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// fmt.Printf("❌ [WEBHOOK] API error, status: %d\n", resp.StatusCode)
return fmt.Errorf("webhook error, status: %d", resp.StatusCode)
}
// fmt.Printf("✅ [WEBHOOK] Message sent successfully!\n")
return nil
}
// generateDiscordPayload generates a Discord-compatible webhook payload
func (ws *WebhookService) generateDiscordPayload(message string) ([]byte, error) {
payload := DiscordWebhookPayload{
Content: message,
}
return json.Marshal(payload)
}
// generateDefaultPayload generates the default webhook payload
func (ws *WebhookService) generateDefaultPayload(config *AlertConfiguration, message string) ([]byte, error) {
payload := WebhookPayload{
Message: message,
NotifyName: config.NotifyName,
Timestamp: time.Now().Format(time.RFC3339),
}
return json.Marshal(payload)
}
// generateCustomPayload generates payload using the custom template
func (ws *WebhookService) generateCustomPayload(config *AlertConfiguration, message string) ([]byte, error) {
// Parse the template
tmpl, err := template.New("webhook").Parse(config.WebhookPayloadTemplate)
if err != nil {
return nil, fmt.Errorf("failed to parse webhook payload template: %v", err)
}
// Create template data
templateData := map[string]interface{}{
"message": message,
"notify_name": config.NotifyName,
"timestamp": time.Now().Format(time.RFC3339),
}
// Execute template
var buf bytes.Buffer
err = tmpl.Execute(&buf, templateData)
if err != nil {
return nil, fmt.Errorf("failed to execute webhook payload template: %v", err)
}
// Validate that the result is valid JSON
var jsonCheck interface{}
if err := json.Unmarshal(buf.Bytes(), &jsonCheck); err != nil {
return nil, fmt.Errorf("webhook payload template did not generate valid JSON: %v", err)
}
return buf.Bytes(), nil
}
// SendServerNotification sends a server-specific notification via webhook
func (ws *WebhookService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
message := ws.generateServerMessage(payload, template, resourceType)
return ws.SendNotification(config, message)
}
// SendServiceNotification sends a service-specific notification via webhook
func (ws *WebhookService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := ws.generateServiceMessage(payload, template)
return ws.SendNotification(config, message)
}
// generateServerMessage creates a message for server notifications using server template
func (ws *WebhookService) 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 = ws.generateDefaultServerMessage(payload, resourceType)
}
return ws.replacePlaceholders(templateMessage, payload)
}
// generateServiceMessage creates a message for service notifications using service template
func (ws *WebhookService) 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 = ws.generateDefaultUptimeMessage(payload)
}
return ws.replacePlaceholders(templateMessage, payload)
}
// replacePlaceholders replaces all placeholders in the message with actual values
func (ws *WebhookService) replacePlaceholders(message string, payload *NotificationPayload) string {
// Replace basic placeholders
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // server_name maps to service_name
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
message = strings.ReplaceAll(message, "${host}", ws.safeString(payload.Host))
message = strings.ReplaceAll(message, "${hostname}", ws.safeString(payload.Hostname))
// Replace URL with fallback to host
url := ws.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}", ws.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}", ws.safeString(payload.RegionName))
message = strings.ReplaceAll(message, "${agent_id}", ws.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}", ws.safeString(payload.CPUUsage))
message = strings.ReplaceAll(message, "${ram_usage}", ws.safeString(payload.RAMUsage))
message = strings.ReplaceAll(message, "${disk_usage}", ws.safeString(payload.DiskUsage))
message = strings.ReplaceAll(message, "${network_usage}", ws.safeString(payload.NetworkUsage))
message = strings.ReplaceAll(message, "${cpu_temp}", ws.safeString(payload.CPUTemp))
message = strings.ReplaceAll(message, "${disk_io}", ws.safeString(payload.DiskIO))
message = strings.ReplaceAll(message, "${threshold}", ws.safeString(payload.Threshold))
// Replace SSL certificate fields
message = strings.ReplaceAll(message, "${certificate_name}", ws.safeString(payload.CertificateName))
message = strings.ReplaceAll(message, "${expiry_date}", ws.safeString(payload.ExpiryDate))
message = strings.ReplaceAll(message, "${days_left}", ws.safeString(payload.DaysLeft))
message = strings.ReplaceAll(message, "${issuer_cn}", ws.safeString(payload.IssuerCN))
message = strings.ReplaceAll(message, "${issuer}", ws.safeString(payload.IssuerCN)) // alias
// Replace error message - important for uptime services
message = strings.ReplaceAll(message, "${error_message}", ws.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${error}", ws.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${message}", ws.safeString(payload.Message))
// 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"))
return message
}
// safeString returns the string value or "N/A" if empty
func (ws *WebhookService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
func (ws *WebhookService) 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 (ws *WebhookService) 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))
}