diff --git a/Server/controllers/notificationController.js b/Server/controllers/notificationController.js new file mode 100644 index 000000000..bce20ea13 --- /dev/null +++ b/Server/controllers/notificationController.js @@ -0,0 +1,59 @@ +import { + triggerNotificationBodyValidation, +} from '../validation/joi.js'; +import { handleError, handleValidationError } from './controllerUtils.js'; + +const SERVICE_NAME = "NotificationController"; + +class NotificationController { + constructor(notificationService, stringService) { + this.notificationService = notificationService; + this.stringService = stringService; + this.triggerNotification = this.triggerNotification.bind(this); + } + + async triggerNotification(req, res, next) { + try { + await triggerNotificationBodyValidation.validateAsync(req.body, { + abortEarly: false, + stripUnknown: true + }); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const { monitorId, type, platform, config } = req.body; + + const networkResponse = { + monitor: { _id: monitorId, name: "Test Monitor", url: "http://www.google.com" }, + status: false, + statusChanged: true, + prevStatus: true, + }; + + if (type === "webhook") { + const notification = { + type, + platform, + config + }; + + await this.notificationService.sendWebhookNotification( + networkResponse, + notification + ); + } + + return res.success({ + msg: this.stringService.webhookSendSuccess + }); + + } catch (error) { + next(handleError(error, SERVICE_NAME, "triggerNotification")); + } + } +} + +export default NotificationController; \ No newline at end of file diff --git a/Server/db/models/Notification.js b/Server/db/models/Notification.js index 8767880c1..53d9eb4a2 100644 --- a/Server/db/models/Notification.js +++ b/Server/db/models/Notification.js @@ -1,4 +1,11 @@ import mongoose from "mongoose"; + +const configSchema = mongoose.Schema({ + webhookUrl: { type: String }, + botToken: { type: String }, + chatId: { type: String } +}, { _id: false }); + const NotificationSchema = mongoose.Schema( { monitorId: { @@ -8,8 +15,12 @@ const NotificationSchema = mongoose.Schema( }, type: { type: String, - enum: ["email", "sms"], + enum: ["email", "sms", "webhook"], }, + config: { + type: configSchema, + default: () => ({}) + }, address: { type: String, }, @@ -76,4 +87,5 @@ NotificationSchema.pre("findOneAndUpdate", function (next) { } next(); }); + export default mongoose.model("Notification", NotificationSchema); diff --git a/Server/index.js b/Server/index.js index 3edcd0e6e..b28fd5a82 100644 --- a/Server/index.js +++ b/Server/index.js @@ -38,6 +38,10 @@ import QueueController from "./controllers/queueController.js"; import DistributedUptimeRoutes from "./routes/distributedUptimeRoute.js"; import DistributedUptimeController from "./controllers/distributedUptimeController.js"; +import NotificationRoutes from "./routes/notificationRoute.js"; + +import NotificationController from "./controllers/notificationController.js"; + //JobQueue service and dependencies import JobQueue from "./service/jobQueue.js"; import { Queue, Worker } from "bullmq"; @@ -166,7 +170,7 @@ const startApp = async () => { logger ); const statusService = new StatusService(db, logger); - const notificationService = new NotificationService(emailService, db, logger); + const notificationService = new NotificationService(emailService, db, logger, networkService, stringService); const jobQueue = new JobQueue( db, @@ -251,6 +255,11 @@ const startApp = async () => { ServiceRegistry.get(StringService.SERVICE_NAME) ); + const notificationController = new NotificationController( + ServiceRegistry.get(NotificationService.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + const distributedUptimeController = new DistributedUptimeController( ServiceRegistry.get(MongoDB.SERVICE_NAME), http, @@ -271,6 +280,9 @@ const startApp = async () => { const distributedUptimeRoutes = new DistributedUptimeRoutes( distributedUptimeController ); + + const notificationRoutes = new NotificationRoutes(notificationController); + // Init job queue await jobQueue.initJobQueue(); // Middleware @@ -293,6 +305,7 @@ const startApp = async () => { app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); app.use("/api/v1/distributed-uptime", distributedUptimeRoutes.getRouter()); app.use("/api/v1/status-page", statusPageRoutes.getRouter()); + app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); app.use(handleErrors); }; diff --git a/Server/routes/notificationRoute.js b/Server/routes/notificationRoute.js new file mode 100644 index 000000000..6e13a53d1 --- /dev/null +++ b/Server/routes/notificationRoute.js @@ -0,0 +1,24 @@ +import express from 'express'; +import { verifyJWT } from '../middleware/verifyJWT.js'; + +class NotificationRoutes { + constructor(notificationController) { + this.notificationController = notificationController; + this.router = express.Router(); + this.initializeRoutes(); + } + + initializeRoutes() { + this.router.post( + '/trigger', + verifyJWT, + this.notificationController.triggerNotification + ); + } + + getRouter() { + return this.router; + } +} + +export default NotificationRoutes; \ No newline at end of file diff --git a/Server/service/networkService.js b/Server/service/networkService.js index a347e8e94..984939bd4 100644 --- a/Server/service/networkService.js +++ b/Server/service/networkService.js @@ -433,6 +433,46 @@ class NetworkService { throw err; } + async requestWebhook(platform, url, message) { + try { + const response = await this.axios.post(url, message, { + headers: { + 'Content-Type': 'application/json' + } + }); + + return { + type: 'webhook', + status: true, + code: response.status, + message: `Successfully sent ${platform} notification`, + payload: response.data + }; + + } catch (error) { + this.logger.warn({ + message: error.message, + service: this.SERVICE_NAME, + method: 'requestWebhook', + url, + platform, + error: error.message, + statusCode: error.response?.status, + responseData: error.response?.data, + requestPayload: message + }); + + return { + type: 'webhook', + status: false, + code: error.response?.status || this.NETWORK_ERROR, + message: `Failed to send ${platform} notification`, + payload: error.response?.data + }; + } + } + + /** * Gets the status of a job based on its type and returns the appropriate response. * diff --git a/Server/service/notificationService.js b/Server/service/notificationService.js index 807640a61..2546df3f9 100644 --- a/Server/service/notificationService.js +++ b/Server/service/notificationService.js @@ -1,4 +1,12 @@ const SERVICE_NAME = "NotificationService"; +const TELEGRAM_API_BASE_URL = "https://api.telegram.org/bot"; +const PLATFORM_TYPES = ['telegram', 'slack', 'discord']; + +const MESSAGE_FORMATTERS = { + telegram: (messageText, chatId) => ({ chat_id: chatId, text: messageText }), + slack: (messageText) => ({ text: messageText }), + discord: (messageText) => ({ content: messageText }) +}; class NotificationService { static SERVICE_NAME = SERVICE_NAME; @@ -8,14 +16,111 @@ class NotificationService { * @param {Object} emailService - The email service used for sending notifications. * @param {Object} db - The database instance for storing notification data. * @param {Object} logger - The logger instance for logging activities. + * @param {Object} networkService - The network service for sending webhook notifications. */ - constructor(emailService, db, logger) { + constructor(emailService, db, logger, networkService, stringService) { this.SERVICE_NAME = SERVICE_NAME; this.emailService = emailService; this.db = db; this.logger = logger; + this.networkService = networkService; + this.stringService = stringService; } + /** + * Formats a notification message based on the monitor status and platform. + * + * @param {Object} monitor - The monitor object. + * @param {string} monitor.name - The name of the monitor. + * @param {string} monitor.url - The URL of the monitor. + * @param {boolean} status - The current status of the monitor (true for up, false for down). + * @param {string} platform - The notification platform (e.g., "telegram", "slack", "discord"). + * @param {string} [chatId] - The chat ID for platforms that require it (e.g., Telegram). + * @returns {Object|null} The formatted message object for the specified platform, or null if the platform is unsupported. + */ + + formatNotificationMessage(monitor, status, platform, chatId) { + const messageText = this.stringService.getMonitorStatus( + monitor.name, + status, + monitor.url + ); + + if (!PLATFORM_TYPES.includes(platform)) { + return undefined; + } + + return MESSAGE_FORMATTERS[platform](messageText, chatId); + } + + /** + * Sends a webhook notification to a specified platform. + * + * @param {Object} networkResponse - The response object from the network. + * @param {Object} networkResponse.monitor - The monitor object. + * @param {boolean} networkResponse.status - The monitor's status (true for up, false for down). + * @param {Object} notification - The notification settings. + * @param {string} notification.platform - The target platform ("telegram", "slack", "discord"). + * @param {Object} notification.config - The configuration object for the webhook. + * @param {string} notification.config.webhookUrl - The webhook URL for the platform. + * @param {string} [notification.config.botToken] - The bot token for Telegram notifications. + * @param {string} [notification.config.chatId] - The chat ID for Telegram notifications. + * @returns {Promise} A promise that resolves to true if the notification was sent successfully, otherwise false. + */ + + async sendWebhookNotification(networkResponse, notification) { + const { monitor, status } = networkResponse; + const { platform } = notification; + const { webhookUrl, botToken, chatId } = notification.config; + + // Early return if platform is not supported + if (!PLATFORM_TYPES.includes(platform)) { + this.logger.warn({ + message: this.stringService.getWebhookUnsupportedPlatform(platform), + service: this.SERVICE_NAME, + method: 'sendWebhookNotification', + platform + }); + return false; + } + + // Early return for telegram if required fields are missing + if (platform === 'telegram' && (!botToken || !chatId)) { + this.logger.warn({ + message: 'Missing required fields for Telegram notification', + service: this.SERVICE_NAME, + method: 'sendWebhookNotification', + platform + }); + return false; + } + + let url = webhookUrl; + if (platform === 'telegram') { + url = `${TELEGRAM_API_BASE_URL}${botToken}/sendMessage`; + } + + // Now that we know the platform is valid, format the message + const message = this.formatNotificationMessage(monitor, status, platform, chatId); + + try { + const response = await this.networkService.requestWebhook(platform, url, message); + return response.status; + } catch (error) { + this.logger.error({ + message: this.stringService.getWebhookSendError(platform), + service: this.SERVICE_NAME, + method: 'sendWebhookNotification', + error: error.message, + stack: error.stack, + url, + platform, + requestPayload: message + }); + return false; + } + } + /** * Sends an email notification for hardware infrastructure alerts * @@ -57,18 +162,18 @@ class NotificationService { async handleStatusNotifications(networkResponse) { try { - //If status hasn't changed, we're done + // If status hasn't changed, we're done if (networkResponse.statusChanged === false) return false; - // if prevStatus is undefined, monitor is resuming, we're done if (networkResponse.prevStatus === undefined) return false; - const notifications = await this.db.getNotificationsByMonitorId( - networkResponse.monitorId - ); - + + const notifications = await this.db.getNotificationsByMonitorId(networkResponse.monitorId); + for (const notification of notifications) { if (notification.type === "email") { - this.sendEmail(networkResponse, notification.address); + await this.sendEmail(networkResponse, notification.address); + } else if (notification.type === "webhook") { + await this.sendWebhookNotification(networkResponse, notification); } // Handle other types of notifications here } diff --git a/Server/service/stringService.js b/Server/service/stringService.js index 1ef208d6c..e3ab03ca5 100644 --- a/Server/service/stringService.js +++ b/Server/service/stringService.js @@ -165,6 +165,36 @@ class StringService { return this.translationService.getTranslation('maintenanceWindowEdit'); } + // Webhook Messages + get webhookUnsupportedPlatform() { + return this.translationService.getTranslation('webhookUnsupportedPlatform'); + } + + get webhookSendError() { + return this.translationService.getTranslation('webhookSendError'); + } + + get webhookSendSuccess() { + return this.translationService.getTranslation('webhookSendSuccess'); + } + + getWebhookUnsupportedPlatform(platform) { + return this.translationService.getTranslation('webhookUnsupportedPlatform') + .replace('{platform}', platform); + } + + getWebhookSendError(platform) { + return this.translationService.getTranslation('webhookSendError') + .replace('{platform}', platform); + } + + getMonitorStatus(name, status, url) { + return this.translationService.getTranslation('monitorStatus') + .replace('{name}', name) + .replace('{status}', status ? "up" : "down") + .replace('{url}', url); + } + // Error Messages get unknownError() { return this.translationService.getTranslation('unknownError'); diff --git a/Server/validation/joi.js b/Server/validation/joi.js index c0ccc1fe9..0967d9150 100644 --- a/Server/validation/joi.js +++ b/Server/validation/joi.js @@ -475,6 +475,72 @@ const imageValidation = joi "any.required": "Image file is required", }); + const webhookConfigValidation = joi.object({ + webhookUrl: joi.string().uri() + .when('$platform', { + switch: [ + { + is: 'telegram', + then: joi.optional() + }, + { + is: 'discord', + then: joi.required().messages({ + 'string.empty': 'Discord webhook URL is required', + 'string.uri': 'Discord webhook URL must be a valid URL', + 'any.required': 'Discord webhook URL is required' + }) + }, + { + is: 'slack', + then: joi.required().messages({ + 'string.empty': 'Slack webhook URL is required', + 'string.uri': 'Slack webhook URL must be a valid URL', + 'any.required': 'Slack webhook URL is required' + }) + } + ] + }), + botToken: joi.string() + .when('$platform', { + is: 'telegram', + then: joi.required().messages({ + 'string.empty': 'Telegram bot token is required', + 'any.required': 'Telegram bot token is required' + }), + otherwise: joi.optional() + }), + chatId: joi.string() + .when('$platform', { + is: 'telegram', + then: joi.required().messages({ + 'string.empty': 'Telegram chat ID is required', + 'any.required': 'Telegram chat ID is required' + }), + otherwise: joi.optional() + }) + }).required(); + + const triggerNotificationBodyValidation = joi.object({ + monitorId: joi.string().required().messages({ + 'string.empty': 'Monitor ID is required', + 'any.required': 'Monitor ID is required' + }), + type: joi.string().valid('webhook').required().messages({ + 'string.empty': 'Notification type is required', + 'any.required': 'Notification type is required', + 'any.only': 'Notification type must be webhook' + }), + platform: joi.string().valid('telegram', 'discord', 'slack').required().messages({ + 'string.empty': 'Platform type is required', + 'any.required': 'Platform type is required', + 'any.only': 'Platform must be telegram, discord, or slack' + }), + config: webhookConfigValidation.required().messages({ + 'any.required': 'Webhook configuration is required' + }) + }); + export { roleValidatior, loginValidation, @@ -533,5 +599,7 @@ export { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, - imageValidation, + imageValidation, + triggerNotificationBodyValidation, + webhookConfigValidation, };