247 lines
10 KiB
Python
247 lines
10 KiB
Python
import asyncio
|
||
import logging
|
||
from datetime import datetime
|
||
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
|
||
|
||
from config import BOT_TOKEN, ADMIN_ID, HOSTS_LIST
|
||
from monitor import collect_metrics
|
||
|
||
# Настройка логирования
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||
handlers=[
|
||
logging.FileHandler("bot.log"),
|
||
logging.StreamHandler()
|
||
]
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Кеш для хранения последних собранных данных
|
||
CACHE = {}
|
||
|
||
def format_metrics(data):
|
||
"""Формирует красивое сообщение из метрик"""
|
||
if "error" in data:
|
||
return f"❌ Ошибка подключения к {data['host']}:\n{data['error']}"
|
||
|
||
m = data['metrics']
|
||
host = data['host']
|
||
ip = data['ip']
|
||
host_type = data.get('host_type', 'unknown')
|
||
|
||
# Эмодзи для типа хоста
|
||
type_emoji = {
|
||
'physical': '🖥️',
|
||
'vm': '🖥️',
|
||
'lxc': '📦',
|
||
'container': '🐳'
|
||
}.get(host_type, '🖥️')
|
||
|
||
type_label = {
|
||
'physical': 'Железо',
|
||
'vm': 'ВМ',
|
||
'lxc': 'LXC',
|
||
'container': 'Контейнер'
|
||
}.get(host_type, 'Неизвестно')
|
||
|
||
text = f"{type_emoji} *{host} ({ip})* - `{type_label}`\n"
|
||
text += f"🕒 *Uptime:* {m.get('uptime', 'N/A')}\n"
|
||
text += f"📊 *Load Avg:* {m.get('load_1', '0')}, {m.get('load_5', '0')}, {m.get('load_15', '0')}\n\n"
|
||
|
||
# Алерты (если есть)
|
||
alerts = m.get('alerts', [])
|
||
if alerts:
|
||
text += "🚨 *АЛЕРТЫ!*\n"
|
||
for alert in alerts:
|
||
text += f" {alert}\n"
|
||
text += "\n"
|
||
|
||
# CPU
|
||
cpu_bar = "█" * int(m['cpu'] // 5) + "░" * (20 - int(m['cpu'] // 5))
|
||
cpu_color = "🔴" if m['cpu'] > 80 else "🟡" if m['cpu'] > 60 else "🟢"
|
||
text += f"{cpu_color} *CPU:* {m['cpu']:.1f}%\n`{cpu_bar}`\n"
|
||
|
||
# RAM
|
||
mem_bar = "█" * int(m['mem_percent'] // 5) + "░" * (20 - int(m['mem_percent'] // 5))
|
||
mem_color = "🔴" if m['mem_percent'] > 85 else "🟡" if m['mem_percent'] > 70 else "🟢"
|
||
text += f"{mem_color} *RAM:* {m['mem_used_mb']} MB / {m.get('mem_total_mb', '?')} MB ({m['mem_percent']}%)\n`{mem_bar}`\n"
|
||
|
||
# Диск (свободное место)
|
||
disk_color = "🔴" if m.get('disk_usage', 0) > 80 else "🟡" if m.get('disk_usage', 0) > 70 else "🟢"
|
||
text += f"{disk_color} *Disk (/):* {m.get('disk_usage', 0):.1f}% used\n"
|
||
|
||
# Сеть
|
||
text += f"🌐 *Network (per hour):* 📥 {m['net_rx_mb']} MB | 📤 {m['net_tx_mb']} MB\n"
|
||
|
||
# Диски iostat
|
||
text += "\n💾 *Disks (top 3 by %util):*\n"
|
||
for disk in m.get('disks', []):
|
||
text += f" └─ {disk['name']}: util {disk['util']:.1f}% (r: {disk['rps']:.1f}/s, w: {disk['wps']:.1f}/s)\n"
|
||
|
||
# Температуры (только для железа)
|
||
if host_type == "physical" and m.get('temps'):
|
||
text += "\n🌡️ *Temperatures:*\n"
|
||
for i, temp in enumerate(m['temps']):
|
||
temp_color = "🔴" if temp > 75 else "🟡" if temp > 60 else "🟢"
|
||
text += f" {temp_color} Sensor {i+1}: {temp:.1f}°C\n"
|
||
elif host_type != "physical":
|
||
text += "\n🌡️ *Temperatures:* Не требуется (ВМ/LXC)\n"
|
||
|
||
# Топ процессов
|
||
text += "🏆 *Top 5 CPU:*\n```\n"
|
||
top_cpu = m.get('top_cpu', 'N/A')
|
||
# Экранируем опасные символы для Markdown
|
||
top_cpu = top_cpu.replace('_', '\\_').replace('*', '\\*').replace('`', '\\`')
|
||
text += top_cpu + "\n```"
|
||
text += "🏆 *Top 5 RAM:*\n```\n"
|
||
top_mem = m.get('top_mem', 'N/A')
|
||
top_mem = top_mem.replace('_', '\\_').replace('*', '\\*').replace('`', '\\`')
|
||
text += top_mem + "\n```"
|
||
# text += "\n🏆 *Top 5 CPU:*\n```\n"
|
||
# text += m.get('top_cpu', 'N/A') + "\n```"
|
||
# text += "🏆 *Top 5 RAM:*\n```\n"
|
||
# text += m.get('top_mem', 'N/A') + "\n```"
|
||
|
||
# Логи и безопасность
|
||
text += "\n⚠️ *Recent Errors (journalctl -p 3):*\n```\n"
|
||
text += m.get('logs_errors', 'N/A')[:300] + "\n```"
|
||
|
||
# OOM
|
||
text += "\n💀 *Out of Memory Events:*\n```\n"
|
||
text += m.get('oom', 'Нет данных') + "\n```"
|
||
|
||
# Fail2ban
|
||
text += "\n🛡️ *Fail2ban Status:*\n```\n"
|
||
text += m.get('fail2ban_status', 'N/A') + "\n```"
|
||
|
||
text += "\n🚫 *Fail2ban Logs (last bans):*\n```\n"
|
||
text += m.get('fail2ban_logs', 'N/A') + "\n```"
|
||
|
||
text += f"\n⏱️ *Обновлено:* {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||
return text
|
||
|
||
async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработчик команды /status"""
|
||
if update.effective_user.id != ADMIN_ID:
|
||
await update.message.reply_text("⛔ Доступ запрещен. Ты не мой хозяин.")
|
||
return
|
||
|
||
# Создаем клавиатуру с хостами
|
||
keyboard = []
|
||
for host in HOSTS_LIST:
|
||
keyboard.append([InlineKeyboardButton(host['name'], callback_data=f"host_{host['ip']}")])
|
||
keyboard.append([InlineKeyboardButton("🔄 Все хосты", callback_data="host_all")])
|
||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||
|
||
await update.message.reply_text(
|
||
"🔍 Выбери хост для мониторинга или нажми 'Все хосты':",
|
||
reply_markup=reply_markup
|
||
)
|
||
|
||
async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработка нажатий на кнопки"""
|
||
query = update.callback_query
|
||
await query.answer()
|
||
|
||
if update.effective_user.id != ADMIN_ID:
|
||
await query.edit_message_text("⛔ Недостаточно прав.")
|
||
return
|
||
|
||
data = query.data
|
||
if data == "host_all":
|
||
await query.edit_message_text("⏳ Собираю данные со всех хостов...")
|
||
full_report = ""
|
||
for host in HOSTS_LIST:
|
||
result = collect_metrics(host)
|
||
report = format_metrics(result)
|
||
full_report += report + "\n\n" + "="*30 + "\n\n"
|
||
# Обрезаем, если слишком длинное
|
||
if len(full_report) > 4000:
|
||
full_report = full_report[:4000] + "\n\n... (обрезано)"
|
||
await query.edit_message_text(full_report, parse_mode="Markdown")
|
||
elif data.startswith("host_"):
|
||
ip = data.replace("host_", "")
|
||
host = next((h for h in HOSTS_LIST if h['ip'] == ip), None)
|
||
if not host:
|
||
await query.edit_message_text("Хост не найден")
|
||
return
|
||
await query.edit_message_text(f"⏳ Собираю данные с {host['name']}...")
|
||
result = collect_metrics(host)
|
||
report = format_metrics(result)
|
||
await query.edit_message_text(report, parse_mode="Markdown")
|
||
|
||
async def scheduled_job(context: ContextTypes.DEFAULT_TYPE):
|
||
"""Функция, вызываемая раз в час"""
|
||
bot: Bot = context.bot
|
||
logger.info("🔄 Запущен плановый сбор метрик (каждый час)")
|
||
|
||
for host in HOSTS_LIST:
|
||
try:
|
||
logger.info(f"📊 Собираю данные с {host['name']}")
|
||
result = collect_metrics(host)
|
||
report = format_metrics(result)
|
||
await bot.send_message(chat_id=ADMIN_ID, text=report, parse_mode="Markdown")
|
||
logger.info(f"✅ Отправлен отчет для {host['name']}")
|
||
except Exception as e:
|
||
error_msg = f"❌ Ошибка при сборе/отправке для {host['name']}: {e}"
|
||
logger.error(error_msg)
|
||
await bot.send_message(chat_id=ADMIN_ID, text=error_msg)
|
||
|
||
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Команда /start - проверка, что бот жив"""
|
||
if update.effective_user.id != ADMIN_ID:
|
||
await update.message.reply_text("Привет! Я бот для мониторинга серверов, но ты не мой хозяин.")
|
||
return
|
||
|
||
await update.message.reply_text(
|
||
"🤖 *Бот мониторинга запущен!*\n\n"
|
||
"📌 Используй команду `/status`, чтобы получить актуальные метрики с серверов.\n"
|
||
"⏰ Каждый час я буду присылать автоматический отчет.\n\n"
|
||
f"📋 Настроено хостов: {len(HOSTS_LIST)}",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
def main():
|
||
"""Главная функция запуска бота"""
|
||
logger.info("🚀 Запуск бота...")
|
||
logger.info(f"📋 Загружено хостов: {len(HOSTS_LIST)}")
|
||
|
||
# Проверяем хосты при старте
|
||
if not HOSTS_LIST:
|
||
logger.error("❌ Нет хостов в конфигурации! Проверь .env файл!")
|
||
return
|
||
|
||
for host in HOSTS_LIST:
|
||
logger.info(f" - {host['name']} ({host['ip']}:{host['port']})")
|
||
|
||
app = Application.builder().token(BOT_TOKEN).build()
|
||
|
||
# Регистрируем команды
|
||
app.add_handler(CommandHandler("start", start_command))
|
||
app.add_handler(CommandHandler("status", status_command))
|
||
app.add_handler(CallbackQueryHandler(button_callback))
|
||
|
||
# Настройка шедулера (раз в час)
|
||
job_queue = app.job_queue
|
||
if job_queue:
|
||
# Запускаем через 10 секунд после старта, потом каждый час
|
||
job_queue.run_repeating(scheduled_job, interval=3600, first=10)
|
||
logger.info("⏰ Шедулер настроен: отчеты будут приходить каждый час")
|
||
else:
|
||
logger.warning("⚠️ JobQueue не доступна! Автоматические отчеты не будут работать.")
|
||
logger.warning("⚠️ Установи: pip install 'python-telegram-bot[job-queue]'")
|
||
|
||
logger.info("🤖 Бот запущен и готов к работе! Напиши /start в Telegram")
|
||
|
||
try:
|
||
app.run_polling()
|
||
except KeyboardInterrupt:
|
||
logger.info("🛑 Бот остановлен пользователем")
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка при работе бота: {e}")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|