feat(oref): Tzeva Adom as primary alert source + Hebrew translation dictionaries (#2863)

* feat(oref): add Tzeva Adom as primary alert source with Hebrew translations

Tzeva Adom API (api.tzevaadom.co.il/notifications) is now the primary
source for Israeli siren alerts. Free, no proxy needed, works from any
IP. OREF direct (via residential proxy) remains as fallback.

Includes Hebrew→English translation dictionaries:
- 1,305 Israeli localities from CBS data
- 28 threat type terms (missiles, rockets, drones, infiltration, etc.)

Alert titles and location names are now served in English.

Fallback chain: Tzeva Adom (primary) → OREF direct (proxy, fallback)

* fix(oref): add threat categorization + misplaced-city guard to Tzeva Adom

- categorizeOrefThreat() classifies Hebrew/English alerts into
  MISSILE/ROCKET/DRONE/MORTAR/INFILTRATION/EARTHQUAKE/TSUNAMI/HAZMAT
- Detects when API puts a city name in the threat field and moves it
  to locations (known API quirk)

* fix(oref): decouple siren poll loop from OREF proxy availability

SIREN_ALERTS_ENABLED is always true (Tzeva Adom needs no proxy).
OREF_PROXY_AVAILABLE gates only the OREF fallback path.
The poll loop now starts regardless of proxy config, using Tzeva Adom
as primary and OREF as fallback only when OREF_PROXY_AUTH is set.

Response payloads report configured: true so the panel activates.

* fix(oref): preserve error state when both siren sources fail

When Tzeva Adom returns null and OREF proxy is unavailable, return
early with lastError set instead of falling through and clearing
the error. Prevents a false green state in the panel when both
sources are down.

* fix(oref): rebuild response cache on source outage

Without calling orefPreSerializeResponses() in the failure branch,
the /oref/alerts handler keeps serving stale _alertsCache from the
last successful poll, masking the outage.
This commit is contained in:
Elie Habib
2026-04-09 12:39:34 +04:00
committed by GitHub
parent 9539df65fd
commit 29925204fa
3 changed files with 1470 additions and 21 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"ירי רקטות וטילים": "Rocket and Missile Fire",
"ירי רקטות": "Rocket Fire",
"ירי טילים": "Missile Fire",
"טיל בליסטי": "Ballistic Missile",
"טילים": "Missiles",
"רקטות": "Rockets",
"חדירת כלי טיס עוין": "Hostile Aircraft Intrusion",
"חדירת כטבם": "UAV Intrusion",
"כלי טיס עוין": "Hostile Aircraft",
"כטבם": "UAV/Drone",
"כטב\"ם": "UAV/Drone",
"חדירת מחבלים": "Terrorist Infiltration",
"חדירה": "Infiltration",
"רעידת אדמה": "Earthquake",
"צונמי": "Tsunami",
"חומרים מסוכנים": "Hazardous Materials",
"אירוע חומרים מסוכנים": "Hazmat Incident",
"אירוע רדיולוגי": "Radiological Event",
"התרעה": "Alert",
"התרעת צבע אדום": "Red Alert Warning",
"צבע אדום": "Red Alert",
"היכנסו למרחב המוגן": "Enter Protected Space",
"היכנסו למבנה": "Enter Building",
"טיל": "Missile",
"מטוס": "Aircraft",
"מסוק": "Helicopter",
"מל\"ט": "Drone",
"רחפן": "Drone"
}

View File

