420 lines
13 KiB
JavaScript
420 lines
13 KiB
JavaScript
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;
|
||
|
||
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) => {
|
||
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 {
|
||
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, () => {});
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Отправить сообщение пользователю
|
||
*/
|
||
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;
|
||
}
|
||
};
|
||
|
||
module.exports = {
|
||
startServerMonitorBot,
|
||
sendChannelMediaGroup,
|
||
sendMessageToUser,
|
||
isModerationAdmin
|
||
};
|
||
|