Refactor bot and integrate services

This commit is contained in:
2026-02-07 22:10:08 +03:00
parent 492e3bd3cf
commit 588127c076
31 changed files with 1061 additions and 849 deletions

1
handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

74
handlers/artifacts.py Normal file
View File

@@ -0,0 +1,74 @@
import asyncio
import json
from datetime import datetime
from pathlib import Path
from aiogram import F
from aiogram.types import Message
from app import dp, ARTIFACT_STATE
from auth import is_admin_msg
from keyboards import artifacts_kb
from lock_utils import acquire_lock, release_lock
from services.artifacts import artifact_last
from services.runner import run_cmd
async def cmd_artifacts_status(msg: Message):
p = Path(ARTIFACT_STATE)
if not p.exists():
await msg.answer("❌ state.json не найден", reply_markup=artifacts_kb)
return
data = json.loads(p.read_text())
lines = [f"🧉 Artifacts ({len(data)})\n"]
for name, info in data.items():
t = datetime.fromisoformat(info["updated_at"])
lines.append(f"{name}{t:%Y-%m-%d %H:%M}")
await msg.answer("\n".join(lines), reply_markup=artifacts_kb)
async def cmd_artifacts_last(msg: Message):
p = Path(ARTIFACT_STATE)
if not p.exists():
await msg.answer("❌ state.json не найден", reply_markup=artifacts_kb)
return
try:
text = await asyncio.to_thread(artifact_last, ARTIFACT_STATE)
except Exception as e:
await msg.answer(f"❌ Last artifact failed: {type(e).__name__}: {e}", reply_markup=artifacts_kb)
return
await msg.answer(text, reply_markup=artifacts_kb)
async def cmd_artifacts_upload(msg: Message):
if not acquire_lock("artifacts"):
await msg.answer("⛔ Upload уже идёт", reply_markup=artifacts_kb)
return
await msg.answer("📤 Upload…", reply_markup=artifacts_kb)
async def worker():
try:
rc, out = await run_cmd(["sudo", "/usr/local/bin/backup.py", "artifact-upload"], timeout=12 * 3600)
await msg.answer(("✅ OK\n" if rc == 0 else "❌ FAIL\n") + out, reply_markup=artifacts_kb)
finally:
release_lock("artifacts")
asyncio.create_task(worker())
@dp.message(F.text == "🧉 Status")
async def ars(msg: Message):
if is_admin_msg(msg):
await cmd_artifacts_status(msg)
@dp.message(F.text == "🧉 Last artifact")
async def ala(msg: Message):
if is_admin_msg(msg):
await cmd_artifacts_last(msg)
@dp.message(F.text == "📤 Upload")
async def aru(msg: Message):
if is_admin_msg(msg):
await cmd_artifacts_upload(msg)

163
handlers/backup.py Normal file
View File

