Files
ridebus-admin/backend/src/services/telegramImportService.js
Zakhar Timoshenko 360b684019 feat: implement board device service with CRUD operations and validation
- Add boardDeviceService for managing device pairing, status, claiming, and revocation.
- Introduce boardDeviceSettings for default settings and validation logic.
- Create integration tests for device lifecycle, concurrent claims, and updates.
- Add unit tests for device pairing, claiming, and settings validation.
- Implement API tests for device endpoints with JWT authentication.
- Develop in-memory repository for testing purposes.
- Create frontend components for managing devices, including a form for device settings.
- Style DevicesPage with CSS for better user experience.
2026-10-10 00:47:26 +03:00

261 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };