From 91ceddc489432046bb0a6afdaec36dd7302fba0c Mon Sep 17 00:00:00 2001 From: MrSchneemann Date: Sun, 1 Mar 2026 10:39:46 +0100 Subject: [PATCH 1/2] feat: add Matrix chat notification support (#211) Implements native Matrix notification channel support, allowing users to receive service alerts in any Matrix room via the Client-Server API. Changes: - server/service-operation/notification/matrix.go: new Matrix service implementing the NotificationService interface (PUT /_matrix/client/v3/...) - server/service-operation/notification/types.go: add MatrixHomeserver, MatrixRoomID, MatrixAccessToken fields to AlertConfiguration - server/service-operation/notification/manager.go: register MatrixService - server/pb_migrations/1772200000_updated_alert_configurations_matrix.js: PocketBase migration adding matrix fields and notification_type value - application: Matrix option in channel dialog with homeserver/room/token fields, tab filter, list display, type definitions and translations (en, km) - application/public/upload/notification/matrix.png: Matrix channel icon --- README.md | 2 +- .../public/upload/notification/matrix.png | Bin 0 -> 250 bytes .../NotificationChannelDialog.tsx | 75 ++++- .../NotificationChannelList.tsx | 3 + .../NotificationSettings.tsx | 1 + .../src/services/alertConfigService.ts | 14 +- application/src/translations/en/settings.ts | 10 + application/src/translations/km/settings.ts | 10 + .../src/translations/types/settings.ts | 10 + ...000_updated_alert_configurations_matrix.js | 73 +++++ .../service-operation/notification/manager.go | 1 + .../service-operation/notification/matrix.go | 301 ++++++++++++++++++ .../service-operation/notification/types.go | 3 + 13 files changed, 495 insertions(+), 8 deletions(-) create mode 100644 application/public/upload/notification/matrix.png create mode 100644 server/pb_migrations/1772200000_updated_alert_configurations_matrix.js create mode 100644 server/service-operation/notification/matrix.go diff --git a/README.md b/README.md index 93f9097..cb16fe5 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s - Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script. - Schedule Maintenance & Incident Management - Operational Status / Public Status Pages -- Notifications via email, Telegram, Discord, and Slack +- Notifications via email, Telegram, Discord, Slack, Matrix, and more - Reports & Analytics - Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates). diff --git a/application/public/upload/notification/matrix.png b/application/public/upload/notification/matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..4321cb808c9fa642063912a0de4118de32204dbd GIT binary patch literal 250 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJMgPtyqAr*7pUUuYbG2n3yRLQ%P zT%@aaLaIgK%2L0F(KiDh?GrSfdeC^M{sOiJYwH7h^qhGZzB3e@pUp$F6tJwss0%LDlu>P^$wEUmo3f zrNIMeH-kdmmUVIW85~w$H)pu;>MJk9ma1B2hPCKIhr1i>7$5L4lr!x3obPas@qrw} iyPtXqM;HpyD-N)g-RVw0vG9B=$Usk5KbLh*2~7YXT43}5 literal 0 HcmV?d00001 diff --git a/application/src/components/settings/notification-settings/NotificationChannelDialog.tsx b/application/src/components/settings/notification-settings/NotificationChannelDialog.tsx index 9533a2c..be13fb2 100644 --- a/application/src/components/settings/notification-settings/NotificationChannelDialog.tsx +++ b/application/src/components/settings/notification-settings/NotificationChannelDialog.tsx @@ -38,7 +38,7 @@ interface NotificationChannelDialogProps { const baseSchema = z.object({ notify_name: z.string().min(1, "Name is required"), - notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "notifiarr", "gotify", "webhook"]), + notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "notifiarr", "gotify", "webhook", "matrix"]), enabled: z.boolean().default(true), service_id: z.string().default("global"), template_id: z.string().optional(), @@ -110,6 +110,13 @@ const gotifySchema = baseSchema.extend({ server_url: z.string().url("Must be a valid server URL"), }); +const matrixSchema = baseSchema.extend({ + notification_type: z.literal("matrix"), + matrix_homeserver: z.string().url("Must be a valid homeserver URL"), + matrix_room_id: z.string().min(1, "Room ID is required"), + matrix_access_token: z.string().min(1, "Access token is required"), +}); + const formSchema = z.discriminatedUnion("notification_type", [ telegramSchema, discordSchema, @@ -122,6 +129,7 @@ const formSchema = z.discriminatedUnion("notification_type", [ notifiarrSchema, gotifySchema, webhookSchema, + matrixSchema, ]); type FormValues = z.infer; @@ -188,12 +196,18 @@ const notificationTypeOptions = [ description: "Send push notifications via Gotify", icon: "/upload/notification/gotify.png" }, - { - value: "webhook", - label: "Webhook", + { + value: "webhook", + label: "Webhook", description: "Send notifications to custom webhook", icon: "/upload/notification/webhook.png" }, + { + value: "matrix", + label: "Matrix", + description: "Send notifications to a Matrix chat room", + icon: "/upload/notification/matrix.png" + }, ]; const webhookPayloadTemplates = { @@ -832,6 +846,59 @@ export const NotificationChannelDialog = ({ )} + {notificationType === "matrix" && ( + <> + ( + + {t("matrixHomeserver")} + + + + + {t("matrixHomeserverDesc")} + + + + )} + /> + ( + + {t("matrixRoomId")} + + + + + {t("matrixRoomIdDesc")} + + + + )} + /> + ( + + {t("matrixAccessToken")} + + + + + {t("matrixAccessTokenDesc")} + + + + )} + /> + + )} + { {t("googleChat")} {t("email")} {t("webhook")} + {t("matrix")} diff --git a/application/src/services/alertConfigService.ts b/application/src/services/alertConfigService.ts index d6f0c30..b4d686d 100644 --- a/application/src/services/alertConfigService.ts +++ b/application/src/services/alertConfigService.ts @@ -7,7 +7,7 @@ export interface AlertConfiguration { collectionId?: string; collectionName?: string; service_id: string; - notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "ntfy" | "pushover" | "notifiarr" | "gotify" | "webhook"; + notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "ntfy" | "pushover" | "notifiarr" | "gotify" | "webhook" | "matrix"; telegram_chat_id?: string; discord_webhook_url?: string; signal_number?: string; @@ -34,6 +34,9 @@ export interface AlertConfiguration { server_url?: string; webhook_url?: string; webhook_payload_template?: string; + matrix_homeserver?: string; + matrix_room_id?: string; + matrix_access_token?: string; } export const alertConfigService = { @@ -105,8 +108,13 @@ export const alertConfigService = { } else if (config.notification_type === "webhook") { cleanConfig.webhook_url = config.webhook_url || ""; cleanConfig.webhook_payload_template = config.webhook_payload_template || ""; - - } + + } else if (config.notification_type === "matrix") { + cleanConfig.matrix_homeserver = config.matrix_homeserver || ""; + cleanConfig.matrix_room_id = config.matrix_room_id || ""; + cleanConfig.matrix_access_token = config.matrix_access_token || ""; + + } const result = await pb.collection('alert_configurations').create(cleanConfig); toast({ diff --git a/application/src/translations/en/settings.ts b/application/src/translations/en/settings.ts index b51b1ca..917e609 100644 --- a/application/src/translations/en/settings.ts +++ b/application/src/translations/en/settings.ts @@ -73,6 +73,7 @@ export const settingsTranslations: SettingsTranslations = { googleChat: "Google Chat", email: "Email", webhook: "Webhook", + matrix: "Matrix", // NotificationChannelDialog.tsx editChannel: "Edit Notification Channel", @@ -129,6 +130,12 @@ export const settingsTranslations: SettingsTranslations = { notifiarrChannelIdDesc: "The Discord channel ID where notifications will be sent", gotifyServerUrl: "Server URL", gotifyServerUrlDesc: "The URL of your Gotify server", + matrixHomeserver: "Homeserver URL", + matrixHomeserverDesc: "The URL of your Matrix homeserver (e.g. https://matrix.org)", + matrixRoomId: "Room ID", + matrixRoomIdDesc: "The Matrix room ID to send notifications to (e.g. !abc123:matrix.org)", + matrixAccessToken: "Access Token", + matrixAccessTokenDesc: "The access token of your Matrix bot account", errorSaveChannel: "Failed to save notification channel", channelNamePlaceholder: "My Notification Channel", @@ -150,6 +157,9 @@ export const settingsTranslations: SettingsTranslations = { notifiarrChannelIdPlaceholder: "Discord Channel ID", gotifyServerUrlPlaceholder: "https://your-gotify-server.com", webhookUrlPlaceholder: "https://api.example.com/webhook", + matrixHomeserverPlaceholder: "https://matrix.org", + matrixRoomIdPlaceholder: "!roomid:matrix.org", + matrixAccessTokenPlaceholder: "syt_...", // DataRetentionSettings.tsx // permissionNotice: "Permission Notice:", diff --git a/application/src/translations/km/settings.ts b/application/src/translations/km/settings.ts index 438643d..daf74fe 100644 --- a/application/src/translations/km/settings.ts +++ b/application/src/translations/km/settings.ts @@ -73,6 +73,7 @@ descriptionChannelsServices: "αž€αŸ†αžŽαžαŸ‹αžšαž…αž“αžΆαžŸαž˜αŸ’αž–αŸαž“αŸ’ googleChat: "Google Chat", email: "αž’αŸŠαžΈαž˜αŸ‚αž›", webhook: "Webhook", + matrix: "Matrix", // NotificationChannelDialog.tsx editChannel: "αž€αŸ‚αžŸαž˜αŸ’αžšαž½αž›αž”αžŽαŸ’αžαžΆαž‰αž‡αžΌαž“αžŠαŸ†αžŽαžΉαž„", @@ -129,6 +130,12 @@ descriptionChannelsServices: "αž€αŸ†αžŽαžαŸ‹αžšαž…αž“αžΆαžŸαž˜αŸ’αž–αŸαž“αŸ’ notifiarrChannelIdDesc: "αž›αŸαžαžŸαž˜αŸ’αž‚αžΆαž›αŸ‹αž”αžŽαŸ’αžαžΆαž‰ Discord αžŠαŸ‚αž›αž€αžΆαžšαž‡αžΌαž“αžŠαŸ†αžŽαžΉαž„αž“αžΉαž„αžαŸ’αžšαžΌαžœαž”αžΆαž“αž•αŸ’αž‰αžΎαž‘αŸ…", gotifyServerUrl: "URL αž“αŸƒαž˜αŸ‰αžΆαžŸαŸŠαžΈαž“αž”αž˜αŸ’αžšαžΎ", gotifyServerUrlDesc: "URL αž“αŸƒαž˜αŸ‰αžΆαžŸαŸŠαžΈαž“αž”αž˜αŸ’αžšαžΎ Gotify αžšαž”αžŸαŸ‹αž’αŸ’αž“αž€", + matrixHomeserver: "Homeserver URL", + matrixHomeserverDesc: "URL αž“αŸƒ Matrix homeserver αžšαž”αžŸαŸ‹αž’αŸ’αž“αž€ (ឧ. https://matrix.org)", + matrixRoomId: "Room ID", + matrixRoomIdDesc: "Matrix room ID αžŠαŸ‚αž›αž€αžΆαžšαž‡αžΌαž“αžŠαŸ†αžŽαžΉαž„αž“αžΉαž„αžαŸ’αžšαžΌαžœαž”αžΆαž“αž•αŸ’αž‰αžΎαž‘αŸ… (ឧ. !abc123:matrix.org)", + matrixAccessToken: "Access Token", + matrixAccessTokenDesc: "Access token αž“αŸƒ Matrix bot account αžšαž”αžŸαŸ‹αž’αŸ’αž“αž€", errorSaveChannel: "αž”αžšαžΆαž‡αŸαž™αž€αŸ’αž“αž»αž„αž€αžΆαžšαžšαž€αŸ’αžŸαžΆαž‘αž»αž€αž”αžŽαŸ’αžαžΆαž‰αž‡αžΌαž“αžŠαŸ†αžŽαžΉαž„", channelNamePlaceholder: "αž”αŸ‰αž»αžŸαŸ’αžαž·αŸαž•αŸ’αž‘αžΆαž›αŸ‹αžŸαžΆαžšαž‡αžΌαž“αžŠαŸ†αžŽαžΉαž„αžšαž”αžŸαŸ‹αžαŸ’αž‰αž»αŸ†", @@ -150,6 +157,9 @@ descriptionChannelsServices: "αž€αŸ†αžŽαžαŸ‹αžšαž…αž“αžΆαžŸαž˜αŸ’αž–αŸαž“αŸ’ notifiarrChannelIdPlaceholder: "ID αž”αŸ‰αž»αžŸαŸ’αžαž·αŸαž•αŸ’αž‘αžΆαž›αŸ‹αžŸαžΆαžš Discord", gotifyServerUrlPlaceholder: "https://your-gotify-server.com", webhookUrlPlaceholder: "https://api.example.com/webhook", + matrixHomeserverPlaceholder: "https://matrix.org", + matrixRoomIdPlaceholder: "!roomid:matrix.org", + matrixAccessTokenPlaceholder: "syt_...", // DataRetentionSettings.tsx permissionNoticeDataRetention: "αž‡αžΆαž’αŸ’αž“αž€αž”αŸ’αžšαžΎαž”αŸ’αžšαžΆαžŸαŸ‹αž’αŸ’αž“αž€αž‚αŸ’αžšαž”αŸ‹αž‚αŸ’αžšαž„ αž’αŸ’αž“αž€αž˜αž·αž“αž˜αžΆαž“αžŸαž·αž‘αŸ’αž’αž…αžΌαž›αžŠαŸ†αžŽαžΎαžšαž€αžΆαžšαž€αžΆαžšαž€αŸ†αžŽαžαŸ‹αžšαž€αŸ’αžŸαžΆαž‘αž»αž€αž‘αž·αž“αŸ’αž“αž“αŸαž™αž‘αŸαŸ” αž€αžΆαžšαž€αŸ†αžŽαžαŸ‹αž‘αžΆαŸ†αž„αž“αŸαŸ‡αž’αžΆαž…αžαŸ’αžšαžΌαžœαž”αžΆαž“αž…αžΌαž›αžŠαŸ†αžŽαžΎαžšαž€αžΆαžšαž“αž·αž„αž€αŸ‚αž”αŸ’αžšαŸ‚αžŠαŸ„αž™αžαŸ‚αž’αŸ’αž“αž€αž‚αŸ’αžšαž”αŸ‹αž‚αŸ’αžšαž„αžŠαŸαžαŸ’αž–αžŸαŸ‹αž”αŸ‰αž»αžŽαŸ’αžŽαŸ„αŸ‡αŸ”", diff --git a/application/src/translations/types/settings.ts b/application/src/translations/types/settings.ts index 9e6d613..087f799 100644 --- a/application/src/translations/types/settings.ts +++ b/application/src/translations/types/settings.ts @@ -71,6 +71,7 @@ export interface SettingsTranslations { googleChat: string; email: string; webhook: string; + matrix: string; // NotificationChannelDialog.tsx editChannel: string; @@ -127,6 +128,12 @@ export interface SettingsTranslations { notifiarrChannelIdDesc: string; gotifyServerUrl: string; gotifyServerUrlDesc: string; + matrixHomeserver: string; + matrixHomeserverDesc: string; + matrixRoomId: string; + matrixRoomIdDesc: string; + matrixAccessToken: string; + matrixAccessTokenDesc: string; errorSaveChannel: string; channelNamePlaceholder: string; @@ -148,6 +155,9 @@ export interface SettingsTranslations { notifiarrChannelIdPlaceholder: string; gotifyServerUrlPlaceholder: string; webhookUrlPlaceholder: string; + matrixHomeserverPlaceholder: string; + matrixRoomIdPlaceholder: string; + matrixAccessTokenPlaceholder: string; // DataRetentionSettings.tsx // permissionNotice: string; diff --git a/server/pb_migrations/1772200000_updated_alert_configurations_matrix.js b/server/pb_migrations/1772200000_updated_alert_configurations_matrix.js new file mode 100644 index 0000000..02cc1a2 --- /dev/null +++ b/server/pb_migrations/1772200000_updated_alert_configurations_matrix.js @@ -0,0 +1,73 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // add matrix_homeserver field + collection.fields.addAt(collection.fields.length, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_matrix_homeserver", + "max": 0, + "min": 0, + "name": "matrix_homeserver", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add matrix_room_id field + collection.fields.addAt(collection.fields.length, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_matrix_room_id", + "max": 0, + "min": 0, + "name": "matrix_room_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add matrix_access_token field + collection.fields.addAt(collection.fields.length, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_matrix_access_token", + "max": 0, + "min": 0, + "name": "matrix_access_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add "matrix" to the notification_type select field values + const notifTypeField = collection.fields.getByName("notification_type") + if (notifTypeField && notifTypeField.values) { + notifTypeField.values.push("matrix") + } + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + collection.fields.removeById("text_matrix_homeserver") + collection.fields.removeById("text_matrix_room_id") + collection.fields.removeById("text_matrix_access_token") + + const notifTypeField = collection.fields.getByName("notification_type") + if (notifTypeField && notifTypeField.values) { + notifTypeField.values = notifTypeField.values.filter(v => v !== "matrix") + } + + return app.save(collection) +}) diff --git a/server/service-operation/notification/manager.go b/server/service-operation/notification/manager.go index eaed9d5..356f130 100644 --- a/server/service-operation/notification/manager.go +++ b/server/service-operation/notification/manager.go @@ -33,6 +33,7 @@ func NewNotificationManager(pbClient *pocketbase.PocketBaseClient) *Notification services["pushover"] = NewPushoverService() services["notifiarr"] = NewNotifiarrService() services["gotify"] = NewGotifyService() + services["matrix"] = NewMatrixService() // log.Printf("βœ… Notification services initialized: %v", getKeys(services)) diff --git a/server/service-operation/notification/matrix.go b/server/service-operation/notification/matrix.go new file mode 100644 index 0000000..2aa975a --- /dev/null +++ b/server/service-operation/notification/matrix.go @@ -0,0 +1,301 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "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 plain message to a Matrix room +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, + } + + 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 +} + +// 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 = "🟑" + } + return fmt.Sprintf("%s πŸ–₯️ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} diff --git a/server/service-operation/notification/types.go b/server/service-operation/notification/types.go index 125cbef..0d5846e 100644 --- a/server/service-operation/notification/types.go +++ b/server/service-operation/notification/types.go @@ -67,6 +67,9 @@ type AlertConfiguration struct { APIToken string `json:"api_token"` UserKey string `json:"user_key"` ServerURL string `json:"server_url"` + MatrixHomeserver string `json:"matrix_homeserver"` + MatrixRoomID string `json:"matrix_room_id"` + MatrixAccessToken string `json:"matrix_access_token"` } // ServerNotificationTemplate represents a server notification template From 463299fa73f372293c17409d009a7f79ac2d4c26 Mon Sep 17 00:00:00 2001 From: MrSchneemann Date: Sun, 1 Mar 2026 10:56:56 +0100 Subject: [PATCH 2/2] feat(matrix): add HTML formatted messages with severity colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix notifications now send both a plain-text fallback and an HTML formatted_body (org.matrix.custom.html) so Element and other Matrix clients render rich, colored alerts: - Down/failed/error β†’ red (#E74C3C) - Warning/expiring β†’ yellow (#F39C12) - Maintenance/paused β†’ orange (#E67E22) - Up/resolved β†’ green (#2ECC71) - Default/info β†’ blue (#3498DB) Detail lines from the plain-text message are rendered as a
    bullet list beneath the colored heading. Plain Body is preserved for clients without HTML support. Also includes resource type (CPU/DISK/etc.) in fallback server alert messages to improve context. --- .../service-operation/notification/matrix.go | 86 ++++++++++++++++++- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/server/service-operation/notification/matrix.go b/server/service-operation/notification/matrix.go index 2aa975a..4e43ab9 100644 --- a/server/service-operation/notification/matrix.go +++ b/server/service-operation/notification/matrix.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "html" "net/http" "net/url" "strings" @@ -26,7 +27,10 @@ type matrixMessagePayload struct { FormattedBody string `json:"formatted_body,omitempty"` } -// SendNotification sends a plain message to a Matrix room +// 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") @@ -45,8 +49,10 @@ func (ms *MatrixService) SendNotification(config *AlertConfiguration, message st ) payload := matrixMessagePayload{ - MsgType: "m.text", - Body: message, + MsgType: "m.text", + Body: message, + Format: "org.matrix.custom.html", + FormattedBody: ms.buildHTMLBody(message), } jsonData, err := json.Marshal(payload) @@ -75,6 +81,74 @@ func (ms *MatrixService) SendNotification(config *AlertConfiguration, message st 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 "

    " + html.EscapeString(plainText) + "

    " + } + + title := strings.TrimSpace(lines[0]) + color := ms.statusColor(title) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("

    %s

    ", 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, "
  • "+html.EscapeString(line)+"
  • ") + } + } + + if len(items) > 0 { + sb.WriteString("
      ") + for _, item := range items { + sb.WriteString(item) + } + sb.WriteString("
    ") + } + + sb.WriteString("

    CheckCle System Alert

    ") + 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) @@ -297,5 +371,9 @@ func (ms *MatrixService) generateDefaultServerMessage(payload *NotificationPaylo case "warning": statusEmoji = "🟑" } - return fmt.Sprintf("%s πŸ–₯️ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) + 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)) }