@@ -0,0 +1,163 @@
import asyncio
import json
from datetime import datetime
from aiogram import F
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
from app import dp
from auth import is_admin_msg
from keyboards import backup_kb
from lock_utils import acquire_lock, release_lock
from services.backup import backup_badge, last_backup, restore_help
from services.runner import run_cmd
async def cmd_repo_stats(msg: Message):
await msg.answer("⏳ Loading repo stats…", reply_markup=backup_kb)
# --- restore-size stats ---
rc1, raw1 = await run_cmd(
["restic", "stats", "--json"],
use_restic_env=True,
timeout=30
)
if rc1 != 0:
await msg.answer(raw1, reply_markup=backup_kb)
return
restore = json.loads(raw1)
# --- raw-data stats ---
rc2, raw2 = await run_cmd(
["restic", "stats", "--json", "--mode", "raw-data"],
use_restic_env=True,
timeout=30
)
if rc2 != 0:
await msg.answer(raw2, reply_markup=backup_kb)
return
raw = json.loads(raw2)
# --- snapshots count ---
rc3, raw_snaps = await run_cmd(
["restic", "snapshots", "--json"],
use_restic_env=True,
timeout=20
)
snaps = len(json.loads(raw_snaps)) if rc3 == 0 else "n/a"
msg_text = (
"📦 **Repository stats**\n\n"
f"🧉 Snapshots: {snaps}\n"
f"📁 Files: {restore.get('total_file_count', 'n/a')}\n"
f"💽 Logical size: {restore.get('total_size', 0) / (1024**3):.2f} GiB\n"
f"🧱 Stored data: {raw.get('total_pack_size', 0) / (1024**2):.2f} MiB\n"
)
await msg.answer(msg_text, reply_markup=backup_kb, parse_mode="Markdown")
async def cmd_backup_status(msg: Message):
await msg.answer("⏳ Loading snapshots…", reply_markup=backup_kb)
async def worker():
rc, raw = await run_cmd(
["restic", "snapshots", "--json"],
use_restic_env=True,
timeout=30
)
if rc != 0:
await msg.answer(raw, reply_markup=backup_kb)
return
snaps = json.loads(raw)
if not snaps:
await msg.answer("📦 Snapshots: none", reply_markup=backup_kb)
return
snaps.sort(key=lambda s: s["time"], reverse=True)
# --- badge ---
last = snaps[0]
last_time = datetime.fromisoformat(
last["time"].replace("Z", "+00:00")
)
badge = backup_badge(last_time)
# --- buttons ---
rows = []
for s in snaps[:5]:
t = datetime.fromisoformat(
s["time"].replace("Z", "+00:00")
)
rows.append([
InlineKeyboardButton(
text=f"🧉 {s['short_id']} | {t:%Y-%m-%d %H:%M}",
callback_data=f"snap:{s['short_id']}"
)
])
kb = InlineKeyboardMarkup(inline_keyboard=rows)
await msg.answer(
f"📦 Snapshots ({len(snaps)})\n{badge}",
reply_markup=kb
)
asyncio.create_task(worker())
async def cmd_backup_now(msg: Message):
if not acquire_lock("backup"):
await msg.answer("⚠️ Backup уже выполняется", reply_markup=backup_kb)
return
await msg.answer("▶️ Backup запущен", reply_markup=backup_kb)
async def worker():
try:
rc, out = await run_cmd(["sudo", "/usr/local/bin/backup.py", "restic-backup"], timeout=6 * 3600)
await msg.answer(("✅ OK\n" if rc == 0 else "❌ FAIL\n") + out, reply_markup=backup_kb)
finally:
release_lock("backup")
asyncio.create_task(worker())
async def cmd_last_backup(msg: Message):
try:
text = await asyncio.to_thread(last_backup)
except Exception as e:
await msg.answer(f"❌ Last backup failed: {type(e).__name__}: {e}", reply_markup=backup_kb)
return
await msg.answer(text, reply_markup=backup_kb)
@dp.message(F.text == "📦 Status")
async def bs(msg: Message):
if is_admin_msg(msg):
await cmd_backup_status(msg)
@dp.message(F.text == "📊 Repo stats")
async def rs(msg: Message):
if is_admin_msg(msg):
await cmd_repo_stats(msg)
@dp.message(F.text == "📦 Last backup")
async def lb(msg: Message):
if is_admin_msg(msg):
await cmd_last_backup(msg)
@dp.message(F.text == "▶️ Run backup")
async def br(msg: Message):
if is_admin_msg(msg):
await cmd_backup_now(msg)
@dp.message(F.text == "🧯 Restore help")
async def rh(msg: Message):
if is_admin_msg(msg):
await msg.answer(restore_help(), reply_markup=backup_kb)

87
handlers/callbacks.py Normal file
View File

