import { readFile, writeFile, mkdir } from "node:fs/promises"; import { DATA } from "./paths.js"; import { diagnose } from "./diagnostics.js"; import { recordEvents } from "./events.js"; // Messages to the server's owner in Telegram, so a new document format, a typo the parser cannot // read or a university that stopped answering is known the same day, not when a student notices. // The same changes go to the dashboard's feed (events.js). Telegram is off unless // TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set. const TOKEN = process.env.TELEGRAM_BOT_TOKEN || ""; const CHAT = process.env.TELEGRAM_CHAT_ID || ""; // Tests point this at a local stand-in. const API = process.env.TELEGRAM_API || "https://api.telegram.org"; // With the public address, messages link to /api/diagnostics of the document. const PUBLIC = (process.env.PSU_PUBLIC_URL || "").replace(/\/$/, ""); // The university is often down for a few minutes; only a longer outage is worth a message. const DOWN_AFTER = Number(process.env.PSU_ALERT_SOURCE_DOWN_MS ?? 60 * 60 * 1000); const STATE = "alerts.json"; const MAX_TEXT = 3900; const MAX_DOCS = 8; const MAX_PROBLEMS = 4; const DAYS = ["", "пн", "вт", "ср", "чт", "пт", "сб", "вс"]; /** For /api/health: whether messages go out and how the last one went. */ export const telegram = { enabled: Boolean(TOKEN && CHAT), sent: 0, lastSentAt: null, lastError: null }; /** What the last message knew: every document with its date and its suspicious cells. */ let state; let sourceNotified = false; async function load() { if (state !== undefined) return state; try { state = JSON.parse(await readFile(DATA + STATE, "utf8")); } catch { state = null; } return state; } async function save(next) { state = next; await mkdir(DATA, { recursive: true }); await writeFile(DATA + STATE, JSON.stringify(next)); } /** Sends a message; false when it did not go out (the error is kept for /api/health). */ export async function send(text) { if (!telegram.enabled) return false; try { const response = await fetch(`${API}/bot${TOKEN}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: CHAT, text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + "\n…" : text, disable_web_page_preview: true, }), signal: AbortSignal.timeout(15000), }); if (!response.ok) throw new Error(`Telegram ответил ${response.status}: ${(await response.text()).slice(0, 200)}`); telegram.sent++; telegram.lastSentAt = new Date().toISOString(); telegram.lastError = null; return true; } catch (error) { // Never the token in a log or in /api/health. telegram.lastError = String(error.message).replaceAll(TOKEN, "…"); console.error(`Telegram: ${telegram.lastError}`); return false; } } const signature = (p) => `${p.code}|${(p.groups || []).join(",")}|${p.day ?? ""}|${p.start ?? ""}|${p.raw ?? ""}`; const link = (id) => (PUBLIC ? `\n${PUBLIC}/api/diagnostics?doc=${id}` : ""); const problemLine = (p) => `• ${[(p.groups || []).join(", "), p.day ? `${DAYS[p.day]} ${p.start || ""}`.trim() : ""].filter(Boolean).join(", ")}: ${p.message}` + (p.raw ? `\n «${p.raw.replace(/\s+/g, " ").slice(0, 90)}»` : ""); /** The catalog's documents as the index read them. */ function snapshot(idx, catalog) { const docs = {}; for (const { id, file, schedule } of idx.docs) docs[id] = { name: file.name, faculty: file.faculty || schedule.source.faculty || "", modified: file.modified, groups: schedule.groups, problems: diagnose(schedule), }; for (const f of idx.failed) { const file = catalog.files.find((c) => c.id === f.id); docs[f.id] = { name: f.name, faculty: file?.faculty || "", modified: file?.modified || null, groups: [], problems: [], failed: f.error }; } return docs; } /** * After every check of the catalog: events for the dashboard (every new, republished or unreadable * document, a new academic year) and a Telegram message about the ones worth attention — new * documents, documents that do not read, republished documents with suspicious cells they did not * have. The first run only takes stock (and says in Telegram that messages are on). A message * that does not go out is tried again on the next check, and its events wait with it. */ export async function checkCatalog(idx, catalog) { if (!idx) return; const was = await load(); const docs = snapshot(idx, catalog); const stored = (d) => ({ ...d, problems: d.problems.map(signature) }); const next = { docs: Object.fromEntries(Object.entries(docs).map(([id, d]) => [id, stored(d)])), year: catalog.year, yearWarning: was?.yearWarning || null, announced: Boolean(was?.announced), }; const sections = []; const events = []; if (was) { for (const [id, now] of Object.entries(docs)) { const before = was.docs?.[id]; if (before && before.modified === now.modified && (before.failed || null) === (now.failed || null)) continue; const title = `${now.name}${now.faculty ? ` (${now.faculty})` : ""}`; if (now.failed) { sections.push(`Не читается: ${title}\n${now.failed}`); events.push({ kind: "failed", title: now.name, text: now.failed, doc: id }); continue; } const known = new Set(before?.problems || []); const added = now.problems.filter((p) => !known.has(signature(p))); const lines = added.slice(0, MAX_PROBLEMS).map(problemLine); if (added.length > MAX_PROBLEMS) lines.push(`• и ещё ${added.length - MAX_PROBLEMS}`); const found = added.length ? `новых подозрительных ячеек: ${added.length}` : "без новых подозрительных ячеек"; if (!before) { sections.push( `Новый документ: ${title}\nГруппы: ${now.groups.join(", ") || "не найдены"}` + (added.length ? `\nПодозрительные ячейки:\n${lines.join("\n")}${link(id)}` : ""), ); events.push({ kind: "new", title: now.name, text: `Группы: ${now.groups.join(", ") || "не найдены"}; ${found}`, doc: id }); } else { if (added.length) sections.push(`Перезалит с новыми подозрительными ячейками: ${title}\n${lines.join("\n")}${link(id)}`); events.push({ kind: "republished", title: now.name, text: found, doc: id }); } } if (was.year && catalog.year && was.year !== catalog.year) { sections.unshift(`Каталог перешёл на учебный год ${catalog.year}.`); events.unshift({ kind: "year", title: `Учебный год ${catalog.year}` }); } } // The new year's folder is late: the catalog still shows last year's documents. if (catalog.currentYear === false && catalog.expectedYear && next.yearWarning !== catalog.expectedYear) { const text = `Папка учебного года ${catalog.expectedYear} ещё не опубликована: используется ${catalog.year}.`; sections.unshift(text); events.unshift({ kind: "year_late", title: text }); next.yearWarning = catalog.expectedYear; } if (telegram.enabled && !next.announced) { const problems = Object.values(docs).reduce((n, d) => n + d.problems.length, 0); const failed = Object.values(docs).filter((d) => d.failed).length; sections.unshift( `Оповещения расписания ПГУ включены.\nКаталог ${catalog.year}: ${idx.docs.length} документов` + (failed ? `, не читаются: ${failed}` : "") + `, подозрительных ячеек: ${problems}.` + (PUBLIC ? `\n${PUBLIC}/status` : ""), ); next.announced = true; } if (telegram.enabled && sections.length) { const shown = sections.slice(0, MAX_DOCS); if (sections.length > MAX_DOCS) shown.push(`И ещё изменений: ${sections.length - MAX_DOCS}.`); const text = shown.length === 1 && !was ? shown[0] : `Расписание ПГУ\n\n${shown.join("\n\n")}`; if (!(await send(text))) return; } await recordEvents(events); await save(next); } /** * The university's catalog failing for longer than an hour, and its return: an event and a * Telegram message. Kept in memory: after a restart a long outage is reported again, which is * better than missing it. */ let downRecorded = false; export async function checkSource(failure) { if (failure) { const since = Date.parse(failure.since); if (!Number.isFinite(since) || Date.now() - since < DOWN_AFTER) return; const at = new Date(since).toLocaleString("ru-RU", { timeZone: "Europe/Minsk", dateStyle: "short", timeStyle: "short" }); if (!downRecorded) { downRecorded = true; await recordEvents([{ kind: "source_down", title: `Каталог университета не отвечает с ${at}`, text: failure.error }]); } if (telegram.enabled && !sourceNotified) sourceNotified = await send( `Каталог университета не отвечает с ${at}.\n${failure.error}\nПриложения показывают сохранённые копии расписаний.`, ); return; } if (downRecorded) { downRecorded = false; await recordEvents([{ kind: "source_up", title: "Каталог университета снова отвечает" }]); } if (sourceNotified && (await send("Каталог университета снова отвечает."))) sourceNotified = false; }