From 0ea4b0d69e95f9a1a719d308be40ca4202d91d47 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 27 Jun 2026 00:27:47 +0300 Subject: [PATCH] =?UTF-8?q?version=201.3:=20inline-=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B8=20=D0=B2=20=D0=B1=D0=BE=D1=82=D0=B5,=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bot.py: полная переработка — inline-клавиатура вместо текстовых команд (/users, /create, /delete, /warp, /stats, /dashboard, /help через кнопки) - bot.py: исправлен /create (secrets/string теперь в top-level импорте), escape_md для MarkdownV2 - templates/index.html: добавлен пропавший в секции мониторинга (ломало все вкладки кроме дашборда) - README: обновлена дорожная карта --- README.md | 5 +- bot.py | 371 ++++++++++++++++++++++++++----------------- templates/index.html | 3 +- 3 files changed, 229 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 09bfd7c..b9183a6 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ curl -Ls https://raw.githubusercontent.com/grendervilll/mitactl/main/bootstrap.s - [x] Защита сессий: динамический Secure cookie, in-memory rate limiter - [x] Bootstrap-установщик (одна команда) - [x] Мониторинг сервера: CPU, RAM, диск на дашборде (автообновление каждые 2 сек) -- [x] Telegram-бот: создание/удаление пользователей, трафик, WARP, мониторинг CPU/RAM/диск -- [x] Установка бота через веб-панель, CLI и полный установщик +- [x] Telegram-бот: inline-кнопки, создание/удаление/WARP/дашборд/системная статистика +- [x] Установка бота: веб-панель, CLI, полный установщик; автоопределение Telegram ID через API +- [x] Установка бота через pip в venv (обход externally-managed на новых Debian/Ubuntu) ### ⬜ Запланировано diff --git a/bot.py b/bot.py index 967f9e0..7b9ad9e 100644 --- a/bot.py +++ b/bot.py @@ -2,7 +2,7 @@ """ mita Telegram Bot — управление сервером через Telegram """ -import os, json, re, subprocess, psutil, logging +import os, json, re, subprocess, secrets, string, psutil, logging from datetime import datetime from pathlib import Path @@ -15,8 +15,8 @@ MITA_CONFIG = "/etc/mita/server_config.json" PANEL_CONFIG = "/etc/mita/panel.json" try: - from telegram import Update - from telegram.ext import Application, CommandHandler, ContextTypes + from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup + from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes except ImportError: _log.error("python-telegram-bot не установлен. pip install python-telegram-bot") raise SystemExit(1) @@ -82,6 +82,18 @@ def _mita_running(): except Exception: return False +def gen_password(length=64): + chars = string.ascii_letters + string.digits + "!@#%^*_-=+?." + return "".join(secrets.choice(chars) for _ in range(length)) + +def gen_username(): + adj = ["swift","brave","quiet","cool","sharp","calm","bright","dark","wild","free"] + nouns = ["fox","hawk","river","storm","ember","peak","orbit","tide","frost","spark"] + return f"{secrets.choice(adj)}_{secrets.choice(nouns)}_{secrets.randbelow(9000)+1000}" + +def escape_md(text): + return str(text).replace("_","\\_").replace("*","\\*").replace("[","\\[").replace("]","\\]").replace("(","\\(").replace(")","\\)").replace("~","\\~").replace(">","\\>").replace("#","\\#").replace("+","\\+").replace("-","\\-").replace("=","\\=").replace("|","\\|").replace("{","\\{").replace("}","\\}").replace(".","\\.").replace("!","\\!") + # ── auth ────────────────────────────────────────────────────────────────────── def admin_only(func): async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -89,83 +101,76 @@ def admin_only(func): cfg = load_bot_config() admins = cfg.get("admin_ids", []) if user_id not in admins: - await update.message.reply_text("⛔ Доступ запрещён.") + if update.callback_query: + await update.callback_query.answer("Доступ запрещён") + elif update.message: + await update.message.reply_text("⛔ Доступ запрещён.") return return await func(update, context) return wrapper -# ── commands ────────────────────────────────────────────────────────────────── +# ── keyboards ───────────────────────────────────────────────────────────────── +MAIN_KEYBOARD = InlineKeyboardMarkup([ + [InlineKeyboardButton("👥 Пользователи", callback_data="users"), + InlineKeyboardButton("📊 Дашборд", callback_data="dashboard")], + [InlineKeyboardButton("➕ Создать", callback_data="create"), + InlineKeyboardButton("🗑 Удалить", callback_data="delete_menu")], + [InlineKeyboardButton("🔄 WARP", callback_data="warp_menu"), + InlineKeyboardButton("📊 Статистика", callback_data="stats")], + [InlineKeyboardButton("ℹ️ Помощь", callback_data="help")], +]) + +def back_button(data="main"): + return InlineKeyboardMarkup([[InlineKeyboardButton("« Назад", callback_data=data)]]) + +# ── main menu ───────────────────────────────────────────────────────────────── @admin_only async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text( - "🤖 *mita Bot* готов к работе\\.\n\n" - "/users \\- список пользователей\n" - "/create \\[имя\\] \\- создать пользователя\n" - "/delete \\<имя\\> \\- удалить пользователя\n" - "/warp \\<имя\\> on\\|off \\- управление WARP\n" - "/stats \\- статистика сервера\n" - "/dashboard \\- полный дашборд\n" - "/help \\- все команды", + "🤖 *mita Bot*\nВыберите действие:", + reply_markup=MAIN_KEYBOARD, parse_mode="MarkdownV2" ) @admin_only -async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): - await update.message.reply_text( - "📋 *Команды*\n\n" - "/users \\- список пользователей с трафиком и статусом\n" - "/create \\- создать пользователя \\(случайное имя\\)\n" - "/create `имя` \\- создать с указанным именем\n" - "/delete `имя` \\- удалить пользователя\n" - "/warp `имя` on \\- включить WARP\n" - "/warp `имя` off \\- выключить WARP\n" - "/stats \\- CPU, RAM, диск\n" - "/dashboard \\- полный обзор сервера\n" - "/config `имя` \\- клиентский конфиг", +async def show_main(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + await query.edit_message_text( + "🤖 *mita Bot*\nВыберите действие:", + reply_markup=MAIN_KEYBOARD, parse_mode="MarkdownV2" ) +# ── users ───────────────────────────────────────────────────────────────────── @admin_only -async def cmd_users(update: Update, context: ContextTypes.DEFAULT_TYPE): +async def show_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() cfg = load_mita_config() users = cfg.get("users", []) if not users: - await update.message.reply_text("👤 Нет пользователей.") + await query.edit_message_text("👤 Нет пользователей.", reply_markup=back_button()) return raw = mita_cmd("get", "users") pc = load_panel_config() warp_users = set(pc.get("warp_users", [])) - - lines = [] today = datetime.now().date() - data_lines = [] + stats_map = {} for line in raw.splitlines(): line = line.strip() if not line or line.upper().startswith("USER"): continue - data_lines.append(line) - - stats_map = {} - for line in data_lines: parts = line.split() if len(parts) < 8: continue try: name = parts[0] - - d1_down = _parse_traffic(parts[2]) - d1_up = _parse_traffic(parts[3]) - d7_down = _parse_traffic(parts[4]) - d7_up = _parse_traffic(parts[5]) - d30_down = _parse_traffic(parts[6]) - d30_up = _parse_traffic(parts[7]) - - d1_bytes = d1_down + d1_up - d7_bytes = d7_down + d7_up - d30_bytes = d30_down + d30_up - + d1_bytes = _parse_traffic(parts[2]) + _parse_traffic(parts[3]) + d7_bytes = _parse_traffic(parts[4]) + _parse_traffic(parts[5]) + d30_bytes = _parse_traffic(parts[6]) + _parse_traffic(parts[7]) online = False la_raw = parts[1] if la_raw and la_raw.lower() not in ("never","-","n/a","никогда"): @@ -175,7 +180,6 @@ async def cmd_users(update: Update, context: ContextTypes.DEFAULT_TYPE): online = True except Exception: pass - stats_map[name] = {"online": online, "day_mb": round(d1_bytes/1024/1024, 2), "week_mb": round(d7_bytes/1024/1024, 2), @@ -183,44 +187,41 @@ async def cmd_users(update: Update, context: ContextTypes.DEFAULT_TYPE): except Exception: continue + lines = ["👥 *Пользователи*\n"] for u in users: name = u["name"] s = stats_map.get(name, {}) w = "🟢" if name in warp_users else "⚪" o = "🟢" if s.get("online") else "🔴" day = f"{s.get('day_mb', 0):.1f} МБ" if s.get("day_mb") else "—" + wk = f"{s.get('week_mb', 0):.1f} МБ" if s.get("week_mb") else "—" mon = f"{s.get('month_mb', 0):.1f} МБ" if s.get("month_mb") else "—" - lines.append(f"{o} `{name}` {w} \\| день: {day} \\| мес: {mon}") + ename = escape_md(name) + lines.append(f"{o} {ename} {w} день: {day} нед: {wk} мес: {mon}") - await update.message.reply_text( - "👥 *Пользователи*\n\n" + "\n".join(lines), + await query.edit_message_text( + "\n".join(lines), + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("➕ Создать", callback_data="create"), + InlineKeyboardButton("🗑 Удалить", callback_data="delete_menu")], + [InlineKeyboardButton("🔄 WARP", callback_data="warp_menu")], + [InlineKeyboardButton("« Назад", callback_data="main")], + ]), parse_mode="MarkdownV2" ) +# ── create ──────────────────────────────────────────────────────────────────── @admin_only -async def cmd_create(update: Update, context: ContextTypes.DEFAULT_TYPE): - args = context.args +async def create_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() cfg = load_mita_config() existing = {u["name"] for u in cfg.get("users", [])} + name = gen_username() + while name in existing: + name = gen_username() - if args: - name = args[0].strip() - else: - import secrets - adj = ["swift","brave","quiet","cool","sharp","calm","bright","dark","wild","free"] - nouns = ["fox","hawk","river","storm","ember","peak","orbit","tide","frost","spark"] - name = f"{secrets.choice(adj)}_{secrets.choice(nouns)}_{secrets.randbelow(9000)+1000}" - while name in existing: - name = f"{secrets.choice(adj)}_{secrets.choice(nouns)}_{secrets.randbelow(9000)+1000}" - - if name in existing: - await update.message.reply_text(f"❌ Пользователь `{name}` уже существует\\.") - return - - import string - chars = string.ascii_letters + string.digits + "!@#%^*_-=+?." - password = "".join(secrets.choice(chars) for _ in range(64)) - + password = gen_password() cfg.setdefault("users", []).append({"name": name, "password": password}) Path(MITA_CONFIG).write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) @@ -231,100 +232,137 @@ async def cmd_create(update: Update, context: ContextTypes.DEFAULT_TYPE): bindings = cfg.get("portBindings", [{}])[0] port = bindings.get("portRange", str(bindings.get("port","?"))) - await update.message.reply_text( - f"✅ Пользователь *{name}* создан\\.\n\n" - f"Сервер: `{ip}`\n" - f"Порт: `{port}`\n" - f"Пароль: `{password}`\n\n" - f"Получить JSON\\-конфиг: /config `{name}`", + ename = escape_md(name) + await query.edit_message_text( + f"✅ Пользователь *{ename}* создан\n\n" + f"Сервер: `{ip}`\nПорт: `{port}`\nПароль: `{password}`", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("➕ Ещё одного", callback_data="create")], + [InlineKeyboardButton("« Назад", callback_data="main")], + ]), + parse_mode="MarkdownV2" + ) + +# ── delete menu ─────────────────────────────────────────────────────────────── +@admin_only +async def delete_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + cfg = load_mita_config() + users = cfg.get("users", []) + if not users: + await query.edit_message_text("👤 Нет пользователей.", reply_markup=back_button()) + return + + buttons = [] + for u in users: + buttons.append([InlineKeyboardButton(f"🗑 {u['name']}", callback_data=f"delete_{u['name']}")]) + buttons.append([InlineKeyboardButton("« Назад", callback_data="main")]) + + await query.edit_message_text( + "🗑 *Выберите пользователя для удаления:*", + reply_markup=InlineKeyboardMarkup(buttons), parse_mode="MarkdownV2" ) @admin_only -async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): - if not context.args: - await update.message.reply_text("❌ Укажите имя: `/delete имя`", parse_mode="MarkdownV2") - return - name = context.args[0].strip() +async def delete_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + name = query.data[len("delete_"):] cfg = load_mita_config() cfg["users"] = [u for u in cfg.get("users", []) if u["name"] != name] Path(MITA_CONFIG).write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) subprocess.run(["mita", "apply", "config", MITA_CONFIG], capture_output=True) subprocess.run(["systemctl", "restart", "mita"], capture_output=True) - await update.message.reply_text(f"🗑 Пользователь `{name}` удалён\\.", parse_mode="MarkdownV2") + await query.edit_message_text( + f"🗑 Пользователь `{escape_md(name)}` удалён", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("« Назад", callback_data="main")], + ]), + parse_mode="MarkdownV2" + ) + +# ── warp menu ───────────────────────────────────────────────────────────────── @admin_only -async def cmd_warp(update: Update, context: ContextTypes.DEFAULT_TYPE): - if len(context.args) < 2: - await update.message.reply_text( - "❌ Формат: `/warp имя on` или `/warp имя off`", parse_mode="MarkdownV2") - return - name = context.args[0].strip() - action = context.args[1].lower() - if action not in ("on", "off"): - await update.message.reply_text("❌ Укажите `on` или `off`\\.", parse_mode="MarkdownV2") - return - +async def warp_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + cfg = load_mita_config() pc = load_panel_config() warp_users = set(pc.get("warp_users", [])) - if action == "on": - warp_users.add(name) - else: - warp_users.discard(name) - pc["warp_users"] = list(warp_users) - Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2)) - await update.message.reply_text( - f"🔄 WARP для `{name}`: *{action.upper()}* \\(сохранено в panel\\.json\\)", + users = cfg.get("users", []) + + buttons = [] + for u in users: + on = u["name"] in warp_users + buttons.append([InlineKeyboardButton( + f"{'🟢' if on else '⚪'} {u['name']}", + callback_data=f"warp_{u['name']}" + )]) + buttons.append([InlineKeyboardButton("« Назад", callback_data="main")]) + + await query.edit_message_text( + "🔄 *WARP* — нажмите на пользователя чтобы переключить:", + reply_markup=InlineKeyboardMarkup(buttons), parse_mode="MarkdownV2" ) @admin_only -async def cmd_config(update: Update, context: ContextTypes.DEFAULT_TYPE): - if not context.args: - await update.message.reply_text("❌ Укажите имя: `/config имя`", parse_mode="MarkdownV2") - return - name = context.args[0].strip() - cfg = load_mita_config() - user = next((u for u in cfg.get("users", []) if u["name"] == name), None) - if not user: - await update.message.reply_text(f"❌ Пользователь `{name}` не найден\\.", parse_mode="MarkdownV2") - return - ip = get_server_ip() - bindings = cfg.get("portBindings", [{}])[0] - port = bindings.get("portRange", str(bindings.get("port","?"))) - data = json.dumps({ - "profiles": [{"profileName": "default", - "user": {"name": user["name"], "password": user["password"]}, - "servers": [{"ipAddress": ip, - "portBindings": [{"portRange": port, - "protocol": bindings.get("protocol","TCP")}]}]}], - "activeProfile": "default", - "rpcPort": 8964, - "socks5Port": 1080, - }, indent=2, ensure_ascii=False) - - await update.message.reply_text(f"```json\n{data}\n```", parse_mode=None) +async def toggle_warp(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + name = query.data[len("warp_"):] + pc = load_panel_config() + warp_users = set(pc.get("warp_users", [])) + if name in warp_users: + warp_users.discard(name) + status = "выключен" + else: + warp_users.add(name) + status = "включён" + pc["warp_users"] = list(warp_users) + Path(PANEL_CONFIG).write_text(json.dumps(pc, indent=2)) + await query.edit_message_text( + f"🔄 WARP для `{escape_md(name)}`: *{status}*", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("« К списку WARP", callback_data="warp_menu")], + [InlineKeyboardButton("« На главную", callback_data="main")], + ]), + parse_mode="MarkdownV2" + ) +# ── stats ───────────────────────────────────────────────────────────────────── @admin_only -async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): - cpu = psutil.cpu_percent(interval=1) +async def show_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + cpu = psutil.cpu_percent(interval=0.5) mem = psutil.virtual_memory() disk = psutil.disk_usage("/") boot = datetime.fromtimestamp(psutil.boot_time()).strftime("%d.%m.%Y %H:%M") - await update.message.reply_text( + await query.edit_message_text( f"📊 *Сервер*\n\n" f"CPU: `{cpu:.1f}%`\n" f"RAM: `{mem.percent:.1f}%` \\({fmt_bytes(mem.used)} / {fmt_bytes(mem.total)}\\)\n" f"Диск: `{disk.percent:.1f}%` \\({fmt_bytes(disk.used)} / {fmt_bytes(disk.total)}\\)\n" f"Аптайм: с `{boot}`\n" f"mita: {'🟢 работает' if _mita_running() else '🔴 остановлен'}", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("🔄 Обновить", callback_data="stats")], + [InlineKeyboardButton("« Назад", callback_data="main")], + ]), parse_mode="MarkdownV2" ) +# ── dashboard ───────────────────────────────────────────────────────────────── @admin_only -async def cmd_dashboard(update: Update, context: ContextTypes.DEFAULT_TYPE): - cpu = psutil.cpu_percent(interval=1) +async def show_dashboard(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + cpu = psutil.cpu_percent(interval=0.5) mem = psutil.virtual_memory() disk = psutil.disk_usage("/") ip = get_server_ip() @@ -344,7 +382,7 @@ async def cmd_dashboard(update: Update, context: ContextTypes.DEFAULT_TYPE): bindings = cfg.get("portBindings", [{}])[0] port = bindings.get("portRange", str(bindings.get("port","?"))) - traffic = None + traffic = (0, 0) try: raw = mita_cmd("get", "users") week_total = month_total = 0.0 @@ -361,21 +399,67 @@ async def cmd_dashboard(update: Update, context: ContextTypes.DEFAULT_TYPE): except Exception: pass - w, m = traffic or (0, 0) + w, m = traffic - await update.message.reply_text( + await query.edit_message_text( f"📊 *Дашборд*\n\n" - f"IP сервера: `{ip}`\n" - f"IP WARP: `{warp_ip}`\n" - f"Порт mita: `{port}`\n" + f"IP: `{ip}`\nWARP: `{warp_ip}`\nПорт: `{port}`\n" f"Пользователей: `{user_count}`\n" - f"Трафик нед\\.: `{w} ГБ`\n" - f"Трафик мес\\.: `{m} ГБ`\n" + f"Трафик нед: `{w} ГБ` мес: `{m} ГБ`\n" f"mita: {'🟢' if mita_ok else '🔴'}\n" f"CPU: `{cpu:.1f}%` RAM: `{mem.percent:.1f}%` Диск: `{disk.percent:.1f}%`", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("🔄 Обновить", callback_data="dashboard")], + [InlineKeyboardButton("« Назад", callback_data="main")], + ]), parse_mode="MarkdownV2" ) +# ── help ────────────────────────────────────────────────────────────────────── +@admin_only +async def show_help(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + await query.edit_message_text( + "📋 *Команды*\n\n" + "Используйте кнопки меню:\n" + "👥 Пользователи — список с трафиком\n" + "➕ Создать — создать пользователя\n" + "🗑 Удалить — выбрать и удалить\n" + "🔄 WARP — включить/выключить\n" + "📊 Статистика — CPU, RAM, диск\n" + "📊 Дашборд — полный обзор", + reply_markup=back_button(), + parse_mode="MarkdownV2" + ) + +# ── callback router ─────────────────────────────────────────────────────────── +@admin_only +async def router(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + data = query.data + + if data == "main": + return await show_main(update, context) + elif data == "users": + return await show_users(update, context) + elif data == "create": + return await create_user(update, context) + elif data == "delete_menu": + return await delete_menu(update, context) + elif data.startswith("delete_"): + return await delete_user(update, context) + elif data == "warp_menu": + return await warp_menu(update, context) + elif data.startswith("warp_"): + return await toggle_warp(update, context) + elif data == "stats": + return await show_stats(update, context) + elif data == "dashboard": + return await show_dashboard(update, context) + elif data == "help": + return await show_help(update, context) + # ── main ────────────────────────────────────────────────────────────────────── def main(): cfg = load_bot_config() @@ -386,14 +470,7 @@ def main(): app = Application.builder().token(token).build() app.add_handler(CommandHandler("start", cmd_start)) - app.add_handler(CommandHandler("help", cmd_help)) - app.add_handler(CommandHandler("users", cmd_users)) - app.add_handler(CommandHandler("create", cmd_create)) - app.add_handler(CommandHandler("delete", cmd_delete)) - app.add_handler(CommandHandler("warp", cmd_warp)) - app.add_handler(CommandHandler("config", cmd_config)) - app.add_handler(CommandHandler("stats", cmd_stats)) - app.add_handler(CommandHandler("dashboard", cmd_dashboard)) + app.add_handler(CallbackQueryHandler(router)) _log.info("Бот запущен") app.run_polling() diff --git a/templates/index.html b/templates/index.html index ba626c5..07967c8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -52,8 +52,9 @@
Аптайм
+
+ -
Быстрые действия