export function notificationText(event, service, config) { const en = config.locale === 'en'; const name = service.name[en ? 'en' : 'ru']; const scope = event.scope === 'scenario' ? (en ? 'User journey' : 'Пользовательский сценарий') : (en ? 'Availability' : 'Доступность'); const recovered = event.kind === 'recovery'; const title = recovered ? (en ? 'Recovered' : 'Восстановлено') : (en ? 'Outage confirmed' : 'Сбой подтверждён'); const date = new Intl.DateTimeFormat(en ? 'en' : 'ru', { dateStyle: 'short', timeStyle: 'short', timeZone: config.timezone }).format(event.created_at); const duration = recovered && event.resolved_at ? Math.max(1, Math.round((event.resolved_at - event.started_at) / 60000)) : null; return [`RideBus Status · ${title}`, name, scope, date, ...(duration === null ? [] : [`${en ? 'Confirmed incident duration' : 'Длительность подтверждённого инцидента'}: ${duration} ${en ? 'min' : 'мин'}`]), ...(config.publicUrl ? [config.publicUrl] : []), ].join('\n').slice(0, 3500); } export async function sendTelegram(config, text, { fetchImpl = fetch } = {}) { try { const response = await fetchImpl(`https://api.telegram.org/bot${config.token}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, redirect: 'error', body: JSON.stringify({ chat_id: config.chatId, text, link_preview_options: { is_disabled: true } }), signal: AbortSignal.timeout(5000), }); let body; try { body = await response.json(); } catch { body = null; } if (response.ok && body?.ok === true) return { ok: true }; const code = body?.error_code || response.status; const retryAfter = Number(body?.parameters?.retry_after); return { ok: false, retryable: code === 429 || code >= 500, reason: code === 429 ? 'rate_limited' : code === 401 || code === 403 ? 'access_denied' : 'telegram_error', retryAfterSeconds: code === 429 && Number.isFinite(retryAfter) ? Math.max(1, Math.min(3600, retryAfter)) : null }; } catch { return { ok: false, retryable: true, reason: 'network', retryAfterSeconds: null }; } } export function createNotifier(store, config, { send = sendTelegram, now = Date.now } = {}) { let active = null, stopped = true, timer; async function flush() { if (!config.telegram.enabled) return; if (active) return active; active = (async () => { for (const event of store.dueNotifications(now())) { const service = config.services.find(service => service.id === event.service_id); const signature = event.scope === 'scenario' ? service?.scenario?.signature : service?.signature; if (!service || signature !== event.signature || now() - event.created_at > 86400000) { store.notificationResult(event.id, { status: 'discarded', reason: 'obsolete' }, now()); continue; } const result = await send(config.telegram, notificationText(event, service, config.telegram)); if (result.ok) store.notificationResult(event.id, { status: 'sent' }, now()); else { const retry = result.retryable && event.attempts < 7; const delaySeconds = result.retryAfterSeconds || Math.min(3600, 30 * 2 ** event.attempts); store.notificationResult(event.id, { status: retry ? 'pending' : 'failed', retryAt: now() + delaySeconds * 1000, reason: result.reason }, now()); // The database holds this incident's recovery until its outage is sent. } } })(); try { await active; } finally { active = null; } } async function loop() { try { await flush(); } catch { console.error('Notification delivery failed; pending events remain in the database.'); } if (!stopped) timer = setTimeout(loop, 15000); } return { flush, start() { if (!stopped || !config.telegram.enabled) return; stopped = false; void loop(); }, async stop() { stopped = true; clearTimeout(timer); if (active) await active; }, }; }