diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0b42b6d40..4a1e05f2c 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -60,6 +60,16 @@ function buildDefaults(data: Notification | null): NotificationFormData { address: data.address || "", }; } + if (data?.type === "twilio") { + return { + type: "twilio", + notificationName: data.notificationName || "", + address: data.address || "", + accessToken: data.accessToken || "", + phone: data.phone || "", + homeserverUrl: data.homeserverUrl || "", + }; + } if (data?.type === "pushover") { return { type: "pushover", diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 3da9e6f75..3d7f85acd 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -149,7 +149,8 @@ const NotificationsCreatePage = () => { /> {watchedType !== "matrix" && watchedType !== "telegram" && - watchedType !== "pushover" && ( + watchedType !== "pushover" && + watchedType !== "twilio" && ( { } /> )} + {watchedType === "twilio" && ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {watchedType === "matrix" && ( ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c2e9ac854..7fbd7dcf2 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -967,6 +967,18 @@ "placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi", "optionUserKey": "User key", "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" + }, + "twilio": { + "title": "Twilio SMS configuration", + "description": "Configure Twilio to send SMS notifications to a phone number.", + "optionAccountSid": "Account SID", + "placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "optionAuthToken": "Auth token", + "placeholderAuthToken": "your_auth_token", + "optionFromNumber": "From number (Twilio)", + "placeholderFromNumber": "+15551234567", + "optionToNumber": "To number (recipient)", + "placeholderToNumber": "+15559876543" } }, "table": { diff --git a/server/src/config/services.ts b/server/src/config/services.ts index ed9f02a9f..8a5db6b49 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -30,6 +30,7 @@ import { TeamsProvider, TelegramProvider, PushoverProvider, + TwilioProvider, // Interfaces INetworkService, IEmailService, @@ -300,6 +301,7 @@ export const initializeServices = async ({ const teamsProvider = new TeamsProvider(logger); const telegramProvider = new TelegramProvider(logger); const pushoverProvider = new PushoverProvider(logger); + const twilioProvider = new TwilioProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -313,6 +315,7 @@ export const initializeServices = async ({ teamsProvider, telegramProvider, pushoverProvider, + twilioProvider, settingsService, logger, notificationMessageBuilder diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 3b424bdde..973f7fe3e 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,7 @@ const NotificationSchema = new Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as NotificationChannel[], + enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[], required: true, }, notificationName: { diff --git a/server/src/service/index.ts b/server/src/service/index.ts index da529ed2b..5db8afc4f 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -31,6 +31,7 @@ export * from "@/service/infrastructure/notificationProviders/teams.js"; export * from "@/service/infrastructure/notificationProviders/webhook.js"; export * from "@/service/infrastructure/notificationProviders/telegram.js"; export * from "@/service/infrastructure/notificationProviders/pushover.js"; +export * from "@/service/infrastructure/notificationProviders/twilio.js"; // System services export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/notificationProviders/twilio.ts b/server/src/service/infrastructure/notificationProviders/twilio.ts new file mode 100644 index 000000000..6d84f80f3 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/twilio.ts @@ -0,0 +1,96 @@ +const SERVICE_NAME = "TwilioProvider"; +import type { Notification } from "@/types/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import got from "got"; + +export class TwilioProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + return false; + } + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + form: { + To: notification.phone, + From: notification.homeserverUrl, + Body: getTestMessage(), + }, + username: notification.address, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + return false; + } + + const text = this.buildSmsText(message); + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + form: { + To: notification.phone, + From: notification.homeserverUrl, + Body: text, + }, + username: notification.address, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Twilio SMS notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio SMS alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private buildSmsText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.title); + lines.push(message.content.summary); + lines.push(""); + lines.push(`URL: ${message.monitor.url}`); + lines.push(`Status: ${message.monitor.status}`); + + if (message.content.thresholds && message.content.thresholds.length > 0) { + message.content.thresholds.forEach((breach) => { + lines.push(`${breach.metric.toUpperCase()}: ${breach.formattedValue}`); + }); + } + + return lines.join("\n"); + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 0344ab30c..90e9d637a 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -35,6 +35,7 @@ export class NotificationsService implements INotificationsService { private teamsProvider: INotificationProvider; private telegramProvider: INotificationProvider; private pushoverProvider: INotificationProvider; + private twilioProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -51,6 +52,7 @@ export class NotificationsService implements INotificationsService { teamsProvider: INotificationProvider, telegramProvider: INotificationProvider, pushoverProvider: INotificationProvider, + twilioProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -66,6 +68,7 @@ export class NotificationsService implements INotificationsService { this.teamsProvider = teamsProvider; this.telegramProvider = telegramProvider; this.pushoverProvider = pushoverProvider; + this.twilioProvider = twilioProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -107,6 +110,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendMessage!(notification, notificationMessage); case "pushover": return await this.pushoverProvider.sendMessage!(notification, notificationMessage); + case "twilio": + return await this.twilioProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -171,6 +176,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendTestAlert(notification); case "pushover": return await this.pushoverProvider.sendTestAlert(notification); + case "twilio": + return await this.twilioProvider.sendTestAlert(notification); default: return false; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index f095be770..f306e2592 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -1,4 +1,15 @@ -export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as const; +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "twilio", +] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; export interface Notification { diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 5cdb44e95..545335dad 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -79,6 +79,15 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ address: z.string().min(1, "User key is required"), accessToken: z.string().min(1, "App token is required"), }), + // Twilio SMS notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("twilio"), + address: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + homeserverUrl: z.string().min(1, "Twilio phone number is required"), + }), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/test/unit/providers/notifications/twilioProvider.test.ts b/server/test/unit/providers/notifications/twilioProvider.test.ts new file mode 100644 index 000000000..6156b648f --- /dev/null +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { TwilioProvider } = await import("../../../../src/service/infrastructure/notificationProviders/twilio.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new TwilioProvider(logger as any), logger }; +}; + +const makeTwilioNotification = (overrides?: Record) => + makeNotification({ + phone: "+15559876543", + homeserverUrl: "+15551234567", + ...overrides, + }); + +testNotificationProviderContract("TwilioProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeTwilioNotification(), +}); + +describe("TwilioProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Twilio API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + expect.stringContaining("api.twilio.com/2010-04-01/Accounts/"), + expect.objectContaining({ + form: expect.objectContaining({ To: "+15559876543", From: "+15551234567" }), + username: expect.any(String), + password: expect.any(String), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ address: "" }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false); + }); + + it("returns false when homeserverUrl (from number) is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ homeserverUrl: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } })); + }); + + it("handles non-Error thrown values in sendTestAlert", async () => { + mockGotPost.mockRejectedValue("string error"); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } })); + }); + }); + + describe("sendMessage", () => { + it("sends SMS and returns true", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(true); + const form = mockGotPost.mock.calls[0][1].form; + expect(form.Body).toContain("Monitor Down"); + expect(form.To).toBe("+15559876543"); + expect(form.From).toBe("+15551234567"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ phone: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("CPU"); + }); + + it("includes monitor URL in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("https://example.com"); + }); + + it("omits thresholds when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + await provider.sendMessage(makeTwilioNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].form.Body; + expect(text).not.toContain("CPU"); + }); + }); +}); diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index 00dd644f8..d8ee4b05c 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -46,6 +46,7 @@ const createService = (overrides?: Record) => { const teamsProvider = createProvider(); const telegramProvider = createProvider(); const pushoverProvider = createProvider(); + const twilioProvider = createProvider(); const settingsService = createSettingsService(); const notificationMessageBuilder = createMessageBuilder(); @@ -62,6 +63,7 @@ const createService = (overrides?: Record) => { teamsProvider, telegramProvider, pushoverProvider, + twilioProvider, settingsService, notificationMessageBuilder, ...overrides, @@ -79,6 +81,7 @@ const createService = (overrides?: Record) => { defaults.teamsProvider as any, defaults.telegramProvider as any, defaults.pushoverProvider as any, + defaults.twilioProvider as any, defaults.settingsService as any, defaults.logger as any, defaults.notificationMessageBuilder as any