/** * Notify Step — send notification to a configured channel. * Config: { channel_id: 1, message: "...", card_template?: {...} } * Channel config is loaded from notification_channels table. */ import { registerStepExecutor } from '../pipeline-engine'; import { postgresClient } from '../postgres-client'; import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline'; async function executeNotify( step: PipelineStep, _context: PipelineContext, _executionId: number ): Promise { const channelId = Number(step.config.channel_id); if (!channelId || isNaN(channelId)) { return { success: false, error: 'Missing or invalid channel_id' }; } const result = await postgresClient.query( `SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`, [channelId] ); if (result.rows.length === 0) { return { success: false, error: `Notification channel #${channelId} not found or inactive` }; } const channel = result.rows[0]; const message = step.config.message || ''; switch (channel.channel_type) { case 'teams': return await sendTeams(channel, step.config, message); case 'telegram': return await sendTelegram(channel, message); case 'ntfy': return await sendNtfy(channel, step.config, message); case 'webhook': return await sendWebhook(channel, step.config, message); default: return { success: false, error: `Unknown channel type: ${channel.channel_type}` }; } } async function sendTeams( channel: NotificationChannel, config: Record, message: string ): Promise { const webhookUrl = channel.config.webhook_url; if (!webhookUrl) { return { success: false, error: 'Teams channel missing webhook_url' }; } // If a card_template is provided, use it as Adaptive Card const body = config.card_template || { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: { type: 'AdaptiveCard', $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', version: '1.4', body: [ { type: 'TextBlock', text: config.title || 'Pulse Notification', weight: 'bolder', size: 'medium' }, { type: 'TextBlock', text: message, wrap: true }, ], }, }], }; const resp = await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!resp.ok) { const errText = await resp.text(); return { success: false, error: `Teams webhook failed (${resp.status}): ${errText.substring(0, 200)}` }; } return { success: true, output: { notified: true, channel: 'teams' } }; } async function sendTelegram( channel: NotificationChannel, message: string ): Promise { const botToken = channel.config.bot_token; const chatId = channel.config.chat_id; if (!botToken || !chatId) { return { success: false, error: 'Telegram channel missing bot_token or chat_id' }; } const resp = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text: message, parse_mode: channel.config.parse_mode || 'HTML', }), }); if (!resp.ok) { const errText = await resp.text(); return { success: false, error: `Telegram API failed (${resp.status}): ${errText.substring(0, 200)}` }; } return { success: true, output: { notified: true, channel: 'telegram' } }; } async function sendNtfy( channel: NotificationChannel, config: Record, message: string ): Promise { const serverUrl = channel.config.server_url || 'https://ntfy.sh'; const topic = channel.config.topic; if (!topic) { return { success: false, error: 'ntfy channel missing topic' }; } const headers: Record = { 'Content-Type': 'text/plain', }; if (config.title || channel.config.default_title) { headers['Title'] = config.title || channel.config.default_title; } if (config.priority || channel.config.default_priority) { headers['Priority'] = config.priority || channel.config.default_priority; } if (channel.config.auth_token) { headers['Authorization'] = `Bearer ${channel.config.auth_token}`; } const resp = await fetch(`${serverUrl}/${topic}`, { method: 'POST', headers, body: message, }); if (!resp.ok) { const errText = await resp.text(); return { success: false, error: `ntfy failed (${resp.status}): ${errText.substring(0, 200)}` }; } return { success: true, output: { notified: true, channel: 'ntfy' } }; } async function sendWebhook( channel: NotificationChannel, config: Record, message: string ): Promise { const url = channel.config.url; if (!url) { return { success: false, error: 'Webhook channel missing url' }; } const method = channel.config.method || 'POST'; const customHeaders = channel.config.headers || {}; const body = config.body_template ? config.body_template : { message, timestamp: new Date().toISOString() }; const resp = await fetch(url, { method, headers: { 'Content-Type': 'application/json', ...customHeaders, }, body: JSON.stringify(body), }); if (!resp.ok) { const errText = await resp.text(); return { success: false, error: `Webhook failed (${resp.status}): ${errText.substring(0, 200)}` }; } return { success: true, output: { notified: true, channel: 'webhook' } }; } registerStepExecutor('notify', executeNotify);