feat: Implement Telegram alerts for catalog updates and failures
This commit is contained in:
175
server/alerts.js
Normal file
175
server/alerts.js
Normal file
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user