feat: enhance Telegram import functionality to support delays and cancellations
- Updated telegramCancellationParser to include parsing for delay notices. - Added new test cases for handling delay messages and stop-point cancellations. - Modified telegramImport integration tests to validate new delay handling. - Enhanced documentation to reflect the addition of delay notifications. - Updated TelegramImportPanel component to accommodate new alert types. - Introduced new fixture files for testing delay and cancellation messages.
This commit is contained in:
@@ -4,6 +4,18 @@ const { normalizeRouteNumber } = require('./alertTargets');
|
|||||||
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
||||||
const ROUTE = /маршрут(?:ам|ы|ов|у|а|е|ом)?\s*(?:№|номер|no\.?)?\s*(\d{1,3}[а-яa-z]?(?![\dа-яa-z])(?:\s*(?:[,;]|и\s+)\s*№?\s*\d{1,3}[а-яa-z]?(?![\dа-яa-z]))*)/giu;
|
const ROUTE = /маршрут(?:ам|ы|ов|у|а|е|ом)?\s*(?:№|номер|no\.?)?\s*(\d{1,3}[а-яa-z]?(?![\dа-яa-z])(?:\s*(?:[,;]|и\s+)\s*№?\s*\d{1,3}[а-яa-z]?(?![\dа-яa-z]))*)/giu;
|
||||||
const TIME = /(?<!\d)(\d{1,2})[:.](\d{2})(?!\d|[.:]\d)/g;
|
const TIME = /(?<!\d)(\d{1,2})[:.](\d{2})(?!\d|[.:]\d)/g;
|
||||||
|
const CANCELLATION = /(?:не\s+буд(?:ут|ет)\s+выполняться|не\s+выполняются|отмен(?:ены|яется|яются|а\s+рейс)|рейс[^\n]*отмен)/i;
|
||||||
|
const DELAY = /(?:(?:буд(?:ут|ет)|могут|может)\s+(?:выполняться|прибыть|прибывать|прийти|следовать)|выполняются|выполняется|прибывают|прибывает|следуют|следует)\s+(?:(?:следующие\s+)?рейсы?\s+)?с\s+(?:опозданием|задержкой)/i;
|
||||||
|
const STOP_INTRO = String.raw`(?:с|от)\s+(?:остановк[иа]|остановочного\s+пункта)\s+`;
|
||||||
|
const TIME_TOKEN = String.raw`(?<!\d)\d{1,2}[:.]\d{2}(?!\d|[.:]\d)`;
|
||||||
|
const TIME_LIST = `${TIME_TOKEN}(?:(?:\\s*[,;]\\s*|\\s+и\\s+|\\s+(?=\\d))${TIME_TOKEN})*`;
|
||||||
|
const QUOTED_STOP = String.raw`(?:"[^"\n]+"|«[^»\n]+»|“[^”\n]+”)`;
|
||||||
|
|
||||||
|
function departureMatches(text) {
|
||||||
|
const pattern = new RegExp(`(?:(${STOP_INTRO})(.+?)|^[ \\t]*(${QUOTED_STOP}))\\s+(?:на|в)\\s+(${TIME_LIST})`, 'gimu');
|
||||||
|
return [...text.matchAll(pattern)].filter(match => match[1]
|
||||||
|
|| new RegExp(STOP_INTRO, 'iu').test(text.slice(0, match.index)));
|
||||||
|
}
|
||||||
|
|
||||||
function validDate(value) {
|
function validDate(value) {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
||||||
@@ -37,25 +49,31 @@ function parseDate(text, publishedAt) {
|
|||||||
const publicationDate = getLocalDateTime(new Date(publishedAt)).date;
|
const publicationDate = getLocalDateTime(new Date(publishedAt)).date;
|
||||||
const dates = [];
|
const dates = [];
|
||||||
let dateText = text;
|
let dateText = text;
|
||||||
|
// Stop names (e.g. "1 Мая") and departure times aren't service dates.
|
||||||
|
// Mask explicitly associated departures, keeping offsets intact.
|
||||||
|
// Quoted list entries inherit the stop heading, including across blank lines.
|
||||||
|
let dateScope = text;
|
||||||
|
for (const match of departureMatches(text)) {
|
||||||
|
dateScope = dateScope.slice(0, match.index) + ' '.repeat(match[0].length) + dateScope.slice(match.index + match[0].length);
|
||||||
|
}
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(?<!\d)(\d{1,2})[./](\d{1,2})[./](\d{4})(?!\d)/g,
|
/(?<!\d)(\d{1,2})[./](\d{1,2})[./](\d{4})(?!\d)/g,
|
||||||
/(?:^|\n|(?:на|с|по|за|дата:?)\s+)(\d{1,2})[./](\d{2})(?![\d.:])/gi,
|
/(?:^|\n|(?:на|с|по|за|дата:?)\s+)(\d{1,2})[./](\d{2})(?![\d.:])/gi,
|
||||||
new RegExp(`(?<!\\d)(\\d{1,2})\\s+(${MONTHS.join('|')})(?:\\s+(\\d{4}))?`, 'gi')
|
new RegExp(`(?<!\\d)(\\d{1,2})\\s+(${MONTHS.join('|')})(?:\\s+(\\d{4}))?`, 'gi')
|
||||||
];
|
];
|
||||||
for (const [i, pattern] of patterns.entries()) {
|
for (const [i, pattern] of patterns.entries()) {
|
||||||
dateText = dateText.replace(pattern, (...args) => {
|
dateScope = dateScope.replace(pattern, (...args) => {
|
||||||
const [full, d, m] = args;
|
const [full, d, m] = args;
|
||||||
const y = i === 1 ? undefined : args[3];
|
const y = i === 1 ? undefined : args[3];
|
||||||
const offset = args.at(-2);
|
const offset = args.at(-2);
|
||||||
// “С/от остановки ... на 09.10” describes a departure, not a date.
|
|
||||||
if (i === 1 && /(?:с|от)\s+остановк[иа]\s+[^\n]*$/i.test(dateText.slice(0, offset))) return full;
|
|
||||||
dates.push(inferYear(d, i === 2 ? MONTHS.indexOf(m.toLowerCase()) + 1 : m, y, publicationDate));
|
dates.push(inferYear(d, i === 2 ? MONTHS.indexOf(m.toLowerCase()) + 1 : m, y, publicationDate));
|
||||||
return ' ';
|
dateText = dateText.slice(0, offset) + ' '.repeat(full.length) + dateText.slice(offset + full.length);
|
||||||
|
return ' '.repeat(full.length);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (/сегодня/i.test(text)) dates.push(publicationDate);
|
if (/сегодня/i.test(dateScope)) dates.push(publicationDate);
|
||||||
if (/завтра/i.test(text)) dates.push(addDays(publicationDate, 1));
|
if (/завтра/i.test(dateScope)) dates.push(addDays(publicationDate, 1));
|
||||||
if (/послезавтра/i.test(text)) return { reason: 'Дата «послезавтра» требует проверки.' };
|
if (/послезавтра/i.test(dateScope)) return { reason: 'Дата «послезавтра» требует проверки.' };
|
||||||
if (dates.some(d => !validDate(d))) return { reason: 'Некорректная дата отмены.' };
|
if (dates.some(d => !validDate(d))) return { reason: 'Некорректная дата отмены.' };
|
||||||
if (new Set(dates).size > 1) return { reason: 'Указаны разные даты отмены.' };
|
if (new Set(dates).size > 1) return { reason: 'Указаны разные даты отмены.' };
|
||||||
return { date: dates[0] || publicationDate, dateText, inferred: dates.length === 0 };
|
return { date: dates[0] || publicationDate, dateText, inferred: dates.length === 0 };
|
||||||
@@ -71,39 +89,55 @@ function normalizeStop(value) {
|
|||||||
}
|
}
|
||||||
function canonicalStop(value) { return normalizeStop(value) === 'автовокзал' ? 'Автовокзал' : value; }
|
function canonicalStop(value) { return normalizeStop(value) === 'автовокзал' ? 'Автовокзал' : value; }
|
||||||
|
|
||||||
function parseCancellation({ text = '', publishedAt, hasMedia = false }, { preserveSourceStops = false } = {}) {
|
function parseTransitNotice({ text = '', publishedAt, hasMedia = false }, { preserveSourceStops = false } = {}) {
|
||||||
const normalized = text.replace(/\u00a0/g, ' ').replace(/\r/g, '').trim();
|
const normalized = text.replace(/\u00a0/g, ' ').replace(/\r/g, '').trim();
|
||||||
let serviceDate;
|
let serviceDate;
|
||||||
const review = reason => ({ status: 'review', reasons: [reason], groups: [], ...(serviceDate ? { serviceDate } : {}) });
|
let alertType;
|
||||||
|
let delayPossible = false;
|
||||||
|
const review = reason => ({ status: 'review', reasons: [reason], groups: [],
|
||||||
|
...(alertType ? { alertType, ...(alertType === 'delay' ? { delayPossible } : {}) } : {}),
|
||||||
|
...(serviceDate ? { serviceDate } : {}) });
|
||||||
if (!normalized) return hasMedia ? review('Публикация содержит вложение без текста. Нужна проверка.') : { status: 'ignored', reasons: [], groups: [] };
|
if (!normalized) return hasMedia ? review('Публикация содержит вложение без текста. Нужна проверка.') : { status: 'ignored', reasons: [], groups: [] };
|
||||||
if (!/(?:не\s+буд(?:ут|ет)\s+выполняться|не\s+выполняются|отмен(?:ены|яется|яются|а\s+рейс)|рейс[^\n]*отмен)/i.test(normalized)) {
|
const cancellation = CANCELLATION.test(normalized);
|
||||||
|
const delay = normalized.match(DELAY);
|
||||||
|
if (!cancellation && !delay) {
|
||||||
return { status: 'ignored', reasons: [], groups: [] };
|
return { status: 'ignored', reasons: [], groups: [] };
|
||||||
}
|
}
|
||||||
if (hasMedia) return review('В публикации есть вложение. Проверьте все отмены по оригиналу.');
|
if (cancellation && delay) return review('В сообщении есть отмены и опоздания. Проверьте тип и текст уведомления.');
|
||||||
|
alertType = delay ? 'delay' : 'cancellation';
|
||||||
|
delayPossible = Boolean(delay && /могут|может/i.test(delay[0]));
|
||||||
|
if (delay && /не\s*$/i.test(normalized.slice(0, delay.index))) return review('Формулировка об опоздании неоднозначна. Нужна проверка.');
|
||||||
|
if (hasMedia) return review('В публикации есть вложение. Проверьте сообщение по оригиналу.');
|
||||||
if (normalized.length > 20_000) return review('Слишком длинное сообщение. Нужна проверка.');
|
if (normalized.length > 20_000) return review('Слишком длинное сообщение. Нужна проверка.');
|
||||||
if (/возобнов|восстанов|кроме|только|ежеднев|еженедель|понедельник|вторник|сред[ау]|четверг|пятниц|суббот|воскресен|будн|выходн|период|дальнейш|неопредел|(?:с\s+\d[^\n]+\s+по\s+\d)|до\s+\d/i.test(normalized)) {
|
if (/возобнов|восстанов|кроме|только|ежеднев|еженедель|понедельник|вторник|сред[ау]|четверг|пятниц|суббот|воскресен|будн|выходн|период|дальнейш|неопредел|(?:с\s+\d[^\n]+\s+по\s+\d)|до\s+\d/i.test(normalized)) {
|
||||||
return review('Есть условия, период или восстановление движения. Нужна проверка.');
|
return review('Есть условия, период или восстановление движения. Нужна проверка.');
|
||||||
}
|
}
|
||||||
if (/буд(?:ут|ет)\s+выполняться/i.test(normalized.replace(/не\s+буд(?:ут|ет)\s+выполняться/gi, '')) || /отмена\s+отменена/i.test(normalized)) {
|
const otherExecution = normalized.replace(/не\s+буд(?:ут|ет)\s+выполняться/gi, '').replace(new RegExp(DELAY.source, 'gi'), '');
|
||||||
|
if (/буд(?:ут|ет)\s+выполняться/i.test(otherExecution) || /отмена\s+отменена/i.test(normalized)) {
|
||||||
return review('В сообщении есть сведения о выполнении рейсов. Нужна проверка.');
|
return review('В сообщении есть сведения о выполнении рейсов. Нужна проверка.');
|
||||||
}
|
}
|
||||||
if (Number.isNaN(new Date(publishedAt).getTime())) return review('Неизвестно время публикации.');
|
if (Number.isNaN(new Date(publishedAt).getTime())) return review('Неизвестно время публикации.');
|
||||||
const parsedDate = parseDate(normalized, publishedAt);
|
const parsedDate = parseDate(normalized, publishedAt);
|
||||||
if (parsedDate.reason) return review(parsedDate.reason);
|
if (parsedDate.reason) return review(alertType === 'delay' ? parsedDate.reason.replace('отмены', 'уведомления') : parsedDate.reason);
|
||||||
serviceDate = parsedDate.date;
|
serviceDate = parsedDate.date;
|
||||||
|
if (delay && /\d+\s*(?:минут|мин(?=[\s.,]|$)|час)/i.test(normalized)) return review('Указана величина опоздания. Сохраните её в тексте при ручной проверке.');
|
||||||
const routeMatches = [...parsedDate.dateText.matchAll(ROUTE)];
|
const routeMatches = [...parsedDate.dateText.matchAll(ROUTE)];
|
||||||
if (!routeMatches.length) return review('Не удалось определить номер маршрута.');
|
if (!routeMatches.length) return review('Не удалось определить номер маршрута.');
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for (const [i, route] of routeMatches.entries()) {
|
for (const [i, route] of routeMatches.entries()) {
|
||||||
const section = parsedDate.dateText.slice(route.index + route[0].length, routeMatches[i + 1]?.index);
|
const section = parsedDate.dateText.slice(route.index + route[0].length, routeMatches[i + 1]?.index);
|
||||||
const stopPattern = /(?:с|от)\s+остановк[иа]\s+(.+?)\s+(?:на|в)\s+((?:\d{1,2}[:.]\d{2}(?:\s*[,;]\s*|\s+и\s+|\s+(?=\d))?)+)/giu;
|
|
||||||
const departures = [];
|
const departures = [];
|
||||||
for (const stop of section.matchAll(stopPattern)) {
|
let unmatched = section;
|
||||||
const times = [...stop[2].matchAll(TIME)].map(t => `${t[1].padStart(2, '0')}:${t[2]}`);
|
for (const stop of departureMatches(section)) {
|
||||||
|
const times = [...stop[4].matchAll(TIME)].map(t => `${t[1].padStart(2, '0')}:${t[2]}`);
|
||||||
if (times.some(t => Number(t.slice(0, 2)) > 23 || Number(t.slice(3)) > 59)) return review('Некорректное время отправления.');
|
if (times.some(t => Number(t.slice(0, 2)) > 23 || Number(t.slice(3)) > 59)) return review('Некорректное время отправления.');
|
||||||
const stopName = stop[1].replace(/^[\s«"“]+|[\s»"”.,:;]+$/g, '').trim();
|
const stopName = (stop[2] || stop[3]).replace(/^[\s«"“]+|[\s»"”.,:;]+$/g, '').trim();
|
||||||
if (!stopName || !times.length) return review('Не удалось определить остановку и время.');
|
if (!stopName || !times.length) return review('Не удалось определить остановку и время.');
|
||||||
departures.push({ stop: canonicalStop(stopName), times: [...new Set(times)], ...(preserveSourceStops ? { sourceStop: stopName } : {}) });
|
departures.push({ stop: canonicalStop(stopName), times: [...new Set(times)], ...(preserveSourceStops ? { sourceStop: stopName } : {}) });
|
||||||
|
unmatched = unmatched.slice(0, stop.index) + ' '.repeat(stop[0].length) + unmatched.slice(stop.index + stop[0].length);
|
||||||
|
}
|
||||||
|
if (new RegExp(STOP_INTRO, 'iu').test(unmatched) || new RegExp(`^[ \\t]*${QUOTED_STOP}`, 'imu').test(unmatched)) {
|
||||||
|
return review('Не удалось определить время отправления для всех остановок.');
|
||||||
}
|
}
|
||||||
if (!departures.length || [...section.matchAll(TIME)].length !== departures.reduce((n, d) => n + d.times.length, 0)) {
|
if (!departures.length || [...section.matchAll(TIME)].length !== departures.reduce((n, d) => n + d.times.length, 0)) {
|
||||||
return review('Не удалось однозначно связать все времена с остановками.');
|
return review('Не удалось однозначно связать все времена с остановками.');
|
||||||
@@ -112,7 +146,8 @@ function parseCancellation({ text = '', publishedAt, hasMedia = false }, { prese
|
|||||||
groups.push({ routeNumber: normalizeRoute(number), departures });
|
groups.push({ routeNumber: normalizeRoute(number), departures });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { status: 'ready', reasons: [], serviceDate: parsedDate.date, inferredDate: parsedDate.inferred, groups };
|
return { status: 'ready', reasons: [], alertType, ...(alertType === 'delay' ? { delayPossible } : {}),
|
||||||
|
serviceDate: parsedDate.date, inferredDate: parsedDate.inferred, groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCancellationStops(parsed, stops, aliases) {
|
function resolveCancellationStops(parsed, stops, aliases) {
|
||||||
@@ -150,27 +185,30 @@ function matchCancellationRoutes(parsed, routes) {
|
|||||||
return { reasons: [], plans: [...plans.values()] };
|
return { reasons: [], plans: [...plans.values()] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCancellationDraft(plan, serviceDate) {
|
function buildTransitNoticeDraft(plan, serviceDate, { alertType = 'cancellation', delayPossible = false } = {}) {
|
||||||
const plans = Array.isArray(plan) ? plan : [plan];
|
const plans = Array.isArray(plan) ? plan : [plan];
|
||||||
|
const delay = alertType === 'delay';
|
||||||
return {
|
return {
|
||||||
title: `Отмена отдельных рейсов ${plans.map(p => `№${p.routeNumber}`).join(', ')}`,
|
title: `${delay ? delayPossible ? 'Возможное опоздание' : 'Задержка' : 'Отмена'} отдельных рейсов ${plans.map(p => `№${p.routeNumber}`).join(', ')}`,
|
||||||
message: `${serviceDate.split('-').reverse().join('.')}: не выполняются рейсы:\n${plans.map(p =>
|
message: `${serviceDate.split('-').reverse().join('.')}: ${delay ? delayPossible ? 'возможны опоздания рейсов' : 'с опозданием выполняются рейсы' : 'не выполняются рейсы'}:\n${plans.map(p =>
|
||||||
`${plans.length > 1 ? `Маршрут №${p.routeNumber}:\n` : ''}${p.departures.map(d => `${d.stop} — ${d.times.join(', ')}`).join('\n')}`).join('\n\n')}`
|
`${plans.length > 1 ? `Маршрут №${p.routeNumber}:\n` : ''}${p.departures.map(d => `${d.stop} — ${d.times.join(', ')}`).join('\n')}`).join('\n\n')}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCancellationAlert(plan, serviceDate, sourceUrl, publishedAt, cityId) {
|
function buildTransitNoticeAlert(plan, serviceDate, sourceUrl, publishedAt, cityId, options = {}) {
|
||||||
const plans = Array.isArray(plan) ? plan : [plan];
|
const plans = Array.isArray(plan) ? plan : [plan];
|
||||||
const bounds = dayBounds(serviceDate);
|
const bounds = dayBounds(serviceDate);
|
||||||
const draft = buildCancellationDraft(plan, serviceDate);
|
const draft = buildTransitNoticeDraft(plan, serviceDate, options);
|
||||||
const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString();
|
const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString();
|
||||||
return {
|
return {
|
||||||
city_id: cityId, route_id: plans.length === 1 ? plans[0].routeId : null,
|
city_id: cityId, route_id: plans.length === 1 ? plans[0].routeId : null,
|
||||||
route_numbers: plans.map(p => p.routeNumber), alert_type: 'cancellation',
|
route_numbers: plans.map(p => p.routeNumber), alert_type: options.alertType || 'cancellation',
|
||||||
...draft,
|
...draft,
|
||||||
message: `${draft.message}\nИсточник: ${sourceUrl}`,
|
message: `${draft.message}`,
|
||||||
start_time: start, end_time: bounds.end, is_active: true
|
start_time: start, end_time: bounds.end, is_active: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, normalizeStopKey, dayBounds, validDate, addDays };
|
module.exports = { parseTransitNotice, buildTransitNoticeDraft, buildTransitNoticeAlert,
|
||||||
|
parseCancellation: parseTransitNotice, buildCancellationDraft: buildTransitNoticeDraft, buildCancellationAlert: buildTransitNoticeAlert,
|
||||||
|
resolveCancellationStops, matchCancellationRoutes, normalizeStopKey, dayBounds, validDate, addDays };
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ const crypto = require('node:crypto');
|
|||||||
const { getTelegramConfig, missingTelegramConfig } = require('../config/telegram');
|
const { getTelegramConfig, missingTelegramConfig } = require('../config/telegram');
|
||||||
const { getLocalDateTime } = require('../utils/etaTime');
|
const { getLocalDateTime } = require('../utils/etaTime');
|
||||||
const { TelegramSource } = require('./telegramSource');
|
const { TelegramSource } = require('./telegramSource');
|
||||||
const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser');
|
const { parseTransitNotice, resolveCancellationStops, matchCancellationRoutes, buildTransitNoticeDraft, buildTransitNoticeAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser');
|
||||||
const { AlertCreationService } = require('./alertCreationService');
|
const { AlertCreationService } = require('./alertCreationService');
|
||||||
const { TelegramStopAliasService } = require('./telegramStopAliasService');
|
const { TelegramStopAliasService } = require('./telegramStopAliasService');
|
||||||
const { resolveAlertTargets } = require('./alertTargets');
|
const { resolveAlertTargets } = require('./alertTargets');
|
||||||
|
|
||||||
|
const PARSER_VERSION = 'transit-notices-v2';
|
||||||
|
|
||||||
|
function noticeOptions(parsed) { return { alertType: parsed.alertType || 'cancellation', delayPossible: parsed.delayPossible }; }
|
||||||
function httpError(status, message) { return Object.assign(new Error(message), { status }); }
|
function httpError(status, message) { return Object.assign(new Error(message), { status }); }
|
||||||
function sourceUrl(row) { return `https://t.me/${row.channel_username}/${row.message_id}`; }
|
function sourceUrl(row) { return `https://t.me/${row.channel_username}/${row.message_id}`; }
|
||||||
function contentHash(message) {
|
function contentHash(message) {
|
||||||
@@ -20,7 +23,7 @@ function messageDrafts(parsed) {
|
|||||||
if (!groups.has(group.routeNumber)) groups.set(group.routeNumber, { routeNumber: group.routeNumber, departures: [] });
|
if (!groups.has(group.routeNumber)) groups.set(group.routeNumber, { routeNumber: group.routeNumber, departures: [] });
|
||||||
groups.get(group.routeNumber).departures.push(...group.departures);
|
groups.get(group.routeNumber).departures.push(...group.departures);
|
||||||
}
|
}
|
||||||
return [...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildCancellationDraft(group, parsed.serviceDate) }));
|
return [...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildTransitNoticeDraft(group, parsed.serviceDate, noticeOptions(parsed)) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicError(error) {
|
function publicError(error) {
|
||||||
@@ -102,7 +105,7 @@ class TelegramImportService {
|
|||||||
async processMessage(message, cityId) {
|
async processMessage(message, cityId) {
|
||||||
if (!Number.isSafeInteger(message.messageId) || message.messageId <= 0 || !/^\d+$/.test(message.channelId)
|
if (!Number.isSafeInteger(message.messageId) || message.messageId <= 0 || !/^\d+$/.test(message.channelId)
|
||||||
|| message.channelUsername.toLowerCase() !== this.config.channel.toLowerCase()) throw new Error('CHANNEL_INVALID');
|
|| message.channelUsername.toLowerCase() !== this.config.channel.toLowerCase()) throw new Error('CHANNEL_INVALID');
|
||||||
let parsed = parseCancellation(message, { preserveSourceStops: true });
|
let parsed = { ...parseTransitNotice(message, { preserveSourceStops: true }), parserVersion: PARSER_VERSION };
|
||||||
const hash = contentHash(message);
|
const hash = contentHash(message);
|
||||||
const db = await this.pool.connect();
|
const db = await this.pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +122,7 @@ class TelegramImportService {
|
|||||||
if (!row) { await db.query('COMMIT'); return { status: 'ignored' }; }
|
if (!row) { await db.query('COMMIT'); return { status: 'ignored' }; }
|
||||||
const initialized = row.parsed && Object.keys(row.parsed).length > 0;
|
const initialized = row.parsed && Object.keys(row.parsed).length > 0;
|
||||||
const sameContent = initialized && row.content_hash === hash;
|
const sameContent = initialized && row.content_hash === hash;
|
||||||
if ((sameContent && (row.status !== 'review' || parsed.status !== 'ready')) || (row.edited_at && (!message.editedAt || Date.parse(message.editedAt) < new Date(row.edited_at).getTime()))) {
|
if ((sameContent && (row.status !== 'review' || (parsed.status !== 'ready' && row.parsed.parserVersion === PARSER_VERSION))) || (row.edited_at && (!message.editedAt || Date.parse(message.editedAt) < new Date(row.edited_at).getTime()))) {
|
||||||
await db.query('COMMIT'); return { status: 'unchanged' };
|
await db.query('COMMIT'); return { status: 'unchanged' };
|
||||||
}
|
}
|
||||||
let routes = [];
|
let routes = [];
|
||||||
@@ -127,7 +130,7 @@ class TelegramImportService {
|
|||||||
const context = await this.getStopContext(db, cityId);
|
const context = await this.getStopContext(db, cityId);
|
||||||
routes = await this.getRoutes(db, cityId);
|
routes = await this.getRoutes(db, cityId);
|
||||||
const resolutionHash = crypto.createHash('sha256').update(JSON.stringify([
|
const resolutionHash = crypto.createHash('sha256').update(JSON.stringify([
|
||||||
'route-number-alerts-v1', context.stops, context.aliases.map(a => [a.normalized_alias, a.stop_id]), routes, this.config.autoPublish
|
PARSER_VERSION, context.stops, context.aliases.map(a => [a.normalized_alias, a.stop_id]), routes, this.config.autoPublish
|
||||||
])).digest('hex');
|
])).digest('hex');
|
||||||
if (sameContent && row.parsed.resolutionHash === resolutionHash && Date.parse(dayBounds(parsed.serviceDate).end) > this.now().getTime()) {
|
if (sameContent && row.parsed.resolutionHash === resolutionHash && Date.parse(dayBounds(parsed.serviceDate).end) > this.now().getTime()) {
|
||||||
await db.query('COMMIT'); return { status: 'unchanged' };
|
await db.query('COMMIT'); return { status: 'unchanged' };
|
||||||
@@ -138,7 +141,7 @@ class TelegramImportService {
|
|||||||
let status = row.status === 'dismissed' ? 'dismissed' : 'review';
|
let status = row.status === 'dismissed' ? 'dismissed' : 'review';
|
||||||
let reasons = parsed.reasons;
|
let reasons = parsed.reasons;
|
||||||
let plans = [];
|
let plans = [];
|
||||||
if (parsed.status === 'ignored') reasons = ['Текст исходной публикации изменился: отмена больше не распознаётся.'];
|
if (parsed.status === 'ignored') reasons = ['Публикация больше не распознаётся как отмена или опоздание рейсов. Проверьте оригинал.'];
|
||||||
if (parsed.status === 'ready') {
|
if (parsed.status === 'ready') {
|
||||||
const matched = matchCancellationRoutes(parsed, routes);
|
const matched = matchCancellationRoutes(parsed, routes);
|
||||||
plans = matched.plans;
|
plans = matched.plans;
|
||||||
@@ -148,14 +151,14 @@ class TelegramImportService {
|
|||||||
else if (!reasons.length) reasons = ['Автоматическая публикация выключена. Подтвердите уведомление.'];
|
else if (!reasons.length) reasons = ['Автоматическая публикация выключена. Подтвердите уведомление.'];
|
||||||
}
|
}
|
||||||
if (status === 'published') {
|
if (status === 'published') {
|
||||||
const draft = buildCancellationDraft(plans, parsed.serviceDate);
|
const draft = buildTransitNoticeDraft(plans, parsed.serviceDate, noticeOptions(parsed));
|
||||||
if (draft.title.length > 200 || draft.message.length > 5000 || plans.length > 100) {
|
if (draft.title.length > 200 || draft.message.length > 5000 || plans.length > 100) {
|
||||||
status = 'review';
|
status = 'review';
|
||||||
reasons = ['Слишком длинное уведомление. Сократите заголовок или текст перед публикацией.'];
|
reasons = ['Слишком длинное уведомление. Сократите заголовок или текст перед публикацией.'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (status === 'published') {
|
if (status === 'published') {
|
||||||
await this.saveAlert(db, row.id, buildCancellationAlert(plans, parsed.serviceDate, sourceUrl(row), message.publishedAt, cityId));
|
await this.saveAlert(db, row.id, buildTransitNoticeAlert(plans, parsed.serviceDate, sourceUrl(row), message.publishedAt, cityId, noticeOptions(parsed)));
|
||||||
}
|
}
|
||||||
await db.query(`UPDATE telegram_import_messages SET content_hash=$2, raw_text=$3, edited_at=$4,
|
await db.query(`UPDATE telegram_import_messages SET content_hash=$2, raw_text=$3, edited_at=$4,
|
||||||
has_media=$5, status=$6, reasons=$7::jsonb, parsed=$8::jsonb, reviewed_by=NULL, updated_at=now() WHERE id=$1`,
|
has_media=$5, status=$6, reasons=$7::jsonb, parsed=$8::jsonb, reviewed_by=NULL, updated_at=now() WHERE id=$1`,
|
||||||
@@ -188,9 +191,9 @@ class TelegramImportService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
// Deletion stays deleted. Editing a delivered alert doesn't resend its push.
|
// Deletion stays deleted. Editing a delivered alert doesn't resend its push.
|
||||||
if (existing.alert_id) await db.query(`UPDATE alerts SET title=$2, message=$3, start_time=$4, end_time=$5,
|
if (existing.alert_id) await db.query(`UPDATE alerts SET title=$2, message=$3, start_time=$4, end_time=$5,
|
||||||
route_id=$6, route_numbers=$7::text[], is_active=true, notification_pending=true,
|
route_id=$6, route_numbers=$7::text[], alert_type=$8, is_active=true, notification_pending=true,
|
||||||
notification_attempts=CASE WHEN notification_sent_at IS NULL THEN 0 ELSE notification_attempts END
|
notification_attempts=CASE WHEN notification_sent_at IS NULL THEN 0 ELSE notification_attempts END
|
||||||
WHERE id=$1`, [existing.alert_id, payload.title, payload.message, payload.start_time, payload.end_time, payload.route_id, payload.route_numbers]);
|
WHERE id=$1`, [existing.alert_id, payload.title, payload.message, payload.start_time, payload.end_time, payload.route_id, payload.route_numbers, payload.alert_type]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await this.alerts.insertAlert(db, payload);
|
const result = await this.alerts.insertAlert(db, payload);
|
||||||
@@ -217,19 +220,22 @@ class TelegramImportService {
|
|||||||
if (!row) throw httpError(404, 'Сообщение не найдено.');
|
if (!row) throw httpError(404, 'Сообщение не найдено.');
|
||||||
if (input.content_hash !== row.content_hash) throw httpError(409, 'Публикация изменилась. Обновите список и проверьте новый текст.');
|
if (input.content_hash !== row.content_hash) throw httpError(409, 'Публикация изменилась. Обновите список и проверьте новый текст.');
|
||||||
if (!['publish', 'dismiss'].includes(input.action)) throw httpError(400, 'Неизвестное действие.');
|
if (!['publish', 'dismiss'].includes(input.action)) throw httpError(400, 'Неизвестное действие.');
|
||||||
|
let alertType = row.parsed.alertType || 'cancellation';
|
||||||
if (input.action === 'publish') {
|
if (input.action === 'publish') {
|
||||||
if (!validDate(input.service_date)) throw httpError(400, 'Укажите корректную дату отмены.');
|
alertType = input.alert_type === undefined ? alertType : input.alert_type;
|
||||||
if (Date.parse(dayBounds(input.service_date).end) <= this.now().getTime()) throw httpError(410, 'День отмены уже закончился.');
|
if (!['cancellation', 'delay'].includes(alertType)) throw httpError(400, 'Выберите отмену или опоздание рейсов.');
|
||||||
|
if (!validDate(input.service_date)) throw httpError(400, 'Укажите корректную дату уведомления.');
|
||||||
|
if (Date.parse(dayBounds(input.service_date).end) <= this.now().getTime()) throw httpError(410, 'День уведомления уже закончился.');
|
||||||
const targets = await resolveAlertTargets(db, cityId, input, { required: true });
|
const targets = await resolveAlertTargets(db, cityId, input, { required: true });
|
||||||
const title = String(input.title || '').trim();
|
const title = String(input.title || '').trim();
|
||||||
const message = String(input.message || '').trim();
|
const message = String(input.message || '').trim();
|
||||||
if (!title || title.length > 200 || !message || message.length > 5000) throw httpError(400, 'Укажите заголовок (до 200 символов) и текст (до 5000 символов).');
|
if (!title || title.length > 200 || !message || message.length > 5000) throw httpError(400, 'Укажите заголовок (до 200 символов) и текст (до 5000 символов).');
|
||||||
await this.deactivate(db, row.id);
|
await this.deactivate(db, row.id);
|
||||||
const bounds = dayBounds(input.service_date);
|
const bounds = dayBounds(input.service_date);
|
||||||
await this.saveAlert(db, row.id, { city_id: cityId, ...targets, alert_type: 'cancellation', title,
|
await this.saveAlert(db, row.id, { city_id: cityId, ...targets, alert_type: alertType, title,
|
||||||
message: `${message}\nИсточник: ${sourceUrl(row)}`, start_time: new Date(Math.max(Date.parse(bounds.start), this.now().getTime())).toISOString(), end_time: bounds.end, is_active: true });
|
message: `${message}\nИсточник: ${sourceUrl(row)}`, start_time: new Date(Math.max(Date.parse(bounds.start), this.now().getTime())).toISOString(), end_time: bounds.end, is_active: true });
|
||||||
} else await this.deactivate(db, row.id);
|
} else await this.deactivate(db, row.id);
|
||||||
const parsed = input.action === 'publish' ? { ...row.parsed, serviceDate: input.service_date, manual: true, selectedRouteNumbers: input.route_numbers } : row.parsed;
|
const parsed = input.action === 'publish' ? { ...row.parsed, serviceDate: input.service_date, alertType, manual: true, selectedRouteNumbers: input.route_numbers } : row.parsed;
|
||||||
await db.query(`UPDATE telegram_import_messages SET status=$2, reviewed_by=$3, parsed=$4::jsonb, updated_at=now() WHERE id=$1`, [row.id, input.action === 'publish' ? 'published' : 'dismissed', userId, JSON.stringify(parsed)]);
|
await db.query(`UPDATE telegram_import_messages SET status=$2, reviewed_by=$3, parsed=$4::jsonb, updated_at=now() WHERE id=$1`, [row.id, input.action === 'publish' ? 'published' : 'dismissed', userId, JSON.stringify(parsed)]);
|
||||||
await db.query('COMMIT');
|
await db.query('COMMIT');
|
||||||
return { id: row.id, status: input.action === 'publish' ? 'published' : 'dismissed' };
|
return { id: row.id, status: input.action === 'publish' ? 'published' : 'dismissed' };
|
||||||
|
|||||||
5
backend/test/fixtures/telegram-delay-route-4.txt
vendored
Normal file
5
backend/test/fixtures/telegram-delay-route-4.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Уважаемые пассажиры!
|
||||||
|
будут выполняться рейсы с опозданием:
|
||||||
|
|
||||||
|
ПО МАРШРУТУ № 4 С ОСТАНОВКИ МАРИНЕНКО НА 19.05
|
||||||
|
С ОСТАНОВКИ БОРОВУХА НА 20.00
|
||||||
7
backend/test/fixtures/telegram-stop-point-cancellation.txt
vendored
Normal file
7
backend/test/fixtures/telegram-stop-point-cancellation.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Уважаемые пассажиры!
|
||||||
|
Не будут выполняться следующие рейсы:
|
||||||
|
|
||||||
|
По маршруту № 4 с остановочного пункта
|
||||||
|
|
||||||
|
"Мариненко" на 6.00, 7.30
|
||||||
|
"Боровуха" на 6.40, 8.15
|
||||||
@@ -2,7 +2,7 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationAlert, normalizeStopKey, dayBounds } = require('../src/services/telegramCancellationParser');
|
const { parseTransitNotice, buildTransitNoticeAlert, parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationAlert, normalizeStopKey, dayBounds } = require('../src/services/telegramCancellationParser');
|
||||||
const hospitalText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-hospital-cancellation.txt'), 'utf8');
|
const hospitalText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-hospital-cancellation.txt'), 'utf8');
|
||||||
|
|
||||||
const text = `Уважаемые пассажиры!
|
const text = `Уважаемые пассажиры!
|
||||||
@@ -128,8 +128,8 @@ test('dictionary selects the actual stop ID and uses its canonical name in conci
|
|||||||
const matched = matchCancellationRoutes(parsed, routes);
|
const matched = matchCancellationRoutes(parsed, routes);
|
||||||
assert.deepEqual(matched.plans[0].routeIds, [1, 2, 3]);
|
assert.deepEqual(matched.plans[0].routeIds, [1, 2, 3]);
|
||||||
const alert = buildCancellationAlert(matched.plans[0], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1);
|
const alert = buildCancellationAlert(matched.plans[0], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1);
|
||||||
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nАвтовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166');
|
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nАвтовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58');
|
||||||
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/);
|
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ|Источник:/);
|
||||||
assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего'));
|
assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,3 +163,117 @@ test('a shared explicit departure list may name several route numbers', () => {
|
|||||||
assert.ok(parsed.groups.every(g => g.departures[0].times.join(',') === '18:00,19:00'));
|
assert.ok(parsed.groups.every(g => g.departures[0].times.join(',') === '18:00,19:00'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const delayText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-delay-route-4.txt'), 'utf8');
|
||||||
|
const stopPointText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-stop-point-cancellation.txt'), 'utf8');
|
||||||
|
const route4Stops = [{ id: 41, name: 'Мариненко' }, { id: 42, name: 'Боровуха' }];
|
||||||
|
const route4 = [{ id: 4, route_number: '4' }, { id: 40, route_number: '4' }];
|
||||||
|
|
||||||
|
function route4Alert(parsed, publishedAt) {
|
||||||
|
const resolved = resolveCancellationStops(parsed, route4Stops, []);
|
||||||
|
const matched = matchCancellationRoutes(resolved, route4);
|
||||||
|
assert.deepEqual(matched.reasons, []);
|
||||||
|
return buildTransitNoticeAlert(matched.plans, parsed.serviceDate, 'https://t.me/avp2Polotsk/1700', publishedAt, 1, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('screenshot delay is a delay notice with both departures and Minsk publication-day expiry', () => {
|
||||||
|
const publishedAt = '2026-10-08T16:14:01Z';
|
||||||
|
const parsed = parseTransitNotice({ text: delayText, publishedAt });
|
||||||
|
assert.equal(parsed.status, 'ready');
|
||||||
|
assert.equal(parsed.alertType, 'delay');
|
||||||
|
assert.equal(parsed.serviceDate, '2026-10-08');
|
||||||
|
assert.deepEqual(parsed.groups[0].departures.map(d => d.times), [['19:05'], ['20:00']]);
|
||||||
|
const alert = route4Alert(parsed, publishedAt);
|
||||||
|
assert.equal(alert.alert_type, 'delay');
|
||||||
|
assert.deepEqual(alert.route_numbers, ['4']);
|
||||||
|
assert.equal(alert.title, 'Задержка отдельных рейсов №4');
|
||||||
|
assert.equal(alert.message, '08.10.2026: с опозданием выполняются рейсы:\nМариненко — 19:05\nБоровуха — 20:00');
|
||||||
|
assert.equal(alert.start_time, '2026-10-08T16:14:01.000Z');
|
||||||
|
assert.equal(alert.end_time, '2026-10-08T21:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screenshot stop-point heading and quoted continuation preserve four cancelled departures', () => {
|
||||||
|
const publishedAt = '2026-10-08T02:03:13Z';
|
||||||
|
const parsed = parseTransitNotice({ text: stopPointText, publishedAt, editedAt: '2026-10-09T01:00Z' }, { preserveSourceStops: true });
|
||||||
|
assert.equal(parsed.status, 'ready');
|
||||||
|
assert.equal(parsed.alertType, 'cancellation');
|
||||||
|
assert.equal(parsed.serviceDate, '2026-10-08');
|
||||||
|
assert.deepEqual(parsed.groups, [{ routeNumber: '4', departures: [
|
||||||
|
{ stop: 'Мариненко', sourceStop: 'Мариненко', times: ['06:00', '07:30'] },
|
||||||
|
{ stop: 'Боровуха', sourceStop: 'Боровуха', times: ['06:40', '08:15'] }
|
||||||
|
] }]);
|
||||||
|
const alert = route4Alert(parsed, publishedAt);
|
||||||
|
assert.equal(alert.alert_type, 'cancellation');
|
||||||
|
assert.equal(alert.message, '08.10.2026: не выполняются рейсы:\nМариненко — 06:00, 07:30\nБоровуха — 06:40, 08:15');
|
||||||
|
assert.equal(alert.end_time, '2026-10-08T21:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop-point heading supports quote variants, mixed prefixes and real dates independently from times', () => {
|
||||||
|
for (const [opening, closing] of [['"', '"'], ['«', '»'], ['“', '”']]) {
|
||||||
|
const text = stopPointText.replace(/"(Мариненко|Боровуха)"/g, (_, name) => opening + name + closing).replace('6.00', '09.10');
|
||||||
|
const parsed = parseTransitNotice({ text: 'На 09.10\n' + text, publishedAt: '2026-10-08T02:03:13Z' });
|
||||||
|
assert.equal(parsed.status, 'ready');
|
||||||
|
assert.equal(parsed.serviceDate, '2026-10-09');
|
||||||
|
assert.deepEqual(parsed.groups[0].departures[0].times, ['09:10', '07:30']);
|
||||||
|
}
|
||||||
|
const mixed = stopPointText.replace('"Боровуха"', 'ОТ ОСТАНОВКИ «Боровуха»');
|
||||||
|
assert.equal(parseTransitNotice({ text: mixed, publishedAt: '2026-10-08T02:03:13Z' }).status, 'ready');
|
||||||
|
for (const text of [stopPointText.replace('6.00', '25.00'), 'На 31.02.2026\n' + stopPointText,
|
||||||
|
stopPointText.replace('с остановочного пункта', ''), stopPointText + '\nДополнительно в 09:00']) {
|
||||||
|
assert.equal(parseTransitNotice({ text, publishedAt: '2026-10-08T02:03:13Z' }).status, 'review');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('possible delay stays possible and unspecified duration is never invented or silently dropped', () => {
|
||||||
|
const publishedAt = '2026-10-08T16:14:01Z';
|
||||||
|
for (const wording of ['могут выполняться рейсы с опозданием', 'рейсы могут прибыть с опозданием', 'рейсы могут прийти с опозданием']) {
|
||||||
|
const parsed = parseTransitNotice({ text: delayText.replace('будут выполняться рейсы с опозданием', wording), publishedAt });
|
||||||
|
assert.equal(parsed.status, 'ready');
|
||||||
|
assert.equal(parsed.delayPossible, true);
|
||||||
|
const alert = route4Alert(parsed, publishedAt);
|
||||||
|
assert.match(alert.title, /Возможное опоздание/);
|
||||||
|
assert.match(alert.message, /возможны опоздания рейсов/);
|
||||||
|
assert.doesNotMatch(alert.message, /минут|час/);
|
||||||
|
}
|
||||||
|
for (const duration of [' на 20 минут', ' на 20 мин.', ' на 1 час']) {
|
||||||
|
const parsed = parseTransitNotice({ text: delayText.replace('с опозданием:', 'с опозданием' + duration + ':'), publishedAt });
|
||||||
|
assert.equal(parsed.status, 'review');
|
||||||
|
assert.equal(parsed.alertType, 'delay');
|
||||||
|
assert.match(parsed.reasons[0], /величина опоздания/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mixed types, negated delay, conditions and incomplete departures stay on review; unrelated execution is ignored', () => {
|
||||||
|
const publishedAt = '2026-10-08T16:14:01Z';
|
||||||
|
for (const text of [delayText + '\n' + stopPointText,
|
||||||
|
delayText.replace('будут выполняться', 'не могут выполняться'),
|
||||||
|
delayText + '\nДвижение восстановлено.',
|
||||||
|
delayText.replace(' НА 20.00', ''),
|
||||||
|
delayText + '\nОстальные рейсы будут выполняться.']) {
|
||||||
|
assert.equal(parseTransitNotice({ text, publishedAt }).status, 'review', text);
|
||||||
|
}
|
||||||
|
assert.equal(parseTransitNotice({ text: 'Будут выполняться рейсы по маршруту №4.', publishedAt }).status, 'ignored');
|
||||||
|
assert.equal(parseTransitNotice({ text: 'С опозданием поздравляем с праздником!', publishedAt }).status, 'ignored');
|
||||||
|
const withMedia = parseTransitNotice({ text: delayText, publishedAt, hasMedia: true });
|
||||||
|
assert.equal(withMedia.status, 'review');
|
||||||
|
assert.equal(withMedia.alertType, 'delay');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple delayed routes are combined while every stop and departure remains visible', () => {
|
||||||
|
const publishedAt = '2026-10-08T16:14:01Z';
|
||||||
|
const parsed = parseTransitNotice({ text: delayText + '\nПО МАРШРУТУ №27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 20.15,21.00.', publishedAt });
|
||||||
|
const resolved = resolveCancellationStops(parsed, [...route4Stops, { id: 1, name: 'Автовокзал' }], []);
|
||||||
|
const matched = matchCancellationRoutes(resolved, [...route4, { id: 27, route_number: '27' }]);
|
||||||
|
const alert = buildTransitNoticeAlert(matched.plans, parsed.serviceDate, '', publishedAt, 1, parsed);
|
||||||
|
assert.equal(alert.alert_type, 'delay');
|
||||||
|
assert.deepEqual(alert.route_numbers, ['4', '27']);
|
||||||
|
assert.match(alert.message, /Маршрут №4:\nМариненко — 19:05\nБоровуха — 20:00/);
|
||||||
|
assert.match(alert.message, /Маршрут №27:\nАвтовокзал — 20:15, 21:00/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calendar words in a stop name are not mistaken for a service date', () => {
|
||||||
|
const parsed = parseTransitNotice({ text: stopPointText.replace('Мариненко', '1 Мая'), publishedAt: '2026-10-08T02:03:13Z' });
|
||||||
|
assert.equal(parsed.status, 'ready');
|
||||||
|
assert.equal(parsed.serviceDate, '2026-10-08');
|
||||||
|
assert.equal(parsed.groups[0].departures[0].stop, '1 Мая');
|
||||||
|
});
|
||||||
|
|||||||
@@ -378,3 +378,128 @@ test('legacy per-direction Telegram alerts consolidate on edit without repeating
|
|||||||
assert.match(alerts[0].message, /Зодчего Иоанна/);
|
assert.match(alerts[0].message, /Зодчего Иоанна/);
|
||||||
} finally { await db.close(); }
|
} finally { await db.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function setupRoute4() {
|
||||||
|
const context = await setup();
|
||||||
|
await context.pool.query("UPDATE routes SET route_number='4' WHERE city_id=1");
|
||||||
|
await context.pool.query("UPDATE stops SET name=CASE id WHEN 1 THEN 'Мариненко' ELSE 'Боровуха' END WHERE city_id=1");
|
||||||
|
context.clock('2026-10-08T16:20:00Z');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function route4Message(id, fixture = 'telegram-delay-route-4.txt') {
|
||||||
|
const text = await fs.readFile(path.join(__dirname, 'fixtures', fixture), 'utf8');
|
||||||
|
return { ...message(id, text), publishedAt: fixture.includes('delay') ? '2026-10-08T16:14:01Z' : '2026-10-08T02:03:13Z' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedLegacyReview(pool, input, parsed, reasons) {
|
||||||
|
return (await pool.query(`INSERT INTO telegram_import_messages
|
||||||
|
(city_id,channel_id,channel_username,message_id,published_at,content_hash,raw_text,status,reasons,parsed)
|
||||||
|
VALUES (1,$1,$2,$3,$4,$5,$6,'review',$7::jsonb,$8::jsonb) RETURNING id`,
|
||||||
|
[input.channelId,input.channelUsername,input.messageId,input.publishedAt,contentHash(input),input.text,JSON.stringify(reasons),JSON.stringify(parsed)])).rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('screenshot reviews reprocess unchanged text into correctly typed alerts with no duplicates', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool, service } = await setupRoute4();
|
||||||
|
try {
|
||||||
|
const delayed = await route4Message(1800);
|
||||||
|
const cancelled = await route4Message(1801, 'telegram-stop-point-cancellation.txt');
|
||||||
|
await seedLegacyReview(pool, delayed, { status: 'ignored', groups: [], plans: [] }, ['Текст исходной публикации изменился: отмена больше не распознаётся.']);
|
||||||
|
await seedLegacyReview(pool, cancelled, { status: 'review', groups: [], plans: [] }, ['Некорректная дата отмены.']);
|
||||||
|
for (const input of [delayed, cancelled]) {
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'published');
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
|
||||||
|
}
|
||||||
|
const rows = await service.list(1);
|
||||||
|
const delay = rows.find(row => row.message_id === delayed.messageId);
|
||||||
|
const cancellation = rows.find(row => row.message_id === cancelled.messageId);
|
||||||
|
assert.deepEqual(delay.reasons, []);
|
||||||
|
assert.deepEqual(cancellation.reasons, []);
|
||||||
|
assert.equal(delay.parsed.alertType, 'delay');
|
||||||
|
assert.equal(cancellation.parsed.alertType, 'cancellation');
|
||||||
|
assert.equal(delay.alert_ids.length, 1);
|
||||||
|
assert.equal(cancellation.alert_ids.length, 1);
|
||||||
|
assert.match(delay.drafts[0].title, /Задержка/);
|
||||||
|
assert.match(delay.drafts[0].message, /Мариненко — 19:05\nБоровуха — 20:00/);
|
||||||
|
assert.match(cancellation.drafts[0].message, /Мариненко — 06:00, 07:30\nБоровуха — 06:40, 08:15/);
|
||||||
|
const alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY id')).rows;
|
||||||
|
assert.deepEqual(alerts.map(alert => alert.alert_type), ['delay', 'cancellation']);
|
||||||
|
assert.ok(alerts.every(alert => alert.end_time.toISOString() === '2026-10-08T21:00:00.000Z'));
|
||||||
|
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_message_alerts')).rows[0].n, 2);
|
||||||
|
} finally { await db.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a cancellation into a delay updates its type and content on the same delivered alert', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool, service } = await setupRoute4();
|
||||||
|
try {
|
||||||
|
const input = await route4Message(1802, 'telegram-stop-point-cancellation.txt');
|
||||||
|
await service.processMessage(input, 1);
|
||||||
|
const row = (await service.list(1))[0];
|
||||||
|
const id = row.alert_ids[0];
|
||||||
|
await pool.query('UPDATE alerts SET notification_sent_at=now() WHERE id=$1', [id]);
|
||||||
|
const delay = await route4Message(1802);
|
||||||
|
await service.processMessage({ ...input, text: delay.text, editedAt: '2026-10-08T16:15:00Z' }, 1);
|
||||||
|
const updated = (await pool.query('SELECT * FROM alerts WHERE id=$1', [id])).rows[0];
|
||||||
|
assert.equal(updated.alert_type, 'delay');
|
||||||
|
assert.match(updated.title, /Задержка/);
|
||||||
|
assert.match(updated.message, /Мариненко — 19:05\nБоровуха — 20:00/);
|
||||||
|
assert.ok(updated.notification_sent_at, 'the delivered push is not repeated');
|
||||||
|
assert.deepEqual((await service.list(1))[0].alert_ids, [id]);
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'unchanged', 'older source cannot restore the cancellation');
|
||||||
|
} finally { await db.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual Telegram review defaults to delay, permits an explicit type choice, and rejects unrelated types', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool, service } = await setupRoute4();
|
||||||
|
service.config = { ...config, autoPublish: false };
|
||||||
|
try {
|
||||||
|
const input = await route4Message(1803);
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'review');
|
||||||
|
const row = (await service.list(1))[0];
|
||||||
|
const values = { action: 'publish', content_hash: row.content_hash, service_date: '2026-10-08', route_numbers: ['4'], ...row.drafts[0] };
|
||||||
|
await assert.rejects(service.review(row.id, 1, { ...values, alert_type: 'info' }, 1), error => error.status === 400);
|
||||||
|
await service.review(row.id, 1, values, 1);
|
||||||
|
const saved = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true')).rows[0];
|
||||||
|
assert.equal(saved.alert_type, 'delay');
|
||||||
|
assert.equal(saved.end_time.toISOString(), '2026-10-08T21:00:00.000Z');
|
||||||
|
await service.review(row.id, 1, { ...values, alert_type: 'cancellation', title: 'Уточнённая отмена', message: 'Уточнённый текст' }, 1);
|
||||||
|
assert.equal((await pool.query('SELECT alert_type FROM alerts WHERE id=$1', [saved.id])).rows[0].alert_type, 'cancellation');
|
||||||
|
assert.equal((await service.list(1))[0].parsed.alertType, 'cancellation');
|
||||||
|
} finally { await db.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('past screenshot messages become expired on parser upgrade and cannot send a new push after midnight', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool, service, clock } = await setupRoute4();
|
||||||
|
clock('2026-10-08T21:00:00Z');
|
||||||
|
try {
|
||||||
|
for (const [id, fixture] of [[1804, 'telegram-delay-route-4.txt'], [1805, 'telegram-stop-point-cancellation.txt']]) {
|
||||||
|
const input = await route4Message(id, fixture);
|
||||||
|
await seedLegacyReview(pool, input, { status: 'review', groups: [] }, ['Старая ошибка']);
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'expired');
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
|
||||||
|
}
|
||||||
|
const rows = await service.list(1);
|
||||||
|
assert.ok(rows.every(row => row.status === 'expired' && row.alert_ids.length === 0));
|
||||||
|
assert.ok(rows.every(row => row.parsed.serviceDate === '2026-10-08'));
|
||||||
|
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
|
||||||
|
const delay = rows.find(row => row.parsed.alertType === 'delay');
|
||||||
|
await assert.rejects(service.review(delay.id, 1, { action: 'publish', content_hash: delay.content_hash, service_date: '2026-10-08', route_numbers: ['4'], title: 'Опоздание', message: 'Текст' }, 1), error => error.status === 410);
|
||||||
|
} finally { await db.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parser upgrade refreshes ambiguous delay review once and does not auto-publish attachments', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool, service } = await setupRoute4();
|
||||||
|
try {
|
||||||
|
const input = { ...await route4Message(1806), hasMedia: true };
|
||||||
|
await seedLegacyReview(pool, input, { status: 'ignored', groups: [] }, ['Отмена не распознана']);
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'review');
|
||||||
|
const row = (await service.list(1))[0];
|
||||||
|
assert.equal(row.parsed.alertType, 'delay');
|
||||||
|
assert.match(row.reasons[0], /вложение/);
|
||||||
|
assert.equal(row.alert_ids.length, 0);
|
||||||
|
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
|
||||||
|
await service.review(row.id, 1, { action: 'dismiss', content_hash: row.content_hash }, 1);
|
||||||
|
assert.equal((await service.processMessage({ ...input, hasMedia: false, editedAt: '2026-10-08T16:16:00Z' }, 1)).status, 'dismissed');
|
||||||
|
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
|
||||||
|
} finally { await db.close(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
# Отмены рейсов из @avp2Polotsk
|
# Отмены и опоздания рейсов из @avp2Polotsk
|
||||||
|
|
||||||
Первый этап читает публичный канал через **отдельный существующий Telegram-аккаунт** и MTProto-клиент `teleproto`. Права управления каналом и Bot API не нужны. Получение выполняется раз в минуту; просматриваются публикации за текущий и два предыдущих дня, включая правки. Вложения не скачиваются. Расписания рейсов не изменяются: импорт создаёт предупреждения существующего типа `cancellation`.
|
Первый этап читает публичный канал через **отдельный существующий Telegram-аккаунт** и MTProto-клиент `teleproto`. Права управления каналом и Bot API не нужны. Получение выполняется раз в минуту; просматриваются публикации за текущий и два предыдущих дня, включая правки. Вложения не скачиваются. Расписания рейсов не изменяются: импорт создаёт предупреждения существующих типов `cancellation` (отмена) и `delay` (опоздание).
|
||||||
|
|
||||||
## Распознавание и срок действия
|
## Распознавание и срок действия
|
||||||
|
|
||||||
Формат «Не будут выполняться рейсы: ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43. С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.» разбирается в четыре конкретных отправления. `АВ/ВОКЗАЛ` означает **Автовокзал**, точки в часах заменяются двоеточием. Отмена всего маршрута из такого объявления не выводится.
|
Формат «Не будут выполняться рейсы: ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43. С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.» разбирается в четыре конкретных отправления. `АВ/ВОКЗАЛ` означает **Автовокзал**, точки в часах заменяются двоеточием. Отмена всего маршрута из такого объявления не выводится.
|
||||||
|
|
||||||
Поддерживаются формулировки **«С ОСТАНОВКИ»** и **«ОТ ОСТАНОВКИ»**, включая их сочетание в одном сообщении. Значения вроде `на 7.15` и `на 8.00` после остановки считаются временем отправления. Если отдельная дата не указана, день отмены берётся из оригинальной публикации. После обновления backend ранее сохранённые сообщения с ошибкой «Некорректная дата отмены» будут обработаны повторно при очередном чтении канала в пределах трёхдневного окна; сообщения с неизвестными остановками или номерами маршрутов останутся на проверке с уточнённой причиной.
|
Поддерживаются формулировки **«С ОСТАНОВКИ»**, **«ОТ ОСТАНОВКИ»** и **«С/ОТ ОСТАНОВОЧНОГО ПУНКТА»**, включая их сочетание в одном сообщении. После общего заголовка можно перечислять остановки в кавычках на отдельных строках, например `«Мариненко» на 6.00, 7.30` и `«Боровуха» на 6.40, 8.15`. Заголовок может отделяться от списка пустыми строками. Значения вроде `на 7.15` и `на 8.00` после остановки считаются временем отправления. Если отдельная дата не указана, день отмены берётся из оригинальной публикации. После обновления backend ранее сохранённые сообщения с ошибкой «Некорректная дата отмены» будут обработаны повторно при очередном чтении канала в пределах трёхдневного окна; сообщения с неизвестными остановками или номерами маршрутов останутся на проверке с уточнённой причиной.
|
||||||
|
|
||||||
Номер маршрута относится ко всем его действующим направлениям в выбранном городе. Один пост создаёт одно уведомление, в том числе при нескольких номерах. Поддерживаются отдельные блоки «по маршруту №…» и общий список «по маршрутам №2, №27» с явно указанными остановками и временами. Неизвестные номера и остановки, разные даты, условия, периоды и изображения без текста требуют проверки администратором. Неизвестный номер блокирует автоматическую публикацию всего поста. Обычные новости не импортируются. Пока нет OCR, распознавания произвольных формулировок и автоматического редактирования расписаний.
|
Номер маршрута относится ко всем его действующим направлениям в выбранном городе. Один пост создаёт одно уведомление, в том числе при нескольких номерах. Поддерживаются отдельные блоки «по маршруту №…» и общий список «по маршрутам №2, №27» с явно указанными остановками и временами. Неизвестные номера и остановки, разные даты, условия, периоды и изображения без текста требуют проверки администратором. Неизвестный номер блокирует автоматическую публикацию всего поста. Обычные новости не импортируются. Пока нет OCR, распознавания произвольных формулировок и автоматического редактирования расписаний.
|
||||||
|
|
||||||
Явная дата, «сегодня» или «завтра» определяют день отмены. Если дата отсутствует, используется **день оригинальной публикации по Europe/Minsk**, а не день импорта или правки. Дата без года выбирается ближайшей к публикации. Конец действия — **00:00 следующего дня по Минску**, граница исключительная. Например, для 02.10.2026 это `2026-10-02T21:00:00Z`. Будущие отмены активируются в начале указанного дня и получают push тогда же. Просроченные объявления сохраняются как завершённые без push.
|
Явная дата, «сегодня» или «завтра» определяют день отмены. Если дата отсутствует, используется **день оригинальной публикации по Europe/Minsk**, а не день импорта или правки. Дата без года выбирается ближайшей к публикации. Конец действия — **00:00 следующего дня по Минску**, граница исключительная. Например, для 02.10.2026 это `2026-10-02T21:00:00Z`. Будущие отмены активируются в начале указанного дня и получают push тогда же. Просроченные объявления сохраняются как завершённые без push.
|
||||||
|
|
||||||
Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Отмены из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор выбирает один или несколько номеров маршрутов, проверяет текст и день отмены; срок до полуночи назначается сервером.
|
Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Уведомления из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор выбирает один или несколько номеров маршрутов, проверяет текст и день отмены; срок до полуночи назначается сервером.
|
||||||
|
|
||||||
### Словарь остановок и текст уведомления
|
### Словарь остановок и текст уведомления
|
||||||
|
|
||||||
В блоке **«Отмены из Telegram» → «Словарь названий остановок»** можно добавлять, изменять и удалять соответствия: название из канала → конкретная остановка из каталога выбранного города. Например, `ЗОДЧЕГО` → `Зодчего Иоанна`. Для разных сокращений одной остановки создайте несколько записей. Регистр, пробелы, знаки препинания и различие `ё/е` не учитываются; два эквивалентных сокращения в одном городе запрещены. Если есть несколько платформ с одним названием, выберите нужную остановку по адресу/ID. Отключённая остановка требует исправления соответствия и ручной проверки.
|
В блоке **«Уведомления из Telegram» → «Словарь названий остановок»** можно добавлять, изменять и удалять соответствия: название из канала → конкретная остановка из каталога выбранного города. Например, `ЗОДЧЕГО` → `Зодчего Иоанна`. Для разных сокращений одной остановки создайте несколько записей. Регистр, пробелы, знаки препинания и различие `ё/е` не учитываются; два эквивалентных сокращения в одном городе запрещены. Если есть несколько платформ с одним названием, выберите нужную остановку по адресу/ID. Отключённая остановка требует исправления соответствия и ручной проверки.
|
||||||
|
|
||||||
Миграция **014** добавляет словарь и начальные соответствия `АВ/ВОКЗАЛ` → `Автовокзал`, `ЗОДЧЕГО` → `Зодчего Иоанна` только для Полоцка и только при единственной действующей остановке с таким названием. При неоднозначности соответствие задаёт администратор. Уже сохранённые соответствия при повторном запуске миграции не перезаписываются.
|
Миграция **014** добавляет словарь и начальные соответствия `АВ/ВОКЗАЛ` → `Автовокзал`, `ЗОДЧЕГО` → `Зодчего Иоанна` только для Полоцка и только при единственной действующей остановке с таким названием. При неоднозначности соответствие задаёт администратор. Уже сохранённые соответствия при повторном запуске миграции не перезаписываются.
|
||||||
|
|
||||||
@@ -28,10 +28,9 @@
|
|||||||
02.10.2026: не выполняются рейсы:
|
02.10.2026: не выполняются рейсы:
|
||||||
Автовокзал — 15:43, 16:43
|
Автовокзал — 15:43, 16:43
|
||||||
Зодчего Иоанна — 15:58, 16:58
|
Зодчего Иоанна — 15:58, 16:58
|
||||||
Источник: https://t.me/avp2Polotsk/1166
|
|
||||||
```
|
```
|
||||||
|
|
||||||
При нескольких номерах создаётся одно уведомление с блоком отправлений для каждого номера. Исходный текст остаётся в истории импорта. В ручной проверке распознанный текст также подставляется в удобочитаемом виде; выбор номеров подставляет соответствующие отправления, пока оператор не изменил текст самостоятельно. Если сообщение не удалось разобрать, текст для ручной правки остаётся исходным.
|
При нескольких номерах создаётся одно уведомление с блоком отправлений для каждого номера. Исходный текст остаётся в истории импорта. В ручной проверке можно выбрать тип «Отмена рейсов» или «Опоздание рейсов». Распознанный текст также подставляется в удобочитаемом виде; выбор номеров подставляет соответствующие отправления, пока оператор не изменил текст самостоятельно. Если сообщение не удалось разобрать, текст для ручной правки остаётся исходным.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ Compose монтирует каталог только для чтения, от
|
|||||||
|
|
||||||
Идентичность публикации: город + числовой ID канала + ID сообщения. Снимок текста, результат распознавания и связи с алертами сохраняются одной транзакцией; повторный импорт и одновременные читатели не создают второй алерт. Более старая правка не заменяет свежую. Администратор публикует только проверенную версию по `content_hash`; изменившийся текст даёт `409` и требует нового просмотра.
|
Идентичность публикации: город + числовой ID канала + ID сообщения. Снимок текста, результат распознавания и связи с алертами сохраняются одной транзакцией; повторный импорт и одновременные читатели не создают второй алерт. Более старая правка не заменяет свежую. Администратор публикует только проверенную версию по `content_hash`; изменившийся текст даёт `409` и требует нового просмотра.
|
||||||
|
|
||||||
Правка публикации обновляет связанные алерты. Если новая версия неоднозначна или больше не распознаётся как отмена, прежние алерты выключаются и сообщение возвращается на проверку. Отклонение сохраняется при последующих правках. Удалённый администратором алерт не воссоздаётся при правке исходного сообщения. Автоудаление сообщения в Telegram не отменяет предупреждение раньше полуночи; удаление не трактуется как восстановление движения.
|
Правка публикации обновляет связанные алерты. Если новая версия неоднозначна или больше не распознаётся как отмена/опоздание, прежние алерты выключаются и сообщение возвращается на проверку. Отклонение сохраняется при последующих правках. Удалённый администратором алерт не воссоздаётся при правке исходного сообщения. Автоудаление сообщения в Telegram не отменяет предупреждение раньше полуночи; удаление не трактуется как восстановление движения.
|
||||||
|
|
||||||
Импорт помечает алерты как `notification_pending` в той же транзакции. Существующий scheduler отправляет их после начала действия, использует блокировки и до пяти попыток; Firebase отключён — ожидание сохраняется. Уже доставленный push при правке того же алерта не отправляется повторно. Остаётся обычное окно между приёмом FCM и записью результата в БД: при аварии в этот момент возможен повтор доставки. `alertId` позволяет клиенту показывать один экземпляр уведомления.
|
Импорт помечает алерты как `notification_pending` в той же транзакции. Существующий scheduler отправляет их после начала действия, использует блокировки и до пяти попыток; Firebase отключён — ожидание сохраняется. Уже доставленный push при правке того же алерта не отправляется повторно. Остаётся обычное окно между приёмом FCM и записью результата в БД: при аварии в этот момент возможен повтор доставки. `alertId` позволяет клиенту показывать один экземпляр уведомления.
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ FCM TTL ограничен остатком срока действия алер
|
|||||||
|
|
||||||
- `GET /api/v1/telegram-import` — безопасный статус и до 100 сообщений, сначала требующие проверки.
|
- `GET /api/v1/telegram-import` — безопасный статус и до 100 сообщений, сначала требующие проверки.
|
||||||
- `POST /api/v1/telegram-import/sync` — чтение настроенного канала; запрещено в другом городе.
|
- `POST /api/v1/telegram-import/sync` — чтение настроенного канала; запрещено в другом городе.
|
||||||
- `POST /api/v1/telegram-import/:id/review` — `content_hash`, `action: publish|dismiss`; для публикации также `service_date`, `route_numbers: ["2", "27"]`, `title`, `message`; старое поле `route_id` также принимается.
|
- `POST /api/v1/telegram-import/:id/review` — `content_hash`, `action: publish|dismiss`; для публикации также `service_date`, `route_numbers: ["2", "27"]`, `title`, `message`; необязательное `alert_type: cancellation|delay` (по умолчанию распознанный тип); старое поле `route_id` также принимается.
|
||||||
- `GET /api/v1/telegram-import/stop-aliases` — словарь выбранного города.
|
- `GET /api/v1/telegram-import/stop-aliases` — словарь выбранного города.
|
||||||
- `POST /api/v1/telegram-import/stop-aliases` — `alias`, `stop_id`; добавить соответствие.
|
- `POST /api/v1/telegram-import/stop-aliases` — `alias`, `stop_id`; добавить соответствие.
|
||||||
- `PUT /api/v1/telegram-import/stop-aliases/:id` — `alias`, `stop_id`; изменить соответствие.
|
- `PUT /api/v1/telegram-import/stop-aliases/:id` — `alias`, `stop_id`; изменить соответствие.
|
||||||
@@ -134,3 +133,21 @@ TELEGRAM_TEST_PGLITE_MODULE=/tmp/polotsk-telegram-test-runtime/node_modules/@ele
|
|||||||
```
|
```
|
||||||
|
|
||||||
Реальное чтение Telegram и доставка на устройство проверяются после локальной авторизации аккаунта, применения миграции и включения Firebase.
|
Реальное чтение Telegram и доставка на устройство проверяются после локальной авторизации аккаунта, применения миграции и включения Firebase.
|
||||||
|
|
||||||
|
## Опоздания и обновление распознавания
|
||||||
|
|
||||||
|
Сообщения «будут выполняться рейсы с опозданием/с задержкой» распознаются как `delay`. Формулировки «могут выполняться/прибыть/прийти с опозданием» сохраняют неопределённость в тексте: «возможны опоздания рейсов». Время после названия остановки — время соответствующего рейса, а не обещанное время прибытия. Длительность опоздания автоматически не придумывается. Если в исходном тексте указана величина задержки, сообщение отправляется на ручную проверку, чтобы она не потерялась. Сообщения с отменами и опозданиями одновременно также требуют проверки.
|
||||||
|
|
||||||
|
Пример автоматически подготовленного уведомления:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Задержка отдельных рейсов №4
|
||||||
|
|
||||||
|
08.10.2026: с опозданием выполняются рейсы:
|
||||||
|
Мариненко — 19:05
|
||||||
|
Боровуха — 20:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Срок действия для опозданий совпадает с отменами: до 00:00 следующего дня по Минску. Истёкшие сообщения не создают новые алерты и не попадают в push-рассылку. Правка источника с отмены на опоздание обновляет тип существующего алерта; уже отправленный push не повторяется.
|
||||||
|
|
||||||
|
Для этого обновления новые миграции не нужны, если ранее применены 013–015. Пересоберите `backend frontend`: `docker compose up -d --build backend frontend`. При следующем чтении канала сообщения на проверке будут пересчитаны в пределах трёхдневного окна, даже без правки исходного текста. Сообщения за 8 октября при повторной обработке 9 октября получат статус «День уведомления завершён» и не вызовут новую рассылку. Уже опубликованные и отклонённые сообщения с неизменившимся исходным текстом не переписываются. Неизвестные названия остановок по-прежнему требуют пополнения словаря.
|
||||||
|
|||||||
@@ -63,19 +63,19 @@ export default function TelegramImportPanel({ routes, onChanged }) {
|
|||||||
const pending = data?.messages.filter(m => m.status === 'review') || [];
|
const pending = data?.messages.filter(m => m.status === 'review') || [];
|
||||||
const rows = showHistory ? data?.messages || [] : pending;
|
const rows = showHistory ? data?.messages || [] : pending;
|
||||||
return (
|
return (
|
||||||
<section className="card mb-6" aria-label="Отмены из Telegram">
|
<section className="card mb-6" aria-label="Уведомления из Telegram">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Отмены из Telegram</h2>
|
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Уведомления из Telegram</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{status ? configuredCity ? connectionLabels[status.connection] : 'Для выбранного города источник не настроен' : 'Загрузка источника…'}
|
{status ? configuredCity ? connectionLabels[status.connection] : 'Для выбранного города источник не настроен' : 'Загрузка источника…'}
|
||||||
{status && <> · @{status.channel}</>}
|
{status && <> · @{status.channel}</>}
|
||||||
</p>
|
</p>
|
||||||
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Отмены будут поступать автоматически.</p>}
|
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Сообщения об отменах и опозданиях будут поступать автоматически.</p>}
|
||||||
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные отмены публикуются автоматически' : 'Публикация после проверки'}</p>}
|
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные сообщения публикуются автоматически' : 'Публикация после проверки'}</p>}
|
||||||
{status?.lastError && <p className="text-sm text-amber-700 mt-2">{errorLabels[status.lastError] || 'Не удалось подключиться к Telegram.'}</p>}
|
{status?.lastError && <p className="text-sm text-amber-700 mt-2">{errorLabels[status.lastError] || 'Не удалось подключиться к Telegram.'}</p>}
|
||||||
{status?.retryAt && <p className="text-xs text-gray-500 mt-1">Следующая попытка не раньше: {formatTime(status.retryAt)}</p>}
|
{status?.retryAt && <p className="text-xs text-gray-500 mt-1">Следующая попытка не раньше: {formatTime(status.retryAt)}</p>}
|
||||||
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи дня отмены по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
|
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи указанного дня по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn-secondary flex items-center justify-center shrink-0" onClick={sync} disabled={busy || !configuredCity || !status?.enabled || status?.missing.length > 0 || status?.syncing}>
|
<button type="button" className="btn-secondary flex items-center justify-center shrink-0" onClick={sync} disabled={busy || !configuredCity || !status?.enabled || status?.missing.length > 0 || status?.syncing}>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${busy || status?.syncing ? 'animate-spin' : ''}`} />Проверить канал
|
<RefreshCw className={`h-4 w-4 mr-2 ${busy || status?.syncing ? 'animate-spin' : ''}`} />Проверить канал
|
||||||
@@ -92,7 +92,7 @@ export default function TelegramImportPanel({ routes, onChanged }) {
|
|||||||
{rows.map(row => (
|
{rows.map(row => (
|
||||||
<div key={row.id} className="mt-3 rounded-lg border border-gray-200 p-3">
|
<div key={row.id} className="mt-3 rounded-lg border border-gray-200 p-3">
|
||||||
<div className="flex flex-wrap justify-between gap-2 text-xs text-gray-500">
|
<div className="flex flex-wrap justify-between gap-2 text-xs text-gray-500">
|
||||||
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День отмены завершён' }[row.status]}</span>
|
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День уведомления завершён' }[row.status]}</span>
|
||||||
<a href={row.source_url} target="_blank" rel="noreferrer" className="text-primary-600 inline-flex items-center gap-1">Оригинал<ExternalLink className="h-3 w-3" /></a>
|
<a href={row.source_url} target="_blank" rel="noreferrer" className="text-primary-600 inline-flex items-center gap-1">Оригинал<ExternalLink className="h-3 w-3" /></a>
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap break-words text-sm text-gray-700 mt-2">{row.raw_text || 'Вложение без текста — откройте оригинал.'}</p>
|
<p className="whitespace-pre-wrap break-words text-sm text-gray-700 mt-2">{row.raw_text || 'Вложение без текста — откройте оригинал.'}</p>
|
||||||
@@ -105,7 +105,7 @@ export default function TelegramImportPanel({ routes, onChanged }) {
|
|||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
|
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах и опозданиях пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{editing && <TelegramReviewForm key={editing.id} row={editing} routes={routes} busy={busy} onCancel={() => setEditing(null)} onSubmit={values => review(editing, values)} />}
|
{editing && <TelegramReviewForm key={editing.id} row={editing} routes={routes} busy={busy} onCancel={() => setEditing(null)} onSubmit={values => review(editing, values)} />}
|
||||||
@@ -119,40 +119,49 @@ function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) {
|
|||||||
}).format(new Date(row.published_at));
|
}).format(new Date(row.published_at));
|
||||||
const initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || [])
|
const initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || [])
|
||||||
.map(normalizeRouteNumber))];
|
.map(normalizeRouteNumber))];
|
||||||
const draftFor = (numbers, date) => {
|
const initialType = row.parsed?.alertType || 'cancellation';
|
||||||
|
const draftFor = (numbers, date, alertType) => {
|
||||||
|
const delay = alertType === 'delay';
|
||||||
|
const possible = delay && row.parsed?.delayPossible;
|
||||||
|
const prefix = delay ? possible ? 'Возможное опоздание' : 'Задержка' : 'Отмена';
|
||||||
|
const description = delay ? possible ? 'возможны опоздания рейсов' : 'с опозданием выполняются рейсы' : 'не выполняются рейсы';
|
||||||
const drafts = numbers.map(number => row.drafts?.find(d => normalizeRouteNumber(d.routeNumber) === number));
|
const drafts = numbers.map(number => row.drafts?.find(d => normalizeRouteNumber(d.routeNumber) === number));
|
||||||
const complete = drafts.length && drafts.every(Boolean);
|
const complete = drafts.length && drafts.every(Boolean);
|
||||||
return {
|
return {
|
||||||
title: `Отмена отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
|
title: `${prefix} отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
|
||||||
message: complete ? `${date.split('-').reverse().join('.')}: не выполняются рейсы:\n${drafts.map((d, i) =>
|
message: complete ? `${date.split('-').reverse().join('.')}: ${description}:\n${drafts.map((d, i) =>
|
||||||
`${drafts.length > 1 ? `Маршрут №${numbers[i]}:\n` : ''}${d.message.replace(/^[^\n]*\n/, '')}`).join('\n\n')}` : row.raw_text.slice(0, 5000)
|
`${drafts.length > 1 ? `Маршрут №${numbers[i]}:\n` : ''}${d.message.replace(/^[^\n]*\n/, '')}`).join('\n\n')}` : row.raw_text.slice(0, 5000)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const initialDate = row.parsed?.serviceDate || publicationDate;
|
const initialDate = row.parsed?.serviceDate || publicationDate;
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
action: 'publish', service_date: initialDate, route_numbers: initialNumbers,
|
action: 'publish', alert_type: initialType, service_date: initialDate, route_numbers: initialNumbers,
|
||||||
...draftFor(initialNumbers, initialDate)
|
...draftFor(initialNumbers, initialDate, initialType)
|
||||||
});
|
});
|
||||||
const [editedText, setEditedText] = useState(false);
|
const [editedText, setEditedText] = useState(false);
|
||||||
const change = e => {
|
const change = e => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
if (name === 'title' || name === 'message') setEditedText(true);
|
if (name === 'title' || name === 'message') setEditedText(true);
|
||||||
setForm(current => ({ ...current, [name]: value,
|
setForm(current => ({ ...current, [name]: value,
|
||||||
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value) : {}) }));
|
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value, current.alert_type) : {}),
|
||||||
|
...(name === 'alert_type' && !editedText ? draftFor(current.route_numbers, current.service_date, value) : {}) }));
|
||||||
};
|
};
|
||||||
const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers,
|
const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers,
|
||||||
...(!editedText ? draftFor(route_numbers, current.service_date) : {}) }));
|
...(!editedText ? draftFor(route_numbers, current.service_date, current.alert_type) : {}) }));
|
||||||
return (
|
return (
|
||||||
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
|
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
|
||||||
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка отмены</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
|
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка уведомления</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
|
||||||
|
<label className="text-sm block">Тип уведомления<select className="input mt-1 w-full" name="alert_type" value={form.alert_type} onChange={change} disabled={busy}>
|
||||||
|
<option value="cancellation">Отмена рейсов</option><option value="delay">Опоздание рейсов</option>
|
||||||
|
</select></label>
|
||||||
<RouteNumberPicker routes={routes} value={form.route_numbers} onChange={changeRoutes} disabled={busy} required />
|
<RouteNumberPicker routes={routes} value={form.route_numbers} onChange={changeRoutes} disabled={busy} required />
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm">День отмены<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
|
<label className="text-sm">День уведомления<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
|
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
|
||||||
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
|
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
|
||||||
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
|
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
|
||||||
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy || !form.route_numbers.length}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
|
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy || !form.route_numbers.length}>Опубликовать уведомление</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user