From ab295f033f209a83b2324d9ede5ba6f99e2e514f Mon Sep 17 00:00:00 2001 From: ada Date: Tue, 26 Aug 2025 11:39:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BD=D0=B8=D1=86=D0=B8=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 ++ README.md | 36 +++++ main.py | 356 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 4 files changed, 409 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..72018c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Пример файла .env для tg-monitor +# Копируйте этот файл в .env и замените значения реальными. + +# Токен Telegram-бота (обязательный) +# Получите у @BotFather, формат: 123456789:AA... или похожий +BOT_TOKEN=PASTE_YOUR_BOT_TOKEN_HERE + +# Разрешённые chat_id через запятую (опционально) +# Если не задано — бот отвечает всем чатам. +# Пример: ALLOWED_CHAT_IDS=123456789,987654321 +ALLOWED_CHAT_IDS= + +# Примечание: +# Храните .env в безопасном месте и не коммитьте реальные токены в публичные репозитории. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a78277 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +tg-monitor — простой Telegram-бот для мониторинга Linux-сервера. + +Требования +- Python 3.8+ +- Виртуальное окружение (рекомендовано) +- Доступ к системным утилитам: sensors, nvme, smartctl, df, ps (по необходимости) + +Установка +1. Создайте и активируйте виртуальное окружение: + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +2. Установите зависимости: + +```bash +pip install -r requirements.txt +``` + +3. Скопируйте пример `.env` и заполните токен: + +```bash +cp .env.example .env +# Отредактируйте .env: BOT_TOKEN и ALLOWED_CHAT_IDS +``` + +Запуск + +```bash +python main.py +``` + +Примечания +- Бота можно добавить, как демон и запускать автоматически от root. diff --git a/main.py b/main.py new file mode 100644 index 0000000..3ccde2e --- /dev/null +++ b/main.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import platform +import shlex +import shutil +import subprocess +import textwrap +import time +from datetime import timedelta +from typing import Iterable, List, Optional, Tuple + +import psutil +from telegram import Update +from telegram.constants import ParseMode +from telegram.ext import Application, CommandHandler, ContextTypes + +from dotenv import load_dotenv + + +# ---------------------------- +# Конфигурация из переменных окружения +# ---------------------------- +load_dotenv() +BOT_TOKEN = os.environ.get("BOT_TOKEN", "").strip() +ALLOWED_CHAT_IDS_RAW = os.environ.get("ALLOWED_CHAT_IDS", "").strip() + +if not BOT_TOKEN: + raise SystemExit("[FATAL] Переменная окружения BOT_TOKEN не задана.") + +# Разрешённые чаты: список целых chat_id, если пусто — разрешено всем. +ALLOWED_CHAT_IDS: Optional[List[int]] = None +if ALLOWED_CHAT_IDS_RAW: + try: + ALLOWED_CHAT_IDS = [ + int(x.strip()) for x in ALLOWED_CHAT_IDS_RAW.split(",") if x.strip() + ] + except Exception as e: + raise SystemExit( + f"Неверное значение ALLOWED_CHAT_IDS: {ALLOWED_CHAT_IDS_RAW!r}: {e}" + ) + +# ---------------------------- +# Логирование +# ---------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) +log = logging.getLogger("telebot") + +# ---------------------------- +# Утилиты +# ---------------------------- + + +def human_uptime() -> str: + """Возвращает аптайм в виде 'Xd Yh Zm'.""" + boot = psutil.boot_time() + seconds = int(time.time() - boot) + td = timedelta(seconds=seconds) + days = td.days + hours, rem = divmod(td.seconds, 3600) + minutes, _ = divmod(rem, 60) + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + parts.append(f"{minutes}m") + return " ".join(parts) + + +def run_cmd(cmd: str, timeout: int = 8) -> Tuple[int, str, str]: + """Выполняет shell-команду и возвращает (returncode, stdout, stderr). + Используйте только для фиксированных команд, НЕ подставляйте пользовательский ввод. + """ + try: + proc = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=timeout, + ) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + except subprocess.TimeoutExpired: + return 124, "", f"Timeout after {timeout}s for: {cmd}" + except Exception as e: + return 125, "", f"Exception for '{cmd}': {e}" + + +def chunk_text(text: str, limit: int = 4000) -> Iterable[str]: + """Бережно режет большой текст на части < limit символов, сохраняя границы строк. + Учитывайте лимит Telegram: 4096 для текста, 4096-~100 для HTML. + """ + lines = text.splitlines(True) + buf = [] + size = 0 + for ln in lines: + if size + len(ln) > limit and buf: + yield "".join(buf) + buf = [ln] + size = len(ln) + else: + buf.append(ln) + size += len(ln) + if buf: + yield "".join(buf) + + +def fmt_pre(s: str) -> str: + """Обернуть в
 с экранированием угловых скобок (минимальным).
