diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 4fa5b2a..98aa6e4 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 """ GoTelegram MTProxy — Telegram-бот для управления MTProxy на сервере. -Функции: установка, статус, ссылка, удаление, рестарт, логи (по аналогии с CLI gotelegram). +Кнопочное меню, проверка портов, совместимость с Amnezia/3x-ui, +кнопка «Поделиться ключом», TCP+UDP для звонков. """ import asyncio import html +import json import os import re from pathlib import Path -# Загрузка .env из текущей папки или /etc/gotelegram-bot _env_path = Path(__file__).resolve().parent / ".env" if not _env_path.exists(): _env_path = Path("/etc/gotelegram-bot/.env") @@ -32,18 +33,16 @@ from telegram.ext import ( filters, ) -# --- Конфиг --- +# ── Конфиг ──────────────────────────────────────────────────────────────────── BOT_TOKEN = os.environ.get("BOT_TOKEN") _allowed = os.environ.get("ALLOWED_IDS", "").strip() -if _allowed: - try: - ALLOWED_IDS = set(int(x.strip()) for x in _allowed.split(",") if x.strip()) - except ValueError: - ALLOWED_IDS = None -else: - ALLOWED_IDS = None # все пользователи +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", @@ -55,199 +54,319 @@ PROMO_LINK = "https://vk.cc/ct29NQ" TIP_LINK = "https://pay.cloudtips.ru/p/7410814f" -def check_access(user_id: int) -> bool: - if ALLOWED_IDS is None: - return True - return user_id in ALLOWED_IDS +# ── Утилиты ────────────────────────────────────────────────────────────────── +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 run_cmd(*args: str, timeout: int = 60) -> tuple[int, str, str]: +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, + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + 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(stdout), _decode(stderr) - - -async def docker_inspect(fmt: str) -> str: - code, out, err = await run_cmd( - "docker", "inspect", CONTAINER_NAME, "--format", fmt, timeout=10 - ) - if code != 0: - return "" - return out.strip() + return proc.returncode or 0, _decode(out), _decode(err) async def get_ip() -> str: - for url in ["https://api.ipify.org", "https://icanhazip.com"]: - code, out, _ = await run_cmd("curl", "-s", "-4", "--max-time", "5", url, timeout=8) + 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"([0-9]{1,3}\.){3}[0-9]{1,3}", 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_is_running() -> bool: - code, out, _ = await run_cmd("docker", "ps", "--format", "{{.Names}}", timeout=10) +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: + """Если порт занят — возвращает описание процесса; иначе 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: - return False - return CONTAINER_NAME in (out or "") + 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) + if code != 0 or not out: + return "" + return out -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: + +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 check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") + 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 - text = ( - "🚀 *GoTelegram MTProxy Bot*\n\n" - "Управление MTProxy на этом сервере.\n\n" - "Команды:\n" - "/install — установить или обновить прокси (выбор домена и порта)\n" - "/status — статус контейнера и данные подключения\n" - "/link — только ссылка tg://proxy\n" - "/restart — перезапустить прокси\n" - "/logs — последние логи\n" - "/remove — удалить прокси\n" - "/promo — промо хостинга\n" - "/help — эта справка" - ) - await update.message.reply_text(text, parse_mode="Markdown") + 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, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: +# ── Статус ─────────────────────────────────────────────────────────────────── +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 check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") + if not _ok(update.effective_user.id): + await msg.reply_text("⛔") return - if not await proxy_is_running(): - await update.message.reply_text( - "❌ Прокси не запущен.\nИспользуйте /install для установки." + 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 - secret = await docker_inspect("{{range .Config.Cmd}}{{.}} {{end}}") - secret = secret.split()[-1] if secret else "" - port = await docker_inspect("{{range $p, $conf := .HostConfig.PortBindings}}{{(index $conf 0).HostPort}}{{end}}") - port = port or "443" - ip = await get_ip() - link = f"tg://proxy?server={ip}&port={port}&secret={secret}" - # HTML безопаснее для произвольного secret (экранируем) - text = ( - "✅ Прокси запущен\n\n" - f"IP: {html.escape(ip)}\n" - f"Port: {html.escape(port)}\n" - f"Secret: {html.escape(secret)}\n\n" - f"Ссылка (скопируйте):\n{html.escape(link)}" + if not _ok(update.effective_user.id): + return + info = await proxy_info() + if not info: + text = "❌ Прокси не запущен." + else: + text = f"{html.escape(info['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 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: + text = "❌ Прокси не запущен." + kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]) + if update.callback_query: + await update.callback_query.edit_message_text(text, reply_markup=kb) + else: + await msg.reply_text(text, reply_markup=kb) + return + + # tg://proxy ссылка, которую Telegram распознает при пересылке + 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"Просто нажмите на ссылку или перешлите это сообщение." ) - await update.message.reply_text(text, parse_mode="HTML") - - -async def cmd_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: - return - if not check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") - return - if not await proxy_is_running(): - await update.message.reply_text("❌ Прокси не запущен. /install") - return - secret = await docker_inspect("{{range .Config.Cmd}}{{.}} {{end}}") - secret = secret.split()[-1] if secret else "" - port = await docker_inspect("{{range $p, $conf := .HostConfig.PortBindings}}{{(index $conf 0).HostPort}}{{end}}") - port = port or "443" - ip = await get_ip() - link = f"tg://proxy?server={ip}&port={port}&secret={secret}" - await update.message.reply_text(link) - - -async def cmd_remove(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: - return - if not check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") - return - await update.message.reply_text("Удаляю прокси...") - await run_cmd("docker", "stop", CONTAINER_NAME, timeout=15) - await run_cmd("docker", "rm", CONTAINER_NAME, timeout=10) - if await proxy_is_running(): - await update.message.reply_text("⚠️ Не удалось удалить. Проверьте docker вручную.") + 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 update.message.reply_text("✅ Прокси удалён.") + await msg.reply_text(share_text, parse_mode="HTML", reply_markup=kb) -async def cmd_restart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: +# ── Удалить ────────────────────────────────────────────────────────────────── +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 check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") + if not _ok(update.effective_user.id): return - if not await proxy_is_running(): - await update.message.reply_text("❌ Прокси не запущен. /install") - return - await update.message.reply_text("Перезапускаю...") - code, _, err = await run_cmd("docker", "restart", CONTAINER_NAME, timeout=30) - if code == 0: - await update.message.reply_text("✅ Прокси перезапущен.") + chat = msg.chat + if update.callback_query: + await update.callback_query.edit_message_text("⏳ Удаляю прокси...") else: - await update.message.reply_text(f"❌ Ошибка: {err or 'unknown'}") + 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_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: +# ── Рестарт ────────────────────────────────────────────────────────────────── +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 check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") + if not _ok(update.effective_user.id): return - if not await proxy_is_running(): - await update.message.reply_text("❌ Прокси не запущен. /install") + 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 run_cmd("docker", "logs", "--tail", "40", CONTAINER_NAME, timeout=15) - text = (out or "") + (("\n" + err) if err else "") - if not text: - text = "Нет вывода." + 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:] - await update.message.reply_text(f"
{html.escape(text)}
", parse_mode="HTML") + 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, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: +# ── Промо ──────────────────────────────────────────────────────────────────── +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 check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") + if not _ok(update.effective_user.id): return text = ( - "💰 *Хостинг со скидкой до -60%*\n" + "💰 Хостинг со скидкой до -60%\n" f"Ссылка: {PROMO_LINK}\n\n" "Промокоды: OFF60, antenka20, antenka6, antenka12\n\n" f"Донат: {TIP_LINK}" ) - await update.message.reply_text(text, parse_mode="Markdown") + 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_choice_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_user or not update.message: +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 check_access(update.effective_user.id): - await update.message.reply_text("⛔ Доступ запрещён.") + if not _ok(update.effective_user.id): return buttons = [] row = [] @@ -258,143 +377,256 @@ async def install_choice_domain(update: Update, context: ContextTypes.DEFAULT_TY row = [] if row: buttons.append(row) - await update.message.reply_text( - "Выберите домен для маскировки (Fake TLS):", - reply_markup=InlineKeyboardMarkup(buttons), + 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") + + # Проверяем порт 443 + 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" + "🔌 Выберите порт или введите свой (1-65535):" + f"{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_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: +async def install_port_chosen(update: Update, ctx: ContextTypes.DEFAULT_TYPE, port_str: str) -> None: + """Порт выбран кнопкой или текстом — проверяем и ставим.""" + port = int(port_str) + msg = None + chat = None + + if update.callback_query: + msg = update.callback_query.message + elif update.message: + msg = 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" + + msg = None + 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 и запуск контейнера...") + if not msg: + return + chat = msg.chat + + # Docker check + 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 + + # generate secret + 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) + + # Запуск с TCP + UDP (UDP нужен для звонков Telegram) + # --network host не используем, чтобы не мешать Amnezia/3x-ui + 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", # tag DNS для TLS + "-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) + + +# ── Callback router ────────────────────────────────────────────────────────── +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 check_access(update.effective_user.id): + if not _ok(update.effective_user.id): await query.edit_message_text("⛔ Доступ запрещён.") return + data = query.data or "" - if data.startswith("dom_"): + + 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.") + await query.edit_message_text("❌ Ошибка. /install") return if not (0 <= idx < len(DOMAINS)): - await query.edit_message_text("❌ Неверный выбор. Начните с /install.") + await query.edit_message_text("❌ Неверный выбор. /install") return - domain = DOMAINS[idx] - context.user_data["gotelegram_domain"] = domain - kb = InlineKeyboardMarkup([ - [InlineKeyboardButton("443 (рекомендуется)", callback_data="port_443"), - InlineKeyboardButton("8443", callback_data="port_8443")], - ]) - await query.edit_message_text( - f"Домен: {domain}\n\nВыберите порт или введите свой (1-65535):", - reply_markup=kb, - ) - context.user_data["gotelegram_wait_port"] = True - return - if data == "port_443": - context.user_data["gotelegram_port"] = "443" - context.user_data["gotelegram_wait_port"] = False - await do_install(update, context) - return - if data == "port_8443": - context.user_data["gotelegram_port"] = "8443" - context.user_data["gotelegram_wait_port"] = False - await do_install(update, context) - 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_"): + port_str = data[6:] + ctx.user_data["install_port"] = port_str + ctx.user_data["install_wait_port"] = False + await do_install(update, ctx) + elif data == "reselect_port": + await install_step_port(update, ctx) -async def do_install(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - domain = context.user_data.get("gotelegram_domain") or "google.com" - port = context.user_data.get("gotelegram_port") or "443" - if update.callback_query: - msg = update.callback_query.message - await msg.edit_text("⏳ Генерация secret и запуск контейнера...", reply_markup=None) - else: - msg = update.message - chat = msg.chat - if not update.callback_query: - await chat.send_message("⏳ Генерация secret и запуск контейнера...") - - # generate secret - code, secret_out, err = await run_cmd( - "docker", "run", "--rm", "nineseconds/mtg:2", "generate-secret", "--hex", domain, - timeout=30, - ) - if code != 0: - await chat.send_message(f"❌ Ошибка генерации secret: {err or secret_out}") - return - secret = (secret_out or "").strip().split()[-1] or secret_out.strip() - if not secret: - await chat.send_message("❌ Не удалось получить secret.") - return - - await run_cmd("docker", "stop", CONTAINER_NAME, timeout=15) - await run_cmd("docker", "rm", CONTAINER_NAME, timeout=10) - code, _, err = await run_cmd( - "docker", "run", "-d", "--name", CONTAINER_NAME, "--restart", "always", - "-p", f"{port}:{port}", - "nineseconds/mtg:2", "simple-run", "-n", "1.1.1.1", "-i", "prefer-ipv4", f"0.0.0.0:{port}", secret, - timeout=60, - ) - if code != 0: - await chat.send_message(f"❌ Ошибка запуска контейнера: {err}") - return - ip = await get_ip() - link = f"tg://proxy?server={ip}&port={port}&secret={secret}" - text = ( - "✅ Прокси установлен.\n" - f"Домен: {html.escape(domain)}, порт: {html.escape(port)}\n\n" - f"Ссылка:\n{html.escape(link)}" - ) - await chat.send_message(text, parse_mode="HTML") - context.user_data.pop("gotelegram_domain", None) - context.user_data.pop("gotelegram_port", None) - context.user_data.pop("gotelegram_wait_port", None) - - -async def handle_port_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.message: - return - if not context.user_data.get("gotelegram_wait_port"): +# ── Ввод порта текстом ────────────────────────────────────────────────────── +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"^[0-9]+$", text): + if not re.match(r"^\d+$", text): return - port_num = int(text) - if not (1 <= port_num <= 65535): + port = int(text) + if not (1 <= port <= 65535): await update.message.reply_text("Введите число от 1 до 65535.") return - context.user_data["gotelegram_port"] = str(port_num) - context.user_data["gotelegram_wait_port"] = False - await do_install(update, context) - - -async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - await start(update, context) + await install_port_chosen(update, ctx, str(port)) +# ── main ───────────────────────────────────────────────────────────────────── def main() -> None: if not BOT_TOKEN: - raise SystemExit("Установите BOT_TOKEN в .env или в переменных окружения.") - app = ( - Application.builder() - .token(BOT_TOKEN) - .build() - ) + raise SystemExit("Задайте BOT_TOKEN в .env") + app = Application.builder().token(BOT_TOKEN).build() app.add_handler(CommandHandler("start", start)) - app.add_handler(CommandHandler("help", help_cmd)) - app.add_handler(CommandHandler("install", install_choice_domain)) + 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(install_callback)) - app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_port_message)) + app.add_handler(CallbackQueryHandler(callback_handler)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler)) app.run_polling(allowed_updates=Update.ALL_TYPES) diff --git a/install.sh b/install.sh index 3344fd1..f49399d 100644 --- a/install.sh +++ b/install.sh @@ -1,89 +1,109 @@ #!/bin/bash -# Установка GoTelegram MTProxy Bot. -# Формат как kaskad: 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 && systemctl restart gotelegram-bot 2>/dev/null; GITHUB_TOKEN=TOKEN gotelegram +# 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 set -e -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' -if [ "$EUID" -ne 0 ]; then - echo -e "${RED}Запустите с sudo.${NC}" - exit 1 -fi - -# Токен можно передать аргументом: gotelegram ВАШ_ТОКЕН (для приватного репо) +[ "$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="${GOTETELEGRAM_REPO_RAW:-https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main}" +REPO_RAW="https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main" +REPO_GIT="https://github.com/anten-ka/gotelegram_pro.git" -echo -e "${GREEN}[*] GoTelegram Bot — установка...${NC}" +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ GoTelegram MTProxy Bot — установка ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════╝${NC}" -# Зависимости (python3, curl, git для приватного репо) -if ! command -v python3 &>/dev/null; then +# ── Зависимости ────────────────────────────────────────────────────────────── +install_pkg() { if command -v apt-get &>/dev/null; then - apt-get update && apt-get install -y python3 python3-pip python3-venv curl git + apt-get update -qq && apt-get install -y -qq "$@" elif command -v dnf &>/dev/null; then - dnf install -y python3 python3-pip python3-virtualenv curl git 2>/dev/null || dnf install -y python3 python3-pip curl git + dnf install -y "$@" elif command -v yum &>/dev/null; then - yum install -y python3 python3-pip curl git - else - echo -e "${RED}Установите python3 и curl.${NC}" - exit 1 + yum install -y "$@" fi -fi -if [ -n "$GITHUB_TOKEN" ] && ! command -v git &>/dev/null; then - echo -e "${GREEN}[*] Установка git для клонирования приватного репо...${NC}" - if command -v apt-get &>/dev/null; then apt-get update && apt-get install -y git; fi - if command -v dnf &>/dev/null; then dnf install -y git; fi - if command -v yum &>/dev/null; then yum install -y git; fi -fi +} + +for cmd in python3 curl git; 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 не найден. Нужен для /install в боте.${NC}" + echo -e "${YELLOW}[*] Docker не найден. Устанавливаю...${NC}" + curl -fsSL https://get.docker.com | sh + systemctl enable --now docker fi -mkdir -p "$BOT_DIR" -cd "$BOT_DIR" +# Проверка: 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 + } +fi -# Получение файлов бота: при токене — только git clone (raw для приватного не работает) -if [ ! -f "$BOT_DIR/bot.py" ]; then - echo -e "${GREEN}[*] Загрузка файлов из репозитория...${NC}" - if [ -n "$GITHUB_TOKEN" ] && command -v git &>/dev/null; then +# Проверка совместимости: показываем существующие контейнеры +EXISTING=$(docker ps --format "{{.Names}}\t{{.Image}}\t{{.Ports}}" 2>/dev/null) +if [ -n "$EXISTING" ]; then + 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_$$" - if git clone --depth 1 --branch main "https://${GITHUB_TOKEN}@github.com/anten-ka/gotelegram_pro.git" "$TMP" 2>/tmp/gotelegram_clone_err_$$; then + 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" - else - echo -e "${RED}Ошибка клонирования:${NC}" - cat /tmp/gotelegram_clone_err_$$ 2>/dev/null - rm -rf "$TMP" /tmp/gotelegram_clone_err_$$ 2>/dev/null - exit 1 + return 0 fi - rm -f /tmp/gotelegram_clone_err_$$ - else - # Публичный репо — пробуем curl - 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 || true - done + rm -rf "$TMP" fi - if [ ! -f "$BOT_DIR/bot.py" ]; then - echo -e "${RED}Не удалось загрузить файлы. Для приватного репо запустите с токеном:${NC}" - echo -e " ${YELLOW}gotelegram ВАШ_GITHUB_ТОКЕН${NC}" - exit 1 - fi -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 +} -# venv +# Всегда обновляем файлы бота при запуске (чтобы подтягивать обновления) +download_files || { + echo -e "${RED}Не удалось загрузить файлы бота.${NC}" + echo -e " Для приватного репо: ${YELLOW}gotelegram ВАШ_GITHUB_ТОКЕН${NC}" + exit 1 +} +echo -e "${GREEN}[*] Файлы бота обновлены.${NC}" + +# ── Python venv ────────────────────────────────────────────────────────────── if [ ! -d "$BOT_DIR/venv" ]; then python3 -m venv "$BOT_DIR/venv" fi +"$BOT_DIR/venv/bin/pip" install --upgrade pip -q 2>/dev/null "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q -# Конфиг +# ── Конфиг (.env) ──────────────────────────────────────────────────────────── if [ ! -f "$BOT_DIR/.env" ]; then + echo "" echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" TOKEN="" while [ -z "$TOKEN" ]; do @@ -92,12 +112,13 @@ if [ ! -f "$BOT_DIR/.env" ]; then [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым.${NC}" done echo "BOT_TOKEN=$TOKEN" > "$BOT_DIR/.env" - [ -f "$BOT_DIR/config.example.env" ] && grep -v "^BOT_TOKEN=" "$BOT_DIR/config.example.env" | grep -v "^#" >> "$BOT_DIR/.env" chmod 600 "$BOT_DIR/.env" echo -e "${GREEN}[*] .env создан.${NC}" +else + echo -e "${GREEN}[*] .env уже есть — пропускаю.${NC}" fi -# systemd +# ── systemd ────────────────────────────────────────────────────────────────── cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF [Unit] Description=GoTelegram MTProxy Bot @@ -109,16 +130,21 @@ WorkingDirectory=$BOT_DIR ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py Restart=always RestartSec=5 -Environment=PATH=$BOT_DIR/venv/bin:/usr/bin +Environment=PATH=$BOT_DIR/venv/bin:/usr/bin:/usr/local/bin [Install] WantedBy=multi-user.target EOF systemctl daemon-reload -systemctl enable "$SERVICE_NAME" +systemctl enable "$SERVICE_NAME" 2>/dev/null systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME" -echo -e "${GREEN}[*] Сервис $SERVICE_NAME запущен.${NC}" -echo -e "Проверка: systemctl status $SERVICE_NAME" -echo -e "Логи: journalctl -u $SERVICE_NAME -f" + +echo "" +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ Установка завершена! ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════╝${NC}" +echo -e "Бот: systemctl status $SERVICE_NAME" +echo -e "Логи: journalctl -u $SERVICE_NAME -f" +echo -e "Конфиг: $BOT_DIR/.env" exit 0