Files
2026-07-13 12:31:02 +08:00

380 lines
13 KiB
Go

package notification
import (
"bytes"
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"time"
)
// MatrixService handles Matrix chat notifications
type MatrixService struct{}
// NewMatrixService creates a new Matrix notification service
func NewMatrixService() *MatrixService {
return &MatrixService{}
}
// matrixMessagePayload represents the payload for the Matrix Client-Server API
type matrixMessagePayload struct {
MsgType string `json:"msgtype"`
Body string `json:"body"`
Format string `json:"format,omitempty"`
FormattedBody string `json:"formatted_body,omitempty"`
}
// SendNotification sends a formatted message to a Matrix room.
// It sends both a plain-text fallback body and an HTML formatted_body with
// severity-based colors so clients that support Matrix's custom HTML format
// display a rich, colored notification.
func (ms *MatrixService) SendNotification(config *AlertConfiguration, message string) error {
if config.MatrixHomeserver == "" || config.MatrixRoomID == "" || config.MatrixAccessToken == "" {
return fmt.Errorf("matrix homeserver, room ID and access token are required")
}
// Encode the room ID for use in the URL path
encodedRoomID := url.PathEscape(config.MatrixRoomID)
// Use Unix nanoseconds as a unique transaction ID to prevent duplicate messages
txnID := fmt.Sprintf("%d", time.Now().UnixNano())
apiURL := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s",
strings.TrimRight(config.MatrixHomeserver, "/"),
encodedRoomID,
txnID,
)
payload := matrixMessagePayload{
MsgType: "m.text",
Body: message,
Format: "org.matrix.custom.html",
FormattedBody: ms.buildHTMLBody(message),
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("matrix: failed to marshal payload: %w", err)
}
req, err := http.NewRequest(http.MethodPut, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("matrix: failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+config.MatrixAccessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("matrix: HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("matrix: API returned status %d", resp.StatusCode)
}
return nil
}
// buildHTMLBody converts a plain-text notification message into Matrix-compatible HTML.
// The first line becomes a colored bold heading based on alert severity; subsequent
// detail lines are rendered as a bullet list. Clients that don't support HTML fall
// back to the plain Body field.
func (ms *MatrixService) buildHTMLBody(plainText string) string {
lines := strings.Split(strings.TrimSpace(plainText), "\n")
if len(lines) == 0 {
return "<p>" + html.EscapeString(plainText) + "</p>"
}
title := strings.TrimSpace(lines[0])
color := ms.statusColor(title)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("<p><strong><font color=%q>%s</font></strong></p>", color, html.EscapeString(title)))
var items []string
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Strip leading bullet markers added by the plain-text generators
line = strings.TrimPrefix(line, "•")
line = strings.TrimPrefix(line, "-")
line = strings.TrimSpace(line)
if line != "" {
items = append(items, "<li>"+html.EscapeString(line)+"</li>")
}
}
if len(items) > 0 {
sb.WriteString("<ul>")
for _, item := range items {
sb.WriteString(item)
}
sb.WriteString("</ul>")
}
sb.WriteString("<p><em>CheckCle System Alert</em></p>")
return sb.String()
}
// statusColor returns an HTML hex color string that reflects the alert severity
// detected from keywords and emoji in the message heading.
func (ms *MatrixService) statusColor(message string) string {
lower := strings.ToLower(message)
if strings.Contains(lower, "🔴") || strings.Contains(lower, "down") ||
strings.Contains(lower, "expired") || strings.Contains(lower, "failed") ||
strings.Contains(lower, "critical") || strings.Contains(lower, "error") {
return "#E74C3C" // Red
}
if strings.Contains(lower, "🟡") || strings.Contains(lower, "warning") ||
strings.Contains(lower, "expiring") {
return "#F39C12" // Yellow
}
if strings.Contains(lower, "🟠") || strings.Contains(lower, "maintenance") ||
strings.Contains(lower, "paused") {
return "#E67E22" // Orange
}
if strings.Contains(lower, "🟢") || strings.Contains(lower, "up") ||
strings.Contains(lower, "resolved") || strings.Contains(lower, "restored") ||
strings.Contains(lower, "success") {
return "#2ECC71" // Green
}
return "#3498DB" // Blue — info / default
}
// SendServerNotification sends a server-specific notification via Matrix
func (ms *MatrixService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
message := ms.generateServerMessage(payload, template, resourceType)
return ms.SendNotification(config, message)
}
// SendServiceNotification sends a service-specific notification via Matrix
func (ms *MatrixService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := ms.generateServiceMessage(payload, template)
return ms.SendNotification(config, message)
}
func (ms *MatrixService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
var templateMessage string
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:
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 templateMessage == "" {
templateMessage = ms.generateDefaultServerMessage(payload, resourceType)
}
return ms.replacePlaceholders(templateMessage, payload)
}
func (ms *MatrixService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
var templateMessage string
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 templateMessage == "" {
templateMessage = ms.generateDefaultUptimeMessage(payload)
}
return ms.replacePlaceholders(templateMessage, payload)
}
func (ms *MatrixService) 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}", ms.safeString(payload.Host))
message = strings.ReplaceAll(message, "${hostname}", ms.safeString(payload.Hostname))
u := ms.safeString(payload.URL)
if u == "N/A" && payload.Host != "" {
u = payload.Host
}
message = strings.ReplaceAll(message, "${url}", u)
message = strings.ReplaceAll(message, "${domain}", ms.safeString(payload.Domain))
if payload.ServiceType != "" {
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
} else {
message = strings.ReplaceAll(message, "${service_type}", "N/A")
}
message = strings.ReplaceAll(message, "${region_name}", ms.safeString(payload.RegionName))
message = strings.ReplaceAll(message, "${agent_id}", ms.safeString(payload.AgentID))
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")
}
message = strings.ReplaceAll(message, "${cpu_usage}", ms.safeString(payload.CPUUsage))
message = strings.ReplaceAll(message, "${ram_usage}", ms.safeString(payload.RAMUsage))
message = strings.ReplaceAll(message, "${disk_usage}", ms.safeString(payload.DiskUsage))
message = strings.ReplaceAll(message, "${network_usage}", ms.safeString(payload.NetworkUsage))
message = strings.ReplaceAll(message, "${cpu_temp}", ms.safeString(payload.CPUTemp))
message = strings.ReplaceAll(message, "${disk_io}", ms.safeString(payload.DiskIO))
message = strings.ReplaceAll(message, "${threshold}", ms.safeString(payload.Threshold))
message = strings.ReplaceAll(message, "${error_message}", ms.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${error}", ms.safeString(payload.ErrorMessage))
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
}
func (ms *MatrixService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
func (ms *MatrixService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
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))
details := []string{}
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))
}
if payload.ServiceType != "" {
details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType)))
}
if payload.Port > 0 {
details = append(details, fmt.Sprintf(" - Port: %d", payload.Port))
}
if payload.Domain != "" {
details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain))
}
if payload.ResponseTime > 0 {
details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime))
} else {
details = append(details, " - Response time: N/A")
}
if payload.RegionName != "" {
details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName))
}
if payload.Uptime > 0 {
details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime))
}
details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")))
if len(details) > 0 {
message += "\n" + strings.Join(details, "\n")
}
return message
}
func (ms *MatrixService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
statusEmoji := "🔵"
switch strings.ToLower(payload.Status) {
case "up":
statusEmoji = "🟢"
case "down":
statusEmoji = "🔴"
case "warning":
statusEmoji = "🟡"
}
resource := strings.ToUpper(resourceType)
if resource == "" {
resource = "GENERAL"
}
return fmt.Sprintf("%s 🖥️ Server %s (%s) [%s] status: %s", statusEmoji, payload.ServiceName, payload.Hostname, resource, strings.ToUpper(payload.Status))
}