+    Здесь мы не экранируем полностью, т.к. выводим системные команды.
+    Убедитесь, что строки не содержат HTML-тегов от пользователя.
+    """
+    # Простейшая защита: заменим только '<' на '⟨' для предотвращения HTML-инъекций.
+    return f"
{s.replace('<', '⟨')}
" + + +# ---------------------------- +# Сбор метрик +# ---------------------------- + + +def collect_status() -> str: + """Формирует краткую сводку по системе.""" + host = platform.node() + try: + load1, load5, load15 = os.getloadavg() # type: ignore[attr-defined] + except Exception: + load1 = load5 = load15 = 0.0 + + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + + # Краткие температуры из sensors (если есть) + rc, sens_out, _ = run_cmd( + "sensors | awk '/^Package id [0-9]:/||/^Tctl:/||/^Tdie:/{gsub(/\+/,\"\"); print}' | head -n 3" + ) + short_temp = sens_out if rc == 0 and sens_out else "" + + msg = ( + f"🖥 {host}\n" + f"⏱ Uptime: {human_uptime()}\n" + f"📊 Load: {load1:.2f} {load5:.2f} {load15:.2f}\n" + f"🧠 RAM: {mem.used//(1024**2)}MB/{mem.total//(1024**2)}MB ({mem.percent}%)\n" + f"🔁 Swap: {swap.used//(1024**2)}MB/{swap.total//(1024**2)}MB ({swap.percent}%)\n" + ) + + if short_temp: + msg += f"🌡 Temp:\n{fmt_pre(short_temp)}\n" + return msg + + +def collect_df() -> str: + """Возвращает вывод df -h (без tmpfs/devtmpfs) в табличном виде.""" + rc, out, err = run_cmd( + "df -h --output=source,size,used,avail,pcent,target -x tmpfs -x devtmpfs" + ) + if rc != 0: + return f"Ошибка df: {err or out or rc}" + # Нормализуем пробелы для красивого вывода в
+    norm = "\n".join(" ".join(line.split()) for line in out.splitlines())
+    return fmt_pre(norm)
+
+
+def collect_top(n: int = 10) -> str:
+    """Топ процессов по CPU (через ps для быстроты)."""
+    cmd = f"ps -eo pid,comm,%cpu,%mem --sort=-%cpu | head -n {n + 1}"
+    rc, out, err = run_cmd(cmd)
+    if rc != 0:
+        return f"Ошибка ps: {err or out or rc}"
+    return "🔥 Топ по CPU:\n" + fmt_pre(out)
+
+
+def collect_temps_extended() -> str:
+    """Расширенный сбор температур: CPU, NVMe, SATA/SSD/HDD, GPU NVIDIA, thermal zones."""
+    parts: List[str] = []
+
+    # CPU / общие датчики (lm-sensors)
+    rc, sens, _ = run_cmd("sensors | sed 's/+//g'")
+    if rc == 0 and sens:
+        parts.append("CPU / sensors:\n" + sens)
+
+    # NVMe
+    nvme_lines: List[str] = []
+    for dev in sorted(
+        p for p in os.listdir("/dev") if p.startswith("nvme") and p.endswith("n1")
+    ):
+        path = f"/dev/{dev}"
+        # nvme smart-log ...
+        rc, out, _ = run_cmd(
+            f"nvme smart-log {shlex.quote(path)} | awk '/^temperature/ {{print $3}}'"
+        )
+        if rc == 0 and out:
+            nvme_lines.append(f"{path}: {out}°C")
+    if nvme_lines:
+        parts.append("NVMe:\n" + "\n".join(nvme_lines))
+
+    # SATA/SSD/HDD (smartctl)
+    sata_lines: List[str] = []
+    # Перебираем /dev/sd? устройства
+    for letter in [chr(c) for c in range(ord("a"), ord("z") + 1)]:
+        dev = f"/dev/sd{letter}"
+        if not os.path.exists(dev):
+            continue
+        rc, sens_out, _ = run_cmd(
+            "sensors | sed 's/+//g' | awk '/^Package id [0-9]:/||/^Tctl:/||/^Tdie:/{print}' | head -n 3"
+        )
+        if rc == 0 and out:
+            sata_lines.append(f"{dev}: {out}°C")
+    if sata_lines:
+        parts.append("SATA/SSD/HDD:\n" + "\n".join(sata_lines))
+
+    # NVIDIA GPU
+    if shutil.which("nvidia-smi"):
+        rc, out, _ = run_cmd(
+            "nvidia-smi --query-gpu=name,temperature.gpu --format=csv,noheader"
+        )
+        if rc == 0 and out:
+            parts.append("GPU (NVIDIA):\n" + out)
+
+    # Fallback: thermal zones
+    if not sens:
+        tz_lines: List[str] = []
+        base = "/sys/class/thermal"
+        if os.path.isdir(base):
+            for entry in sorted(os.listdir(base)):
+                if not entry.startswith("thermal_zone"):
+                    continue
+                tfile = os.path.join(base, entry, "temp")
+                try:
+                    with open(tfile, "r") as f:
+                        val = f.read().strip()
+                    c = float(val) / 1000.0
+                    tz_lines.append(f"{entry}: {c:.1f}°C")
+                except Exception:
+                    continue
+        if tz_lines:
+            parts.append("Thermal zones:\n" + "\n".join(tz_lines))
+
+    return "\n\n".join(parts) if parts else "Датчики не найдены."
+
+
+# ----------------------------
+# Проверка доступа
+# ----------------------------
+
+
+def is_allowed(chat_id: int) -> bool:
+    """Если ALLOWED_CHAT_IDS задан — пропускаем только из этого списка."""
+    if ALLOWED_CHAT_IDS is None:
+        return True
+    return chat_id in ALLOWED_CHAT_IDS
+
+
+async def guard(update: Update) -> bool:
+    """Возвращает True, если доступ разрешён, иначе отправляет отказ."""
+    chat = update.effective_chat
+    if chat is None:
+        return False
+    if is_allowed(chat.id):
+        return True
+    try:
+        await update.effective_message.reply_text("⛔ Доступ запрещён для этого чата.")
+    except Exception:
+        pass
+    log.warning("Access denied for chat_id=%s", chat.id)
+    return False
+
+
+# ----------------------------
+# Хендлеры команд
+# ----------------------------
+
+HELP_TEXT = textwrap.dedent(
+    """
+    Доступные команды:
+      /status — сводка по системе (uptime, load, RAM, swap, temp кратко)
+      /df — использование дисков (df -h)
+      /top — топ процессов по CPU
+      /docker — загрузка контейнеров Docker
+      /temp — расширенная сводка температур
+      /help — эта справка
+    """
+).strip()
+
+
+async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+    if not await guard(update):
+        return
+    await update.message.reply_html("👋 Готов к работе.\n" + fmt_pre(HELP_TEXT))
+
+
+async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+    if not await guard(update):
+        return
+    await update.message.reply_html(fmt_pre(HELP_TEXT))
+
+
+async def status_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+    if not await guard(update):
+        return
+    msg = collect_status()
+    # Сообщение может быть длинным из-за 
 блока темп-данных
+    for chunk in chunk_text(msg, limit=3900):
+        await update.message.reply_html(chunk)
+
+
+async def df_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+    if not await guard(update):
+        return
+    await update.message.reply_html(collect_df())
+
+
+async def top_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+    if not await guard(update):
+        return
+    await update.message.reply_html(collect_top())
+
+
+async def temp_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+    if not await guard(update):
+        return
+    data = collect_temps_extended()
+    # Разбиваем, т.к. sensors может быть очень многословным
+    text = "🌡 Температуры\n" + fmt_pre(data)
+    for chunk in chunk_text(text, limit=3900):
+        await update.message.reply_html(chunk)
+
+
+# ----------------------------
+# main()
+# ----------------------------
+
+
+def main() -> None:
+    log.info("Starting bot…")
+    app = Application.builder().token(BOT_TOKEN).build()
+
+    # тут ваши add_handler(...)
+    app.add_handler(CommandHandler(["start", "help"], start))
+    app.add_handler(CommandHandler("status", status_cmd))
+    app.add_handler(CommandHandler("df", df_cmd))
+    app.add_handler(CommandHandler("top", top_cmd))
+    app.add_handler(CommandHandler("temp", temp_cmd))
+
+    # Блокирующий вызов — без await и без asyncio.run(...)
+    app.run_polling()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..ed75ee6
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+psutil
+python-telegram-bot
+dotenv