Merge pull request #3439 from LeC-D/fix/telegram-notification-type

feat(notifications): add Telegram notification channel (#2753)
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-03-27 12:04:01 -07:00
commit 69b6955ced
12 files changed
+260 -22

No files matched your search

+63 -15
View File
@@ -1,28 +1,76 @@
import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions {
data?: Notification | null;
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
};
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => {
const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
const defaults = buildDefaults(data);
return { schema: notificationSchema, defaults };
}, [data]);
};
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && (
{watchedType !== "matrix" && watchedType !== "telegram" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
<Controller
name="address"
control={control}
defaultValue={defaults.address}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -171,6 +171,49 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
@@ -180,7 +223,7 @@ const NotificationsCreatePage = () => {
<Controller
name="homeserverUrl"
control={control}
defaultValue={defaults.homeserverUrl}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -196,7 +239,7 @@ const NotificationsCreatePage = () => {
<Controller
name="roomId"
control={control}
defaultValue={defaults.roomId}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -212,7 +255,7 @@ const NotificationsCreatePage = () => {
<Controller
name="accessToken"
control={control}
defaultValue={defaults.accessToken}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
+1
View File
@@ -6,6 +6,7 @@ export const NotificationChannels = [
"pager_duty",
"matrix",
"teams",
"telegram",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+7
View File
@@ -50,6 +50,12 @@ const teamsSchema = baseSchema.extend({
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
});
const telegramSchema = baseSchema.extend({
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -58,6 +64,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
pagerDutySchema,
matrixSchema,
teamsSchema,
telegramSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+8
View File
@@ -951,6 +951,14 @@
"description": "Select the type of notification channel to create.",
"optionType": "Type",
"title": "Channel type"
},
"telegram": {
"title": "Telegram configuration",
"description": "Configure your Telegram bot to send notifications.",
"optionBotToken": "Bot token",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
}
},
"table": {
+3
View File
@@ -28,6 +28,7 @@ import {
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
TelegramProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -296,6 +297,7 @@ export const initializeServices = async ({
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -307,6 +309,7 @@ export const initializeServices = async ({
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
logger,
notificationMessageBuilder
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
required: true,
},
notificationName: {
+1
View File
@@ -29,6 +29,7 @@ export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -0,0 +1,113 @@
const SERVICE_NAME = "TelegramProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class TelegramProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
json: {
chat_id: notification.address,
text: getTestMessage(),
parse_mode: "HTML",
},
});
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: "Telegram 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.address || !notification.accessToken) {
return false;
}
const text = this.buildTelegramText(message);
try {
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
json: {
chat_id: notification.address,
text,
parse_mode: "HTML",
},
});
this.logger.info({
message: "Telegram 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: "Telegram alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private buildTelegramText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(`<b>${message.content.title}</b>`);
lines.push(message.content.summary);
lines.push("");
lines.push("<b>Monitor Details:</b>");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("<b>Additional Information:</b>");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("<b>Threshold Breaches:</b>");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`<a href="${message.clientHost}/incidents/${message.monitor.id}">View Incident</a>`);
}
return lines.join("\n");
}
}
@@ -33,6 +33,7 @@ export class NotificationsService implements INotificationsService {
private pagerDutyProvider: INotificationProvider;
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -47,6 +48,7 @@ export class NotificationsService implements INotificationsService {
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -60,6 +62,7 @@ export class NotificationsService implements INotificationsService {
this.pagerDutyProvider = pagerDutyProvider;
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -97,6 +100,8 @@ export class NotificationsService implements INotificationsService {
return await this.emailProvider.sendMessage!(notification, notificationMessage);
case "teams":
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
case "telegram":
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -157,6 +162,8 @@ export class NotificationsService implements INotificationsService {
return await this.webhookProvider.sendTestAlert(notification);
case "teams":
return await this.teamsProvider.sendTestAlert(notification);
case "telegram":
return await this.telegramProvider.sendTestAlert(notification);
default:
return false;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as const;
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export interface Notification {
@@ -65,6 +65,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
type: z.literal("teams"),
address: z.url({ message: "Please enter a valid Webhook URL" }),
}),
// Telegram notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
}),
]);
export const testNotificationBodyValidation = createNotificationBodyValidation;