Merge pull request #3534 from egeoztass/feat/twilio-sms-notification

feat: add Twilio SMS notification channel
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-04-20 10:54:12 -07:00
commit 984a611d19
16 files changed
+409 -8

No files matched your search

+10
View File
@@ -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",
@@ -149,7 +149,8 @@ const NotificationsCreatePage = () => {
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" && (
watchedType !== "pushover" &&
watchedType !== "twilio" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -262,6 +263,88 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
+3
View File
@@ -8,6 +8,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"twilio",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
@@ -22,6 +23,8 @@ export interface Notification {
homeserverUrl?: string;
roomId?: string;
accessToken?: string;
accountSid?: string;
twilioPhoneNumber?: string;
createdAt: string;
updatedAt: string;
}
+9
View File
@@ -62,6 +62,14 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const twilioSchema = baseSchema.extend({
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 notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -72,6 +80,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
twilioSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+12
View File
@@ -968,6 +968,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": {
+3
View File
@@ -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
+3 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
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<NotificationDocument>(
homeserverUrl: { type: String },
roomId: { type: String },
accessToken: { type: String },
accountSid: { type: String },
twilioPhoneNumber: { type: String },
},
{
timestamps: true,
@@ -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),
};
@@ -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<Notification>): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(
`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(),
});
+1
View File
@@ -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";
@@ -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<Notification>): Promise<boolean> {
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<boolean> {
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");
}
}
@@ -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;
}
+14 -1
View File
@@ -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;
}
@@ -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;
@@ -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<string, unknown>) =>
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");
});
});
});
@@ -46,6 +46,7 @@ const createService = (overrides?: Record<string, unknown>) => {
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<string, unknown>) => {
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
settingsService,
notificationMessageBuilder,
...overrides,
@@ -79,6 +81,7 @@ const createService = (overrides?: Record<string, unknown>) => {
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);
}