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()