Инициализация репозитория

This commit is contained in:
ada
2025-08-26 11:39:41 +03:00
commit ab295f033f
4 changed files with 409 additions and 0 deletions
+14
View File
@@ -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 в безопасном месте и не коммитьте реальные токены в публичные репозитории.
+36
View File
@@ -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.
+356
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
psutil
python-telegram-bot
dotenv