Files
admin/host-stacks/local/sched-pipelines/bridge/index.cjs

130 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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=<hex> (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'})`);
});