mirror of
https://github.com/ada-dmitry/tg-monitor.git
synced 2026-09-24 01:10:24 +00:00
Инициализация репозитория
This commit is contained in:
@@ -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 в безопасном месте и не коммитьте реальные токены в публичные репозитории.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
"""Обернуть в <pre> с экранированием угловых скобок (минимальным).
|
||||
Здесь мы не экранируем полностью, т.к. выводим системные команды.
|
||||
Убедитесь, что строки не содержат HTML-тегов от пользователя.
|
||||
"""
|
||||
# Простейшая защита: заменим только '<' на '⟨' для предотвращения HTML-инъекций.
|
||||
return f"<pre>{s.replace('<', '⟨')}</pre>"
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Сбор метрик
|
||||
# ----------------------------
|
||||
|
||||
|
||||
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"🖥 <b>{host}</b>\n"
|
||||
f"⏱ Uptime: <code>{human_uptime()}</code>\n"
|
||||
f"📊 Load: <code>{load1:.2f} {load5:.2f} {load15:.2f}</code>\n"
|
||||
f"🧠 RAM: <code>{mem.used//(1024**2)}MB/{mem.total//(1024**2)}MB ({mem.percent}%)</code>\n"
|
||||
f"🔁 Swap: <code>{swap.used//(1024**2)}MB/{swap.total//(1024**2)}MB ({swap.percent}%)</code>\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}"
|
||||
# Нормализуем пробелы для красивого вывода в <pre>
|
||||
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()
|
||||
# Сообщение может быть длинным из-за <pre> блока темп-данных
|
||||
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 = "<b>🌡 Температуры</b>\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()
|
||||
@@ -0,0 +1,3 @@
|
||||
psutil
|
||||
python-telegram-bot
|
||||
dotenv
|
||||
Reference in New Issue
Block a user