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:
2026-10-09 02:59:52 +03:00
parent c0d40f9b90
commit 249c338c2a
8 changed files with 390 additions and 69 deletions

View File

@@ -0,0 +1,5 @@
Уважаемые пассажиры!
будут выполняться рейсы с опозданием:
ПО МАРШРУТУ № 4 С ОСТАНОВКИ МАРИНЕНКО НА 19.05
С ОСТАНОВКИ БОРОВУХА НА 20.00

View File

@@ -0,0 +1,7 @@
Уважаемые пассажиры!
Не будут выполняться следующие рейсы:
По маршруту № 4 с остановочного пункта
"Мариненко" на 6.00, 7.30
"Боровуха" на 6.40, 8.15

View File

@@ -2,7 +2,7 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
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 text = `Уважаемые пассажиры!
@@ -128,8 +128,8 @@ test('dictionary selects the actual stop ID and uses its canonical name in conci
const matched = matchCancellationRoutes(parsed, routes);
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);
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nАвтовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166');
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/);
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nАвтовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58');
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ|Источник:/);
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'));
}
});
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 Мая');
});

View File

@@ -378,3 +378,128 @@ test('legacy per-direction Telegram alerts consolidate on edit without repeating
assert.match(alerts[0].message, /Зодчего Иоанна/);
} 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(); }
});