diff --git a/modules/bookings/index.js b/modules/bookings/index.js index aaa7f34..ef80851 100644 --- a/modules/bookings/index.js +++ b/modules/bookings/index.js @@ -145,6 +145,7 @@ function createBooking(req, res) { total_price: totalPrice, promocode_id: promocodeId }; try { require('../email').sendBookingConfirmation(booking); } catch {} + try { require('../telegram').sendBookingNotification(booking); } catch {} res.status(201).json({ id: this.lastID, message: 'Booking saved', base_price: safeBasePrice, discount_percent: discountPercent, diff --git a/modules/settings/index.js b/modules/settings/index.js index fc0f568..684d841 100644 --- a/modules/settings/index.js +++ b/modules/settings/index.js @@ -16,7 +16,10 @@ const DEFAULT_SETTINGS = { hotel_max_link: 'kalugin66', hotel_max_active: 'true', hotel_instagram_id: '', - hotel_instagram_active: 'true' + hotel_instagram_active: 'true', + telegram_bot_token: '', + telegram_notify_enabled: 'false', + telegram_notify_users: '' }; function init(database) { diff --git a/modules/telegram/index.js b/modules/telegram/index.js new file mode 100644 index 0000000..4f14496 --- /dev/null +++ b/modules/telegram/index.js @@ -0,0 +1,289 @@ +let db; +let settingsModule; +let lastUpdateId = 0; +let pollingTimeout = null; + +let pollErrorCount = 0; +let lastPollErrorKey = null; +let pollingFatalLogged = false; +let pollingActiveLogged = false; + +let lastSendErrors = {}; + +function init(database, settings) { + db = database; + settingsModule = settings; +} + +function getSettings(callback) { + settingsModule.getAll((err, all) => { + if (err) return callback(err); + callback(null, all); + }); +} + +function sendMessage(token, chatId, text, callback) { + const body = JSON.stringify({ + chat_id: chatId, + text: text, + parse_mode: 'HTML' + }); + + fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body + }) + .then(res => res.json()) + .then(data => { + if (!data.ok) { + const key = `${chatId}:${data.error_code}:${data.description}`; + if (lastSendErrors[key] !== true) { + lastSendErrors[key] = true; + console.error(`Telegram sendMessage: chat ${chatId} — ${data.description}`); + } + if (callback) callback(new Error(data.description)); + } else { + if (callback) callback(null, data); + } + }) + .catch(err => { + const key = `fetch:${chatId}:${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; + + fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=${lastUpdateId + 1}&timeout=10`) + .then(res => res.json()) + .then(data => { + if (!data.ok) { + if (data.error_code === 404) { + if (!pollingFatalLogged) { + pollingFatalLogged = true; + console.error(`Telegram polling: бот не найден — ${data.description}. Polling остановлен до смены токена.`); + } + return; + } + + pollErrorCount++; + const backoff = Math.min(5000 * Math.pow(2, pollErrorCount - 1), 300000); + const errKey = `${data.error_code}:${data.description}`; + if (lastPollErrorKey !== errKey) { + lastPollErrorKey = errKey; + console.error(`Telegram polling: ${data.description}`); + } + schedulePolling(backoff); + return; + } + + pollErrorCount = 0; + lastPollErrorKey = null; + + if (!pollingActiveLogged) { + pollingActiveLogged = true; + console.log('Telegram polling active — бот слушает сообщения'); + } + + 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); + }) + .catch(err => { + pollErrorCount++; + const backoff = Math.min(5000 * Math.pow(2, pollErrorCount - 1), 300000); + const errKey = `fetch:${err.message}`; + if (lastPollErrorKey !== errKey) { + lastPollErrorKey = errKey; + console.error(`Telegram polling: ${err.message}`); + } + schedulePolling(backoff); + }); + }); +} + +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 = {}; + 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) { + errors.push(`chat_id ${chatId}: ${err.message}`); + } 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 || '' + }); + }); + }); + + app.put('/api/admin/settings/telegram', authenticateToken, requireAdmin, (req, res) => { + const { telegram_bot_token, telegram_notify_enabled, telegram_notify_users } = 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]); + } + + 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 }; diff --git a/public/admin.html b/public/admin.html index fec4d54..f8c48a4 100644 --- a/public/admin.html +++ b/public/admin.html @@ -581,6 +581,35 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid # +
+

Telegram-уведомления о новых заявках

+
+

+ + Отправляет уведомления о новых бронях в Telegram. Бота можно создать через @BotFather. + Чтобы узнать свой chat_id — напишите боту любое сообщение, он ответит вашим ID. +