@@ -91,12 +91,40 @@ const OPENSKY_PROXY_ENABLED = !!OPENSKY_PROXY_AUTH;
const PROXY_URL = process.env.PROXY_URL || ''; // generic residential proxy (US exit) — http://user:pass@host:port or host:port:user:pass (Decodo)
// OREF (Israel Home Front Command) siren alerts — fetched via HTTP proxy (Israel exit)
// Tzeva Adom (primary) + OREF (fallback) siren alerts
const TZEVA_ADOM_URL = 'https://api.tzevaadom.co.il/notifications';
const OREF_PROXY_AUTH = process.env.OREF_PROXY_AUTH || ''; // format: user:pass@host:port
const OREF_ALERTS_URL = 'https://www.oref.org.il/WarningMessages/alert/alerts.json';
const OREF_HISTORY_URL = 'https://www.oref.org.il/WarningMessages/alert/History/AlertsHistory.json';
const OREF_POLL_INTERVAL_MS = Math.max(30_000, Number(process.env.OREF_POLL_INTERVAL_MS || 300_000));
const OREF_ENABLED = !!OREF_PROXY_AUTH;
const OREF_PROXY_AVAILABLE = !!OREF_PROXY_AUTH;
const SIREN_ALERTS_ENABLED = true; // Tzeva Adom is free, no proxy needed
// Hebrew→English translation dictionaries for siren alerts
const OREF_THREAT_TRANSLATIONS = (() => {
try { return JSON.parse(require('fs').readFileSync(path.join(__dirname, '..', 'data', 'oref-threat-translations-he-en.json'), 'utf8')); }
catch { return {}; }
})();
const OREF_CITY_TRANSLATIONS = (() => {
try { return JSON.parse(require('fs').readFileSync(path.join(__dirname, '..', 'data', 'israeli-localities-he-en.json'), 'utf8')); }
catch { return {}; }
})();
function translateHebrew(text) {
if (!text) return text;
if (OREF_THREAT_TRANSLATIONS[text]) return OREF_THREAT_TRANSLATIONS[text];
if (OREF_CITY_TRANSLATIONS[text]) return OREF_CITY_TRANSLATIONS[text];
let result = text;
for (const [heb, eng] of Object.entries(OREF_THREAT_TRANSLATIONS)) {
if (result.includes(heb)) result = result.replace(heb, eng);
}
return result;
}
function translateCity(city) {
if (!city) return city;
return OREF_CITY_TRANSLATIONS[city] || city;
}
const OREF_DATA_DIR = process.env.OREF_DATA_DIR || '';
const OREF_LOCAL_FILE = (() => {
if (!OREF_DATA_DIR) return '';
@@ -805,19 +833,102 @@ function orefCurlFetch(proxyAuth, url, { toFile } = {}) {
return result;
}
async function orefFetchAlerts() {
if (!OREF_ENABLED) return;
try {
const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL);
const cleaned = stripBom(raw).trim();
function categorizeOrefThreat(threat) {
const t = (threat || '').toLowerCase();
if (t.includes('missile') || t.includes('טיל') || t.includes('ballistic')) return 'MISSILE';
if (t.includes('rocket') || t.includes('רקט')) return 'ROCKET';
if (t.includes('drone') || t.includes('uav') || t.includes('כטב') || t.includes('hostile aircraft') || t.includes('כלי טיס')) return 'DRONE';
if (t.includes('mortar')) return 'MORTAR';
if (t.includes('infiltration') || t.includes('חדיר') || t.includes('מחבל')) return 'INFILTRATION';
if (t.includes('earthquake') || t.includes('רעידת')) return 'EARTHQUAKE';
if (t.includes('tsunami') || t.includes('צונמי')) return 'TSUNAMI';
if (t.includes('chemical') || t.includes('hazmat') || t.includes('חומרים מסוכנים') || t.includes('רדיולוגי')) return 'HAZMAT';
return 'ALERT';
}
let alerts = [];
if (cleaned && cleaned !== '[]' && cleaned !== 'null') {
try {
const parsed = JSON.parse(cleaned);
alerts = Array.isArray(parsed) ? parsed : [parsed];
} catch { alerts = []; }
async function tzevaAdomFetchAlerts() {
try {
const resp = await fetch(TZEVA_ADOM_URL, {
headers: { 'User-Agent': 'WorldMonitor/1.0', Accept: 'application/json' },
signal: AbortSignal.timeout(12_000),
});
if (!resp.ok) return null;
const data = await resp.json();
if (!Array.isArray(data) || data.length === 0) return [];
return data.map((alert) => {
const rawThreat = alert.threat || alert.title || '';
const rawCities = Array.isArray(alert.cities) ? alert.cities : (alert.data ? [alert.data] : []);
let translatedThreat = translateHebrew(rawThreat);
const translatedLocations = rawCities.map(translateCity);
// API sometimes puts city name in threat field; detect and move to locations
if (OREF_CITY_TRANSLATIONS[rawThreat]) {
if (!rawCities.includes(rawThreat)) {
translatedLocations.push(OREF_CITY_TRANSLATIONS[rawThreat]);
}
translatedThreat = 'Rocket/Missile Alert';
}
return {
id: alert.notificationId || String(Date.now()),
cat: categorizeOrefThreat(rawThreat),
title: translatedThreat,
titleHe: rawThreat,
data: translatedLocations,
dataHe: rawCities,
desc: alert.desc || '',
date: alert.date || new Date().toISOString(),
source: 'tzeva-adom',
};
});
} catch (err) {
console.warn(`[TzevaAdom] Fetch failed: ${err?.message || err}`);
return null;
}
}
async function orefFetchAlerts() {
let alerts = [];
let source = 'none';
// Primary: Tzeva Adom (free, no proxy needed)
const tzevaAlerts = await tzevaAdomFetchAlerts();
if (tzevaAlerts !== null) {
alerts = tzevaAlerts;
source = 'tzeva-adom';
} else if (OREF_PROXY_AVAILABLE) {
// Fallback: OREF direct (requires Israeli proxy)
try {
const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL);
const cleaned = stripBom(raw).trim();
if (cleaned && cleaned !== '[]' && cleaned !== 'null') {
try {
const parsed = JSON.parse(cleaned);
const orefArr = Array.isArray(parsed) ? parsed : [parsed];
alerts = orefArr.map((a) => ({
...a,
title: translateHebrew(a.title || ''),
titleHe: a.title || '',
data: Array.isArray(a.data) ? a.data.map(translateCity) : a.data ? [translateCity(a.data)] : [],
dataHe: Array.isArray(a.data) ? a.data : a.data ? [a.data] : [],
source: 'oref-direct',
}));
source = 'oref-direct';
} catch { alerts = []; }
}
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : '';
orefState.lastError = redactOrefError(stderr || err.message);
console.warn('[Relay] OREF fallback poll error:', orefState.lastError);
}
}
if (source === 'none') {
orefState.lastError = orefState.lastError || 'All siren sources unavailable';
orefState.lastPollAt = Date.now();
console.warn('[Relay] Siren poll: both Tzeva Adom and OREF failed');
orefPreSerializeResponses();
return;
}
try {
const newJson = JSON.stringify(alerts);
const changed = newJson !== orefState.lastAlertsJson;
@@ -843,7 +954,7 @@ async function orefFetchAlerts() {
: '';
publishNotificationEvent({
eventType: 'oref_siren',
payload: { title: orefTitle + orefLocationSuffix, source: 'OREF Pikud HaOref' },
payload: { title: orefTitle + orefLocationSuffix, source: source === 'tzeva-adom' ? 'Tzeva Adom / Pikud HaOref' : 'OREF Pikud HaOref' },
severity: 'critical',
variant: undefined,
}).catch(e => console.warn('[Notify] OREF publish error:', e?.message));
@@ -876,7 +987,7 @@ async function orefFetchAlerts() {
function orefPreSerializeResponses() {
const ts = orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : new Date().toISOString();
const alertsJson = JSON.stringify({
configured: OREF_ENABLED,
configured: SIREN_ALERTS_ENABLED,
alerts: orefState.lastAlerts || [],
historyCount24h: orefState.historyCount24h,
totalHistoryCount: orefState.totalHistoryCount,
@@ -886,7 +997,7 @@ function orefPreSerializeResponses() {
orefState._alertsCache = { json: alertsJson, gzip: gzipSyncBuffer(alertsJson), brotli: brotliSyncBuffer(alertsJson) };
const historyJson = JSON.stringify({
configured: OREF_ENABLED,
configured: SIREN_ALERTS_ENABLED,
history: orefState.history || [],
historyCount24h: orefState.historyCount24h,
totalHistoryCount: orefState.totalHistoryCount,
@@ -1110,10 +1221,11 @@ async function orefBootstrapHistoryWithRetry() {
}
async function startOrefPollLoop() {
if (!OREF_ENABLED) {
console.log('[Relay] OREF disabled (no OREF_PROXY_AUTH)');
if (!SIREN_ALERTS_ENABLED) {
console.log('[Relay] Siren alerts disabled');
return;
}
console.log(`[Relay] Siren alerts: primary=Tzeva Adom, fallback=${OREF_PROXY_AVAILABLE ? 'OREF (proxy)' : 'none'}`);
await orefBootstrapHistoryWithRetry();
console.log(`[Relay] OREF bootstrap complete (source: ${orefState.bootstrapSource || 'none'}, redis: ${UPSTASH_ENABLED})`);
orefFetchAlerts().catch(e => console.warn('[Relay] OREF initial poll error:', e?.message || e));
@@ -8493,7 +8605,7 @@ const server = http.createServer(async (req, res) => {
pollInFlightSince: telegramPollInFlight && telegramPollStartedAt ? new Date(telegramPollStartedAt).toISOString() : null,
},
oref: {
enabled: OREF_ENABLED,
enabled: SIREN_ALERTS_ENABLED,
alertCount: orefState.lastAlerts?.length || 0,
historyCount24h: orefState.historyCount24h,
totalHistoryCount: orefState.totalHistoryCount,
@@ -8915,7 +9027,7 @@ const server = http.createServer(async (req, res) => {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=3',
}, JSON.stringify({
configured: OREF_ENABLED,
configured: SIREN_ALERTS_ENABLED,
alerts: orefState.lastAlerts || [],
historyCount24h: orefState.historyCount24h,
totalHistoryCount: orefState.totalHistoryCount,
@@ -8935,7 +9047,7 @@ const server = http.createServer(async (req, res) => {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=10',
}, JSON.stringify({
configured: OREF_ENABLED,
configured: SIREN_ALERTS_ENABLED,
history: orefState.history || [],
historyCount24h: orefState.historyCount24h,
totalHistoryCount: orefState.totalHistoryCount,