diff --git a/install.sh b/install.sh index f49399d..f18c4f8 100644 --- a/install.sh +++ b/install.sh @@ -1,17 +1,14 @@ #!/bin/bash -# GoTelegram MTProxy Bot — установка. -# curl -sL -H "Authorization: token TOKEN" https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main/install.sh -o /usr/local/bin/gotelegram && chmod +x /usr/local/bin/gotelegram && gotelegram TOKEN +# GoTelegram MTProxy Bot — всё в одном файле. +# Установка: curl -sL -H "Authorization: token TOKEN" https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main/install.sh -o /usr/local/bin/gotelegram && chmod +x /usr/local/bin/gotelegram && gotelegram set -e RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' [ "$EUID" -ne 0 ] && { echo -e "${RED}Запустите с sudo.${NC}"; exit 1; } -[ -n "$1" ] && export GITHUB_TOKEN="$1" BOT_DIR="/opt/gotelegram-bot" SERVICE_NAME="gotelegram-bot" -REPO_RAW="https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main" -REPO_GIT="https://github.com/anten-ka/gotelegram_pro.git" echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ GoTelegram MTProxy Bot — установка ║${NC}" @@ -28,71 +25,605 @@ install_pkg() { fi } -for cmd in python3 curl git; do +for cmd in python3 curl; do command -v $cmd &>/dev/null || { echo -e "${YELLOW}[*] Установка $cmd...${NC}"; install_pkg $cmd; } done - -# python3-venv может быть отдельным пакетом python3 -m venv --help &>/dev/null 2>&1 || install_pkg python3-venv 2>/dev/null || true -# Docker if ! command -v docker &>/dev/null; then echo -e "${YELLOW}[*] Docker не найден. Устанавливаю...${NC}" curl -fsSL https://get.docker.com | sh systemctl enable --now docker fi - -# Проверка: Docker запущен? if ! docker info &>/dev/null 2>&1; then systemctl start docker 2>/dev/null || true sleep 2 - docker info &>/dev/null 2>&1 || { - echo -e "${RED}Docker не запускается. Проверьте вручную: systemctl status docker${NC}" - exit 1 - } + docker info &>/dev/null 2>&1 || { echo -e "${RED}Docker не запускается.${NC}"; exit 1; } fi -# Проверка совместимости: показываем существующие контейнеры EXISTING=$(docker ps --format "{{.Names}}\t{{.Image}}\t{{.Ports}}" 2>/dev/null) if [ -n "$EXISTING" ]; then - echo -e "${CYAN}[*] Обнаружены работающие контейнеры:${NC}" + echo -e "${CYAN}[*] Работающие контейнеры (не будут затронуты):${NC}" echo "$EXISTING" | while IFS= read -r line; do echo " $line"; done - echo -e "${GREEN}[*] Бот будет работать параллельно, не затрагивая их.${NC}" fi -# ── Файлы бота ─────────────────────────────────────────────────────────────── +# ── Встроенные файлы бота ──────────────────────────────────────────────────── mkdir -p "$BOT_DIR" -download_files() { - # Приватный репо: git clone - if [ -n "$GITHUB_TOKEN" ]; then - echo -e "${GREEN}[*] Клонирование репозитория...${NC}" - TMP="/tmp/gotelegram_pro_$$" - rm -rf "$TMP" - if git clone --depth 1 --branch main "https://${GITHUB_TOKEN}@${REPO_GIT#https://}" "$TMP" 2>/dev/null; then - cp -r "$TMP/gotelegram-bot"/* "$BOT_DIR/" - rm -rf "$TMP" - return 0 - fi - rm -rf "$TMP" - fi - # Публичный репо: curl - echo -e "${YELLOW}[*] Скачивание файлов...${NC}" - local ok=1 - for f in bot.py requirements.txt config.example.env; do - curl -sL -f "$REPO_RAW/gotelegram-bot/$f" -o "$BOT_DIR/$f" 2>/dev/null || ok=0 - done - [ "$ok" -eq 1 ] && return 0 - return 1 -} +cat > "$BOT_DIR/requirements.txt" << 'REQEOF' +python-telegram-bot>=21.0 +REQEOF -# Всегда обновляем файлы бота при запуске (чтобы подтягивать обновления) -download_files || { - echo -e "${RED}Не удалось загрузить файлы бота.${NC}" - echo -e " Для приватного репо: ${YELLOW}gotelegram ВАШ_GITHUB_ТОКЕН${NC}" - exit 1 -} -echo -e "${GREEN}[*] Файлы бота обновлены.${NC}" +cat > "$BOT_DIR/bot.py" << 'BOTEOF' +#!/usr/bin/env python3 +""" +GoTelegram MTProxy — Telegram-бот для управления MTProxy на сервере. +Кнопочное меню, проверка портов, совместимость с Amnezia/3x-ui, +кнопка «Поделиться ключом», TCP+UDP для звонков. +""" + +import asyncio +import html +import json +import os +import re +from pathlib import Path + +_env_path = Path(__file__).resolve().parent / ".env" +if not _env_path.exists(): + _env_path = Path("/etc/gotelegram-bot/.env") +if _env_path.exists(): + with open(_env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + Application, + CommandHandler, + CallbackQueryHandler, + ContextTypes, + MessageHandler, + filters, +) + +BOT_TOKEN = os.environ.get("BOT_TOKEN") +_allowed = os.environ.get("ALLOWED_IDS", "").strip() +try: + ALLOWED_IDS = set(int(x) for x in _allowed.split(",") if x.strip()) if _allowed else None +except ValueError: + ALLOWED_IDS = None + +CONTAINER_NAME = "mtproto-proxy" +CONFIG_FILE = Path("/opt/gotelegram-bot/proxy.json") +DOMAINS = [ + "google.com", "wikipedia.org", "habr.com", "github.com", + "coursera.org", "udemy.com", "medium.com", "stackoverflow.com", + "bbc.com", "cnn.com", "reuters.com", "nytimes.com", + "lenta.ru", "rbc.ru", "ria.ru", "kommersant.ru", + "stepik.org", "duolingo.com", "khanacademy.org", "ted.com", +] +PROMO_LINK = "https://vk.cc/ct29NQ" +TIP_LINK = "https://pay.cloudtips.ru/p/7410814f" + + +def _ok(uid: int) -> bool: + return ALLOWED_IDS is None or uid in ALLOWED_IDS + + +def _decode(data: bytes) -> str: + return (data or b"").decode("utf-8", errors="replace").strip() + + +async def sh(*args: str, timeout: int = 60) -> tuple[int, str, str]: + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return -1, "", "Timeout" + return proc.returncode or 0, _decode(out), _decode(err) + + +async def get_ip() -> str: + for url in ("https://api.ipify.org", "https://icanhazip.com", "https://ifconfig.me"): + code, out, _ = await sh("curl", "-s", "-4", "--max-time", "5", url, timeout=8) + if code == 0 and out: + m = re.search(r"(\d{1,3}\.){3}\d{1,3}", out) + if m: + return m.group(0) + return "0.0.0.0" + + +async def proxy_running() -> bool: + code, out, _ = await sh("docker", "ps", "--format", "{{.Names}}", timeout=10) + return code == 0 and CONTAINER_NAME in out + + +async def docker_val(fmt: str) -> str: + code, out, _ = await sh("docker", "inspect", CONTAINER_NAME, "--format", fmt, timeout=10) + return out.strip() if code == 0 else "" + + +async def check_port(port: int) -> str | None: + if await proxy_running(): + hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}} {{end}}") + if str(port) in hp.split(): + return None + code, out, _ = await sh("ss", "-tlnp", timeout=5) + if code != 0: + code, out, _ = await sh("netstat", "-tlnp", timeout=5) + for line in out.splitlines(): + if f":{port} " in line or f":{port}\t" in line: + return line + return None + + +async def docker_containers_info() -> str: + code, out, _ = await sh("docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}", timeout=10) + return out if code == 0 else "" + + +def save_config(data: dict) -> None: + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def load_config() -> dict: + if CONFIG_FILE.exists(): + try: + return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except Exception: + pass + return {} + + +async def proxy_info() -> dict | None: + if not await proxy_running(): + return None + cmd_str = await docker_val("{{range .Config.Cmd}}{{.}} {{end}}") + secret = cmd_str.split()[-1] if cmd_str else "" + hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}}{{end}}") + port = hp or "443" + ip = await get_ip() + link = f"tg://proxy?server={ip}&port={port}&secret={secret}" + cfg = load_config() + return {"ip": ip, "port": port, "secret": secret, "link": link, "domain": cfg.get("domain", "—")} + + +def main_menu_kb() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([ + [InlineKeyboardButton("🔧 Установить / Обновить", callback_data="menu_install")], + [InlineKeyboardButton("📊 Статус", callback_data="menu_status"), + InlineKeyboardButton("🔗 Ссылка", callback_data="menu_link")], + [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")], + [InlineKeyboardButton("🔄 Перезапуск", callback_data="menu_restart"), + InlineKeyboardButton("📋 Логи", callback_data="menu_logs")], + [InlineKeyboardButton("🗑 Удалить", callback_data="menu_remove"), + InlineKeyboardButton("🏷 Промо", callback_data="menu_promo")], + ]) + + +HELP_TEXT = ( + "🚀 GoTelegram MTProxy Bot\n\n" + "Управление MTProxy (Fake TLS) на сервере.\n" + "TCP + UDP (звонки) поддержаны.\n\n" + "Используйте кнопки ниже или команды:\n" + "/install /status /link /share /restart /logs /remove /promo" +) + + +async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not update.effective_user: + return + if not _ok(update.effective_user.id): + msg = update.message or (update.callback_query and update.callback_query.message) + if msg: + await msg.reply_text("⛔ Доступ запрещён.") + return + if update.message: + await update.message.reply_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb()) + elif update.callback_query: + await update.callback_query.edit_message_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb()) + + +async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + await msg.reply_text("⛔") + return + info = await proxy_info() + if not info: + text = "❌ Прокси не запущен.\nНажмите Установить для настройки." + else: + containers = await docker_containers_info() + other = "\n".join(l for l in containers.splitlines() if CONTAINER_NAME not in l) + text = ( + "✅ Прокси работает\n\n" + f"IP: {html.escape(info['ip'])}\n" + f"Порт: {html.escape(info['port'])}\n" + f"Домен: {html.escape(info['domain'])}\n" + f"Secret: {html.escape(info['secret'])}\n\n" + f"Ссылка:\n{html.escape(info['link'])}" + ) + if other: + text += f"\n\n📦 Другие контейнеры:\n
{html.escape(other)}
" + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) + else: + await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) + + +async def cmd_link(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + return + info = await proxy_info() + text = f"{html.escape(info['link'])}" if info else "❌ Прокси не запущен." + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) + else: + await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) + + +async def cmd_share(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + return + info = await proxy_info() + if not info: + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) + else: + await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) + return + tg_link = info["link"] + share_text = ( + f"🔐 MTProxy для Telegram\n\n" + f"🌍 Сервер: {html.escape(info['ip'])}\n" + f"🔌 Порт: {html.escape(info['port'])}\n" + f"🔑 Secret: {html.escape(info['secret'])}\n\n" + f"👉 Подключиться одним нажатием:\n" + f"{html.escape(tg_link)}\n\n" + f"Просто нажмите на ссылку или перешлите это сообщение." + ) + kb = InlineKeyboardMarkup([ + [InlineKeyboardButton("📤 Переслать другу", switch_inline_query=tg_link)], + [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], + ]) + if update.callback_query: + await update.callback_query.edit_message_text(share_text, parse_mode="HTML", reply_markup=kb) + else: + await msg.reply_text(share_text, parse_mode="HTML", reply_markup=kb) + + +async def cmd_remove(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + return + chat = msg.chat + if update.callback_query: + await update.callback_query.edit_message_text("⏳ Удаляю прокси...") + else: + await chat.send_message("⏳ Удаляю прокси...") + await sh("docker", "stop", CONTAINER_NAME, timeout=15) + await sh("docker", "rm", CONTAINER_NAME, timeout=10) + text = "✅ Прокси удалён." if not await proxy_running() else "⚠️ Не удалось удалить." + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + await chat.send_message(text, reply_markup=kb) + + +async def cmd_restart(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + return + if not await proxy_running(): + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) + else: + await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) + return + chat = msg.chat + if update.callback_query: + await update.callback_query.edit_message_text("⏳ Перезапуск...") + code, _, err = await sh("docker", "restart", CONTAINER_NAME, timeout=30) + text = "✅ Перезапущен." if code == 0 else f"❌ Ошибка: {err or 'unknown'}" + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + await chat.send_message(text, reply_markup=kb) + + +async def cmd_logs(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + return + if not await proxy_running(): + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb) + else: + await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb) + return + code, out, err = await sh("docker", "logs", "--tail", "40", CONTAINER_NAME, timeout=15) + text = (out or "") + (("\n" + err) if err else "") or "Нет вывода." + if len(text) > 4000: + text = text[-4000:] + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text(f"
{html.escape(text)}
", parse_mode="HTML", reply_markup=kb) + else: + await msg.reply_text(f"
{html.escape(text)}
", parse_mode="HTML", reply_markup=kb) + + +async def cmd_promo(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + return + text = ( + "💰 Хостинг со скидкой до -60%\n" + f"Ссылка: {PROMO_LINK}\n\n" + "Промокоды: OFF60, antenka20, antenka6, antenka12\n\n" + f"Донат: {TIP_LINK}" + ) + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) + else: + await msg.reply_text(text, parse_mode="HTML", reply_markup=kb) + + +async def install_step_domain(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + msg = update.message or (update.callback_query and update.callback_query.message) + if not update.effective_user or not msg: + return + if not _ok(update.effective_user.id): + return + buttons = [] + row = [] + for i, d in enumerate(DOMAINS): + row.append(InlineKeyboardButton(d, callback_data=f"dom_{i}")) + if len(row) == 2: + buttons.append(row) + row = [] + if row: + buttons.append(row) + text = "🌐 Выберите домен для маскировки (Fake TLS):" + if update.callback_query: + await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) + else: + await msg.reply_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons)) + + +async def install_step_port(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + domain = ctx.user_data.get("install_domain", "google.com") + busy_443 = await check_port(443) + busy_8443 = await check_port(8443) + rows = [] + label_443 = "443 (рекомендуется)" if not busy_443 else "443 ⚠️ занят" + label_8443 = "8443" if not busy_8443 else "8443 ⚠️ занят" + rows.append([ + InlineKeyboardButton(label_443, callback_data="port_443"), + InlineKeyboardButton(label_8443, callback_data="port_8443"), + ]) + rows.append([InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]) + port_info = "" + if busy_443: + port_info += f"\n⚠️ Порт 443 занят:\n
{html.escape(busy_443[:300])}
\n" + if busy_8443: + port_info += f"\n⚠️ Порт 8443 занят:\n
{html.escape(busy_8443[:300])}
\n" + text = ( + f"Домен: {html.escape(domain)}\n\n" + f"🔌 Выберите порт или введите свой (1-65535):{port_info}" + ) + ctx.user_data["install_wait_port"] = True + await query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(rows)) + + +async def install_port_chosen(update: Update, ctx: ContextTypes.DEFAULT_TYPE, port_str: str) -> None: + port = int(port_str) + msg = update.callback_query.message if update.callback_query else update.message + if not msg: + return + chat = msg.chat + busy = await check_port(port) + if busy: + kb = InlineKeyboardMarkup([ + [InlineKeyboardButton(f"Всё равно использовать {port}", callback_data=f"force_{port}")], + [InlineKeyboardButton("Выбрать другой порт", callback_data="reselect_port")], + [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], + ]) + text = ( + f"⚠️ Порт {port} занят!\n\n" + f"
{html.escape(busy[:500])}
\n\n" + "Можно использовать всё равно (если это ваш процесс) или выбрать другой." + ) + if update.callback_query: + await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb) + else: + await chat.send_message(text, parse_mode="HTML", reply_markup=kb) + ctx.user_data["install_port"] = port_str + return + ctx.user_data["install_port"] = port_str + ctx.user_data["install_wait_port"] = False + await do_install(update, ctx) + + +async def do_install(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + domain = ctx.user_data.get("install_domain") or "google.com" + port = ctx.user_data.get("install_port") or "443" + if update.callback_query: + msg = update.callback_query.message + await msg.edit_text("⏳ Генерация secret и запуск контейнера...", reply_markup=None) + elif update.message: + msg = update.message + await msg.reply_text("⏳ Генерация secret и запуск контейнера...") + else: + return + chat = msg.chat + code, _, _ = await sh("docker", "info", timeout=10) + if code != 0: + await chat.send_message( + "❌ Docker не установлен или не запущен.\n" + "Установите: curl -fsSL https://get.docker.com | sh", + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]), + ) + return + code, secret_out, err = await sh( + "docker", "run", "--rm", "nineseconds/mtg:2", "generate-secret", "--hex", domain, timeout=60, + ) + if code != 0: + await chat.send_message(f"❌ Генерация secret: {err or secret_out}") + return + secret = secret_out.strip().split()[-1] if secret_out.strip() else "" + if not secret: + await chat.send_message("❌ Пустой secret.") + return + await sh("docker", "stop", CONTAINER_NAME, timeout=15) + await sh("docker", "rm", CONTAINER_NAME, timeout=10) + code, _, err = await sh( + "docker", "run", "-d", + "--name", CONTAINER_NAME, "--restart", "always", + "-p", f"{port}:{port}/tcp", "-p", f"{port}:{port}/udp", + "nineseconds/mtg:2", "simple-run", + "-n", "1.1.1.1", "-t", "1.0.0.1", "-i", "prefer-ipv4", + f"0.0.0.0:{port}", secret, timeout=90, + ) + if code != 0: + await chat.send_message(f"❌ Запуск контейнера: {err}") + return + save_config({"domain": domain, "port": port, "secret": secret}) + ip = await get_ip() + link = f"tg://proxy?server={ip}&port={port}&secret={secret}" + text = ( + "✅ Прокси установлен!\n\n" + f"🌍 IP: {html.escape(ip)}\n" + f"🔌 Порт: {html.escape(port)} (TCP + UDP)\n" + f"🎭 Домен: {html.escape(domain)}\n" + f"🔑 Secret: {html.escape(secret)}\n\n" + f"👉 Ссылка:\n{html.escape(link)}\n\n" + "📞 Звонки в Telegram поддержаны (UDP)." + ) + kb = InlineKeyboardMarkup([ + [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")], + [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")], + ]) + await chat.send_message(text, parse_mode="HTML", reply_markup=kb) + for k in ("install_domain", "install_port", "install_wait_port"): + ctx.user_data.pop(k, None) + + +async def callback_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if not query or not update.effective_user: + return + await query.answer() + if not _ok(update.effective_user.id): + await query.edit_message_text("⛔ Доступ запрещён.") + return + data = query.data or "" + if data == "menu_main": + await start(update, ctx) + elif data == "menu_install": + await install_step_domain(update, ctx) + elif data == "menu_status": + await cmd_status(update, ctx) + elif data == "menu_link": + await cmd_link(update, ctx) + elif data == "menu_share": + await cmd_share(update, ctx) + elif data == "menu_restart": + await cmd_restart(update, ctx) + elif data == "menu_logs": + await cmd_logs(update, ctx) + elif data == "menu_remove": + await cmd_remove(update, ctx) + elif data == "menu_promo": + await cmd_promo(update, ctx) + elif data.startswith("dom_"): + try: + idx = int(data[4:]) + except ValueError: + await query.edit_message_text("❌ Ошибка. /install") + return + if not (0 <= idx < len(DOMAINS)): + await query.edit_message_text("❌ Неверный выбор. /install") + return + ctx.user_data["install_domain"] = DOMAINS[idx] + await install_step_port(update, ctx) + elif data == "port_443": + await install_port_chosen(update, ctx, "443") + elif data == "port_8443": + await install_port_chosen(update, ctx, "8443") + elif data.startswith("force_"): + ctx.user_data["install_port"] = data[6:] + ctx.user_data["install_wait_port"] = False + await do_install(update, ctx) + elif data == "reselect_port": + await install_step_port(update, ctx) + + +async def text_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not update.message or not ctx.user_data.get("install_wait_port"): + return + text = (update.message.text or "").strip() + if not re.match(r"^\d+$", text): + return + port = int(text) + if not (1 <= port <= 65535): + await update.message.reply_text("Введите число от 1 до 65535.") + return + await install_port_chosen(update, ctx, str(port)) + + +def main() -> None: + if not BOT_TOKEN: + raise SystemExit("Задайте BOT_TOKEN в .env") + app = Application.builder().token(BOT_TOKEN).build() + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("help", start)) + app.add_handler(CommandHandler("install", install_step_domain)) + app.add_handler(CommandHandler("status", cmd_status)) + app.add_handler(CommandHandler("link", cmd_link)) + app.add_handler(CommandHandler("share", cmd_share)) + app.add_handler(CommandHandler("remove", cmd_remove)) + app.add_handler(CommandHandler("restart", cmd_restart)) + app.add_handler(CommandHandler("logs", cmd_logs)) + app.add_handler(CommandHandler("promo", cmd_promo)) + app.add_handler(CallbackQueryHandler(callback_handler)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler)) + app.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == "__main__": + main() +BOTEOF + +echo -e "${GREEN}[*] Файлы бота записаны.${NC}" # ── Python venv ────────────────────────────────────────────────────────────── if [ ! -d "$BOT_DIR/venv" ]; then