let db; let settingsModule; let lastUpdateId = 0; let pollingTimeout = null; let pollErrorCount = 0; let lastPollErrorKey = null; let pollingFatalLogged = false; let pollingActiveLogged = false; let lastSendErrors = {}; let connectionMode = null; let connectionLoggedKey = null; const DEFAULT_API = 'https://api.telegram.org'; function init(database, settings) { db = database; settingsModule = settings; } function getSettings(callback) { settingsModule.getAll((err, all) => { if (err) return callback(err); callback(null, all); }); } function isNetworkError(err) { const msg = (err.message || '').toLowerCase(); return msg.includes('econnrefused') || msg.includes('etimedout') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('network') || err.name === 'TypeError' || err.cause !== undefined; } function apiCall(settings, method, body, callback) { const token = settings.telegram_bot_token; const apiUrl = (settings.telegram_api_url || DEFAULT_API).replace(/\/+$/, ''); const proxyUrl = apiUrl !== DEFAULT_API ? apiUrl : null; const urlsToTry = proxyUrl ? [proxyUrl, DEFAULT_API] : [DEFAULT_API]; function tryNext(index) { if (index >= urlsToTry.length) { return callback(new Error('Telegram недоступен: все адреса недоступны')); } const url = urlsToTry[index]; const fullUrl = `${url}/bot${token}/${method}`; const fetchOpts = body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}; fetch(fullUrl, fetchOpts) .then(res => res.json().then(data => ({ status: res.status, data }))) .then(result => { const data = result.data; if (!data.ok) { const err = new Error(data.description); err.code = data.error_code; err.fromProxy = url !== DEFAULT_API; return callback(err); } if (url !== DEFAULT_API) { updateConnectionMode('proxied', url); } else { updateConnectionMode('direct', null); } callback(null, data); }) .catch(fetchErr => { if (isNetworkError(fetchErr) && index < urlsToTry.length - 1) { tryNext(index + 1); } else if (isNetworkError(fetchErr)) { updateConnectionMode('blocked', null); callback(fetchErr); } else { callback(fetchErr); } }); } tryNext(0); } function updateConnectionMode(mode, proxyUrl) { const key = mode + (proxyUrl || ''); if (connectionLoggedKey === key) return; connectionLoggedKey = key; connectionMode = mode; if (mode === 'direct') { console.log('Telegram подключён напрямую'); } else if (mode === 'proxied') { console.log(`Telegram подключён через прокси-сервер: ${proxyUrl}`); } else if (mode === 'blocked') { console.error('Telegram заблокирован. Укажите прокси-URL в настройках Telegram.'); } } function sendMessage(token, chatId, text, callback) { getSettings((err, settings) => { if (err) { if (callback) callback(err); return; } apiCall(settings, 'sendMessage', { chat_id: chatId, text: text, parse_mode: 'HTML' }, (err, data) => { if (!err) { if (callback) callback(null, data); return; } if (!err.code) { const key = `network:${chatId}`; if (lastSendErrors[key] !== true) { lastSendErrors[key] = true; console.error(`Telegram sendMessage: chat ${chatId} — ${err.message}`); } } else { const key = `${chatId}:${err.code}:${err.message}`; if (lastSendErrors[key] !== true) { lastSendErrors[key] = true; console.error(`Telegram sendMessage: chat ${chatId} — ${err.message}`); } } if (callback) callback(err); }); }); } function sendBookingNotification(booking) { if (!booking.phone) return; getSettings((err, settings) => { if (err || settings.telegram_notify_enabled !== 'true') return; const token = settings.telegram_bot_token; if (!token) return; const usersStr = settings.telegram_notify_users || ''; const chatIds = usersStr.split(',').map(s => s.trim()).filter(s => s); if (chatIds.length === 0) return; const text = [ 'Hotel 777 — Новая бронь', '', `Имя: ${escapeHtml(booking.name)}`, `Телефон: ${escapeHtml(booking.phone)}`, `Номер: ${escapeHtml(booking.room_type || '—')}`, `Гостей: ${booking.adults} взр.${booking.children ? ', ' + booking.children + ' дет.' : ''}`, `Заезд: ${escapeHtml(booking.checkin_date)}`, `Выезд: ${escapeHtml(booking.checkout_date)}`, `Сумма: ${booking.total_price || booking.base_price || 0} ₽`, ]; if (booking.wishes) { text.push(`Пожелания: ${escapeHtml(booking.wishes)}`); } text.push(''); text.push('Перейти в админ-панель'); const message = text.join('\n'); chatIds.forEach(chatId => { sendMessage(token, chatId, message); }); }); } function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function startPolling() { getSettings((err, settings) => { if (err || !settings.telegram_bot_token) { schedulePolling(60000); return; } const token = settings.telegram_bot_token; apiCall(settings, `getUpdates?offset=${lastUpdateId + 1}&timeout=10`, null, (err, data) => { if (err) { if (err.code === 404) { if (!pollingFatalLogged) { pollingFatalLogged = true; console.error(`Telegram polling: бот не найден — ${err.message}. Polling остановлен до смены токена.`); } return; } if (err.code) { pollErrorCount++; const backoff = Math.min(5000 * Math.pow(2, pollErrorCount - 1), 300000); const errKey = `${err.code}:${err.message}`; if (lastPollErrorKey !== errKey) { lastPollErrorKey = errKey; console.error(`Telegram polling: ${err.message}`); } schedulePolling(backoff); return; } pollErrorCount++; const backoff = Math.min(5000 * Math.pow(2, pollErrorCount - 1), 300000); const errKey = `network:${err.message}`; if (lastPollErrorKey !== errKey) { lastPollErrorKey = errKey; } schedulePolling(backoff); return; } pollErrorCount = 0; lastPollErrorKey = null; if (!pollingActiveLogged) { pollingActiveLogged = true; const modeText = connectionMode === 'proxied' ? ' (через прокси)' : ''; console.log(`Telegram polling active — бот слушает сообщения${modeText}`); } if (data.result && data.result.length > 0) { data.result.forEach(update => { lastUpdateId = update.update_id; if (update.message && update.message.text && update.message.chat) { const chatId = update.message.chat.id; const replyText = `Ваш chat_id: ${chatId}\n\nДобавьте его в настройках админ-панели Hotel 777 для получения уведомлений о новых заявках.`; sendMessage(token, chatId, replyText); } }); } schedulePolling(1000); }); }); } function schedulePolling(ms) { if (pollingTimeout) clearTimeout(pollingTimeout); pollingTimeout = setTimeout(startPolling, ms); } function stopPolling() { if (pollingTimeout) { clearTimeout(pollingTimeout); pollingTimeout = null; } } function resetPollState() { stopPolling(); lastUpdateId = 0; pollErrorCount = 0; lastPollErrorKey = null; pollingFatalLogged = false; pollingActiveLogged = false; lastSendErrors = {}; connectionMode = null; connectionLoggedKey = null; startPolling(); } function sendTestMessage(settings, callback) { const token = settings.telegram_bot_token; if (!token) return callback(new Error('Токен бота не настроен')); const usersStr = settings.telegram_notify_users || ''; const chatIds = usersStr.split(',').map(s => s.trim()).filter(s => s); if (chatIds.length === 0) return callback(new Error('Не указаны chat_id получателей')); let sent = 0; let errors = []; chatIds.forEach((chatId, index) => { sendMessage(token, chatId, 'Hotel 777 — Тестовое уведомление Telegram. Бот работает корректно.', (err) => { if (err) { let errText = err.message; if (!err.code) errText = `${errText} (проверьте подключение или укажите прокси-URL)`; errors.push(`chat_id ${chatId}: ${errText}`); } else { sent++; } if (index === chatIds.length - 1) { if (errors.length > 0) { callback(null, { sent, total: chatIds.length, errors }); } else { callback(null, { sent, total: chatIds.length, errors: [] }); } } }); }); } function setupRoutes(app, authenticateToken, requireAdmin) { app.get('/api/admin/settings/telegram', authenticateToken, requireAdmin, (req, res) => { getSettings((err, settings) => { if (err) return res.status(500).json({ error: 'Database error' }); const token = settings.telegram_bot_token || ''; res.json({ telegram_bot_token_masked: token ? maskToken(token) : '', telegram_notify_enabled: settings.telegram_notify_enabled || 'false', telegram_notify_users: settings.telegram_notify_users || '', telegram_api_url: settings.telegram_api_url || DEFAULT_API, telegram_proxy_status: connectionMode || 'unknown' }); }); }); app.put('/api/admin/settings/telegram', authenticateToken, requireAdmin, (req, res) => { const { telegram_bot_token, telegram_notify_enabled, telegram_notify_users, telegram_api_url } = req.body; const updates = []; if (telegram_bot_token !== undefined && !(telegram_bot_token || '').startsWith('****')) { updates.push(['telegram_bot_token', telegram_bot_token]); } if (telegram_notify_enabled !== undefined) { updates.push(['telegram_notify_enabled', telegram_notify_enabled]); } if (telegram_notify_users !== undefined) { updates.push(['telegram_notify_users', telegram_notify_users]); } if (telegram_api_url !== undefined) { const url = (telegram_api_url || '').trim(); updates.push(['telegram_api_url', url || DEFAULT_API]); } let processed = 0; if (updates.length === 0) return res.json({ message: 'No changes' }); updates.forEach(([key, value]) => { settingsModule.set(key, value, (err) => { if (err) console.error('Telegram settings update error:', err); processed++; if (processed === updates.length) { res.json({ message: 'Настройки Telegram сохранены' }); resetPollState(); } }); }); }); app.post('/api/admin/settings/telegram/test', authenticateToken, requireAdmin, (req, res) => { getSettings((err, settings) => { if (err) return res.status(500).json({ error: 'Database error' }); sendTestMessage(settings, (sendErr, result) => { if (sendErr) return res.status(400).json({ error: sendErr.message }); if (result.errors.length > 0) { return res.json({ message: `Отправлено ${result.sent} из ${result.total}. Ошибки: ${result.errors.join('; ')}`, sent: result.sent, total: result.total }); } res.json({ message: `Тестовое сообщение отправлено (${result.sent}/${result.total})` }); }); }); }); } function maskToken(val) { if (!val) return ''; const parts = val.split(':'); if (parts.length === 2) { const idPart = parts[0]; const hashPart = parts[1]; if (hashPart.length <= 4) return '****'; return idPart.slice(0, 3) + '***:' + '****' + hashPart.slice(-4); } return '****'; } module.exports = { init, setupRoutes, sendBookingNotification, sendTestMessage, startPolling, stopPolling, connectionMode };