const crypto = require('node:crypto'); const { getTelegramConfig, missingTelegramConfig } = require('../config/telegram'); const { getLocalDateTime } = require('../utils/etaTime'); const { TelegramSource } = require('./telegramSource'); const { parseTransitNotice, resolveCancellationStops, matchCancellationRoutes, buildTransitNoticeDraft, buildTransitNoticeAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser'); const { AlertCreationService } = require('./alertCreationService'); const { TelegramStopAliasService } = require('./telegramStopAliasService'); 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 sourceUrl(row) { return `https://t.me/${row.channel_username}/${row.message_id}`; } 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 [...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildTransitNoticeDraft(group, parsed.serviceDate, noticeOptions(parsed)) })); } 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; if (known.includes(value)) return value; if (error.code === 'ENOENT') return 'SESSION_MISSING'; if (error.seconds) return 'FLOOD_WAIT'; return 'IMPORT_FAILED'; } class TelegramImportService { constructor({ pool, config = getTelegramConfig(), source, logger, now = () => new Date() } = {}) { this.pool = pool || require('../config/database'); this.config = config; this.source = source || new TelegramSource(config); this.logger = logger || require('../config/logger'); this.now = now; this.running = false; 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() { const missing = missingTelegramConfig(this.config); return { ...this.state, enabled: this.config.enabled, autoPublish: this.config.autoPublish, channel: this.config.channel, citySlug: this.config.citySlug, connection: !this.config.enabled ? 'disabled' : missing.length ? 'configuration_required' : this.state.connection, missing, syncing: this.running }; } async sync() { if (!this.config.enabled) throw httpError(409, 'Импорт Telegram выключен в настройках сервера.'); if (missingTelegramConfig(this.config).length) throw httpError(409, 'Укажите Telegram API ID, API hash и файл авторизации на сервере.'); if (this.running || this.stopped) throw httpError(409, 'Импорт уже выполняется или остановлен.'); if (this.state.retryAt && Date.parse(this.state.retryAt) > this.now().getTime()) throw httpError(429, 'Telegram ограничил частоту запросов. Дождитесь следующей попытки.'); this.running = true; this.state.connection = 'connecting'; try { const city = (await this.pool.query('SELECT id FROM cities WHERE slug = $1 AND is_active = true', [this.config.citySlug])).rows[0]; if (!city) throw httpError(409, 'Город источника отсутствует или отключён.'); const today = getLocalDateTime(this.now()).date; const messages = await this.source.fetchMessages(new Date(dayBounds(addDays(today, -2)).start)); const counts = { published: 0, review: 0, unchanged: 0, ignored: 0, expired: 0 }; for (const message of messages) { if (this.stopped) break; const result = await this.processMessage(message, city.id); counts[result.status] = (counts[result.status] || 0) + 1; } this.state = { connection: 'ready', lastSyncAt: this.now().toISOString(), lastError: null, retryAt: null }; return counts; } catch (error) { const code = publicError(error); const delay = error.seconds ? Math.min(86_400, Math.max(30, Number(error.seconds) || 60)) : 60; this.state.connection = 'error'; this.state.lastError = code; this.state.retryAt = new Date(this.now().getTime() + delay * 1000).toISOString(); this.logger.warn('Telegram channel import failed', { code }); await this.source.close().catch(() => {}); if (error.status) throw error; throw httpError(503, `Не удалось прочитать канал Telegram (${code}). Проверьте подключение и авторизацию.`); } finally { this.running = false; } } async getRoutes(db, cityId) { return (await db.query('SELECT id,route_number FROM routes WHERE city_id=$1 AND is_active=true ORDER BY 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'); let parsed = { ...parseTransitNotice(message, { preserveSourceStops: true }), parserVersion: PARSER_VERSION }; const hash = contentHash(message); const db = await this.pool.connect(); try { await db.query('BEGIN'); // The unique key and row lock serialize duplicate readers and concurrent reviews. if (parsed.status !== 'ignored') { await db.query(`INSERT INTO telegram_import_messages (city_id, channel_id, channel_username, message_id, published_at, edited_at, content_hash, raw_text, has_media, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'review') ON CONFLICT DO NOTHING`, [cityId, message.channelId, message.channelUsername, message.messageId, message.publishedAt, message.editedAt, hash, message.text.slice(0, 20_000), message.hasMedia]); } const row = (await db.query(`SELECT * FROM telegram_import_messages 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; const sameContent = initialized && row.content_hash === hash; 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' }; } 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([ PARSER_VERSION, 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, routes); plans = matched.plans; reasons = matched.reasons; if (Date.parse(dayBounds(parsed.serviceDate).end) <= this.now().getTime()) status = status === 'dismissed' ? status : 'expired'; else if (!reasons.length && this.config.autoPublish && status !== 'dismissed') status = 'published'; else if (!reasons.length) reasons = ['Автоматическая публикация выключена. Подтвердите уведомление.']; } if (status === 'published') { const draft = buildTransitNoticeDraft(plans, parsed.serviceDate, noticeOptions(parsed)); if (draft.title.length > 200 || draft.message.length > 5000 || plans.length > 100) { status = 'review'; reasons = ['Слишком длинное уведомление. Сократите заголовок или текст перед публикацией.']; } } if (status === 'published') { 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, has_media=$5, status=$6, reasons=$7::jsonb, parsed=$8::jsonb, reviewed_by=NULL, updated_at=now() WHERE id=$1`, [row.id, hash, message.text.slice(0, 20_000), message.editedAt, message.hasMedia, status, JSON.stringify(reasons), JSON.stringify({ ...parsed, plans })]); await db.query('COMMIT'); return { status, id: row.id }; } catch (error) { await db.query('ROLLBACK'); throw error; } finally { db.release(); } } async deactivate(db, messageId) { await db.query(`UPDATE alerts SET is_active=false, notification_pending=false WHERE id IN (SELECT alert_id FROM telegram_import_alerts WHERE message_id=$1 UNION SELECT alert_id FROM telegram_message_alerts WHERE message_id=$1)`, [messageId]); } async saveAlert(db, messageId, payload) { let existing = (await db.query('SELECT alert_id FROM telegram_message_alerts WHERE message_id=$1', [messageId])).rows[0]; if (!existing) { const legacy = (await db.query('SELECT alert_id FROM telegram_import_alerts WHERE message_id=$1 ORDER BY route_id', [messageId])).rows; if (legacy.length) { // Consolidate only on an edit/review; keep tombstones and delivered pushes. existing = { alert_id: legacy.some(link => link.alert_id === null) ? null : legacy[0].alert_id }; if (existing.alert_id && legacy.length > 1) await db.query(`UPDATE alerts SET notification_sent_at=(SELECT max(notification_sent_at) FROM alerts WHERE id=ANY($2::int[])) WHERE id=$1`, [existing.alert_id, legacy.map(link => link.alert_id)]); await db.query('INSERT INTO telegram_message_alerts(message_id,alert_id) VALUES ($1,$2)', [messageId, existing.alert_id]); } } if (existing) { // 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, 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 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; } const result = await this.alerts.insertAlert(db, payload); await db.query('UPDATE alerts SET notification_pending=true WHERE id=$1', [result.alert.id]); await db.query('INSERT INTO telegram_message_alerts(message_id,alert_id) VALUES ($1,$2)', [messageId, result.alert.id]); } async list(cityId) { return (await this.pool.query(`SELECT m.*, CASE WHEN EXISTS(SELECT 1 FROM telegram_message_alerts t WHERE t.message_id=m.id) THEN COALESCE((SELECT json_agg(t.alert_id) FROM telegram_message_alerts t WHERE t.message_id=m.id AND t.alert_id IS NOT NULL), '[]') ELSE COALESCE((SELECT json_agg(t.alert_id) FROM telegram_import_alerts t WHERE t.message_id=m.id AND t.alert_id IS NOT NULL), '[]') END 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 })); } async review(id, cityId, input, userId) { const db = await this.pool.connect(); try { await db.query('BEGIN'); const row = (await db.query('SELECT * FROM telegram_import_messages WHERE id=$1 AND city_id=$2 FOR UPDATE', [id, cityId])).rows[0]; if (!row) throw httpError(404, 'Сообщение не найдено.'); if (input.content_hash !== row.content_hash) throw httpError(409, 'Публикация изменилась. Обновите список и проверьте новый текст.'); if (!['publish', 'dismiss'].includes(input.action)) throw httpError(400, 'Неизвестное действие.'); let alertType = row.parsed.alertType || 'cancellation'; if (input.action === 'publish') { alertType = input.alert_type === undefined ? alertType : input.alert_type; 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 title = String(input.title || '').trim(); const message = String(input.message || '').trim(); if (!title || title.length > 200 || !message || message.length > 5000) throw httpError(400, 'Укажите заголовок (до 200 символов) и текст (до 5000 символов).'); await this.deactivate(db, row.id); const bounds = dayBounds(input.service_date); await this.saveAlert(db, row.id, { city_id: cityId, ...targets, alert_type: alertType, title, message: `${message}`, 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); 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('COMMIT'); return { id: row.id, status: input.action === 'publish' ? 'published' : 'dismissed' }; } catch (error) { await db.query('ROLLBACK'); throw error; } finally { db.release(); } } async stop() { this.stopped = true; await this.source.close(); } } let singleton; function getTelegramImportService() { return singleton || (singleton = new TelegramImportService()); } function startTelegramImporter(service = getTelegramImportService()) { if (!service.config.enabled) return () => service.stop(); const run = () => service.sync().catch(() => {}); // sync records a sanitized error for the admin status. run(); const timer = setInterval(run, service.config.intervalMs); timer.unref?.(); return () => { clearInterval(timer); return service.stop(); }; } module.exports = { TelegramImportService, getTelegramImportService, startTelegramImporter, contentHash, publicError };