375 lines
14 KiB
JavaScript
375 lines
14 KiB
JavaScript
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 = [
|
||
'<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;
|
||
|
||
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 = `<b>Ваш chat_id:</b> <code>${chatId}</code>\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, '<b>Hotel 777</b> — Тестовое уведомление 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 };
|