v2: кнопочное меню, проверка портов, совместимость Docker, кнопка Поделиться, TCP+UDP звонки, auto-install Docker

Made-with: Cursor
This commit is contained in:
anten-ka
2026-03-06 15:38:48 +03:00
parent 1f38f068f7
commit a438b361e1
2 changed files with 566 additions and 308 deletions

View File

@@ -1,16 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
GoTelegram MTProxy — Telegram-бот для управления MTProxy на сервере. GoTelegram MTProxy — Telegram-бот для управления MTProxy на сервере.
Функции: установка, статус, ссылка, удаление, рестарт, логи (по аналогии с CLI gotelegram). Кнопочное меню, проверка портов, совместимость с Amnezia/3x-ui,
кнопка «Поделиться ключом», TCP+UDP для звонков.
""" """
import asyncio import asyncio
import html import html
import json
import os import os
import re import re
from pathlib import Path from pathlib import Path
# Загрузка .env из текущей папки или /etc/gotelegram-bot
_env_path = Path(__file__).resolve().parent / ".env" _env_path = Path(__file__).resolve().parent / ".env"
if not _env_path.exists(): if not _env_path.exists():
_env_path = Path("/etc/gotelegram-bot/.env") _env_path = Path("/etc/gotelegram-bot/.env")
@@ -32,18 +33,16 @@ from telegram.ext import (
filters, filters,
) )
# --- Конфиг --- # ── Конфиг ────────────────────────────────────────────────────────────────────
BOT_TOKEN = os.environ.get("BOT_TOKEN") BOT_TOKEN = os.environ.get("BOT_TOKEN")
_allowed = os.environ.get("ALLOWED_IDS", "").strip() _allowed = os.environ.get("ALLOWED_IDS", "").strip()
if _allowed: try:
try: ALLOWED_IDS = set(int(x) for x in _allowed.split(",") if x.strip()) if _allowed else None
ALLOWED_IDS = set(int(x.strip()) for x in _allowed.split(",") if x.strip()) except ValueError:
except ValueError: ALLOWED_IDS = None
ALLOWED_IDS = None
else:
ALLOWED_IDS = None # все пользователи
CONTAINER_NAME = "mtproto-proxy" CONTAINER_NAME = "mtproto-proxy"
CONFIG_FILE = Path("/opt/gotelegram-bot/proxy.json")
DOMAINS = [ DOMAINS = [
"google.com", "wikipedia.org", "habr.com", "github.com", "google.com", "wikipedia.org", "habr.com", "github.com",
"coursera.org", "udemy.com", "medium.com", "stackoverflow.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" TIP_LINK = "https://pay.cloudtips.ru/p/7410814f"
def check_access(user_id: int) -> bool: # ── Утилиты ──────────────────────────────────────────────────────────────────
if ALLOWED_IDS is None: def _ok(uid: int) -> bool:
return True return ALLOWED_IDS is None or uid in ALLOWED_IDS
return user_id in ALLOWED_IDS
def _decode(data: bytes) -> str: def _decode(data: bytes) -> str:
return (data or b"").decode("utf-8", errors="replace").strip() 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( proc = await asyncio.create_subprocess_exec(
*args, *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
) )
try: try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError: except asyncio.TimeoutError:
proc.kill() proc.kill()
await proc.wait() await proc.wait()
return -1, "", "Timeout" return -1, "", "Timeout"
return proc.returncode or 0, _decode(stdout), _decode(stderr) return proc.returncode or 0, _decode(out), _decode(err)
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()
async def get_ip() -> str: async def get_ip() -> str:
for url in ["https://api.ipify.org", "https://icanhazip.com"]: for url in ("https://api.ipify.org", "https://icanhazip.com", "https://ifconfig.me"):
code, out, _ = await run_cmd("curl", "-s", "-4", "--max-time", "5", url, timeout=8) code, out, _ = await sh("curl", "-s", "-4", "--max-time", "5", url, timeout=8)
if code == 0 and out: 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: if m:
return m.group(0) return m.group(0)
return "0.0.0.0" return "0.0.0.0"
async def proxy_is_running() -> bool: async def proxy_running() -> bool:
code, out, _ = await run_cmd("docker", "ps", "--format", "{{.Names}}", timeout=10) 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: if code != 0:
return False code, out, _ = await sh("netstat", "-tlnp", timeout=5)
return CONTAINER_NAME in (out or "") 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 = (
"🚀 <b>GoTelegram MTProxy Bot</b>\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 return
if not check_access(update.effective_user.id): if not _ok(update.effective_user.id):
await update.message.reply_text("⛔ Доступ запрещён.") msg = update.message or (update.callback_query and update.callback_query.message)
if msg:
await msg.reply_text("⛔ Доступ запрещён.")
return return
text = ( if update.message:
"🚀 *GoTelegram MTProxy Bot*\n\n" await update.message.reply_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb())
"Управление MTProxy на этом сервере.\n\n" elif update.callback_query:
"Команды:\n" await update.callback_query.edit_message_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb())
"/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")
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 return
if not check_access(update.effective_user.id): if not _ok(update.effective_user.id):
await update.message.reply_text(" Доступ запрещён.") await msg.reply_text("")
return return
if not await proxy_is_running(): info = await proxy_info()
await update.message.reply_text( if not info:
"❌ Прокси не запущен.\nИспользуйте /install для установки." text = "❌ Прокси не запущен.\nНажмите <b>Установить</b> для настройки."
else:
containers = await docker_containers_info()
other = "\n".join(l for l in containers.splitlines() if CONTAINER_NAME not in l)
text = (
"✅ <b>Прокси работает</b>\n\n"
f"IP: <code>{html.escape(info['ip'])}</code>\n"
f"Порт: <code>{html.escape(info['port'])}</code>\n"
f"Домен: <code>{html.escape(info['domain'])}</code>\n"
f"Secret: <code>{html.escape(info['secret'])}</code>\n\n"
f"Ссылка:\n<code>{html.escape(info['link'])}</code>"
) )
if other:
text += f"\n\n📦 <b>Другие контейнеры:</b>\n<pre>{html.escape(other)}</pre>"
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 return
secret = await docker_inspect("{{range .Config.Cmd}}{{.}} {{end}}") if not _ok(update.effective_user.id):
secret = secret.split()[-1] if secret else "" return
port = await docker_inspect("{{range $p, $conf := .HostConfig.PortBindings}}{{(index $conf 0).HostPort}}{{end}}") info = await proxy_info()
port = port or "443" if not info:
ip = await get_ip() text = "❌ Прокси не запущен."
link = f"tg://proxy?server={ip}&port={port}&secret={secret}" else:
# HTML безопаснее для произвольного secret (экранируем) text = f"<code>{html.escape(info['link'])}</code>"
text = ( kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
"✅ <b>Прокси запущен</b>\n\n" if update.callback_query:
f"IP: <code>{html.escape(ip)}</code>\n" await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
f"Port: <code>{html.escape(port)}</code>\n" else:
f"Secret: <code>{html.escape(secret)}</code>\n\n" await msg.reply_text(text, parse_mode="HTML", reply_markup=kb)
f"Ссылка (скопируйте):\n<code>{html.escape(link)}</code>"
# ── Поделиться ключом ────────────────────────────────────────────────────────
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"🔐 <b>MTProxy для Telegram</b>\n\n"
f"🌍 Сервер: <code>{html.escape(info['ip'])}</code>\n"
f"🔌 Порт: <code>{html.escape(info['port'])}</code>\n"
f"🔑 Secret: <code>{html.escape(info['secret'])}</code>\n\n"
f"👉 <b>Подключиться одним нажатием:</b>\n"
f"{html.escape(tg_link)}\n\n"
f"Просто нажмите на ссылку или перешлите это сообщение."
) )
await update.message.reply_text(text, parse_mode="HTML") kb = InlineKeyboardMarkup([
[InlineKeyboardButton("📤 Переслать другу", switch_inline_query=tg_link)],
[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")],
async def cmd_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ])
if not update.effective_user or not update.message: if update.callback_query:
return await update.callback_query.edit_message_text(share_text, parse_mode="HTML", reply_markup=kb)
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 вручную.")
else: 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 return
if not check_access(update.effective_user.id): if not _ok(update.effective_user.id):
await update.message.reply_text("⛔ Доступ запрещён.")
return return
if not await proxy_is_running(): chat = msg.chat
await update.message.reply_text("❌ Прокси не запущен. /install") if update.callback_query:
return await update.callback_query.edit_message_text("⏳ Удаляю прокси...")
await update.message.reply_text("Перезапускаю...")
code, _, err = await run_cmd("docker", "restart", CONTAINER_NAME, timeout=30)
if code == 0:
await update.message.reply_text("✅ Прокси перезапущен.")
else: 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 return
if not check_access(update.effective_user.id): if not _ok(update.effective_user.id):
await update.message.reply_text("⛔ Доступ запрещён.")
return return
if not await proxy_is_running(): if not await proxy_running():
await update.message.reply_text("❌ Прокси не запущен. /install") 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 return
code, out, err = await run_cmd("docker", "logs", "--tail", "40", CONTAINER_NAME, timeout=15) chat = msg.chat
text = (out or "") + (("\n" + err) if err else "") if update.callback_query:
if not text: await update.callback_query.edit_message_text("⏳ Перезапуск...")
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: if len(text) > 4000:
text = text[-4000:] text = text[-4000:]
await update.message.reply_text(f"<pre>{html.escape(text)}</pre>", parse_mode="HTML") kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
if update.callback_query:
await update.callback_query.edit_message_text(f"<pre>{html.escape(text)}</pre>", parse_mode="HTML", reply_markup=kb)
else:
await msg.reply_text(f"<pre>{html.escape(text)}</pre>", 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 return
if not check_access(update.effective_user.id): if not _ok(update.effective_user.id):
await update.message.reply_text("⛔ Доступ запрещён.")
return return
text = ( text = (
"💰 *Хостинг со скидкой до -60%*\n" "💰 <b>Хостинг со скидкой до -60%</b>\n"
f"Ссылка: {PROMO_LINK}\n\n" f"Ссылка: {PROMO_LINK}\n\n"
"Промокоды: OFF60, antenka20, antenka6, antenka12\n\n" "Промокоды: OFF60, antenka20, antenka6, antenka12\n\n"
f"Донат: {TIP_LINK}" 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: async def install_step_domain(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_user or not update.message: msg = update.message or (update.callback_query and update.callback_query.message)
if not update.effective_user or not msg:
return return
if not check_access(update.effective_user.id): if not _ok(update.effective_user.id):
await update.message.reply_text("⛔ Доступ запрещён.")
return return
buttons = [] buttons = []
row = [] row = []
@@ -258,143 +377,256 @@ async def install_choice_domain(update: Update, context: ContextTypes.DEFAULT_TY
row = [] row = []
if row: if row:
buttons.append(row) buttons.append(row)
await update.message.reply_text( text = "🌐 <b>Выберите домен для маскировки (Fake TLS):</b>"
"Выберите домен для маскировки (Fake TLS):", if update.callback_query:
reply_markup=InlineKeyboardMarkup(buttons), 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<pre>{html.escape(busy_443[:300])}</pre>\n"
if busy_8443:
port_info += f"\n⚠️ Порт 8443 занят:\n<pre>{html.escape(busy_8443[:300])}</pre>\n"
text = (
f"Домен: <b>{html.escape(domain)}</b>\n\n"
"🔌 <b>Выберите порт</b> или введите свой (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"⚠️ <b>Порт {port} занят!</b>\n\n"
f"<pre>{html.escape(busy[:500])}</pre>\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"
"Установите: <code>curl -fsSL https://get.docker.com | sh</code>",
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 = (
"✅ <b>Прокси установлен!</b>\n\n"
f"🌍 IP: <code>{html.escape(ip)}</code>\n"
f"🔌 Порт: <code>{html.escape(port)}</code> (TCP + UDP)\n"
f"🎭 Домен: <code>{html.escape(domain)}</code>\n"
f"🔑 Secret: <code>{html.escape(secret)}</code>\n\n"
f"👉 Ссылка:\n<code>{html.escape(link)}</code>\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 query = update.callback_query
if not query or not update.effective_user: if not query or not update.effective_user:
return return
await query.answer() await query.answer()
if not check_access(update.effective_user.id): if not _ok(update.effective_user.id):
await query.edit_message_text("⛔ Доступ запрещён.") await query.edit_message_text("⛔ Доступ запрещён.")
return return
data = query.data or "" 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: try:
idx = int(data[4:]) idx = int(data[4:])
except ValueError: except ValueError:
await query.edit_message_text("Неверные данные. Начните с /install.") await query.edit_message_text("Ошибка. /install")
return return
if not (0 <= idx < len(DOMAINS)): if not (0 <= idx < len(DOMAINS)):
await query.edit_message_text("❌ Неверный выбор. Начните с /install.") await query.edit_message_text("❌ Неверный выбор. /install")
return return
domain = DOMAINS[idx] ctx.user_data["install_domain"] = DOMAINS[idx]
context.user_data["gotelegram_domain"] = domain await install_step_port(update, ctx)
kb = InlineKeyboardMarkup([
[InlineKeyboardButton("443 (рекомендуется)", callback_data="port_443"), elif data == "port_443":
InlineKeyboardButton("8443", callback_data="port_8443")], await install_port_chosen(update, ctx, "443")
]) elif data == "port_8443":
await query.edit_message_text( await install_port_chosen(update, ctx, "8443")
f"Домен: {domain}\n\nВыберите порт или введите свой (1-65535):", elif data.startswith("force_"):
reply_markup=kb, port_str = data[6:]
) ctx.user_data["install_port"] = port_str
context.user_data["gotelegram_wait_port"] = True ctx.user_data["install_wait_port"] = False
return await do_install(update, ctx)
if data == "port_443": elif data == "reselect_port":
context.user_data["gotelegram_port"] = "443" await install_step_port(update, ctx)
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
async def do_install(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # ── Ввод порта текстом ──────────────────────────────────────────────────────
domain = context.user_data.get("gotelegram_domain") or "google.com" async def text_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
port = context.user_data.get("gotelegram_port") or "443" if not update.message or not ctx.user_data.get("install_wait_port"):
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<code>{html.escape(link)}</code>"
)
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"):
return return
text = (update.message.text or "").strip() text = (update.message.text or "").strip()
if not re.match(r"^[0-9]+$", text): if not re.match(r"^\d+$", text):
return return
port_num = int(text) port = int(text)
if not (1 <= port_num <= 65535): if not (1 <= port <= 65535):
await update.message.reply_text("Введите число от 1 до 65535.") await update.message.reply_text("Введите число от 1 до 65535.")
return return
context.user_data["gotelegram_port"] = str(port_num) await install_port_chosen(update, ctx, str(port))
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)
# ── main ─────────────────────────────────────────────────────────────────────
def main() -> None: def main() -> None:
if not BOT_TOKEN: if not BOT_TOKEN:
raise SystemExit("Установите BOT_TOKEN в .env или в переменных окружения.") raise SystemExit("Задайте BOT_TOKEN в .env")
app = ( app = Application.builder().token(BOT_TOKEN).build()
Application.builder()
.token(BOT_TOKEN)
.build()
)
app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", help_cmd)) app.add_handler(CommandHandler("help", start))
app.add_handler(CommandHandler("install", install_choice_domain)) app.add_handler(CommandHandler("install", install_step_domain))
app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("link", cmd_link)) 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("remove", cmd_remove))
app.add_handler(CommandHandler("restart", cmd_restart)) app.add_handler(CommandHandler("restart", cmd_restart))
app.add_handler(CommandHandler("logs", cmd_logs)) app.add_handler(CommandHandler("logs", cmd_logs))
app.add_handler(CommandHandler("promo", cmd_promo)) app.add_handler(CommandHandler("promo", cmd_promo))
app.add_handler(CallbackQueryHandler(install_callback)) app.add_handler(CallbackQueryHandler(callback_handler))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_port_message)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler))
app.run_polling(allowed_updates=Update.ALL_TYPES) app.run_polling(allowed_updates=Update.ALL_TYPES)

View File

@@ -1,89 +1,109 @@
#!/bin/bash #!/bin/bash
# Установка GoTelegram MTProxy Bot. # 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 # 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 set -e
RED='\033[0;31m' RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
if [ "$EUID" -ne 0 ]; then [ "$EUID" -ne 0 ] && { echo -e "${RED}Запустите с sudo.${NC}"; exit 1; }
echo -e "${RED}Запустите с sudo.${NC}"
exit 1
fi
# Токен можно передать аргументом: gotelegram ВАШ_ТОКЕН (для приватного репо)
[ -n "$1" ] && export GITHUB_TOKEN="$1" [ -n "$1" ] && export GITHUB_TOKEN="$1"
BOT_DIR="/opt/gotelegram-bot" BOT_DIR="/opt/gotelegram-bot"
SERVICE_NAME="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 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 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 elif command -v yum &>/dev/null; then
yum install -y python3 python3-pip curl git yum install -y "$@"
else
echo -e "${RED}Установите python3 и curl.${NC}"
exit 1
fi fi
fi }
if [ -n "$GITHUB_TOKEN" ] && ! command -v git &>/dev/null; then
echo -e "${GREEN}[*] Установка git для клонирования приватного репо...${NC}" for cmd in python3 curl git; do
if command -v apt-get &>/dev/null; then apt-get update && apt-get install -y git; fi command -v $cmd &>/dev/null || { echo -e "${YELLOW}[*] Установка $cmd...${NC}"; install_pkg $cmd; }
if command -v dnf &>/dev/null; then dnf install -y git; fi done
if command -v yum &>/dev/null; then yum install -y git; fi
fi # 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 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 fi
mkdir -p "$BOT_DIR" # Проверка: Docker запущен?
cd "$BOT_DIR" 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 EXISTING=$(docker ps --format "{{.Names}}\t{{.Image}}\t{{.Ports}}" 2>/dev/null)
echo -e "${GREEN}[*] Загрузка файлов из репозитория...${NC}" if [ -n "$EXISTING" ]; then
if [ -n "$GITHUB_TOKEN" ] && command -v git &>/dev/null; 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_$$" 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/" cp -r "$TMP/gotelegram-bot"/* "$BOT_DIR/"
rm -rf "$TMP" rm -rf "$TMP"
else return 0
echo -e "${RED}Ошибка клонирования:${NC}"
cat /tmp/gotelegram_clone_err_$$ 2>/dev/null
rm -rf "$TMP" /tmp/gotelegram_clone_err_$$ 2>/dev/null
exit 1
fi fi
rm -f /tmp/gotelegram_clone_err_$$ rm -rf "$TMP"
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
fi fi
if [ ! -f "$BOT_DIR/bot.py" ]; then # Публичный репо: curl
echo -e "${RED}Не удалось загрузить файлы. Для приватного репо запустите с токеном:${NC}" echo -e "${YELLOW}[*] Скачивание файлов...${NC}"
echo -e " ${YELLOW}gotelegram ВАШ_GITHUB_ТОКЕН${NC}" local ok=1
exit 1 for f in bot.py requirements.txt config.example.env; do
fi curl -sL -f "$REPO_RAW/gotelegram-bot/$f" -o "$BOT_DIR/$f" 2>/dev/null || ok=0
fi 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 if [ ! -d "$BOT_DIR/venv" ]; then
python3 -m venv "$BOT_DIR/venv" python3 -m venv "$BOT_DIR/venv"
fi 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 "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q
# Конфиг # ── Конфиг (.env) ────────────────────────────────────────────────────────────
if [ ! -f "$BOT_DIR/.env" ]; then if [ ! -f "$BOT_DIR/.env" ]; then
echo ""
echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}"
TOKEN="" TOKEN=""
while [ -z "$TOKEN" ]; do while [ -z "$TOKEN" ]; do
@@ -92,12 +112,13 @@ if [ ! -f "$BOT_DIR/.env" ]; then
[ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым.${NC}" [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым.${NC}"
done done
echo "BOT_TOKEN=$TOKEN" > "$BOT_DIR/.env" 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" chmod 600 "$BOT_DIR/.env"
echo -e "${GREEN}[*] .env создан.${NC}" echo -e "${GREEN}[*] .env создан.${NC}"
else
echo -e "${GREEN}[*] .env уже есть — пропускаю.${NC}"
fi fi
# systemd # ── systemd ──────────────────────────────────────────────────────────────────
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
[Unit] [Unit]
Description=GoTelegram MTProxy Bot Description=GoTelegram MTProxy Bot
@@ -109,16 +130,21 @@ WorkingDirectory=$BOT_DIR
ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=PATH=$BOT_DIR/venv/bin:/usr/bin Environment=PATH=$BOT_DIR/venv/bin:/usr/bin:/usr/local/bin
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload 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" systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME"
echo -e "${GREEN}[*] Сервис $SERVICE_NAME запущен.${NC}"
echo -e "Проверка: systemctl status $SERVICE_NAME" echo ""
echo -e "Логи: journalctl -u $SERVICE_NAME -f" 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 exit 0