|
| 1 | +import { cache } from "../../cache"; |
| 2 | +import { Logger } from "../../logger"; |
| 3 | +import { formatDateTimeLocal, formatDuration } from "../../times"; |
| 4 | +import type { NtfyConfig, NotificationEvent, NotificationProvider } from "../../types"; |
| 5 | + |
| 6 | +export class NtfyProvider implements NotificationProvider { |
| 7 | + private config: NtfyConfig; |
| 8 | + |
| 9 | + constructor(config: NtfyConfig) { |
| 10 | + this.config = config; |
| 11 | + } |
| 12 | + |
| 13 | + async sendNotification(event: NotificationEvent): Promise<void> { |
| 14 | + if (!this.config.enabled) return; |
| 15 | + |
| 16 | + try { |
| 17 | + const { title, message, priority, tags } = this.generateNotificationContent(event); |
| 18 | + |
| 19 | + const headers: Record<string, string> = { |
| 20 | + Title: title, |
| 21 | + Priority: priority, |
| 22 | + Tags: tags.join(","), |
| 23 | + }; |
| 24 | + |
| 25 | + // Add authentication if configured |
| 26 | + if (this.config.token) { |
| 27 | + headers["Authorization"] = `Bearer ${this.config.token}`; |
| 28 | + } else if (this.config.username && this.config.password) { |
| 29 | + const credentials = Buffer.from(`${this.config.username}:${this.config.password}`).toString("base64"); |
| 30 | + headers["Authorization"] = `Basic ${credentials}`; |
| 31 | + } |
| 32 | + |
| 33 | + const url = `${this.config.server.replace(/\/$/, "")}/${this.config.topic}`; |
| 34 | + |
| 35 | + const response = await fetch(url, { |
| 36 | + method: "POST", |
| 37 | + body: message, |
| 38 | + headers, |
| 39 | + }); |
| 40 | + |
| 41 | + if (!response.ok) { |
| 42 | + throw new Error(`Ntfy request failed: ${response.status} ${response.statusText}`); |
| 43 | + } |
| 44 | + |
| 45 | + Logger.info("Ntfy notification sent successfully", { |
| 46 | + type: event.type, |
| 47 | + monitorId: event.monitorId, |
| 48 | + monitorName: event.monitorName, |
| 49 | + server: this.config.server, |
| 50 | + topic: this.config.topic, |
| 51 | + }); |
| 52 | + } catch (error) { |
| 53 | + Logger.error("Failed to send Ntfy notification", { |
| 54 | + type: event.type, |
| 55 | + monitorId: event.monitorId, |
| 56 | + monitorName: event.monitorName, |
| 57 | + error: error instanceof Error ? error.message : "Unknown error", |
| 58 | + }); |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + private generateNotificationContent(event: NotificationEvent): { |
| 63 | + title: string; |
| 64 | + message: string; |
| 65 | + priority: string; |
| 66 | + tags: string[]; |
| 67 | + } { |
| 68 | + const downtimeDuration = event.downtime ? formatDuration(event.downtime) : "Just now"; |
| 69 | + const interval = event.sourceType === "group" ? cache.getGroup(event.monitorId)!.interval : cache.getMonitor(event.monitorId)!.interval; |
| 70 | + const formattedTime = formatDateTimeLocal(event.timestamp); |
| 71 | + |
| 72 | + let title: string; |
| 73 | + let message: string; |
| 74 | + let priority: string; |
| 75 | + let tags: string[]; |
| 76 | + |
| 77 | + switch (event.type) { |
| 78 | + case "down": |
| 79 | + title = `${event.sourceType === "group" ? "Group" : "Monitor"} Down: ${event.monitorName}`; |
| 80 | + priority = "urgent"; |
| 81 | + tags = ["rotating_light", event.sourceType === "group" ? "group" : "monitor", "down"]; |
| 82 | + |
| 83 | + if (event.sourceType === "group" && event.groupInfo) { |
| 84 | + message = [ |
| 85 | + `Group "${event.monitorName}" has degraded below acceptable thresholds.`, |
| 86 | + "", |
| 87 | + `Status: DOWN`, |
| 88 | + `Type: Group`, |
| 89 | + `Strategy: ${event.groupInfo.strategy}`, |
| 90 | + `Children Status: ${event.groupInfo.childrenUp}/${event.groupInfo.totalChildren} up (${event.groupInfo.upPercentage.toFixed(1)}%)`, |
| 91 | + `Detected at: ${formattedTime}`, |
| 92 | + `Downtime: ${downtimeDuration}`, |
| 93 | + `Group ID: ${event.monitorId}`, |
| 94 | + ].join("\n"); |
| 95 | + } else { |
| 96 | + message = [ |
| 97 | + `Monitor "${event.monitorName}" has stopped responding and is now marked as DOWN.`, |
| 98 | + "", |
| 99 | + `Status: DOWN`, |
| 100 | + `Type: Monitor`, |
| 101 | + `Detected at: ${formattedTime}`, |
| 102 | + `Downtime: ${downtimeDuration}`, |
| 103 | + `Monitor ID: ${event.monitorId}`, |
| 104 | + ].join("\n"); |
| 105 | + } |
| 106 | + break; |
| 107 | + |
| 108 | + case "still-down": |
| 109 | + const stillDownDuration = event.downtime ? formatDuration(event.downtime) : formatDuration((event.consecutiveDownCount || 0) * (interval || 30) * 1000); |
| 110 | + |
| 111 | + title = `Still Down: ${event.monitorName} (${event.consecutiveDownCount || 0} checks)`; |
| 112 | + priority = "high"; |
| 113 | + tags = ["warning", event.sourceType === "group" ? "group" : "monitor", "still-down"]; |
| 114 | + |
| 115 | + message = [ |
| 116 | + `${event.sourceType === "group" ? "Group" : "Monitor"} "${event.monitorName}" remains down after multiple consecutive checks.`, |
| 117 | + "", |
| 118 | + `Status: STILL DOWN`, |
| 119 | + `Type: ${event.sourceType === "group" ? "Group" : "Monitor"}`, |
| 120 | + `Checked at: ${formattedTime}`, |
| 121 | + `Consecutive downs: ${event.consecutiveDownCount || 0}`, |
| 122 | + `Total downtime: ${stillDownDuration}`, |
| 123 | + `${event.sourceType === "group" ? "Group" : "Monitor"} ID: ${event.monitorId}`, |
| 124 | + ].join("\n"); |
| 125 | + |
| 126 | + if (event.groupInfo) { |
| 127 | + message += `\nStrategy: ${event.groupInfo.strategy}`; |
| 128 | + message += `\nChildren Status: ${event.groupInfo.childrenUp}/${event.groupInfo.totalChildren} up (${event.groupInfo.upPercentage.toFixed(1)}%)`; |
| 129 | + } |
| 130 | + break; |
| 131 | + |
| 132 | + case "recovered": |
| 133 | + const outageDuration = event.downtime |
| 134 | + ? formatDuration(event.downtime) |
| 135 | + : formatDuration((event.previousConsecutiveDownCount || 0) * (interval || 30) * 1000); |
| 136 | + |
| 137 | + title = `${event.sourceType === "group" ? "Group" : "Monitor"} Recovered: ${event.monitorName}`; |
| 138 | + priority = "default"; |
| 139 | + tags = ["white_check_mark", event.sourceType === "group" ? "group" : "monitor", "recovered"]; |
| 140 | + |
| 141 | + if (event.sourceType === "group" && event.groupInfo) { |
| 142 | + message = [ |
| 143 | + `Great news! Group "${event.monitorName}" has recovered and is now healthy.`, |
| 144 | + "", |
| 145 | + `Status: UP`, |
| 146 | + `Type: Group`, |
| 147 | + `Strategy: ${event.groupInfo.strategy}`, |
| 148 | + `Children Status: ${event.groupInfo.childrenUp}/${event.groupInfo.totalChildren} up (${event.groupInfo.upPercentage.toFixed(1)}%)`, |
| 149 | + `Recovered at: ${formattedTime}`, |
| 150 | + `Previous outage: ${event.previousConsecutiveDownCount || 0} consecutive down checks`, |
| 151 | + `Total outage duration: ${outageDuration}`, |
| 152 | + `Group ID: ${event.monitorId}`, |
| 153 | + ].join("\n"); |
| 154 | + } else { |
| 155 | + message = [ |
| 156 | + `Great news! Monitor "${event.monitorName}" has recovered and is now responding normally.`, |
| 157 | + "", |
| 158 | + `Status: UP`, |
| 159 | + `Type: Monitor`, |
| 160 | + `Recovered at: ${formattedTime}`, |
| 161 | + `Previous outage: ${event.previousConsecutiveDownCount || 0} consecutive down checks`, |
| 162 | + `Total outage duration: ${outageDuration}`, |
| 163 | + `Monitor ID: ${event.monitorId}`, |
| 164 | + ].join("\n"); |
| 165 | + } |
| 166 | + break; |
| 167 | + |
| 168 | + default: |
| 169 | + title = `Unknown Notification: ${event.monitorName}`; |
| 170 | + message = `Unknown notification type: ${event.type}`; |
| 171 | + priority = "default"; |
| 172 | + tags = ["question"]; |
| 173 | + } |
| 174 | + |
| 175 | + return { title, message, priority, tags }; |
| 176 | + } |
| 177 | +} |
0 commit comments