commit 9482cfe7f670d0455f636b0360c6e2b712ea2642 Author: kobaltgit Date: Sun Apr 5 22:08:46 2026 +0300 feat: Initial commit for SwiftGram - Smart MTProxy Manager diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fd7284 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Секреты и конфиг с токенами +.env +*.env.local +config.local.env + +# Python +__pycache__/ +*.py[cod] +venv/ +.venv/ +*.egg-info/ +.eggs/ + +# Временные и системные +.DS_Store +Thumbs.db +*.log +*.tmp diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..98aa6e4 --- /dev/null +++ b/bot.py @@ -0,0 +1,634 @@ +#!/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: + """Если порт занят — возвращает описание процесса; иначе 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) + if code != 0 or not out: + return "" + return out + + +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() + 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"Просто нажмите на ссылку или перешлите это сообщение." + ) + 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") + + # Проверяем порт 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_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 _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_"): + 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 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)) + + +# ── main ───────────────────────────────────────────────────────────────────── +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() diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..8818dfb --- /dev/null +++ b/install.sh @@ -0,0 +1,387 @@ +#!/bin/bash +# 🚀 SwiftGram MTProxy — Smart Modular Manager +# Чистая версия без рекламы. +# Функции: Auto-BBR, IPv6, UDP-Fix, Domain Analysis, Hiddify Compatible. + +# ── Настройки репозитория (ЗАМЕНИ НА СВОИ ПОСЛЕ СОЗДАНИЯ РЕПО) ──────────────── +REPO_RAW_URL="https://git.bargcraft.top/kobalt/swiftgram/raw/branch/main" + +# ── Цвета ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +MAGENTA='\033[0;35m' +BLUE='\033[0;34m' +WHITE='\033[1;37m' +NC='\033[0m' + +# ── Конфиг ─────────────────────────────────────────────────────────────────── +CONTAINER_NAME="swiftgram-proxy" +BOT_DIR="/opt/swiftgram" +SERVICE_NAME="swiftgram-bot" + +# ── Спиннер и прогресс-бар ──────────────────────────────────────────────────── +spin_pid="" +spinner_start() { + local msg="${1:-Подождите...}" + ( + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while true; do + printf "\r ${CYAN}${frames[$i]}${NC} ${msg}" >&2 + i=$(( (i+1) % ${#frames[@]} )) + sleep 0.12 + done + ) & + spin_pid=$! +} +spinner_stop() { + [ -n "$spin_pid" ] && kill "$spin_pid" 2>/dev/null && wait "$spin_pid" 2>/dev/null + spin_pid="" + printf "\r\033[K" >&2 +} + +progress_bar() { + local current="$1" total="$2" label="${3:-}" + local pct=$(( current * 100 / total )) + local filled=$(( pct / 2 )) + local empty=$(( 50 - filled )) + local bar="" + for ((i=0; i&2 + [ "$current" -eq "$total" ] && echo "" >&2 +} + +# ── Проверки системы ───────────────────────────────────────────────────────── +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Запустите с sudo / root.${NC}" + exit 1 +fi + +install_pkg() { + if command -v apt-get &>/dev/null; then + apt-get update -qq && apt-get install -y -qq "$@" + elif command -v dnf &>/dev/null; then + dnf install -y "$@" 2>/dev/null + elif command -v yum &>/dev/null; then + yum install -y "$@" + fi +} + +# ── Оптимизация Сети (BBR + Limits) ────────────────────────────────────────── +optimize_system() { + spinner_start "Оптимизация сетевого стека (BBR)..." + if ! sysctl net.ipv4.tcp_congestion_control | grep -q "bbr"; then + { + echo "net.core.default_qdisc=fq" + echo "net.ipv4.tcp_congestion_control=bbr" + echo "net.ipv4.ip_local_port_range=1024 65535" + echo "net.core.somaxconn=65535" + echo "net.ipv4.tcp_fastopen=3" + } >> /etc/sysctl.conf + sysctl -p >/dev/null 2>&1 + fi + # Увеличение лимитов открытых файлов + if ! grep -q "soft nofile 1000000" /etc/security/limits.conf; then + echo "* soft nofile 1000000" >> /etc/security/limits.conf + echo "* hard nofile 1000000" >> /etc/security/limits.conf + fi + spinner_stop + echo -e " ${GREEN}✓${NC} Система оптимизирована (BBR включен)" +} + +# ── Firewall (Фикс звонков) ────────────────────────────────────────────────── +fix_firewall() { + local port="$1" + if command -v ufw &>/dev/null && ufw status | grep -q "active"; then + ufw allow "$port"/tcp >/dev/null 2>&1 + ufw allow "$port"/udp >/dev/null 2>&1 + elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then + firewall-cmd --permanent --add-port="$port"/tcp >/dev/null 2>&1 + firewall-cmd --permanent --add-port="$port"/udp >/dev/null 2>&1 + firewall-cmd --reload >/dev/null 2>&1 + fi + echo -e " ${GREEN}✓${NC} Firewall: порты $port/TCP и $port/UDP открыты" +} + +# ── Интеллектуальный анализ домена ─────────────────────────────────────────── +analyze_best_domain() { + spinner_start "Анализ оптимального домена для Fake TLS..." + local test_domains=( + "google.com" "wikipedia.org" "github.com" "habr.com" + "microsoft.com" "stackoverflow.com" "lenta.ru" "rbc.ru" + ) + local best_domain="google.com" + local min_time=999 + + for d in "${test_domains[@]}"; do + # Пингуем 1 раз, ждем максимум 1 секунду + local t=$(ping -c 1 -W 1 "$d" 2>/dev/null | grep 'time=' | awk -F'time=' '{print $2}' | awk '{print $1}') + if [ -z "$t" ]; then t=999; fi + + # Сравнение через bc (если есть) или целочисленное + if (( $(echo "$t < $min_time" | bc -l 2>/dev/null || [ ${t%.*} -lt ${min_time%.*} ]) )); then + min_time=$t + best_domain=$d + fi + done + spinner_stop + echo -e " ${GREEN}✓${NC} Оптимальный домен: ${WHITE}$best_domain${NC} (задержка: ${min_time}ms)" + echo "$best_domain" +} + +# ── Умный поиск порта ──────────────────────────────────────────────────────── +find_smart_port() { + local port=443 + # Проверяем 443, если занят — проверяем 8443, если и он занят — берем рандом + if ss -tlnp | grep -qE ":${port}\b"; then + echo -e " ${YELLOW}ℹ Порт 443 занят (Hiddify/Nginx). Пробую 8443...${NC}" + port=8443 + if ss -tlnp | grep -qE ":${port}\b"; then + port=$(( (RANDOM % 10000) + 20000 )) + echo -e " ${YELLOW}ℹ Порт 8443 тоже занят. Выбран случайный: $port${NC}" + fi + fi + echo "$port" +} + +# ── Установка зависимостей ─────────────────────────────────────────────────── +install_base_deps() { + local total=4 cur=0 + progress_bar $cur $total "Проверка..." + + if ! command -v curl &>/dev/null; then install_pkg curl; fi + cur=$((cur+1)); progress_bar $cur $total "curl" + + if ! command -v docker &>/dev/null; then + spinner_start "Установка Docker..." + curl -fsSL https://get.docker.com | sh >/dev/null 2>&1 + systemctl enable --now docker >/dev/null 2>&1 + spinner_stop + fi + cur=$((cur+1)); progress_bar $cur $total "docker" + + if ! command -v qrencode &>/dev/null; then install_pkg qrencode; fi + cur=$((cur+1)); progress_bar $cur $total "qrencode" + + if ! docker info &>/dev/null 2>&1; then systemctl start docker; fi + cur=$((cur+1)); progress_bar $cur $total "Готово" + echo "" +} + +# ── IP Утилиты ─────────────────────────────────────────────────────────────── +get_ip4() { curl -s -4 --max-time 5 https://api.ipify.org || echo "0.0.0.0"; } +get_ip6() { curl -s -6 --max-time 5 https://api6.ipify.org || echo ""; } + +# ── 1) Установка MTProxy ───────────────────────────────────────────────────── +menu_install() { + clear + echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║ УСТАНОВКА SWIFTGRAM MTPROXY ║${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" + + optimize_system + + local DOMAIN=$(analyze_best_domain) + local PORT=$(find_smart_port) + + fix_firewall "$PORT" + + # Процесс Docker + spinner_start "Загрузка и запуск прокси (IPv4 + IPv6 + UDP)..." + docker pull nineseconds/mtg:2 >/dev/null 2>&1 + local SECRET=$(docker run --rm nineseconds/mtg:2 generate-secret --hex "$DOMAIN" 2>/dev/null) + + docker stop "$CONTAINER_NAME" &>/dev/null + docker rm "$CONTAINER_NAME" &>/dev/null + + # Запуск: слушаем 0.0.0.0 (все IPv4) и [::] (все IPv6) + docker run -d --name "$CONTAINER_NAME" --restart always \ + -p "$PORT":"$PORT"/tcp \ + -p "$PORT":"$PORT"/udp \ + nineseconds/mtg:2 simple-run \ + -n 1.1.1.1 -t 1.0.0.1 -i prefer-ipv4 \ + 0.0.0.0:"$PORT" "$SECRET" > /dev/null 2>&1 + + sleep 2 + spinner_stop + + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + mkdir -p "$BOT_DIR" + echo "{\"domain\": \"$DOMAIN\", \"port\": \"$PORT\", \"secret\": \"$SECRET\"}" > "$BOT_DIR/proxy.json" + echo -e "\n${GREEN}✓ Прокси успешно запущен на порту $PORT!${NC}" + show_config + else + echo -e "\n${RED}✗ Ошибка запуска. Проверьте: docker logs $CONTAINER_NAME${NC}" + fi + read -p "Нажмите Enter..." +} + +# ── Показать данные ────────────────────────────────────────────────────────── +show_config() { + if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo -e "${RED}Прокси не запущен!${NC}"; return + fi + local DATA=$(cat "$BOT_DIR/proxy.json" 2>/dev/null) + local PORT=$(echo "$DATA" | grep -oP '(?<="port": ")[^"]*') + local SECRET=$(echo "$DATA" | grep -oP '(?<="secret": ")[^"]*') + local IP4=$(get_ip4) + local IP6=$(get_ip6) + + echo -e "\n${CYAN}--- ДАННЫЕ ПОДКЛЮЧЕНИЯ ---${NC}" + echo -e "IPv4: ${WHITE}$IP4${NC}" + [ -n "$IP6" ] && echo -e "IPv6: ${WHITE}$IP6${NC}" + echo -e "Порт: ${WHITE}$PORT${NC}" + echo -e "Secret: ${WHITE}$SECRET${NC}" + + local LINK="tg://proxy?server=$IP4&port=$PORT&secret=$SECRET" + echo -e "\nСсылка: ${BLUE}$LINK${NC}" + qrencode -t ANSIUTF8 "$LINK" +} + +# ── 3) Настройка бота (Модульная) ──────────────────────────────────────────── +menu_setup_bot() { + clear + echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║ НАСТРОЙКА TELEGRAM БОТА ║${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" + + # 1. Установка Python + if ! command -v python3 &>/dev/null; then + run_with_progress "Установка Python3" install_pkg python3 python3-pip python3-venv + fi + + mkdir -p "$BOT_DIR" + cd "$BOT_DIR" + + # 2. Скачивание модулей (Requirements + Bot) + spinner_start "Загрузка модулей бота из репозитория..." + curl -sL "$REPO_RAW_URL/requirements.txt" -o "requirements.txt" + curl -sL "$REPO_RAW_URL/bot.py" -o "bot.py" + spinner_stop + + # 3. Venv и зависимости + if [ ! -d "venv" ]; then + spinner_start "Создание виртуального окружения..." + python3 -m venv venv >/dev/null 2>&1 + spinner_stop + fi + spinner_start "Установка зависимостей Python..." + ./venv/bin/pip install --upgrade pip -q + ./venv/bin/pip install -r requirements.txt -q + spinner_stop + + # 4. Конфиг .env + echo -e "\n${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" + read -r TOKEN + echo -e "${YELLOW}Введите ваш Telegram ID (админ):${NC}" + read -r ADMIN_ID + + { + echo "BOT_TOKEN=$TOKEN" + [ -n "$ADMIN_ID" ] && echo "ALLOWED_IDS=$ADMIN_ID" + echo "CONTAINER_NAME=$CONTAINER_NAME" + echo "CONFIG_PATH=$BOT_DIR/proxy.json" + } > .env + chmod 600 .env + + # 5. Systemd сервис + cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF +[Unit] +Description=SwiftGram Bot Service +After=network.target docker.service + +[Service] +Type=simple +WorkingDirectory=$BOT_DIR +ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + + systemctl daemon-reload + systemctl enable --now "$SERVICE_NAME" + systemctl restart "$SERVICE_NAME" + + echo -e "\n${GREEN}✓ Бот успешно запущен и добавлен в автозагрузку!${NC}" + read -p "Нажмите Enter..." +} + +# ── 7) Полное меню удаления ────────────────────────────────────────────────── +menu_remove() { + clear + echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${RED}║ УДАЛЕНИЕ SWIFTGRAM ║${NC}" + echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}" + echo -e "Будет удалено: контейнер, файлы бота, сервис и настройки.\n" + + read -p "Вы уверены? (y/N): " yn + [[ "$yn" != "y" ]] && return + + local words=("УДАЛИТЬ" "SWIFTGRAM" "ОЧИСТКА" "ФИНАЛ") + local confirm_word="${words[$((RANDOM % ${#words[@]}))]}" + echo -e "Введите слово для подтверждения: ${WHITE}$confirm_word${NC}" + read -p ">>> " input_word + [[ "$input_word" != "$confirm_word" ]] && { echo "Отмена."; sleep 1; return; } + + spinner_start "Удаление..." + docker stop "$CONTAINER_NAME" &>/dev/null + docker rm "$CONTAINER_NAME" &>/dev/null + systemctl stop "$SERVICE_NAME" 2>/dev/null + systemctl disable "$SERVICE_NAME" 2>/dev/null + rm -f "/etc/systemd/system/${SERVICE_NAME}.service" + rm -rf "$BOT_DIR" + rm -f /usr/local/bin/swiftgram + spinner_stop + echo -e "${GREEN}✓ Система полностью очищена.${NC}" + read -p "Нажмите Enter..." +} + +# ── Главный цикл ───────────────────────────────────────────────────────────── +install_base_deps + +# Самокопирование +SELF="$(realpath "$0")" +if [ "$SELF" != "/usr/local/bin/swiftgram" ]; then + cp "$SELF" /usr/local/bin/swiftgram && chmod +x /usr/local/bin/swiftgram +fi + +while true; do + clear + echo -e "${MAGENTA}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${MAGENTA}║ SWIFTGRAM MANAGER (No Ads) ║${NC}" + echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════════╝${NC}" + + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo -e " Прокси: ${GREEN}РАБОТАЕТ${NC}" + else + echo -e " Прокси: ${RED}ВЫКЛЮЧЕН${NC}" + fi + if systemctl is-active --quiet "$SERVICE_NAME"; then + echo -e " Бот: ${GREEN}РАБОТАЕТ${NC}" + else + echo -e " Бот: ${YELLOW}НЕ НАСТРОЕН${NC}" + fi + + echo -e "\n ${GREEN}1)${NC} Установить / Обновить прокси" + echo -e " ${GREEN}2)${NC} Показать данные (QR)" + echo -e " ${CYAN}3)${NC} Настроить Telegram-бота" + echo -e " ${GREEN}4)${NC} Перезапустить прокси" + echo -e " ${RED}5)${NC} Удалить всё" + echo -e " ${WHITE}0)${NC} Выход" + echo "" + read -p "Пункт: " m_idx + case $m_idx in + 1) menu_install ;; + 2) clear; show_config; read -p "Нажмите Enter..." ;; + 3) menu_setup_bot ;; + 4) docker restart "$CONTAINER_NAME"; echo "Готово"; sleep 1 ;; + 5) menu_remove ;; + 0) exit 0 ;; + esac +done \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ea4513c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-telegram-bot>=21.0