diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0b42b6d40d..d08f175481 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 || "", + accountSid: data.accountSid || "", + accessToken: data.accessToken || "", + phone: data.phone || "", + twilioPhoneNumber: data.twilioPhoneNumber || "", + }; + } 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 3da9e6f757..0f6d9b6525 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 c2e9ac854e..7fbd7dcf2a 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 ed9f02a9f5..8a5db6b494 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 3b424bddeb..2f7effa17d 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: { @@ -37,6 +37,8 @@ const NotificationSchema = new Schema( homeserverUrl: { type: String }, roomId: { type: String }, accessToken: { type: String }, + accountSid: { type: String }, + twilioPhoneNumber: { type: String }, }, { timestamps: true, diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 3c4225b50e..5303aa6ab2 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -32,6 +32,8 @@ class MongoNotificationsRepository implements INotificationsRepository { homeserverUrl: doc.homeserverUrl ?? undefined, roomId: doc.roomId ?? undefined, accessToken: doc.accessToken ?? undefined, + accountSid: doc.accountSid ?? undefined, + twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts index b2c369c863..b266f33f0f 100644 --- a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts +++ b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts @@ -14,20 +14,22 @@ interface NotificationRow { homeserver_url: string | null; room_id: string | null; access_token: string | null; + account_sid: string | null; + twilio_phone_number: string | null; created_at: Date; updated_at: Date; } const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone, - homeserver_url, room_id, access_token, created_at, updated_at`; + homeserver_url, room_id, access_token, account_sid, twilio_phone_number, created_at, updated_at`; export class TimescaleNotificationsRepository implements INotificationsRepository { constructor(private pool: Pool) {} create = async (data: Partial): Promise => { const result = await this.pool.query( - `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token, account_sid, twilio_phone_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING ${COLUMNS}`, [ data.userId, @@ -39,6 +41,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor data.homeserverUrl ?? null, data.roomId ?? null, data.accessToken ?? null, + data.accountSid ?? null, + data.twilioPhoneNumber ?? null, ] ); const row = result.rows[0]; @@ -83,6 +87,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor ["homeserverUrl", "homeserver_url"], ["roomId", "room_id"], ["accessToken", "access_token"], + ["accountSid", "account_sid"], + ["twilioPhoneNumber", "twilio_phone_number"], ]; for (const [key, column] of fieldMap) { @@ -134,6 +140,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor homeserverUrl: row.homeserver_url ?? undefined, roomId: row.room_id ?? undefined, accessToken: row.access_token ?? undefined, + accountSid: row.account_sid ?? undefined, + twilioPhoneNumber: row.twilio_phone_number ?? undefined, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), }); diff --git a/server/src/service/index.ts b/server/src/service/index.ts index da529ed2bc..5db8afc4fc 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 0000000000..a871fe5cb7 --- /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.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { + return false; + } + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { + form: { + To: notification.phone, + From: notification.twilioPhoneNumber, + Body: getTestMessage(), + }, + username: notification.accountSid, + 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.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { + return false; + } + + const text = this.buildSmsText(message); + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { + form: { + To: notification.phone, + From: notification.twilioPhoneNumber, + Body: text, + }, + username: notification.accountSid, + 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 0344ab30cb..90e9d637ab 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 f095be7702..9d5b914c02 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 { @@ -12,6 +23,8 @@ export interface Notification { homeserverUrl?: string; roomId?: string; accessToken?: string; + accountSid?: string; + twilioPhoneNumber?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 5cdb44e95b..cf74786101 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"), + accountSid: 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"), + twilioPhoneNumber: 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 0000000000..88bfcdf7e8 --- /dev/null +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -0,0 +1,136 @@ +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", + twilioPhoneNumber: "+15551234567", + accountSid: "ACtest123", + ...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 accountSid is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accountSid: "" }))).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 twilioPhoneNumber (from number) is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ twilioPhoneNumber: "" }))).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 accountSid is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accountSid: "" }) 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 00dd644f83..cdc1ad3207 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 @@ -144,7 +147,7 @@ describe("NotificationsService", () => { }); it("routes to correct provider for each notification type", async () => { - const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const; + const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const; for (const type of types) { const deps = createService(); (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); @@ -160,6 +163,8 @@ describe("NotificationsService", () => { email: deps.emailProvider, teams: deps.teamsProvider, telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, }; expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1); } @@ -233,7 +238,7 @@ describe("NotificationsService", () => { // ── sendTestNotification ───────────────────────────────────────────────── describe("sendTestNotification", () => { - it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)( + it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)( "routes %s to the correct provider", async (type) => { const deps = createService(); @@ -251,6 +256,8 @@ describe("NotificationsService", () => { email: deps.emailProvider, teams: deps.teamsProvider, telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, }; expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification); }