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.strip()) for x in _allowed.split(",") if x.strip()) ALLOWED_IDS = set(int(x) for x in _allowed.split(",") if x.strip()) if _allowed else None
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)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if code != 0 or not out:
if not update.effective_user or not update.message: return ""
return return out
if not check_access(update.effective_user.id):
await update.message.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")
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: def save_config(data: dict) -> None:
if not update.effective_user or not update.message: CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
return CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
if not check_access(update.effective_user.id):
await update.message.reply_text("⛔ Доступ запрещён.")
return def load_config() -> dict:
if not await proxy_is_running(): if CONFIG_FILE.exists():
await update.message.reply_text( try:
"❌ Прокси не запущен.\nИспользуйте /install для установки." return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
) except Exception:
return pass
secret = await docker_inspect("{{range .Config.Cmd}}{{.}} {{end}}") return {}
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" # ── Получение данных прокси ──────────────────────────────────────────────────
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() ip = await get_ip()
link = f"tg://proxy?server={ip}&port={port}&secret={secret}" link = f"tg://proxy?server={ip}&port={port}&secret={secret}"
# HTML безопаснее для произвольного secret (экранируем) cfg = load_config()
text = ( return {"ip": ip, "port": port, "secret": secret, "link": link, "domain": cfg.get("domain", "")}
"✅ <b>Прокси запущен</b>\n\n"
f"IP: <code>{html.escape(ip)}</code>\n"
f"Port: <code>{html.escape(port)}</code>\n" # ── Главное меню (кнопки) ────────────────────────────────────────────────────
f"Secret: <code>{html.escape(secret)}</code>\n\n" def main_menu_kb() -> InlineKeyboardMarkup:
f"Ссылка (скопируйте):\n<code>{html.escape(link)}</code>" 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"
) )
await update.message.reply_text(text, parse_mode="HTML")
async def cmd_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_user or not update.message: 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
if not await proxy_is_running(): if update.message:
await update.message.reply_text("❌ Прокси не запущен. /install") await update.message.reply_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb())
return elif update.callback_query:
secret = await docker_inspect("{{range .Config.Cmd}}{{.}} {{end}}") await update.callback_query.edit_message_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb())
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: 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
await update.message.reply_text("Удаляю прокси...") info = await proxy_info()
await run_cmd("docker", "stop", CONTAINER_NAME, timeout=15) if not info:
await run_cmd("docker", "rm", CONTAINER_NAME, timeout=10) text = "❌ Прокси не запущен.\nНажмите <b>Установить</b> для настройки."
if await proxy_is_running():
await update.message.reply_text("⚠️ Не удалось удалить. Проверьте docker вручную.")
else: else:
await update.message.reply_text("✅ Прокси удалён.") containers = await docker_containers_info()
other = "\n".join(l for l in containers.splitlines() if CONTAINER_NAME not in l)
text = (
async def cmd_restart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "✅ <b>Прокси работает</b>\n\n"
if not update.effective_user or not update.message: f"IP: <code>{html.escape(info['ip'])}</code>\n"
return f"Порт: <code>{html.escape(info['port'])}</code>\n"
if not check_access(update.effective_user.id): f"Домен: <code>{html.escape(info['domain'])}</code>\n"
await update.message.reply_text("⛔ Доступ запрещён.") f"Secret: <code>{html.escape(info['secret'])}</code>\n\n"
return f"Ссылка:\n<code>{html.escape(info['link'])}</code>"
if not await proxy_is_running(): )
await update.message.reply_text("❌ Прокси не запущен. /install") if other:
return text += f"\n\n📦 <b>Другие контейнеры:</b>\n<pre>{html.escape(other)}</pre>"
await update.message.reply_text("Перезапускаю...") kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
code, _, err = await run_cmd("docker", "restart", CONTAINER_NAME, timeout=30) if update.callback_query:
if code == 0: await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
await update.message.reply_text("✅ Прокси перезапущен.")
else: else:
await update.message.reply_text(f"❌ Ошибка: {err or 'unknown'}") await msg.reply_text(text, parse_mode="HTML", 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_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
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(): info = await proxy_info()
await update.message.reply_text("❌ Прокси не запущен. /install") if not info:
text = "❌ Прокси не запущен."
else:
text = f"<code>{html.escape(info['link'])}</code>"
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 return
code, out, err = await run_cmd("docker", "logs", "--tail", "40", CONTAINER_NAME, timeout=15) if not _ok(update.effective_user.id):
text = (out or "") + (("\n" + err) if err else "") return
if not text: info = await proxy_info()
text = "Нет вывода." 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"Просто нажмите на ссылку или перешлите это сообщение."
)
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: 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
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 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" 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
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 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 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 fi
# Публичный репо — пробуем curl # Публичный репо: curl
echo -e "${YELLOW}[*] Скачивание файлов...${NC}"
local ok=1
for f in bot.py requirements.txt config.example.env; do 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 curl -sL -f "$REPO_RAW/gotelegram-bot/$f" -o "$BOT_DIR/$f" 2>/dev/null || ok=0
done done
fi [ "$ok" -eq 1 ] && return 0
if [ ! -f "$BOT_DIR/bot.py" ]; then return 1
echo -e "${RED}Не удалось загрузить файлы. Для приватного репо запустите с токеном:${NC}" }
echo -e " ${YELLOW}gotelegram ВАШ_GITHUB_ТОКЕН${NC}"
exit 1
fi
fi
# 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 "${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 "Логи: journalctl -u $SERVICE_NAME -f"
echo -e "Конфиг: $BOT_DIR/.env"
exit 0 exit 0