nakama/backend/bots/serverMonitor.js

509 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const axios = require('axios');
const os = require('os');
const { exec } = require('child_process');
const FormData = require('form-data');
const fs = require('fs');
const config = require('../config');
const { log } = require('../middleware/logger');
const { listAdmins, addAdmin, removeAdmin, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
const BOT_TOKEN = config.moderationBotToken;
const TELEGRAM_API = BOT_TOKEN ? `https://api.telegram.org/bot${BOT_TOKEN}` : null;
const ERROR_SUPPORT_SUFFIX = ' Сообщите об ошибке в https://t.me/NakamaReportbot';
const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []);
let isPolling = false;
let offset = 0;
const execAsync = (command) =>
new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
return reject(new Error(stderr || error.message));
}
resolve(stdout);
});
});
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(value >= 10 ? 0 : 1)} ${sizes[i]}`;
};
const formatDuration = (seconds) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const segments = [];
if (days) segments.push(`${days}д`);
if (hours) segments.push(`${hours}ч`);
if (minutes || segments.length === 0) segments.push(`${minutes}м`);
return segments.join(' ');
};
const getDiskUsage = async () => {
try {
const output = await execAsync('df -h /');
const lines = output.trim().split('\n');
if (lines.length < 2) return null;
const parts = lines[1].split(/\s+/);
return {
filesystem: parts[0],
size: parts[1],
used: parts[2],
available: parts[3],
percent: parseInt(parts[4], 10)
};
} catch (error) {
log('error', 'Не удалось получить информацию о диске', { error: error.message });
return null;
}
};
const buildStatus = ({ loadPerCore, memUsage, diskUsage }) => {
const issues = [];
let severity = 0;
if (loadPerCore > 1.5) {
issues.push('Высокая загрузка CPU (>150% на ядро)');
severity = Math.max(severity, 2);
} else if (loadPerCore > 1.0) {
issues.push('Нагрузка CPU растёт (>100% на ядро)');
severity = Math.max(severity, 1);
}
if (memUsage > 90) {
issues.push('Критический уровень памяти (>90%)');
severity = Math.max(severity, 2);
} else if (memUsage > 75) {
issues.push('Память почти заполнена (>75%)');
severity = Math.max(severity, 1);
}
if (diskUsage && diskUsage.percent) {
if (diskUsage.percent > 90) {
issues.push('Заканчивается место на диске (>90%)');
severity = Math.max(severity, 2);
} else if (diskUsage.percent > 80) {
issues.push('Мало свободного места на диске (>80%)');
severity = Math.max(severity, 1);
}
}
if (severity === 0) {
return { icon: '✅', text: 'Нагрузка в норме' };
}
if (severity === 1) {
return { icon: '⚠️', text: `Есть предупреждения:\n${issues.map((i) => `${i}`).join('\n')}` };
}
return { icon: '🔥', text: `Критические метрики:\n${issues.map((i) => `${i}`).join('\n')}` };
};
const buildStatsMessage = async () => {
const load = os.loadavg();
const cpuCount = os.cpus().length || 1;
const loadPerCore = load[0] / cpuCount;
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const memUsage = (usedMem / totalMem) * 100;
const diskUsage = await getDiskUsage();
const uptime = formatDuration(os.uptime());
const processUptime = formatDuration(process.uptime());
const status = buildStatus({ loadPerCore, memUsage, diskUsage });
const now = new Date();
const formatter = new Intl.DateTimeFormat('ru-RU', {
dateStyle: 'long',
timeStyle: 'medium'
});
const lines = [
`<b>Статистика сервера</b> ${status.icon}`,
`⏰ <b>Время:</b> ${formatter.format(now)}`,
`🆙 <b>Аптайм системы:</b> ${uptime}`,
`🔁 <b>Аптайм процесса:</b> ${processUptime}`,
'',
`🧠 <b>Загрузка CPU:</b> ${(load[0] || 0).toFixed(2)} / ${(load[1] || 0).toFixed(2)} / ${(load[2] || 0).toFixed(2)}`,
`⚙️ <b>На ядро:</b> ${(loadPerCore * 100).toFixed(0)}% (ядер: ${cpuCount})`,
'',
`💾 <b>Память:</b> ${formatBytes(usedMem)} / ${formatBytes(totalMem)} (${memUsage.toFixed(1)}%)`,
`🟢 <b>Свободно:</b> ${formatBytes(freeMem)}`,
''
];
if (diskUsage) {
lines.push(
`💽 <b>Диск:</b> ${diskUsage.used} / ${diskUsage.size} (${diskUsage.percent}% занято)`,
`📂 <b>Свободно:</b> ${diskUsage.available}`
);
} else {
lines.push('💽 <b>Диск:</b> не удалось получить информацию');
}
lines.push('', `🏷️ <b>Платформа:</b> ${os.type()} ${os.release()}`, `🔧 <b>Node.js:</b> ${process.version}`);
if (status.text) {
lines.push('', status.text);
}
return lines.join('\n');
};
const sendMessage = async (chatId, text) => {
if (!TELEGRAM_API) return;
try {
await axios.post(`${TELEGRAM_API}/sendMessage`, {
chat_id: chatId,
text: `${text}${ERROR_SUPPORT_SUFFIX}`,
parse_mode: 'HTML',
disable_web_page_preview: true
});
} catch (error) {
log('error', 'Не удалось отправить сообщение модераторским ботом', {
error: error.response?.data || error.message
});
}
};
const requireOwner = (message) => {
const username = normalizeUsername(message.from?.username || '');
return OWNER_USERNAMES.has(username);
};
const handleListAdmins = async (chatId) => {
const admins = await listAdmins();
if (!admins.length) {
await sendMessage(chatId, 'Список модераторов пуст.');
return;
}
const lines = admins.map((admin, index) => {
const name = [admin.firstName, admin.lastName].filter(Boolean).join(' ') || '-';
return `${index + 1}. @${admin.username} (${name || 'нет имени'})`;
});
await sendMessage(chatId, `<b>Модераторы MiniApp</b>\n${lines.join('\n')}`);
};
const handleAddAdmin = async (chatId, message, args) => {
if (!requireOwner(message)) {
await sendMessage(chatId, 'У вас нет прав добавлять модераторов.');
return;
}
const username = normalizeUsername(args[1] || '');
if (!username) {
await sendMessage(chatId, 'Использование: /addadmin @username');
return;
}
try {
const admin = await addAdmin({ username, addedBy: message.from?.username });
await sendMessage(
chatId,
`✅ @${admin.username} добавлен в список модераторов MiniApp. Теперь этому пользователю доступен модераторский интерфейс.`
);
} catch (error) {
await sendMessage(chatId, `${error.message}`);
}
};
const handleRemoveAdmin = async (chatId, message, args) => {
if (!requireOwner(message)) {
await sendMessage(chatId, 'У вас нет прав удалять модераторов.');
return;
}
const username = normalizeUsername(args[1] || '');
if (!username) {
await sendMessage(chatId, 'Использование: /removeadmin @username');
return;
}
try {
await removeAdmin(username);
await sendMessage(chatId, `✅ @${username} удалён из списка модераторов MiniApp.`);
} catch (error) {
await sendMessage(chatId, `${error.message}`);
}
};
const handleCommand = async (message) => {
const chatId = message.chat.id;
const text = (message.text || '').trim();
const args = text.split(/\s+/);
const command = args[0].toLowerCase();
if (command === '/start') {
await sendMessage(chatId, '✅ Бот активен');
return;
}
if (command === '/load') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
const reply = await buildStatsMessage();
await sendMessage(chatId, reply);
return;
}
if (command === '/admins') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
await handleListAdmins(chatId);
return;
}
if (command === '/addadmin') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
await handleAddAdmin(chatId, message, args);
return;
}
if (command === '/removeadmin') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
await handleRemoveAdmin(chatId, message, args);
return;
}
// Игнорируем неизвестные команды
};
const processUpdate = async (update) => {
const message = update.message || update.edited_message;
if (!message || !message.text) {
return;
}
try {
await handleCommand(message);
} catch (error) {
log('error', 'Ошибка обработки команды модераторского бота', { error: error.message });
await sendMessage(message.chat.id, `Не удалось обработать команду: ${error.message}`);
}
};
const pollUpdates = async () => {
if (!TELEGRAM_API) return;
const poll = async () => {
if (!isPolling) {
return;
}
try {
const response = await axios.get(`${TELEGRAM_API}/getUpdates`, {
params: {
timeout: 25,
offset,
allowed_updates: ['message']
}
});
const updates = response.data?.result || [];
for (const update of updates) {
offset = update.update_id + 1;
await processUpdate(update);
}
// Продолжить опрос
setTimeout(poll, 100);
} catch (error) {
const errorData = error.response?.data || {};
const errorCode = errorData.error_code;
const errorDescription = errorData.description || error.message;
// Обработка конфликта 409 - другой экземпляр бота уже опрашивает
if (errorCode === 409) {
// Не логируем 409 - это ожидаемая ситуация при конфликте экземпляров
// Подождать дольше перед повторной попыткой
await new Promise((resolve) => setTimeout(resolve, 10000));
} else {
log('error', 'Ошибка опроса Telegram для модераторского бота', {
error: errorData || error.message
});
// Обычная задержка при других ошибках
await new Promise((resolve) => setTimeout(resolve, 5000));
}
// Продолжить опрос только если isPolling все еще true
if (isPolling) {
setTimeout(poll, 0);
}
}
};
poll();
};
const startServerMonitorBot = () => {
if (!TELEGRAM_API) {
log('warn', 'MODERATION_BOT_TOKEN не установлен, модераторский бот не запущен');
return;
}
if (isPolling) {
log('warn', 'Модераторский бот уже запущен, пропускаем повторный запуск');
return;
}
// Инициализировать offset перед началом опроса
const initializeOffset = async () => {
try {
const response = await axios.get(`${TELEGRAM_API}/getUpdates`, {
params: {
timeout: 1,
allowed_updates: ['message']
}
});
const updates = response.data?.result || [];
if (updates.length > 0) {
offset = updates[updates.length - 1].update_id + 1;
log('info', `Модераторский бот: пропущено ${updates.length} старых обновлений, offset установлен на ${offset}`);
}
} catch (error) {
log('warn', 'Не удалось инициализировать offset для модераторского бота, начнем с 0', {
error: error.response?.data || error.message
});
}
};
isPolling = true;
log('info', 'Модераторский Telegram бот запущен');
initializeOffset().then(() => {
pollUpdates();
}).catch((error) => {
log('error', 'Не удалось запустить модераторский бот', { error: error.message });
isPolling = false;
});
};
const sendChannelMediaGroup = async (files, caption) => {
if (!TELEGRAM_API) throw new Error('Модераторский бот не настроен');
const chatId = config.moderationChannelUsername || '@reichenbfurry';
const form = new FormData();
const media = files.map((file, index) => {
const isVideo = file.mimetype && file.mimetype.startsWith('video/');
return {
type: isVideo ? 'video' : 'photo',
media: `attach://file${index}`,
...(index === 0 ? { caption: `${caption}${ERROR_SUPPORT_SUFFIX}`, parse_mode: 'HTML' } : {}),
...(isVideo ? { supports_streaming: true } : {})
};
});
form.append('chat_id', chatId);
form.append('media', JSON.stringify(media));
files.forEach((file, index) => {
form.append(`file${index}`, fs.createReadStream(file.path), {
filename: file.originalname || file.filename || `media${index}`
});
});
try {
const response = await axios.post(`${TELEGRAM_API}/sendMediaGroup`, form, {
headers: form.getHeaders()
});
// Вернуть ID первого сообщения из группы
const messageId = response.data?.result?.[0]?.message_id;
return messageId;
} catch (error) {
log('error', 'Не удалось отправить медиа-группу в канал', { error: error.response?.data || error.message });
throw error;
} finally {
files.forEach((file) => {
fs.unlink(file.path, () => {});
});
}
};
/**
* Отправить сообщение пользователю
*/
const sendMessageToUser = async (userId, message) => {
if (!TELEGRAM_API) {
throw new Error('Бот модерации не инициализирован');
}
try {
await axios.post(`${TELEGRAM_API}/sendMessage`, {
chat_id: userId,
text: message,
parse_mode: 'HTML'
});
log('info', 'Сообщение отправлено пользователю', { userId });
} catch (error) {
log('error', 'Не удалось отправить сообщение пользователю', { userId, error: error.response?.data || error.message });
throw error;
}
};
/**
* Обновить сообщение в канале (только текст)
*/
const updateChannelMessage = async (messageId, content, hashtags) => {
if (!TELEGRAM_API) {
throw new Error('Бот модерации не инициализирован');
}
const chatId = config.moderationChannelUsername || '@reichenbfurry';
// Формируем текст с хэштегами
const text = [content, hashtags?.length ? hashtags.map(tag => `#${tag}`).join(' ') : ''].filter(Boolean).join('\n\n');
try {
await axios.post(`${TELEGRAM_API}/editMessageCaption`, {
chat_id: chatId,
message_id: messageId,
caption: `${text}${ERROR_SUPPORT_SUFFIX}`,
parse_mode: 'HTML'
});
log('info', 'Сообщение в канале обновлено', { messageId });
} catch (error) {
log('error', 'Не удалось обновить сообщение в канале', {
messageId,
error: error.response?.data || error.message
});
throw error;
}
};
module.exports = {
startServerMonitorBot,
sendChannelMediaGroup,
sendMessageToUser,
updateChannelMessage,
isModerationAdmin
};