nakama/backend/bots/serverMonitor.js

403 lines
13 KiB
JavaScript
Raw Normal View History

2025-11-10 20:13:22 +00:00
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();
2025-11-10 20:28:24 +00:00
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 } : {})
};
});
2025-11-10 20:13:22 +00:00
form.append('chat_id', chatId);
form.append('media', JSON.stringify(media));
files.forEach((file, index) => {
form.append(`file${index}`, fs.createReadStream(file.path), {
2025-11-10 20:28:24 +00:00
filename: file.originalname || file.filename || `media${index}`
2025-11-10 20:13:22 +00:00
});
});
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
};