This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
289
modules/telegram/index.js
Normal file
289
modules/telegram/index.js
Normal file
@@ -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 = [
|
||||
'<b>Hotel 777 — Новая бронь</b>',
|
||||
'',
|
||||
`<b>Имя:</b> ${escapeHtml(booking.name)}`,
|
||||
`<b>Телефон:</b> ${escapeHtml(booking.phone)}`,
|
||||
`<b>Номер:</b> ${escapeHtml(booking.room_type || '—')}`,
|
||||
`<b>Гостей:</b> ${booking.adults} взр.${booking.children ? ', ' + booking.children + ' дет.' : ''}`,
|
||||
`<b>Заезд:</b> ${escapeHtml(booking.checkin_date)}`,
|
||||
`<b>Выезд:</b> ${escapeHtml(booking.checkout_date)}`,
|
||||
`<b>Сумма:</b> ${booking.total_price || booking.base_price || 0} ₽`,
|
||||
];
|
||||
|
||||
if (booking.wishes) {
|
||||
text.push(`<b>Пожелания:</b> ${escapeHtml(booking.wishes)}`);
|
||||
}
|
||||
|
||||
text.push('');
|
||||
text.push('<a href="https://hotel777.ru/admin">Перейти в админ-панель</a>');
|
||||
|
||||
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, '>').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 = `<b>Ваш chat_id:</b> <code>${chatId}</code>\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, '<b>Hotel 777</b> — Тестовое уведомление 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 };
|
||||
@@ -581,6 +581,35 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="telegramCard">
|
||||
<div class="card-header-custom"><h3><i class="fab fa-telegram me-2"></i>Telegram-уведомления о новых заявках</h3></div>
|
||||
<div class="card-body-custom">
|
||||
<p style="color: #64748b; margin-bottom: 20px;">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Отправляет уведомления о новых бронях в Telegram. Бота можно создать через <a href="https://t.me/BotFather" target="_blank">@BotFather</a>.
|
||||
Чтобы узнать свой chat_id — напишите боту любое сообщение, он ответит вашим ID.
|
||||
</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Токен бота</label>
|
||||
<input type="password" class="form-control" id="telegramBotToken" placeholder="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Chat ID получателей (через запятую)</label>
|
||||
<input type="text" class="form-control" id="telegramNotifyUsers" placeholder="123456789,987654321">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 d-flex align-items-center gap-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="telegramEnabled" style="width: 50px; height: 24px; cursor: pointer;">
|
||||
<label class="form-check-label fw-500 ms-2" for="telegramEnabled">Включить уведомления</label>
|
||||
</div>
|
||||
<button class="btn-primary-custom btn-sm" onclick="saveTelegramSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
|
||||
<button class="btn-gold btn-sm" id="btnTelegramTest" onclick="sendTestTelegram()"><i class="fab fa-telegram me-1"></i>Отправить тест</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="emailCard">
|
||||
<div class="card-header-custom"><h3><i class="fas fa-envelope me-2"></i>Email-уведомления</h3></div>
|
||||
<div class="card-body-custom">
|
||||
@@ -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 = '<span class="spinner-border spinner-border-sm me-1"></span>Отправка...';
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user