/** * Notification Manager - Multi-provider notification delivery * Supports Discord, Telegram, ntfy, and Email notifications */ const EventEmitter = require('events'); const fs = require('fs'); const path = require('path'); const nodemailer = require('nodemailer'); const DEFAULT_CONFIG = { enabled: true, providers: { discord: { enabled: false, webhookUrl: '' }, telegram: { enabled: false, botToken: '', chatId: '' }, ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' }, email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' } }, events: { 'container-down': true, 'container-up': false, 'alert': true, 'backup-complete': true, 'backup-failed': true, 'update-available': true } }; class NotificationManager extends EventEmitter { constructor(ctx) { super(); this.ctx = ctx; this.NOTIFICATIONS_FILE = ctx.NOTIFICATIONS_FILE; this.log = ctx.log || console; this.config = { ...DEFAULT_CONFIG }; this.lastSent = null; this.history = []; this.maxHistory = 100; this.healthDaemonInterval = null; this.healthState = new Map(); this._loadConfig(); } /** * Load config from file */ _loadConfig() { try { if (fs.existsSync(this.NOTIFICATIONS_FILE)) { const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8')); this.config = this._mergeConfig(DEFAULT_CONFIG, data); } } catch (error) { this.log.error('notification', 'Failed to load config', { error: error.message }); } } /** * Merge loaded config with defaults */ _mergeConfig(defaults, loaded) { const result = { ...defaults }; for (const key of Object.keys(defaults)) { if (loaded && typeof defaults[key] === 'object' && !Array.isArray(defaults[key])) { result[key] = { ...defaults[key], ...loaded[key] }; } else if (loaded && loaded[key] !== undefined) { result[key] = loaded[key]; } } return result; } /** * Get current config (for API) */ getConfig() { return this.config; } /** * Save config to file */ async saveConfig() { try { const dir = path.dirname(this.NOTIFICATIONS_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(this.NOTIFICATIONS_FILE, JSON.stringify(this.config, null, 2)); return true; } catch (error) { this.log.error('notification', 'Failed to save config', { error: error.message }); throw error; } } /** * Get notification history */ getHistory() { return this.history.slice(); } /** * Clear notification history */ clearHistory() { this.history = []; } /** * Add entry to history */ _addToHistory(entry) { this.history.unshift({ ...entry, timestamp: new Date().toISOString() }); if (this.history.length > this.maxHistory) { this.history = this.history.slice(0, this.maxHistory); } this.lastSent = new Date().toISOString(); } /** * Send notification via all enabled providers */ async send(event, data, type = 'info') { if (!this.config.enabled) { return { success: false, error: 'Notifications disabled' }; } // Check if event is enabled if (event && this.config.events && !this.config.events[event]) { return { success: false, error: `Event ${event} not enabled` }; } const results = []; const providers = this.config.providers; // Discord if (providers.discord?.enabled && providers.discord?.webhookUrl) { try { const result = await this.sendDiscord(this._formatText(data, event), this._formatEmbed(data, event, type)); results.push({ provider: 'discord', ...result }); } catch (error) { results.push({ provider: 'discord', success: false, error: error.message }); } } // Telegram if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) { try { const result = await this.sendTelegram(this._formatText(data, event)); results.push({ provider: 'telegram', ...result }); } catch (error) { results.push({ provider: 'telegram', success: false, error: error.message }); } } // ntfy if (providers.ntfy?.enabled && providers.ntfy?.topic) { try { const result = await this.sendNtfy(this._formatText(data, event), this._formatTitle(event)); results.push({ provider: 'ntfy', ...result }); } catch (error) { results.push({ provider: 'ntfy', success: false, error: error.message }); } } // Email if (providers.email?.enabled && providers.email?.host && providers.email?.to) { try { const result = await this.sendEmail( this._formatTitle(event), this._formatText(data, event) ); results.push({ provider: 'email', ...result }); } catch (error) { results.push({ provider: 'email', success: false, error: error.message }); } } const allSucceeded = results.every(r => r.success); this._addToHistory({ title: this._formatTitle(event), type, event, results }); return { success: allSucceeded, results }; } /** * Send Discord webhook notification */ async sendDiscord(text, embed) { const { webhookUrl } = this.config.providers.discord; if (!webhookUrl) { throw new Error('Discord webhook not configured'); } const payload = { content: text, embeds: embed ? [embed] : [] }; const response = await this.ctx.fetchT(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`Discord API error: ${response.status}`); } return { success: true }; } /** * Send Telegram message */ async sendTelegram(text) { const { botToken, chatId } = this.config.providers.telegram; if (!botToken || !chatId) { throw new Error('Telegram not configured'); } const url = `https://api.telegram.org/bot${botToken}/sendMessage`; const payload = { chat_id: chatId, text, parse_mode: 'Markdown' }; const response = await this.ctx.fetchT(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!data.ok) { throw new Error(`Telegram error: ${data.description}`); } return { success: true }; } /** * Send ntfy notification */ async sendNtfy(text, title) { const { serverUrl, topic } = this.config.providers.ntfy; if (!topic) { throw new Error('ntfy topic not configured'); } const url = `${serverUrl.replace(/\/$/, '')}/${topic}`; const response = await this.ctx.fetchT(url, { method: 'POST', headers: { 'Content-Type': 'text/plain', 'Title': title || 'DashCaddy', 'Priority': '3' }, body: text }); if (!response.ok) { throw new Error(`ntfy error: ${response.status}`); } return { success: true }; } /** * Send email notification */ async sendEmail(subject, body) { const { host, port, to, from, username, password, secure } = this.config.providers.email; if (!host || !to) { throw new Error('Email not configured'); } // Create transporter const transporter = nodemailer.createTransport({ host, port: parseInt(port) || 587, secure: !!secure, auth: username ? { user: username, pass: password } : undefined }); // Send mail await transporter.sendMail({ from: from || username, to, subject, text: body, html: `
${body}
` }); return { success: true }; } /** * Send resource alert */ async sendAlert(alert) { const text = this._formatAlertText(alert); const embed = { title: `⚠️ Resource Alert: ${alert.containerName}`, color: this._getAlertColor(alert.alerts), fields: alert.alerts.map(a => ({ name: a.type.toUpperCase(), value: a.message, inline: true })), footer: { text: 'DashCaddy Resource Monitor' }, timestamp: alert.timestamp }; return this.send('alert', { ...alert, text, embed }, 'warning'); } /** * Send backup complete notification */ async sendBackupComplete(backup) { const event = backup.status === 'success' ? 'backup-complete' : 'backup-failed'; const type = backup.status === 'success' ? 'success' : 'error'; const text = backup.status === 'success' ? `✅ Backup "${backup.name}" completed successfully` : `❌ Backup "${backup.name}" failed: ${backup.error}`; return this.send(event, { ...backup, text }, type); } /** * Send service event notification (container up/down, deploy success/fail) */ async sendServiceEvent(event, service) { const eventMap = { 'container-up': { type: 'success', text: `✅ Container "${service.containerName || service.name}" is now UP` }, 'container-down': { type: 'error', text: `🔴 Container "${service.containerName || service.name}" is DOWN` }, 'deploy-success': { type: 'success', text: `✅ "${service.name}" deployed successfully` }, 'deploy-failed': { type: 'error', text: `❌ "${service.name}" deployment failed` }, 'auto-restart': { type: 'warning', text: `🔄 Container "${service.containerName || service.name}" auto-restarted` } }; const info = eventMap[event] || { type: 'info', text: `Service event: ${event}` }; return this.send(event, { ...service, text: info.text }, info.type); } // ===== Helper Methods ===== _formatTitle(event) { const titles = { 'container-down': 'Container Down', 'container-up': 'Container Recovered', 'alert': 'Resource Alert', 'backup-complete': 'Backup Complete', 'backup-failed': 'Backup Failed', 'update-available': 'Update Available', 'test': 'Test Notification', 'auto-restart': 'Auto-Restart', 'deploy-success': 'Deployment Success', 'deploy-failed': 'Deployment Failed' }; return titles[event] || 'DashCaddy Notification'; } _formatText(data, event) { if (typeof data === 'string') return data; return data.text || data.message || this._formatTitle(event); } _formatEmbed(data, event, type) { if (typeof data === 'string') return null; if (data.embed) return data.embed; return { title: this._formatTitle(event), description: data.text || data.message || '', color: this._getTypeColor(type), timestamp: new Date().toISOString() }; } _formatAlertText(alert) { const lines = [ `**${alert.containerName}**`, '', ...alert.alerts.map(a => `• ${a.message}`) ]; return lines.join('\n'); } _getAlertColor(alerts) { if (alerts.some(a => a.severity === 'critical')) return 15158332; // Red if (alerts.some(a => a.severity === 'warning')) return 16776960; // Yellow return 3447003; // Blue } _getTypeColor(type) { const colors = { success: 3066993, // Green error: 15158332, // Red warning: 16776960, // Yellow info: 3447003 // Blue }; return colors[type] || colors.info; } // ===== Health Check Daemon ===== startHealthDaemon() { if (this.healthDaemonInterval) return; const interval = (this.config.healthCheck?.intervalMinutes || 5) * 60 * 1000; this.healthDaemonInterval = setInterval(() => { this.checkHealth().catch(err => { this.log.error('notification', 'Health check failed', { error: err.message }); }); }, interval); this.log.info('notification', 'Health daemon started', { intervalMinutes: this.config.healthCheck?.intervalMinutes }); } stopHealthDaemon() { if (this.healthDaemonInterval) { clearInterval(this.healthDaemonInterval); this.healthDaemonInterval = null; this.log.info('notification', 'Health daemon stopped'); } } async checkHealth() { if (!this.config.healthCheck?.enabled || !this.ctx.docker) { return { checked: false }; } try { const containers = await this.ctx.docker.listContainers({ all: true }); const previousState = new Map(this.healthState); for (const container of containers) { const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12); const wasDown = previousState.get(container.Id) === false; const isDown = container.State !== 'running'; this.healthState.set(container.Id, isDown); if (wasDown && !isDown) { // Container recovered await this.sendServiceEvent('container-up', { containerId: container.Id, containerName: name, state: container.State }); } else if (!wasDown && isDown) { // Container went down await this.sendServiceEvent('container-down', { containerId: container.Id, containerName: name, state: container.State }); } } // Update last check time this.config.healthCheck = this.config.healthCheck || {}; this.config.healthCheck.lastCheck = new Date().toISOString(); await this.saveConfig(); return { checked: true, containersMonitored: containers.length, lastCheck: this.config.healthCheck.lastCheck }; } catch (error) { this.log.error('notification', 'Health check error', { error: error.message }); throw error; } } getHealthState() { return new Map(this.healthState); } } module.exports = NotificationManager;