You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

246 lines
6.7 KiB
JavaScript

const fs = require('node:fs/promises');
const path = require('node:path');
const ROOT_DIRECTORY = path.join(__dirname, '..');
const NOTIFICATION_CONFIG_FILE = process.env.NOTIFICATION_CONFIG_FILE
? path.resolve(process.env.NOTIFICATION_CONFIG_FILE)
: path.join(ROOT_DIRECTORY, 'config', 'notifications.json');
const SEVERITY_RANK = {
RECOVERY: 0,
NORMAL: 0,
WARNING: 1,
OFFLINE: 2,
CRITICAL: 3
};
const DEFAULT_NOTIFICATION_CONFIG = {
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: {
enabled: false,
url: '',
headers: {}
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
}
}
};
function mergeNotificationConfig(config = {}) {
return {
enabled: config.enabled === true,
minSeverity: SEVERITY_RANK[config.minSeverity] === undefined
? DEFAULT_NOTIFICATION_CONFIG.minSeverity
: config.minSeverity,
channels: {
webhook: {
...DEFAULT_NOTIFICATION_CONFIG.channels.webhook,
...(config.channels && config.channels.webhook)
},
telegram: {
...DEFAULT_NOTIFICATION_CONFIG.channels.telegram,
...(config.channels && config.channels.telegram)
}
}
};
}
async function readNotificationConfig() {
try {
const rawConfig = await fs.readFile(NOTIFICATION_CONFIG_FILE, 'utf8');
return mergeNotificationConfig(JSON.parse(rawConfig));
} catch {
return mergeNotificationConfig();
}
}
function validateNotificationConfig(rawConfig) {
const config = mergeNotificationConfig(rawConfig);
if (SEVERITY_RANK[config.minSeverity] === undefined) {
throw Object.assign(new Error('Severidad minima no valida.'), { status: 400 });
}
if (config.channels.webhook.enabled && !isValidHttpUrl(config.channels.webhook.url)) {
throw Object.assign(new Error('URL de webhook no valida.'), { status: 400 });
}
if (config.channels.telegram.enabled) {
if (!config.channels.telegram.botToken || !config.channels.telegram.chatId) {
throw Object.assign(
new Error('Telegram requiere botToken y chatId.'),
{ status: 400 }
);
}
}
if (
config.channels.webhook.headers &&
(typeof config.channels.webhook.headers !== 'object' ||
Array.isArray(config.channels.webhook.headers))
) {
throw Object.assign(new Error('Headers de webhook invalidos.'), { status: 400 });
}
return config;
}
async function writeNotificationConfig(rawConfig) {
const config = validateNotificationConfig(rawConfig);
await fs.mkdir(path.dirname(NOTIFICATION_CONFIG_FILE), { recursive: true });
await fs.writeFile(
NOTIFICATION_CONFIG_FILE,
`${JSON.stringify(config, null, 4)}\n`,
'utf8'
);
return config;
}
function redactNotificationConfig(config) {
return {
...config,
channels: {
webhook: {
...config.channels.webhook,
headers: Object.keys(config.channels.webhook.headers || {}).length > 0
? '[configured]'
: {}
},
telegram: {
...config.channels.telegram,
botToken: config.channels.telegram.botToken ? '[configured]' : ''
}
}
};
}
function isValidHttpUrl(value) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol);
} catch {
return false;
}
}
function shouldNotify(event, minSeverity) {
const eventRank = SEVERITY_RANK[event.state] ?? 0;
const minimumRank = SEVERITY_RANK[minSeverity] ?? SEVERITY_RANK.CRITICAL;
return eventRank >= minimumRank;
}
function buildNotificationMessage(event) {
const value = event.value === '' || event.value === null || event.value === undefined
? 'N/A'
: event.value;
return [
`Photobioreactor alarm: ${event.state}`,
`Sensor: ${event.sensorId || event.sensor}`,
`Value: ${value}`,
`Message: ${event.message}`,
`Time: ${event.timestamp}`
].join('\n');
}
async function dispatchAlarmNotifications(events) {
const config = await readNotificationConfig();
if (!config.enabled || !Array.isArray(events) || events.length === 0) {
return [];
}
const eligibleEvents = events.filter((event) =>
shouldNotify(event, config.minSeverity)
);
const results = [];
for (const event of eligibleEvents) {
if (config.channels.webhook.enabled) {
results.push(await sendSafely('webhook', event, () =>
sendWebhookNotification(event, config.channels.webhook)
));
}
if (config.channels.telegram.enabled) {
results.push(await sendSafely('telegram', event, () =>
sendTelegramNotification(event, config.channels.telegram)
));
}
}
return results;
}
async function sendSafely(channel, event, send) {
try {
await send();
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: true
};
} catch (error) {
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: false,
error: error.message
};
}
}
async function sendWebhookNotification(event, channelConfig) {
await postJson(channelConfig.url, {
event,
text: buildNotificationMessage(event)
}, channelConfig.headers || {});
}
async function sendTelegramNotification(event, channelConfig) {
const url = `https://api.telegram.org/bot${channelConfig.botToken}/sendMessage`;
await postJson(url, {
chat_id: channelConfig.chatId,
text: buildNotificationMessage(event)
});
}
async function postJson(url, payload, headers = {}) {
if (typeof fetch !== 'function') {
throw new Error('fetch no esta disponible en este runtime.');
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
}
module.exports = {
DEFAULT_NOTIFICATION_CONFIG,
NOTIFICATION_CONFIG_FILE,
dispatchAlarmNotifications,
readNotificationConfig,
redactNotificationConfig,
shouldNotify,
validateNotificationConfig,
writeNotificationConfig
};