Files
psu-schedule/server/alerts.js

176 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { DATA } from "./paths.js";
import { diagnose } from "./diagnostics.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.
// 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: a message about documents that appeared, documents that do
* not read, republished documents with suspicious cells they did not have, and a new academic
* year. The first run only says that messages are on. A message that does not go out is tried
* again on the next check.
*/
export async function checkCatalog(idx, catalog) {
if (!telegram.enabled || !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,
};
if (!was) {
const problems = Object.values(docs).reduce((n, d) => n + d.problems.length, 0);
const failed = Object.values(docs).filter((d) => d.failed).length;
const sent = await send(
`Оповещения расписания ПГУ включены.\nКаталог ${catalog.year}: ${idx.docs.length} документов` +
(failed ? `, не читаются: ${failed}` : "") +
`, подозрительных ячеек: ${problems}.` +
(PUBLIC ? `\n${PUBLIC}/api/diagnostics` : ""),
);
if (sent) await save(next);
return;
}
const sections = [];
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}`);
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}`);
if (!before)
sections.push(
`Новый документ: ${title}\nГруппы: ${now.groups.join(", ") || "не найдены"}` +
(added.length ? `\nПодозрительные ячейки:\n${lines.join("\n")}` : "") +
(added.length ? link(id) : ""),
);
else if (added.length) sections.push(`Перезалит с новыми подозрительными ячейками: ${title}\n${lines.join("\n")}${link(id)}`);
}
if (was.year && catalog.year && was.year !== catalog.year) sections.unshift(`Каталог перешёл на учебный год ${catalog.year}.`);
// The new year's folder is late: the catalog still shows last year's documents.
if (catalog.currentYear === false && catalog.expectedYear && was.yearWarning !== catalog.expectedYear) {
sections.unshift(`Папка учебного года ${catalog.expectedYear} ещё не опубликована: используется ${catalog.year}.`);
next.yearWarning = catalog.expectedYear;
}
if (!sections.length) return save(next);
const shown = sections.slice(0, MAX_DOCS);
if (sections.length > MAX_DOCS) shown.push(`И ещё изменений: ${sections.length - MAX_DOCS}.`);
if (await send(`Расписание ПГУ\n\n${shown.join("\n\n")}`)) await save(next);
}
/**
* The university's catalog failing for longer than an hour, and its return. Kept in memory: after
* a restart a long outage is reported again, which is better than missing it.
*/
export async function checkSource(failure) {
if (!telegram.enabled) return;
if (failure) {
const since = Date.parse(failure.since);
if (!sourceNotified && Number.isFinite(since) && Date.now() - since >= DOWN_AFTER) {
const at = new Date(since).toLocaleString("ru-RU", { timeZone: "Europe/Minsk", dateStyle: "short", timeStyle: "short" });
sourceNotified = await send(
`Каталог университета не отвечает с ${at}.\n${failure.error}\nПриложения показывают сохранённые копии расписаний.`,
);
}
} else if (sourceNotified && (await send("Каталог университета снова отвечает."))) sourceNotified = false;
}