'use strict'; /** * alert-bridge — приёмник sched webhook-алертов (универсальный outbound webhook). * Форматирует пейлоад sched → ntfy + email (Unisender Go, go2). * * sched шлёт: POST {version, event, task, run|schedule}, подпись * X-Sched-Signature-256: sha256= (HMAC-SHA256, GitHub-модель) если WEBHOOK_SECRET задан. * * Env: NTFY_URL (http://ntfy:8096), NTFY_TOPIC, WEBHOOK_SECRET, * UNISENDER_API_KEY, UNISENDER_FROM, EMAIL_TO, PORT (9090) */ const http = require('node:http'); const crypto = require('node:crypto'); const { NTFY_URL = 'http://ntfy', NTFY_TOPIC = 'sched-alerts', WEBHOOK_SECRET = '', UNISENDER_API_KEY = '', UNISENDER_FROM = '', EMAIL_TO = '', PORT = '9090', } = process.env; const UNISENDER_ENDPOINT = process.env.UNISENDER_ENDPOINT || 'https://goapi.unisender.ru/ru/transactional/api/v1/email/send.json'; function verify(req, raw) { if (!WEBHOOK_SECRET) return true; const sig = req.headers['x-sched-signature-256'] || ''; const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(raw).digest('hex'); if (sig.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } function fmt(payload) { const task = payload.task ?? {}; const name = task.name ?? '?'; if (payload.event === 'missed-slot') { const s = payload.schedule ?? {}; return { title: `⏰ missed-slot: ${name}`, body: `Задача ${name} не успела на слот ${s.scheduledAt ?? '?'} (задержка ${s.delayMs ?? '?'} ms).`, prio: 4, alert: true, }; } const status = (payload.event ?? '').replace(/^run\./, '') || '?'; const run = payload.run ?? {}; const err = run.error ? `\nОшибка: ${run.error}` : ''; const failed = status === 'failed'; return { title: `${failed ? '🚨' : 'ℹ️'} run.${status}: ${name}`, body: `Рана ${(run.id ?? '?').slice(0, 12)} · статус ${status} · attempt ${run.attempt ?? 1}${err}`, prio: failed ? 5 : 3, alert: failed, }; } async function sendNtfy(m) { const base = NTFY_URL.replace(/\/+$/, ''); const res = await fetch(`${base}/${encodeURIComponent(NTFY_TOPIC)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ topic: NTFY_TOPIC, title: m.title, message: m.body, priority: m.prio, tags: [m.prio >= 4 ? 'rotating_light' : 'info'], }), }); if (!res.ok) throw new Error(`ntfy ${res.status}`); } async function sendEmail(m) { if (!UNISENDER_API_KEY || !EMAIL_TO) return { skipped: 'no UNISENDER_API_KEY/EMAIL_TO' }; const res = await fetch(UNISENDER_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ api_key: UNISENDER_API_KEY, message: { template_engine: 'velocity', body: { plaintext: m.body }, subject: m.title, from_email: UNISENDER_FROM, from_name: 'sched-pipeline', recipients: [{ email: EMAIL_TO }], }, }), }); const text = await res.text(); if (!res.ok) throw new Error(`unisender ${res.status}: ${text.slice(0, 200)}`); return text; } const server = http.createServer(async (req, res) => { const json = (code, body) => { const payload = JSON.stringify(body); res.writeHead(code, { 'content-type': 'application/json' }); res.end(payload); }; if (req.method !== 'POST' || req.url !== '/webhook') return json(404, { ok: false, error: 'not found' }); const chunks = []; for await (const c of req) chunks.push(c); const raw = Buffer.concat(chunks); if (!verify(req, raw)) return json(401, { ok: false, error: 'bad signature' }); let payload; try { payload = JSON.parse(raw.toString('utf8')); } catch { return json(400, { ok: false, error: 'bad json' }); } const m = fmt(payload); const errs = []; try { await sendNtfy(m); } catch (e) { errs.push(`ntfy: ${e.message}`); } if (m.alert) { try { await sendEmail(m); } catch (e) { errs.push(`email: ${e.message}`); } } json(200, { ok: true, errors: errs }); }); server.listen(Number(PORT), '0.0.0.0', () => { console.log(`alert-bridge on :${PORT} (ntfy=${NTFY_URL}/${NTFY_TOPIC}, email=${EMAIL_TO ? 'on' : 'off'})`); });