@@ -0,0 +1,87 @@
import json
from aiogram import F
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from app import dp, ADMIN_ID
from services.runner import run_cmd
from state import DOCKER_MAP
from handlers.backup import cmd_backup_status
@dp.callback_query(F.data.startswith("docker:"))
async def docker_callback(cb: CallbackQuery):
if cb.from_user.id != ADMIN_ID:
return
_, action, alias = cb.data.split(":", 2)
real = DOCKER_MAP[alias]
if action == "restart":
await cb.answer("Restarting…")
rc, out = await run_cmd(["sudo", "docker", "restart", real])
await cb.message.answer(
f"🔄 **{alias} restarted**\n```{out}```",
parse_mode="Markdown"
)
elif action == "logs":
await cb.answer("Loading logs…")
rc, out = await run_cmd(
["sudo", "docker", "logs", "--tail", "80", real]
)
await cb.message.answer(
f"📜 **Logs: {alias}**\n```{out}```",
parse_mode="Markdown"
)
@dp.callback_query(F.data.startswith("snap:"))
async def snapshot_details(cb: CallbackQuery):
if cb.from_user.id != ADMIN_ID:
return
snap_id = cb.data.split(":", 1)[1]
await cb.answer("Loading snapshot…")
# получаем статистику snapshot
rc, raw = await run_cmd(
["restic", "stats", snap_id, "--json"],
use_restic_env=True,
timeout=20
)
if rc != 0:
await cb.message.answer(raw)
return
stats = json.loads(raw)
msg = (
f"🧉 **Snapshot {snap_id}**\n\n"
f"📁 Files: {stats.get('total_file_count', 'n/a')}\n"
f"💽 Size: {stats.get('total_size', 0) / (1024**3):.2f} GiB\n\n"
"🧯 Restore:\n"
f"`restic restore {snap_id} --target /restore`\n"
)
back_kb = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="⬅️ Back to snapshots",
callback_data="snapback"
)
]
]
)
await cb.message.answer(msg, reply_markup=back_kb, parse_mode="Markdown")
@dp.callback_query(F.data == "snapback")
async def snapshot_back(cb: CallbackQuery):
await cb.answer()
# просто вызываем статус снова
fake_msg = cb.message
await cmd_backup_status(fake_msg)

81
handlers/docker.py Normal file
View File

@@ -0,0 +1,81 @@
from aiogram import F
from aiogram.types import Message
from app import dp
from auth import is_admin_msg
from keyboards import docker_kb, docker_inline_kb
from services.docker import container_uptime
from services.runner import run_cmd
from state import DOCKER_MAP
async def cmd_docker_status(msg: Message):
try:
if not DOCKER_MAP:
await msg.answer(
"⚠️ DOCKER_MAP пуст.\n"
"Контейнеры не обнаружены.",
reply_markup=docker_kb,
)
return
lines = ["🐳 Docker containers\n"]
for alias, real in DOCKER_MAP.items():
rc, raw = await run_cmd(
[
"sudo", "docker", "inspect",
"-f", "{{.State.Status}}|{{.State.StartedAt}}",
real
],
timeout=10,
)
if rc != 0:
lines.append(f"🔴 {alias}: inspect error")
continue
raw = raw.strip()
if "|" not in raw:
lines.append(f"🟡 {alias}: invalid inspect output")
continue
status, started = raw.split("|", 1)
up = container_uptime(started)
icon = "🟢" if status == "running" else "🔴"
lines.append(f"{icon} {alias}: {status} ({up})")
await msg.answer("\n".join(lines), reply_markup=docker_kb)
except Exception as e:
# ⬅️ КРИТИЧЕСКИ ВАЖНО
await msg.answer(
"❌ Docker status crashed:\n"
f"```{type(e).__name__}: {e}```",
reply_markup=docker_kb,
parse_mode="Markdown",
)
@dp.message(F.text == "🔄 Restart")
async def dr(msg: Message):
if is_admin_msg(msg):
await msg.answer(
"🔄 Выберите контейнер для рестарта:",
reply_markup=docker_inline_kb("restart")
)
@dp.message(F.text == "📜 Logs")
async def dl(msg: Message):
if is_admin_msg(msg):
await msg.answer(
"📜 Выберите контейнер для логов:",
reply_markup=docker_inline_kb("logs")
)
@dp.message(F.text == "🐳 Status")
async def ds(msg: Message):
if is_admin_msg(msg):
await cmd_docker_status(msg)

24
handlers/help.py Normal file
View File

@@ -0,0 +1,24 @@
from aiogram import F
from aiogram.types import Message
from app import dp
from auth import is_admin_msg
from keyboards import menu_kb
@dp.message(F.text == " Help")
async def help_cmd(msg: Message):
if not is_admin_msg(msg):
return
await msg.answer(
" **Help / Справка**\n\n"
"🩺 Health — быстрый health-check сервера\n"
"📊 Статус — общая загрузка сервера\n"
"🐳 Docker — управление контейнерами\n"
"📦 Backup — restic бэкапы\n"
"🧉 Artifacts — критичные образы (Clonezilla, NAND)\n"
"⚙️ System — диски, безопасность, reboot\n\n"
"Inline-кнопки используются для выбора контейнеров.",
reply_markup=menu_kb,
parse_mode="Markdown",
)