+
+
+ + +
+
+ + +
+
+
+
+ + +
+ + +
+
+
+

Email-уведомления

@@ -2579,6 +2608,68 @@ async function savePhoneSettings() { } catch(err) { showToast(err.message, 'error'); } } +// Telegram notification settings +async function loadTelegramSettings() { + try { + const data = await api('/api/admin/settings/telegram'); + document.getElementById('telegramBotToken').value = data.telegram_bot_token_masked || ''; + document.getElementById('telegramNotifyUsers').value = data.telegram_notify_users || ''; + document.getElementById('telegramEnabled').checked = data.telegram_notify_enabled === 'true'; + } catch(e) {} +} + +async function saveTelegramSettings() { + const tokenVal = document.getElementById('telegramBotToken').value; + const token = tokenVal.startsWith('****') ? '' : tokenVal.trim(); + const enabled = document.getElementById('telegramEnabled').checked; + const usersStr = document.getElementById('telegramNotifyUsers').value.trim(); + const usersList = usersStr ? usersStr.split(',').map(s => s.trim()).filter(s => s) : []; + const data = { + telegram_bot_token: token, + telegram_notify_users: usersStr, + telegram_notify_enabled: enabled ? 'true' : 'false' + }; + try { + await api('/api/admin/settings/telegram', { method: 'PUT', body: JSON.stringify(data) }); + let msg = 'Настройки Telegram сохранены'; + if (enabled && usersList.length > 0) { + msg += `. Уведомления включены, получателей: ${usersList.length}`; + } else if (!enabled) { + msg += '. Уведомления выключены'; + } else { + msg += '. Не указаны получатели — уведомления не будут отправляться'; + } + showToast(msg, 'success'); + loadTelegramSettings(); + } catch(err) { showToast('Ошибка при сохранении: ' + err.message, 'error'); } +} + +async function sendTestTelegram() { + const btn = document.getElementById('btnTelegramTest'); + const originalText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Отправка...'; + try { + const result = await api('/api/admin/settings/telegram/test', { method: 'POST' }); + if (result.errors && result.errors.length > 0) { + showToast(result.message, 'error'); + } else { + showToast(result.message || 'Тест отправлен', 'success'); + } + } catch(err) { + let errMsg = err.message; + if (errMsg.includes('chat not found') || errMsg.includes('chat_id')) { + errMsg = 'Ошибка: указанный chat_id не найден. Пользователь должен сначала написать боту любое сообщение.'; + } else if (errMsg.includes('token') || errMsg.includes('unauthorized')) { + errMsg = 'Ошибка: неверный токен бота. Проверьте токен, полученный от @BotFather.'; + } + showToast('Ошибка отправки: ' + errMsg, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = originalText; + } +} + // Export async function exportCSV() { const status = document.getElementById('filterStatus').value; @@ -2609,6 +2700,7 @@ async function loadSettings() { loadSecurityKeys(); loadEmailSettings(); loadPhoneSettings(); + loadTelegramSettings(); } // Show room modal with images diff --git a/server.js b/server.js index b6fd03e..73b7ed6 100644 --- a/server.js +++ b/server.js @@ -267,6 +267,7 @@ const translationsModule = require('./modules/translations'); const backupModule = require('./modules/backup'); const seasonalPricesModule = require('./modules/seasonalPrices'); const emailModule = require('./modules/email'); +const telegramModule = require('./modules/telegram'); const reportsModule = require('./modules/reports'); const activitiesModule = require('./modules/activities'); const heroModule = require('./modules/hero'); @@ -284,6 +285,7 @@ modules.translations = translationsModule; modules.backup = backupModule; modules.seasonalPrices = seasonalPricesModule; modules.email = emailModule; +modules.telegram = telegramModule; modules.reports = reportsModule; modules.activities = activitiesModule; modules.hero = heroModule; @@ -299,6 +301,7 @@ reviewsModule.init(db, settingsModule); backupModule.init(db, dbPath); seasonalPricesModule.init(db); emailModule.init(db, settingsModule); +telegramModule.init(db, settingsModule); reportsModule.init(db); activitiesModule.init(db); heroModule.init(db); @@ -372,6 +375,7 @@ reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireA backupModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); seasonalPricesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); emailModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); +telegramModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin); activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity); heroModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadHeroImage, uploadHeroVideo); @@ -501,6 +505,7 @@ convertImages().then(() => { console.log(''); await runStartupTests(db, modules); console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`); + setTimeout(() => { telegramModule.startPolling(); }, 2000); }); function gracefulShutdown(signal) {