Skip to content

Commit 25b9f5f

Browse files
committed
Update add support for Ntfy notification provider
1 parent a143aa5 commit 25b9f5f

File tree

7 files changed

+288
-3
lines changed

7 files changed

+288
-3
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,17 @@ port = 465
331331
secure = true
332332
user = "info@rabbit-company.com"
333333
pass = ""
334+
335+
# Ntfy configuration for critical alerts
336+
[notifications.channels.critical.ntfy]
337+
enabled = false
338+
server = "https://ntfy.sh"
339+
topic = "your-topic-here"
340+
# Optional: Token authentication
341+
#token = "tk_your_token_here"
342+
# Optional: Username/password authentication
343+
#username = "your_username"
344+
#password = "your_password"
334345
```
335346

336347
## 📡 Sending Pulses

config.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,14 @@ port = 465
265265
secure = true
266266
user = "info@rabbit-company.com"
267267
pass = ""
268+
269+
# Ntfy configuration for critical alerts
270+
[notifications.channels.critical.ntfy]
271+
enabled = false
272+
server = "https://ntfy.sh"
273+
topic = "uptime-monitor"
274+
# Optional: Token authentication
275+
#token = "tk_your_token_here"
276+
# Optional: Username/password authentication
277+
#username = "your_username"
278+
#password = "your_password"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "uptimemonitor-server",
33
"module": "src/index.ts",
4-
"version": "0.2.13",
4+
"version": "0.2.14",
55
"type": "module",
66
"private": true,
77
"scripts": {

src/config.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,65 @@ function validateDiscordConfig(config: unknown, channelId: string): any {
995995
return result;
996996
}
997997

998+
function validateNtfyConfig(config: unknown, channelId: string): any {
999+
const errors: string[] = [];
1000+
const cfg = config as Record<string, unknown>;
1001+
1002+
if (!isBoolean(cfg.enabled)) {
1003+
errors.push(`notifications.channels.${channelId}.ntfy.enabled must be a boolean`);
1004+
}
1005+
1006+
if (!cfg.enabled) {
1007+
return { enabled: false };
1008+
}
1009+
1010+
// Validate server
1011+
if (!isString(cfg.server) || cfg.server.trim().length === 0) {
1012+
errors.push(`notifications.channels.${channelId}.ntfy.server must be a non-empty string`);
1013+
}
1014+
1015+
// Validate topic
1016+
if (!isString(cfg.topic) || cfg.topic.trim().length === 0) {
1017+
errors.push(`notifications.channels.${channelId}.ntfy.topic must be a non-empty string`);
1018+
}
1019+
1020+
// Validate optional username
1021+
if (cfg.username !== undefined && (!isString(cfg.username) || cfg.username.trim().length === 0)) {
1022+
errors.push(`notifications.channels.${channelId}.ntfy.username must be a non-empty string if provided`);
1023+
}
1024+
1025+
// Validate optional password
1026+
if (cfg.password !== undefined && (!isString(cfg.password) || cfg.password.trim().length === 0)) {
1027+
errors.push(`notifications.channels.${channelId}.ntfy.password must be a non-empty string if provided`);
1028+
}
1029+
1030+
// Validate optional token
1031+
if (cfg.token !== undefined && (!isString(cfg.token) || cfg.token.trim().length === 0)) {
1032+
errors.push(`notifications.channels.${channelId}.ntfy.token must be a non-empty string if provided`);
1033+
}
1034+
1035+
// Warn if username is provided without password or vice versa
1036+
if ((cfg.username && !cfg.password) || (!cfg.username && cfg.password)) {
1037+
errors.push(`notifications.channels.${channelId}.ntfy: both username and password must be provided together`);
1038+
}
1039+
1040+
if (errors.length > 0) {
1041+
throw new ConfigValidationError(errors);
1042+
}
1043+
1044+
const result: any = {
1045+
enabled: cfg.enabled,
1046+
server: cfg.server,
1047+
topic: cfg.topic,
1048+
};
1049+
1050+
if (cfg.username) result.username = cfg.username;
1051+
if (cfg.password) result.password = cfg.password;
1052+
if (cfg.token) result.token = cfg.token;
1053+
1054+
return result;
1055+
}
1056+
9981057
function validateNotificationChannel(channel: unknown, channelId: string): NotificationChannel {
9991058
const errors: string[] = [];
10001059

@@ -1054,6 +1113,16 @@ function validateNotificationChannel(channel: unknown, channelId: string): Notif
10541113
}
10551114
}
10561115

1116+
if (channel.ntfy) {
1117+
try {
1118+
result.ntfy = validateNtfyConfig(channel.ntfy, channelId);
1119+
} catch (error) {
1120+
if (error instanceof ConfigValidationError) {
1121+
throw error;
1122+
}
1123+
}
1124+
}
1125+
10571126
return result;
10581127
}
10591128

@@ -1327,8 +1396,9 @@ function validateNotificationChannelProviders(config: Config): void {
13271396
// Check that enabled channels have at least one provider configured
13281397
const hasEmail = channel.email?.enabled;
13291398
const hasDiscord = channel.discord?.enabled;
1399+
const hasNtfy = channel.ntfy?.enabled;
13301400

1331-
if (!hasEmail && !hasDiscord) {
1401+
if (!hasEmail && !hasDiscord && !hasNtfy) {
13321402
errors.push(`Notification channel '${channelId}' is enabled but has no providers configured`);
13331403
}
13341404
}

src/notifications/channel-manager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EmailProvider } from "./providers/email";
22
import { DiscordProvider } from "./providers/discord";
3+
import { NtfyProvider } from "./providers/ntfy";
34
import { Logger } from "../logger";
45
import type { NotificationChannel, NotificationEvent, NotificationProvider } from "../types";
56

@@ -28,6 +29,10 @@ export class NotificationChannelManager {
2829
channelProviders.push(new DiscordProvider(channel.discord));
2930
}
3031

32+
if (channel.ntfy?.enabled) {
33+
channelProviders.push(new NtfyProvider(channel.ntfy));
34+
}
35+
3136
if (channelProviders.length > 0) {
3237
this.providers.set(channelId, channelProviders);
3338
Logger.info("Notification channel initialized", {
@@ -75,7 +80,7 @@ export class NotificationChannelManager {
7580
error: error instanceof Error ? error.message : "Unknown error",
7681
event,
7782
});
78-
})
83+
}),
7984
);
8085
}
8186
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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+
}

src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,15 @@ export interface WebhookConfig {
591591
template?: string;
592592
}
593593

594+
export interface NtfyConfig {
595+
enabled: boolean;
596+
server: string;
597+
topic: string;
598+
username?: string;
599+
password?: string;
600+
token?: string;
601+
}
602+
594603
export interface DowntimeRecord {
595604
startTime: Date;
596605
endTime: Date;
@@ -615,6 +624,8 @@ export interface NotificationChannel {
615624
email?: EmailConfig;
616625
/** Discord configuration for this channel */
617626
discord?: DiscordConfig;
627+
/** Ntfy configuration for this channel */
628+
ntfy?: NtfyConfig;
618629
}
619630

620631
export interface NotificationEvent {

0 commit comments

Comments
 (0)