feat: implement stop alias management for Telegram import
- Added TelegramStopAliasService for handling stop aliases in the database. - Created API endpoints for managing stop aliases (list, create, update, delete). - Introduced TelegramStopAliases component in the frontend for user interaction with stop aliases. - Updated TelegramImportService to utilize stop aliases for message processing. - Enhanced telegramCancellationParser to resolve stop names using aliases. - Added migration script to create telegram_stop_aliases table and seed initial data. - Updated documentation to include details about stop alias functionality.
This commit is contained in:
@@ -7,9 +7,22 @@ function createTelegramImportRouter(service = getTelegramImportService()) {
|
||||
router.use(authenticateToken, requireAdmin);
|
||||
const handle = action => async (req, res) => {
|
||||
try { res.json(await action(req)); }
|
||||
catch (error) { res.status(error.status || 500).json({ error: error.status ? error.message : 'Не удалось обработать импорт Telegram. Проверьте миграцию 013 и подключение базы.' }); }
|
||||
catch (error) { res.status(error.status || 500).json({ error: error.status ? error.message : 'Не удалось обработать импорт Telegram. Проверьте миграции 013–014 и подключение базы.' }); }
|
||||
};
|
||||
const aliasId = req => {
|
||||
const id = Number(req.params.id);
|
||||
if (!/^\d+$/.test(req.params.id) || !Number.isSafeInteger(id) || id <= 0) {
|
||||
const error = new Error('Некорректный идентификатор соответствия.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
router.get('/', handle(async req => ({ status: service.getStatus(), messages: await service.list(req.city.id) })));
|
||||
router.get('/stop-aliases', handle(req => service.stopAliases.list(req.city.id)));
|
||||
router.post('/stop-aliases', handle(req => service.stopAliases.save(req.city.id, req.body)));
|
||||
router.put('/stop-aliases/:id', handle(req => service.stopAliases.save(req.city.id, req.body, aliasId(req))));
|
||||
router.delete('/stop-aliases/:id', handle(req => service.stopAliases.remove(aliasId(req), req.city.id)));
|
||||
router.post('/sync', handle(req => {
|
||||
if (req.city.slug !== service.config.citySlug) {
|
||||
const error = new Error('Источник Telegram настроен для другого города.');
|
||||
|
||||
@@ -61,13 +61,16 @@ function parseDate(text, publishedAt) {
|
||||
}
|
||||
|
||||
function normalizeRoute(value) { return String(value).trim().toLowerCase(); }
|
||||
function normalizeStopKey(value) {
|
||||
return String(value || '').toLowerCase().replace(/ё/g, 'е').replace(/[^\p{L}\p{N}]/gu, '');
|
||||
}
|
||||
function normalizeStop(value) {
|
||||
const key = String(value).toLowerCase().replace(/ё/g, 'е').replace(/[^а-яa-z0-9]/g, '');
|
||||
const key = normalizeStopKey(value);
|
||||
return key === 'аввокзал' ? 'автовокзал' : key;
|
||||
}
|
||||
function canonicalStop(value) { return normalizeStop(value) === 'автовокзал' ? 'Автовокзал' : value; }
|
||||
|
||||
function parseCancellation({ text = '', publishedAt, hasMedia = false }) {
|
||||
function parseCancellation({ text = '', publishedAt, hasMedia = false }, { preserveSourceStops = false } = {}) {
|
||||
const normalized = text.replace(/\u00a0/g, ' ').replace(/\r/g, '').trim();
|
||||
let serviceDate;
|
||||
const review = reason => ({ status: 'review', reasons: [reason], groups: [], ...(serviceDate ? { serviceDate } : {}) });
|
||||
@@ -99,7 +102,7 @@ function parseCancellation({ text = '', publishedAt, hasMedia = false }) {
|
||||
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();
|
||||
if (!stopName || !times.length) return review('Не удалось определить остановку и время.');
|
||||
departures.push({ stop: canonicalStop(stopName), times: [...new Set(times)] });
|
||||
departures.push({ stop: canonicalStop(stopName), times: [...new Set(times)], ...(preserveSourceStops ? { sourceStop: stopName } : {}) });
|
||||
}
|
||||
if (!departures.length || [...section.matchAll(TIME)].length !== departures.reduce((n, d) => n + d.times.length, 0)) {
|
||||
return review('Не удалось однозначно связать все времена с остановками.');
|
||||
@@ -109,12 +112,32 @@ function parseCancellation({ text = '', publishedAt, hasMedia = false }) {
|
||||
return { status: 'ready', reasons: [], serviceDate: parsedDate.date, inferredDate: parsedDate.inferred, groups };
|
||||
}
|
||||
|
||||
function resolveCancellationStops(parsed, stops, aliases) {
|
||||
const byId = new Map(stops.map(stop => [Number(stop.id), stop]));
|
||||
const byAlias = new Map(aliases.map(alias => [alias.normalized_alias, alias]));
|
||||
const groups = parsed.groups.map(group => ({ ...group, departures: group.departures.map(departure => {
|
||||
const sourceStop = departure.sourceStop || departure.stop;
|
||||
const alias = byAlias.get(normalizeStopKey(sourceStop));
|
||||
const matches = alias ? [byId.get(Number(alias.stop_id))].filter(Boolean)
|
||||
: stops.filter(stop => normalizeStop(stop.name) === normalizeStop(sourceStop));
|
||||
const stop = matches.length === 1 ? matches[0] : null;
|
||||
const readable = departure.stop === departure.stop.toUpperCase()
|
||||
? departure.stop.charAt(0).toUpperCase() + departure.stop.slice(1).toLowerCase() : departure.stop;
|
||||
return { ...departure, sourceStop, stop: stop?.name || readable,
|
||||
...(stop ? { stopId: Number(stop.id) } : {}),
|
||||
...(alias && !stop ? { invalidAlias: true } : {}) };
|
||||
}) }));
|
||||
return { ...parsed, groups };
|
||||
}
|
||||
|
||||
function matchCancellationRoutes(parsed, routes) {
|
||||
const plans = new Map();
|
||||
for (const group of parsed.groups) {
|
||||
const candidates = routes.filter(r => normalizeRoute(r.route_number) === group.routeNumber);
|
||||
for (const departure of group.departures) {
|
||||
const matches = candidates.length === 1 ? candidates : candidates.filter(r => normalizeStop(r.origin_stop || '') === normalizeStop(departure.stop));
|
||||
if (departure.invalidAlias) return { reasons: [`Остановка для «${departure.sourceStop}» отключена. Обновите словарь названий.`], plans: [] };
|
||||
const matches = candidates.length === 1 ? candidates : candidates.filter(r => departure.stopId
|
||||
? Number(r.origin_stop_id) === departure.stopId : normalizeStop(r.origin_stop || '') === normalizeStop(departure.stop));
|
||||
if (matches.length !== 1) return { reasons: [`Маршрут №${group.routeNumber}, остановка «${departure.stop}»: направление не определено однозначно.`], plans: [] };
|
||||
const route = matches[0];
|
||||
if (!plans.has(route.id)) plans.set(route.id, { routeId: route.id, routeNumber: route.route_number, departures: [] });
|
||||
@@ -124,16 +147,23 @@ function matchCancellationRoutes(parsed, routes) {
|
||||
return { reasons: [], plans: [...plans.values()] };
|
||||
}
|
||||
|
||||
function buildCancellationDraft(plan, serviceDate) {
|
||||
return {
|
||||
title: `Отмена отдельных рейсов №${plan.routeNumber}`,
|
||||
message: `${serviceDate.split('-').reverse().join('.')}: не выполняются рейсы:\n${plan.departures.map(d => `${d.stop} — ${d.times.join(', ')}`).join('\n')}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildCancellationAlert(plan, serviceDate, sourceUrl, publishedAt, cityId) {
|
||||
const bounds = dayBounds(serviceDate);
|
||||
const dateLabel = serviceDate.split('-').reverse().join('.');
|
||||
const draft = buildCancellationDraft(plan, serviceDate);
|
||||
const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString();
|
||||
return {
|
||||
city_id: cityId, route_id: plan.routeId, alert_type: 'cancellation',
|
||||
title: `Отмена отдельных рейсов маршрута №${plan.routeNumber}`,
|
||||
message: `${dateLabel}: не выполняются отправления ${plan.departures.map(d => `с остановки «${d.stop}» в ${d.times.join(', ')}`).join('; ')}.\nИсточник: ${sourceUrl}`,
|
||||
...draft,
|
||||
message: `${draft.message}\nИсточник: ${sourceUrl}`,
|
||||
start_time: start, end_time: bounds.end, is_active: true
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseCancellation, matchCancellationRoutes, buildCancellationAlert, dayBounds, validDate, addDays };
|
||||
module.exports = { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, normalizeStopKey, dayBounds, validDate, addDays };
|
||||
|
||||
@@ -2,8 +2,9 @@ const crypto = require('node:crypto');
|
||||
const { getTelegramConfig, missingTelegramConfig } = require('../config/telegram');
|
||||
const { getLocalDateTime } = require('../utils/etaTime');
|
||||
const { TelegramSource } = require('./telegramSource');
|
||||
const { parseCancellation, matchCancellationRoutes, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser');
|
||||
const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser');
|
||||
const { AlertCreationService } = require('./alertCreationService');
|
||||
const { TelegramStopAliasService } = require('./telegramStopAliasService');
|
||||
|
||||
function httpError(status, message) { return Object.assign(new Error(message), { status }); }
|
||||
function sourceUrl(row) { return `https://t.me/${row.channel_username}/${row.message_id}`; }
|
||||
@@ -11,6 +12,19 @@ function contentHash(message) {
|
||||
return crypto.createHash('sha256').update(JSON.stringify([message.text, message.hasMedia, message.publishedAt])).digest('hex');
|
||||
}
|
||||
|
||||
function messageDrafts(parsed) {
|
||||
if (!validDate(parsed.serviceDate)) return [];
|
||||
const groups = new Map();
|
||||
for (const group of parsed.groups || []) {
|
||||
if (!groups.has(group.routeNumber)) groups.set(group.routeNumber, { routeNumber: group.routeNumber, departures: [] });
|
||||
groups.get(group.routeNumber).departures.push(...group.departures);
|
||||
}
|
||||
return [
|
||||
...(parsed.plans || []).map(plan => ({ routeId: plan.routeId, routeNumber: plan.routeNumber, ...buildCancellationDraft(plan, parsed.serviceDate) })),
|
||||
...[...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildCancellationDraft(group, parsed.serviceDate) }))
|
||||
];
|
||||
}
|
||||
|
||||
function publicError(error) {
|
||||
const known = ['AUTH_REQUIRED', 'USER_ACCOUNT_REQUIRED', 'SESSION_MISSING', 'SESSION_FILE_INVALID', 'SESSION_FILE_IN_CHECKOUT', 'SESSION_FILE_PERMISSIONS', 'CHANNEL_INVALID', 'CHANNEL_PRIVATE', 'USERNAME_NOT_OCCUPIED', 'HISTORY_LIMIT', 'AUTH_KEY_UNREGISTERED', 'SESSION_REVOKED'];
|
||||
const value = error.errorMessage || error.message;
|
||||
@@ -31,6 +45,7 @@ class TelegramImportService {
|
||||
this.stopped = false;
|
||||
this.state = { connection: config.enabled ? 'waiting' : 'disabled', lastSyncAt: null, lastError: null, retryAt: null };
|
||||
this.alerts = new AlertCreationService({ pool: this.pool, logger: this.logger });
|
||||
this.stopAliases = new TelegramStopAliasService(this.pool);
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
@@ -77,17 +92,23 @@ class TelegramImportService {
|
||||
}
|
||||
|
||||
async getRoutes(db, cityId) {
|
||||
return (await db.query(`SELECT r.id, r.route_number, r.name, origin.name AS origin_stop
|
||||
return (await db.query(`SELECT r.id, r.route_number, r.name, origin.name AS origin_stop, origin.id AS origin_stop_id
|
||||
FROM routes r LEFT JOIN LATERAL (
|
||||
SELECT s.name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id
|
||||
SELECT s.id, s.name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id AND s.city_id = r.city_id AND s.is_active=true
|
||||
WHERE rs.route_id = r.id ORDER BY rs.sequence LIMIT 1
|
||||
) origin ON true WHERE r.city_id = $1 AND r.is_active = true`, [cityId])).rows;
|
||||
) origin ON true WHERE r.city_id = $1 AND r.is_active = true ORDER BY r.id`, [cityId])).rows;
|
||||
}
|
||||
|
||||
async getStopContext(db, cityId) {
|
||||
const stops = (await db.query('SELECT id,name FROM stops WHERE city_id=$1 AND is_active=true ORDER BY id', [cityId])).rows;
|
||||
const aliases = await this.stopAliases.list(cityId, db);
|
||||
return { stops, aliases };
|
||||
}
|
||||
|
||||
async processMessage(message, cityId) {
|
||||
if (!Number.isSafeInteger(message.messageId) || message.messageId <= 0 || !/^\d+$/.test(message.channelId)
|
||||
|| message.channelUsername.toLowerCase() !== this.config.channel.toLowerCase()) throw new Error('CHANNEL_INVALID');
|
||||
const parsed = parseCancellation(message);
|
||||
let parsed = parseCancellation(message, { preserveSourceStops: true });
|
||||
const hash = contentHash(message);
|
||||
const db = await this.pool.connect();
|
||||
try {
|
||||
@@ -103,16 +124,29 @@ class TelegramImportService {
|
||||
WHERE city_id = $1 AND channel_id = $2 AND message_id = $3 FOR UPDATE`, [cityId, message.channelId, message.messageId])).rows[0];
|
||||
if (!row) { await db.query('COMMIT'); return { status: 'ignored' }; }
|
||||
const initialized = row.parsed && Object.keys(row.parsed).length > 0;
|
||||
if ((initialized && row.content_hash === hash) || (row.edited_at && (!message.editedAt || Date.parse(message.editedAt) < new Date(row.edited_at).getTime()))) {
|
||||
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()))) {
|
||||
await db.query('COMMIT'); return { status: 'unchanged' };
|
||||
}
|
||||
let routes = [];
|
||||
if (parsed.status === 'ready') {
|
||||
const context = await this.getStopContext(db, cityId);
|
||||
routes = await this.getRoutes(db, cityId);
|
||||
const resolutionHash = crypto.createHash('sha256').update(JSON.stringify([
|
||||
context.stops, context.aliases.map(a => [a.normalized_alias, a.stop_id]), routes, this.config.autoPublish
|
||||
])).digest('hex');
|
||||
if (sameContent && row.parsed.resolutionHash === resolutionHash && Date.parse(dayBounds(parsed.serviceDate).end) > this.now().getTime()) {
|
||||
await db.query('COMMIT'); return { status: 'unchanged' };
|
||||
}
|
||||
parsed = { ...resolveCancellationStops(parsed, context.stops, context.aliases), resolutionHash };
|
||||
}
|
||||
if (initialized) await this.deactivate(db, row.id);
|
||||
let status = row.status === 'dismissed' ? 'dismissed' : 'review';
|
||||
let reasons = parsed.reasons;
|
||||
let plans = [];
|
||||
if (parsed.status === 'ignored') reasons = ['Текст исходной публикации изменился: отмена больше не распознаётся.'];
|
||||
if (parsed.status === 'ready') {
|
||||
const matched = matchCancellationRoutes(parsed, await this.getRoutes(db, cityId));
|
||||
const matched = matchCancellationRoutes(parsed, routes);
|
||||
plans = matched.plans;
|
||||
reasons = matched.reasons;
|
||||
if (Date.parse(dayBounds(parsed.serviceDate).end) <= this.now().getTime()) status = status === 'dismissed' ? status : 'expired';
|
||||
@@ -156,6 +190,7 @@ class TelegramImportService {
|
||||
WHERE t.message_id=m.id AND t.alert_id IS NOT NULL), '[]') AS alert_ids
|
||||
FROM telegram_import_messages m WHERE m.city_id=$1 ORDER BY (m.status='review') DESC,m.published_at DESC,m.id DESC LIMIT 100`, [cityId])).rows.map(row => ({
|
||||
...row, source_url: sourceUrl(row),
|
||||
drafts: messageDrafts(row.parsed),
|
||||
status: row.status === 'published' && validDate(row.parsed.serviceDate) && Date.parse(dayBounds(row.parsed.serviceDate).end) <= this.now().getTime() ? 'expired' : row.status
|
||||
}));
|
||||
}
|
||||
|
||||
46
backend/src/services/telegramStopAliasService.js
Normal file
46
backend/src/services/telegramStopAliasService.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { normalizeStopKey } = require('./telegramCancellationParser');
|
||||
|
||||
function httpError(status, message) { return Object.assign(new Error(message), { status }); }
|
||||
|
||||
class TelegramStopAliasService {
|
||||
constructor(pool) { this.pool = pool || require('../config/database'); }
|
||||
|
||||
async list(cityId, db = this.pool) {
|
||||
return (await db.query(`SELECT a.*, s.name AS stop_name, s.address AS stop_address, s.is_active AS stop_active
|
||||
FROM telegram_stop_aliases a JOIN stops s ON s.id=a.stop_id AND s.city_id=a.city_id
|
||||
WHERE a.city_id=$1 ORDER BY a.alias,a.id`, [cityId])).rows;
|
||||
}
|
||||
|
||||
async save(cityId, input = {}, id = null) {
|
||||
const alias = typeof input.alias === 'string' ? input.alias.trim() : '';
|
||||
const key = normalizeStopKey(alias);
|
||||
const stopId = Number(input.stop_id);
|
||||
if (!alias || alias.length > 200 || !key || /[\r\n]/.test(alias)) throw httpError(400, 'Укажите название из канала (до 200 символов).');
|
||||
if (!Number.isSafeInteger(stopId) || stopId <= 0) throw httpError(400, 'Выберите остановку.');
|
||||
const db = await this.pool.connect();
|
||||
try {
|
||||
await db.query('BEGIN');
|
||||
const stop = (await db.query('SELECT id FROM stops WHERE id=$1 AND city_id=$2 AND is_active=true FOR KEY SHARE', [stopId, cityId])).rows[0];
|
||||
if (!stop) throw httpError(400, 'Выберите действующую остановку выбранного города.');
|
||||
const result = id === null
|
||||
? await db.query(`INSERT INTO telegram_stop_aliases(city_id,alias,normalized_alias,stop_id) VALUES ($1,$2,$3,$4) RETURNING id`, [cityId, alias, key, stopId])
|
||||
: await db.query(`UPDATE telegram_stop_aliases SET alias=$3,normalized_alias=$4,stop_id=$5,updated_at=now()
|
||||
WHERE id=$1 AND city_id=$2 RETURNING id`, [id, cityId, alias, key, stopId]);
|
||||
if (!result.rows.length) throw httpError(404, 'Соответствие не найдено.');
|
||||
await db.query('COMMIT');
|
||||
return { id: result.rows[0].id };
|
||||
} catch (error) {
|
||||
await db.query('ROLLBACK');
|
||||
if (error.code === '23505') throw httpError(409, 'Такое название уже есть в словаре. Измените существующее соответствие.');
|
||||
throw error;
|
||||
} finally { db.release(); }
|
||||
}
|
||||
|
||||
async remove(id, cityId) {
|
||||
const result = await this.pool.query('DELETE FROM telegram_stop_aliases WHERE id=$1 AND city_id=$2 RETURNING id', [id, cityId]);
|
||||
if (!result.rows.length) throw httpError(404, 'Соответствие не найдено.');
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TelegramStopAliasService };
|
||||
Reference in New Issue
Block a user