41
handlers/menu.py Normal file
View File

@@ -0,0 +1,41 @@
from aiogram import F
from aiogram.types import Message
from app import dp
from auth import is_admin_msg
from keyboards import menu_kb, docker_kb, backup_kb, artifacts_kb, system_kb
@dp.message(F.text == "/start")
async def start(msg: Message):
if is_admin_msg(msg):
await msg.answer("🏠 Главное меню", reply_markup=menu_kb)
@dp.message(F.text == "⬅️ Назад")
async def back(msg: Message):
if is_admin_msg(msg):
await msg.answer("🏠 Главное меню", reply_markup=menu_kb)
@dp.message(F.text == "🐳 Docker")
async def dm(msg: Message):
if is_admin_msg(msg):
await msg.answer("🐳 Docker", reply_markup=docker_kb)
@dp.message(F.text == "📦 Backup")
async def bm(msg: Message):
if is_admin_msg(msg):
await msg.answer("📦 Backup", reply_markup=backup_kb)
@dp.message(F.text == "🧉 Artifacts")
async def am(msg: Message):
if is_admin_msg(msg):
await msg.answer("🧉 Artifacts", reply_markup=artifacts_kb)
@dp.message(F.text == "⚙️ System")
async def sm(msg: Message):
if is_admin_msg(msg):
await msg.answer("⚙️ System", reply_markup=system_kb)

69
handlers/status.py Normal file
View File

@@ -0,0 +1,69 @@
import asyncio
import socket
import time
import psutil
from aiogram import F
from aiogram.types import Message
from app import dp, cfg
from auth import is_admin_msg
from keyboards import menu_kb
from services.system import format_disks
from services.health import health
from state import DOCKER_MAP
async def cmd_status(msg: Message):
now = time.time()
uptime_sec = int(now - psutil.boot_time())
days, rem = divmod(uptime_sec, 86400)
hours, rem = divmod(rem, 3600)
minutes, _ = divmod(rem, 60)
load1 = psutil.getloadavg()[0]
cpu_icon = "🟢"
if load1 > 2.0:
cpu_icon = "🔴"
elif load1 > 1.0:
cpu_icon = "🟡"
mem = psutil.virtual_memory()
disks = format_disks()
await msg.answer(
"📊 **Server status**\n\n"
f"🖥 **Host:** `{socket.gethostname()}`\n"
f"⏱ **Uptime:** {days}d {hours}h {minutes}m\n"
f"{cpu_icon} **Load (1m):** {load1:.2f}\n"
f"🧠 **RAM:** {mem.used // (1024**3)} / {mem.total // (1024**3)} GiB ({mem.percent}%)\n\n"
f"{disks}",
reply_markup=menu_kb,
parse_mode="Markdown",
)
async def cmd_health(msg: Message):
await msg.answer("⏳ Health-check…", reply_markup=menu_kb)
async def worker():
try:
text = await asyncio.to_thread(health, cfg, DOCKER_MAP)
except Exception as e:
await msg.answer(f"❌ Health failed: {type(e).__name__}: {e}", reply_markup=menu_kb)
return
await msg.answer(text, reply_markup=menu_kb)
asyncio.create_task(worker())
@dp.message(F.text == "🩺 Health")
async def h(msg: Message):
if is_admin_msg(msg):
await cmd_health(msg)
@dp.message(F.text == "📊 Статус")
async def st(msg: Message):
if is_admin_msg(msg):
await cmd_status(msg)

18
handlers/system.py Normal file
View File

@@ -0,0 +1,18 @@
from aiogram import F
from aiogram.types import Message
from app import dp
from auth import is_admin_msg
from keyboards import system_kb
from system_checks import security, disks
@dp.message(F.text == "💽 Disks")
async def sd(msg: Message):
if is_admin_msg(msg):
await msg.answer(disks(), reply_markup=system_kb)
@dp.message(F.text == "🔐 Security")
async def sec(msg: Message):
if is_admin_msg(msg):
await msg.answer(security(), reply_markup=system_kb)