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