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. Бота можно создать через @BotFather. + Чтобы узнать свой chat_id — напишите боту любое сообщение, он ответит вашим ID. +
+