diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index eff890638..95e0da2d7 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -62,8 +62,8 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { return "threshold_breach"; } - // Recovery from threshold breach - if (decision.notificationReason === "status_change" && monitor.status === "up") { + // Recovery from threshold breach (only for hardware monitors) + if (decision.notificationReason === "status_change" && monitor.status === "up" && monitor.type === "hardware") { return "threshold_resolved"; } @@ -156,7 +156,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { clientHost: string ): NotificationContent { const title = `Threshold Breach: ${monitor.name}`; - const summary = `Hardware monitor "${monitor.name}" has breached one or more thresholds.`; + const summary = `Monitor "${monitor.name}" has breached one or more thresholds.`; const details = [`URL: ${monitor.url}`, `Status: Breached`, `Type: ${monitor.type}`]; const thresholds = this.extractThresholdBreaches(monitor, monitorStatusResponse); @@ -172,7 +172,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { private buildThresholdResolvedContent(monitor: Monitor, clientHost: string): NotificationContent { const title = `Thresholds Resolved: ${monitor.name}`; - const summary = `Hardware monitor "${monitor.name}" thresholds have returned to normal.`; + const summary = `Monitor "${monitor.name}" thresholds have returned to normal.`; const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`]; return { diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index f99af48d4..ede6b8d40 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -2,6 +2,7 @@ const SERVICE_NAME = "SlackProvider"; import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; import { buildHardwareAlerts, buildHardwareNotificationMessage, @@ -99,4 +100,162 @@ export class SlackProvider implements INotificationProvider { return false; } } + + /** + * New unified message format - builds Slack Block Kit payload from NotificationMessage + */ + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + const payload = this.buildSlackPayload(message); + + try { + await got.post(notification.address, { + json: payload, + headers: { + "Content-Type": "application/json", + }, + }); + this.logger.info({ + message: "[NEW] Slack notification sent via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "[NEW] Slack alert failed via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + /** + * Build Slack Block Kit payload from NotificationMessage + * Uses Slack's rich formatting with sections, fields, and context blocks + */ + private buildSlackPayload(message: NotificationMessage): object { + const blocks: any[] = []; + + // Determine color based on severity + const colorMap = { + critical: "#FF0000", // Red + warning: "#FFA500", // Orange + success: "#00FF00", // Green + info: "#0000FF", // Blue + }; + const color = colorMap[message.severity] || "#808080"; + + // Header block with title + blocks.push({ + type: "header", + text: { + type: "plain_text", + text: message.content.title, + emoji: true, + }, + }); + + // Summary section + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: message.content.summary, + }, + }); + + // Monitor details as fields + const monitorFields = [ + { type: "mrkdwn", text: `*Name:*\n${message.monitor.name}` }, + { type: "mrkdwn", text: `*Type:*\n${message.monitor.type}` }, + { type: "mrkdwn", text: `*Status:*\n${message.monitor.status}` }, + { type: "mrkdwn", text: `*URL:*\n${message.monitor.url}` }, + ]; + + blocks.push({ + type: "section", + fields: monitorFields, + }); + + // Divider + blocks.push({ type: "divider" }); + + // Threshold breaches (if any) + if (message.content.thresholds && message.content.thresholds.length > 0) { + const thresholdText = message.content.thresholds + .map((breach) => `• *${breach.metric.toUpperCase()}:* ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`) + .join("\n"); + + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `*Threshold Breaches:*\n${thresholdText}`, + }, + }); + + blocks.push({ type: "divider" }); + } + + // Additional details (if any) + if (message.content.details && message.content.details.length > 0) { + const detailsText = message.content.details.map((detail) => `• ${detail}`).join("\n"); + + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `*Additional Information:*\n${detailsText}`, + }, + }); + } + + // Incident link button (if incident exists) + if (message.content.incident) { + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Incident", + emoji: true, + }, + url: `${message.clientHost}/infrastructure/${message.monitor.id}`, + style: "primary", + }, + ], + }); + } + + // Context footer with timestamp + blocks.push({ + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`, + }, + ], + }); + + // Return Slack payload with blocks and attachment color + return { + blocks, + attachments: [ + { + color, + blocks: [], // Empty blocks in attachment for color bar + }, + ], + }; + } } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 71d678bf8..253102f4e 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -71,7 +71,7 @@ export class NotificationsService implements INotificationsService { const settings = this.settingsService.getSettings(); const clientHost = settings.clientHost || "Host not defined"; - // For webhook notifications, try new sendMessage method if available + // Route to new sendMessage method if provider supports it if (notification.type === "webhook" && this.webhookProvider.sendMessage && notificationMessage) { this.logger.info({ message: "[NEW] Using sendMessage for webhook", @@ -81,6 +81,15 @@ export class NotificationsService implements INotificationsService { return await this.webhookProvider.sendMessage(notification, notificationMessage); } + if (notification.type === "slack" && this.slackProvider.sendMessage && notificationMessage) { + this.logger.info({ + message: "[NEW] Using sendMessage for slack", + service: SERVICE_NAME, + method: "send", + }); + return await this.slackProvider.sendMessage(notification, notificationMessage); + } + // Fallback to existing sendAlert for all providers switch (notification.type) { case "email":