nakama/backend/bots/serverMonitor.js

399 lines
12 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,
'<b>NakamaHost Moderation</b>\nКоманды:\n• /load — состояние сервера\n• /admins — список админов\n• /addadmin @username — добавить админа (только владельцы)\n• /removeadmin @username — убрать админа (только владельцы)'
);
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;
}
if (command.startsWith('/')) {
await sendMessage(chatId, 'Неизвестная команда. Используйте /start, /load, /admins.');
}
};
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;
while (isPolling) {
try {
const response = await axios.get(`${TELEGRAM_API}/getUpdates`, {
params: {
timeout: 25,
offset
}
});
const updates = response.data?.result || [];
for (const update of updates) {
offset = update.update_id + 1;
await processUpdate(update);
}
} catch (error) {
log('error', 'Ошибка опроса Telegram для модераторского бота', {
error: error.response?.data || error.message
});
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
};
const startServerMonitorBot = () => {
if (!TELEGRAM_API) {
log('warn', 'MODERATION_BOT_TOKEN не установлен, модераторский бот не запущен');
return;
}
if (isPolling) {
return;
}
isPolling = true;
log('info', 'Модераторский Telegram бот запущен');
pollUpdates().catch((error) => {
log('error', 'Не удалось запустить модераторский бот', { error: error.message });
});
};
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) => ({
type: 'photo',
media: `attach://file${index}`,
...(index === 0 ? { caption: `${caption}${ERROR_SUPPORT_SUFFIX}`, parse_mode: 'HTML' } : {})
}));
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.filename || `image${index}.jpg`
});
});
try {
await axios.post(`${TELEGRAM_API}/sendMediaGroup`, form, {
headers: form.getHeaders()
});
} catch (error) {
log('error', 'Не удалось отправить медиа-группу в канал', { error: error.response?.data || error.message });
throw error;
} finally {
files.forEach((file) => {
fs.unlink(file.path, () => {});
});
}
};
module.exports = {
startServerMonitorBot,
sendChannelMediaGroup,
isModerationAdmin
};