diff --git a/gotelegram-bot/README.md b/gotelegram-bot/README.md
index dd97fc3..a01a37b 100644
--- a/gotelegram-bot/README.md
+++ b/gotelegram-bot/README.md
@@ -1,70 +1,70 @@
-# GoTelegram MTProxy Bot
-
-Telegram-бот для управления MTProxy на сервере — те же функции, что и у CLI `gotelegram`, но через бота.
-
-## Команды
-
-| Команда | Описание |
-|--------|----------|
-| `/start`, `/help` | Справка |
-| `/install` | Установить или обновить прокси (выбор домена и порта) |
-| `/status` | Статус и данные подключения (IP, порт, secret, ссылка) |
-| `/link` | Только ссылка `tg://proxy` |
-| `/restart` | Перезапустить контейнер |
-| `/logs` | Последние логи контейнера |
-| `/remove` | Удалить прокси |
-| `/promo` | Промо хостинга |
-
-## Установка на сервер
-
-### Публичный репозиторий (одной командой)
-
-```bash
-curl -sL https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main/install_gotelegram_bot.sh -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh
-```
-
-При установке скрипт запросит **BOT_TOKEN** (получить у [@BotFather](https://t.me/BotFather)).
-
-### Закрытый репозиторий (установка по ключу)
-
-Для **приватного** репо используется клонирование по **SSH-ключу** или по **токену (PAT)**. Подробно: **[INSTALL_PRIVATE.md](../INSTALL_PRIVATE.md)** в корне репозитория.
-
-Кратко:
-- **По SSH:** скопируйте `bootstrap_install.sh` на сервер, затем
- `GIT_REPO_SSH=git@github.com:USER/REPO.git sudo bash bootstrap_install.sh`
-- **По токену:**
- `GITHUB_TOKEN=ghp_xxx GIT_REPO_HTTPS=https://github.com/USER/REPO.git sudo -E bash bootstrap_install.sh`
-- Или клонируйте репо вручную и запустите:
- `sudo ./install_gotelegram_bot.sh`
-
-### Локально (файлы уже рядом со скриптом)
-
-```bash
-sudo ./install_gotelegram_bot.sh
-```
-
-## Конфигурация
-
-Файл: `/opt/gotelegram-bot/.env`
-
-- **BOT_TOKEN** — токен от @BotFather (обязательно).
-- **ALLOWED_IDS** — опционально. Список ID пользователей через запятую; если не задан, бот доступен всем.
-
-После изменения `.env` перезапуск сервиса:
-
-```bash
-sudo systemctl restart gotelegram-bot
-```
-
-## Требования на сервере
-
-- Linux (systemd), Docker, Python 3.
-- Перед использованием бота на сервере должен быть установлен Docker (бот сам поднимает контейнер `nineseconds/mtg:2` по команде `/install`).
-
-## Управление сервисом
-
-```bash
-sudo systemctl status gotelegram-bot
-sudo systemctl restart gotelegram-bot
-journalctl -u gotelegram-bot -f
-```
+# GoTelegram MTProxy Bot
+
+Telegram-бот для управления MTProxy на сервере — те же функции, что и у CLI `gotelegram`, но через бота.
+
+## Команды
+
+| Команда | Описание |
+|--------|----------|
+| `/start`, `/help` | Справка |
+| `/install` | Установить или обновить прокси (выбор домена и порта) |
+| `/status` | Статус и данные подключения (IP, порт, secret, ссылка) |
+| `/link` | Только ссылка `tg://proxy` |
+| `/restart` | Перезапустить контейнер |
+| `/logs` | Последние логи контейнера |
+| `/remove` | Удалить прокси |
+| `/promo` | Промо хостинга |
+
+## Установка на сервер
+
+### Публичный репозиторий (одной командой)
+
+```bash
+curl -sL https://raw.githubusercontent.com/anten-ka/gotelegram_pro/main/install_gotelegram_bot.sh -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh
+```
+
+При установке скрипт запросит **BOT_TOKEN** (получить у [@BotFather](https://t.me/BotFather)).
+
+### Закрытый репозиторий (установка по ключу)
+
+Для **приватного** репо используется клонирование по **SSH-ключу** или по **токену (PAT)**. Подробно: **[INSTALL_PRIVATE.md](../INSTALL_PRIVATE.md)** в корне репозитория.
+
+Кратко:
+- **По SSH:** скопируйте `bootstrap_install.sh` на сервер, затем
+ `GIT_REPO_SSH=git@github.com:USER/REPO.git sudo bash bootstrap_install.sh`
+- **По токену:**
+ `GITHUB_TOKEN=ghp_xxx GIT_REPO_HTTPS=https://github.com/USER/REPO.git sudo -E bash bootstrap_install.sh`
+- Или клонируйте репо вручную и запустите:
+ `sudo ./install_gotelegram_bot.sh`
+
+### Локально (файлы уже рядом со скриптом)
+
+```bash
+sudo ./install_gotelegram_bot.sh
+```
+
+## Конфигурация
+
+Файл: `/opt/gotelegram-bot/.env`
+
+- **BOT_TOKEN** — токен от @BotFather (обязательно).
+- **ALLOWED_IDS** — опционально. Список ID пользователей через запятую; если не задан, бот доступен всем.
+
+После изменения `.env` перезапуск сервиса:
+
+```bash
+sudo systemctl restart gotelegram-bot
+```
+
+## Требования на сервере
+
+- Linux (systemd), Docker, Python 3.
+- Перед использованием бота на сервере должен быть установлен Docker (бот сам поднимает контейнер `nineseconds/mtg:2` по команде `/install`).
+
+## Управление сервисом
+
+```bash
+sudo systemctl status gotelegram-bot
+sudo systemctl restart gotelegram-bot
+journalctl -u gotelegram-bot -f
+```
diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py
index 98aa6e4..aaf4ac5 100644
--- a/gotelegram-bot/bot.py
+++ b/gotelegram-bot/bot.py
@@ -1,634 +1,1397 @@
-#!/usr/bin/env python3
-"""
-GoTelegram MTProxy — Telegram-бот для управления MTProxy на сервере.
-Кнопочное меню, проверка портов, совместимость с Amnezia/3x-ui,
-кнопка «Поделиться ключом», TCP+UDP для звонков.
-"""
-
-import asyncio
-import html
-import json
-import os
-import re
-from pathlib import Path
-
-_env_path = Path(__file__).resolve().parent / ".env"
-if not _env_path.exists():
- _env_path = Path("/etc/gotelegram-bot/.env")
-if _env_path.exists():
- with open(_env_path, encoding="utf-8") as f:
- for line in f:
- line = line.strip()
- if line and not line.startswith("#") and "=" in line:
- k, v = line.split("=", 1)
- os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
-
-from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
-from telegram.ext import (
- Application,
- CommandHandler,
- CallbackQueryHandler,
- ContextTypes,
- MessageHandler,
- filters,
-)
-
-# ── Конфиг ────────────────────────────────────────────────────────────────────
-BOT_TOKEN = os.environ.get("BOT_TOKEN")
-_allowed = os.environ.get("ALLOWED_IDS", "").strip()
-try:
- ALLOWED_IDS = set(int(x) for x in _allowed.split(",") if x.strip()) if _allowed else None
-except ValueError:
- ALLOWED_IDS = None
-
-CONTAINER_NAME = "mtproto-proxy"
-CONFIG_FILE = Path("/opt/gotelegram-bot/proxy.json")
-DOMAINS = [
- "google.com", "wikipedia.org", "habr.com", "github.com",
- "coursera.org", "udemy.com", "medium.com", "stackoverflow.com",
- "bbc.com", "cnn.com", "reuters.com", "nytimes.com",
- "lenta.ru", "rbc.ru", "ria.ru", "kommersant.ru",
- "stepik.org", "duolingo.com", "khanacademy.org", "ted.com",
-]
-PROMO_LINK = "https://vk.cc/ct29NQ"
-TIP_LINK = "https://pay.cloudtips.ru/p/7410814f"
-
-
-# ── Утилиты ──────────────────────────────────────────────────────────────────
-def _ok(uid: int) -> bool:
- return ALLOWED_IDS is None or uid in ALLOWED_IDS
-
-
-def _decode(data: bytes) -> str:
- return (data or b"").decode("utf-8", errors="replace").strip()
-
-
-async def sh(*args: str, timeout: int = 60) -> tuple[int, str, str]:
- proc = await asyncio.create_subprocess_exec(
- *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
- )
- try:
- out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
- except asyncio.TimeoutError:
- proc.kill()
- await proc.wait()
- return -1, "", "Timeout"
- return proc.returncode or 0, _decode(out), _decode(err)
-
-
-async def get_ip() -> str:
- for url in ("https://api.ipify.org", "https://icanhazip.com", "https://ifconfig.me"):
- code, out, _ = await sh("curl", "-s", "-4", "--max-time", "5", url, timeout=8)
- if code == 0 and out:
- m = re.search(r"(\d{1,3}\.){3}\d{1,3}", out)
- if m:
- return m.group(0)
- return "0.0.0.0"
-
-
-async def proxy_running() -> bool:
- code, out, _ = await sh("docker", "ps", "--format", "{{.Names}}", timeout=10)
- return code == 0 and CONTAINER_NAME in out
-
-
-async def docker_val(fmt: str) -> str:
- code, out, _ = await sh("docker", "inspect", CONTAINER_NAME, "--format", fmt, timeout=10)
- return out.strip() if code == 0 else ""
-
-
-async def check_port(port: int) -> str | None:
- """Если порт занят — возвращает описание процесса; иначе None."""
- # Пропускаем, если порт занят нашим же контейнером
- if await proxy_running():
- hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}} {{end}}")
- if str(port) in hp.split():
- return None
- code, out, _ = await sh("ss", "-tlnp", timeout=5)
- if code != 0:
- code, out, _ = await sh("netstat", "-tlnp", timeout=5)
- for line in out.splitlines():
- if f":{port} " in line or f":{port}\t" in line:
- return line
- return None
-
-
-async def docker_containers_info() -> str:
- code, out, _ = await sh("docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}", timeout=10)
- if code != 0 or not out:
- return ""
- return out
-
-
-def save_config(data: dict) -> None:
- CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
- CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
-
-
-def load_config() -> dict:
- if CONFIG_FILE.exists():
- try:
- return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
- except Exception:
- pass
- return {}
-
-
-# ── Получение данных прокси ──────────────────────────────────────────────────
-async def proxy_info() -> dict | None:
- if not await proxy_running():
- return None
- cmd_str = await docker_val("{{range .Config.Cmd}}{{.}} {{end}}")
- secret = cmd_str.split()[-1] if cmd_str else ""
- hp = await docker_val("{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}}{{end}}")
- port = hp or "443"
- ip = await get_ip()
- link = f"tg://proxy?server={ip}&port={port}&secret={secret}"
- cfg = load_config()
- return {"ip": ip, "port": port, "secret": secret, "link": link, "domain": cfg.get("domain", "—")}
-
-
-# ── Главное меню (кнопки) ────────────────────────────────────────────────────
-def main_menu_kb() -> InlineKeyboardMarkup:
- return InlineKeyboardMarkup([
- [InlineKeyboardButton("🔧 Установить / Обновить", callback_data="menu_install")],
- [InlineKeyboardButton("📊 Статус", callback_data="menu_status"),
- InlineKeyboardButton("🔗 Ссылка", callback_data="menu_link")],
- [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")],
- [InlineKeyboardButton("🔄 Перезапуск", callback_data="menu_restart"),
- InlineKeyboardButton("📋 Логи", callback_data="menu_logs")],
- [InlineKeyboardButton("🗑 Удалить", callback_data="menu_remove"),
- InlineKeyboardButton("🏷 Промо", callback_data="menu_promo")],
- ])
-
-
-HELP_TEXT = (
- "🚀 GoTelegram MTProxy Bot\n\n"
- "Управление MTProxy (Fake TLS) на сервере.\n"
- "TCP + UDP (звонки) поддержаны.\n\n"
- "Используйте кнопки ниже или команды:\n"
- "/install /status /link /share /restart /logs /remove /promo"
-)
-
-
-async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- if not update.effective_user:
- return
- if not _ok(update.effective_user.id):
- msg = update.message or (update.callback_query and update.callback_query.message)
- if msg:
- await msg.reply_text("⛔ Доступ запрещён.")
- return
- if update.message:
- await update.message.reply_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb())
- elif update.callback_query:
- await update.callback_query.edit_message_text(HELP_TEXT, parse_mode="HTML", reply_markup=main_menu_kb())
-
-
-# ── Статус ───────────────────────────────────────────────────────────────────
-async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- await msg.reply_text("⛔")
- return
- info = await proxy_info()
- if not info:
- text = "❌ Прокси не запущен.\nНажмите Установить для настройки."
- else:
- containers = await docker_containers_info()
- other = "\n".join(l for l in containers.splitlines() if CONTAINER_NAME not in l)
- text = (
- "✅ Прокси работает\n\n"
- f"IP: {html.escape(info['ip'])}\n"
- f"Порт: {html.escape(info['port'])}\n"
- f"Домен: {html.escape(info['domain'])}\n"
- f"Secret: {html.escape(info['secret'])}\n\n"
- f"Ссылка:\n{html.escape(info['link'])}"
- )
- if other:
- text += f"\n\n📦 Другие контейнеры:\n
{html.escape(other)}"
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(text, parse_mode="HTML", reply_markup=kb)
-
-
-# ── Ссылка ───────────────────────────────────────────────────────────────────
-async def cmd_link(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- return
- info = await proxy_info()
- if not info:
- text = "❌ Прокси не запущен."
- else:
- text = f"{html.escape(info['link'])}"
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(text, parse_mode="HTML", reply_markup=kb)
-
-
-# ── Поделиться ключом ────────────────────────────────────────────────────────
-async def cmd_share(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- return
- info = await proxy_info()
- if not info:
- text = "❌ Прокси не запущен."
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text(text, reply_markup=kb)
- else:
- await msg.reply_text(text, reply_markup=kb)
- return
-
- # tg://proxy ссылка, которую Telegram распознает при пересылке
- tg_link = info["link"]
- # Красивое сообщение для пересылки
- share_text = (
- f"🔐 MTProxy для Telegram\n\n"
- f"🌍 Сервер: {html.escape(info['ip'])}\n"
- f"🔌 Порт: {html.escape(info['port'])}\n"
- f"🔑 Secret: {html.escape(info['secret'])}\n\n"
- f"👉 Подключиться одним нажатием:\n"
- f"{html.escape(tg_link)}\n\n"
- f"Просто нажмите на ссылку или перешлите это сообщение."
- )
- kb = InlineKeyboardMarkup([
- [InlineKeyboardButton("📤 Переслать другу", switch_inline_query=tg_link)],
- [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")],
- ])
- if update.callback_query:
- await update.callback_query.edit_message_text(share_text, parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(share_text, parse_mode="HTML", reply_markup=kb)
-
-
-# ── Удалить ──────────────────────────────────────────────────────────────────
-async def cmd_remove(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- return
- chat = msg.chat
- if update.callback_query:
- await update.callback_query.edit_message_text("⏳ Удаляю прокси...")
- else:
- await chat.send_message("⏳ Удаляю прокси...")
- await sh("docker", "stop", CONTAINER_NAME, timeout=15)
- await sh("docker", "rm", CONTAINER_NAME, timeout=10)
- text = "✅ Прокси удалён." if not await proxy_running() else "⚠️ Не удалось удалить."
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- await chat.send_message(text, reply_markup=kb)
-
-
-# ── Рестарт ──────────────────────────────────────────────────────────────────
-async def cmd_restart(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- return
- if not await proxy_running():
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb)
- else:
- await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb)
- return
- chat = msg.chat
- if update.callback_query:
- await update.callback_query.edit_message_text("⏳ Перезапуск...")
- code, _, err = await sh("docker", "restart", CONTAINER_NAME, timeout=30)
- text = "✅ Перезапущен." if code == 0 else f"❌ Ошибка: {err or 'unknown'}"
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- await chat.send_message(text, reply_markup=kb)
-
-
-# ── Логи ─────────────────────────────────────────────────────────────────────
-async def cmd_logs(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- return
- if not await proxy_running():
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb)
- else:
- await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb)
- return
- code, out, err = await sh("docker", "logs", "--tail", "40", CONTAINER_NAME, timeout=15)
- text = (out or "") + (("\n" + err) if err else "") or "Нет вывода."
- if len(text) > 4000:
- text = text[-4000:]
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text(f"{html.escape(text)}", parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(f"{html.escape(text)}", parse_mode="HTML", reply_markup=kb)
-
-
-# ── Промо ────────────────────────────────────────────────────────────────────
-async def cmd_promo(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- return
- text = (
- "💰 Хостинг со скидкой до -60%\n"
- f"Ссылка: {PROMO_LINK}\n\n"
- "Промокоды: OFF60, antenka20, antenka6, antenka12\n\n"
- f"Донат: {TIP_LINK}"
- )
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(text, parse_mode="HTML", reply_markup=kb)
-
-
-# ── Установка: домен → порт → проверка → запуск ─────────────────────────────
-
-async def install_step_domain(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg:
- return
- if not _ok(update.effective_user.id):
- return
- buttons = []
- row = []
- for i, d in enumerate(DOMAINS):
- row.append(InlineKeyboardButton(d, callback_data=f"dom_{i}"))
- if len(row) == 2:
- buttons.append(row)
- row = []
- if row:
- buttons.append(row)
- text = "🌐 Выберите домен для маскировки (Fake TLS):"
- if update.callback_query:
- await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons))
- else:
- await msg.reply_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons))
-
-
-async def install_step_port(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- query = update.callback_query
- domain = ctx.user_data.get("install_domain", "google.com")
-
- # Проверяем порт 443
- busy_443 = await check_port(443)
- busy_8443 = await check_port(8443)
-
- rows = []
- label_443 = "443 (рекомендуется)" if not busy_443 else "443 ⚠️ занят"
- label_8443 = "8443" if not busy_8443 else "8443 ⚠️ занят"
- rows.append([
- InlineKeyboardButton(label_443, callback_data="port_443"),
- InlineKeyboardButton(label_8443, callback_data="port_8443"),
- ])
- rows.append([InlineKeyboardButton("◀️ Меню", callback_data="menu_main")])
-
- port_info = ""
- if busy_443:
- port_info += f"\n⚠️ Порт 443 занят:\n{html.escape(busy_443[:300])}\n"
- if busy_8443:
- port_info += f"\n⚠️ Порт 8443 занят:\n{html.escape(busy_8443[:300])}\n"
-
- text = (
- f"Домен: {html.escape(domain)}\n\n"
- "🔌 Выберите порт или введите свой (1-65535):"
- f"{port_info}"
- )
- ctx.user_data["install_wait_port"] = True
- await query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(rows))
-
-
-async def install_port_chosen(update: Update, ctx: ContextTypes.DEFAULT_TYPE, port_str: str) -> None:
- """Порт выбран кнопкой или текстом — проверяем и ставим."""
- port = int(port_str)
- msg = None
- chat = None
-
- if update.callback_query:
- msg = update.callback_query.message
- elif update.message:
- msg = update.message
- if not msg:
- return
- chat = msg.chat
-
- # Проверка занятости
- busy = await check_port(port)
- if busy:
- kb = InlineKeyboardMarkup([
- [InlineKeyboardButton(f"Всё равно использовать {port}", callback_data=f"force_{port}")],
- [InlineKeyboardButton("Выбрать другой порт", callback_data="reselect_port")],
- [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")],
- ])
- text = (
- f"⚠️ Порт {port} занят!\n\n"
- f"{html.escape(busy[:500])}\n\n"
- "Можно использовать всё равно (если это ваш процесс) или выбрать другой."
- )
- if update.callback_query:
- await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
- else:
- await chat.send_message(text, parse_mode="HTML", reply_markup=kb)
- ctx.user_data["install_port"] = port_str
- return
-
- ctx.user_data["install_port"] = port_str
- ctx.user_data["install_wait_port"] = False
- await do_install(update, ctx)
-
-
-async def do_install(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- domain = ctx.user_data.get("install_domain") or "google.com"
- port = ctx.user_data.get("install_port") or "443"
-
- msg = None
- if update.callback_query:
- msg = update.callback_query.message
- await msg.edit_text("⏳ Генерация secret и запуск контейнера...", reply_markup=None)
- elif update.message:
- msg = update.message
- await msg.reply_text("⏳ Генерация secret и запуск контейнера...")
- if not msg:
- return
- chat = msg.chat
-
- # Docker check
- code, _, _ = await sh("docker", "info", timeout=10)
- if code != 0:
- await chat.send_message(
- "❌ Docker не установлен или не запущен.\n"
- "Установите: curl -fsSL https://get.docker.com | sh",
- parse_mode="HTML",
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]),
- )
- return
-
- # generate secret
- code, secret_out, err = await sh(
- "docker", "run", "--rm", "nineseconds/mtg:2", "generate-secret", "--hex", domain,
- timeout=60,
- )
- if code != 0:
- await chat.send_message(f"❌ Генерация secret: {err or secret_out}")
- return
- secret = secret_out.strip().split()[-1] if secret_out.strip() else ""
- if not secret:
- await chat.send_message("❌ Пустой secret.")
- return
-
- # Остановка старого
- await sh("docker", "stop", CONTAINER_NAME, timeout=15)
- await sh("docker", "rm", CONTAINER_NAME, timeout=10)
-
- # Запуск с TCP + UDP (UDP нужен для звонков Telegram)
- # --network host не используем, чтобы не мешать Amnezia/3x-ui
- code, _, err = await sh(
- "docker", "run", "-d",
- "--name", CONTAINER_NAME,
- "--restart", "always",
- "-p", f"{port}:{port}/tcp",
- "-p", f"{port}:{port}/udp",
- "nineseconds/mtg:2",
- "simple-run",
- "-n", "1.1.1.1",
- "-t", "1.0.0.1", # tag DNS для TLS
- "-i", "prefer-ipv4",
- f"0.0.0.0:{port}", secret,
- timeout=90,
- )
- if code != 0:
- await chat.send_message(f"❌ Запуск контейнера: {err}")
- return
-
- save_config({"domain": domain, "port": port, "secret": secret})
-
- ip = await get_ip()
- link = f"tg://proxy?server={ip}&port={port}&secret={secret}"
- text = (
- "✅ Прокси установлен!\n\n"
- f"🌍 IP: {html.escape(ip)}\n"
- f"🔌 Порт: {html.escape(port)} (TCP + UDP)\n"
- f"🎭 Домен: {html.escape(domain)}\n"
- f"🔑 Secret: {html.escape(secret)}\n\n"
- f"👉 Ссылка:\n{html.escape(link)}\n\n"
- "📞 Звонки в Telegram поддержаны (UDP)."
- )
- kb = InlineKeyboardMarkup([
- [InlineKeyboardButton("📤 Поделиться ключом", callback_data="menu_share")],
- [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")],
- ])
- await chat.send_message(text, parse_mode="HTML", reply_markup=kb)
- for k in ("install_domain", "install_port", "install_wait_port"):
- ctx.user_data.pop(k, None)
-
-
-# ── Callback router ──────────────────────────────────────────────────────────
-async def callback_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- query = update.callback_query
- if not query or not update.effective_user:
- return
- await query.answer()
- if not _ok(update.effective_user.id):
- await query.edit_message_text("⛔ Доступ запрещён.")
- return
-
- data = query.data or ""
-
- if data == "menu_main":
- await start(update, ctx)
- elif data == "menu_install":
- await install_step_domain(update, ctx)
- elif data == "menu_status":
- await cmd_status(update, ctx)
- elif data == "menu_link":
- await cmd_link(update, ctx)
- elif data == "menu_share":
- await cmd_share(update, ctx)
- elif data == "menu_restart":
- await cmd_restart(update, ctx)
- elif data == "menu_logs":
- await cmd_logs(update, ctx)
- elif data == "menu_remove":
- await cmd_remove(update, ctx)
- elif data == "menu_promo":
- await cmd_promo(update, ctx)
-
- elif data.startswith("dom_"):
- try:
- idx = int(data[4:])
- except ValueError:
- await query.edit_message_text("❌ Ошибка. /install")
- return
- if not (0 <= idx < len(DOMAINS)):
- await query.edit_message_text("❌ Неверный выбор. /install")
- return
- ctx.user_data["install_domain"] = DOMAINS[idx]
- await install_step_port(update, ctx)
-
- elif data == "port_443":
- await install_port_chosen(update, ctx, "443")
- elif data == "port_8443":
- await install_port_chosen(update, ctx, "8443")
- elif data.startswith("force_"):
- port_str = data[6:]
- ctx.user_data["install_port"] = port_str
- ctx.user_data["install_wait_port"] = False
- await do_install(update, ctx)
- elif data == "reselect_port":
- await install_step_port(update, ctx)
-
-
-# ── Ввод порта текстом ──────────────────────────────────────────────────────
-async def text_handler(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
- if not update.message or not ctx.user_data.get("install_wait_port"):
- return
- text = (update.message.text or "").strip()
- if not re.match(r"^\d+$", text):
- return
- port = int(text)
- if not (1 <= port <= 65535):
- await update.message.reply_text("Введите число от 1 до 65535.")
- return
- await install_port_chosen(update, ctx, str(port))
-
-
-# ── main ─────────────────────────────────────────────────────────────────────
-def main() -> None:
- if not BOT_TOKEN:
- raise SystemExit("Задайте BOT_TOKEN в .env")
- app = Application.builder().token(BOT_TOKEN).build()
- app.add_handler(CommandHandler("start", start))
- app.add_handler(CommandHandler("help", start))
- app.add_handler(CommandHandler("install", install_step_domain))
- app.add_handler(CommandHandler("status", cmd_status))
- app.add_handler(CommandHandler("link", cmd_link))
- app.add_handler(CommandHandler("share", cmd_share))
- app.add_handler(CommandHandler("remove", cmd_remove))
- app.add_handler(CommandHandler("restart", cmd_restart))
- app.add_handler(CommandHandler("logs", cmd_logs))
- app.add_handler(CommandHandler("promo", cmd_promo))
- app.add_handler(CallbackQueryHandler(callback_handler))
- app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler))
- app.run_polling(allowed_updates=Update.ALL_TYPES)
-
-
-if __name__ == "__main__":
- main()
+#!/usr/bin/env python3
+"""
+GoTelegram v2.2 Bot - MTProxy Management for Linux
+Manages telemt engine via Telegram interface with full CLI feature parity
+Uses python-telegram-bot v21+
+"""
+
+import asyncio
+import html
+import json
+import logging
+import os
+import re
+import subprocess
+import toml
+from datetime import datetime
+from pathlib import Path
+from typing import Tuple, Optional, List, Dict, Any
+
+from dotenv import load_dotenv
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile,
+)
+from telegram.ext import (
+ Application,
+ CommandHandler,
+ CallbackQueryHandler,
+ ContextTypes,
+ filters,
+)
+from telegram.error import TelegramError, BadRequest
+
+# Load environment variables
+load_dotenv()
+
+# Logging configuration
+logging.basicConfig(
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ level=logging.INFO,
+)
+logger = logging.getLogger(__name__)
+
+# ============================================================================
+# CONFIGURATION
+# ============================================================================
+
+GOTELEGRAM_VERSION = "2.2.0"
+GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
+TELEMT_CONFIG = "/etc/telemt/config.toml"
+TELEMT_SERVICE = "telemt"
+WEBSITE_ROOT = "/var/www/gotelegram-site"
+BACKUP_DIR = "/opt/gotelegram/backups"
+TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json"
+
+PROMO_LINK = "https://vk.cc/ct29NQ"
+TIP_LINK = "https://pay.cloudtips.ru/p/7410814f"
+
+BOT_TOKEN = os.getenv("BOT_TOKEN")
+ALLOWED_IDS_STR = os.getenv("ALLOWED_IDS", "")
+ALLOWED_IDS: set = set()
+for _id_str in ALLOWED_IDS_STR.split(","):
+ _id_str = _id_str.strip()
+ if _id_str:
+ try:
+ ALLOWED_IDS.add(int(_id_str))
+ except ValueError:
+ logging.warning(f"Invalid ALLOWED_IDS entry: {_id_str}")
+
+QUICK_DOMAINS = [
+ "google.com",
+ "microsoft.com",
+ "cloudflare.com",
+ "apple.com",
+ "amazon.com",
+ "github.com",
+ "stackoverflow.com",
+ "medium.com",
+ "wikipedia.org",
+ "coursera.org",
+ "udemy.com",
+ "habr.com",
+ "stepik.org",
+ "duolingo.com",
+ "khanacademy.org",
+ "bbc.com",
+ "reuters.com",
+ "nytimes.com",
+ "ted.com",
+ "zoom.us",
+]
+
+# ============================================================================
+# UTILITY FUNCTIONS
+# ============================================================================
+
+
+async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]:
+ """Execute shell command asynchronously.
+
+ Args:
+ *args: Command and arguments
+ timeout: Timeout in seconds
+
+ Returns:
+ Tuple of (return_code, stdout, stderr)
+ """
+ try:
+ process = await asyncio.create_subprocess_exec(
+ *args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(
+ process.communicate(), timeout=timeout
+ )
+ return (
+ process.returncode,
+ stdout.decode("utf-8", errors="replace"),
+ stderr.decode("utf-8", errors="replace"),
+ )
+ except asyncio.TimeoutError:
+ try:
+ process.kill()
+ await process.wait()
+ except Exception:
+ pass
+ return (-1, "", f"Command timeout after {timeout}s")
+ except Exception as e:
+ return (-1, "", str(e))
+
+
+def load_json(path: str) -> Optional[Dict]:
+ """Load JSON file."""
+ try:
+ with open(path, "r") as f:
+ return json.load(f)
+ except Exception as e:
+ logger.warning(f"Failed to load {path}: {e}")
+ return None
+
+
+def load_toml(path: str) -> Optional[Dict]:
+ """Load TOML file."""
+ try:
+ with open(path, "r") as f:
+ return toml.load(f)
+ except Exception as e:
+ logger.warning(f"Failed to load {path}: {e}")
+ return None
+
+
+def save_json(path: str, data: Dict) -> bool:
+ """Save JSON file."""
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w") as f:
+ json.dump(data, f, indent=2)
+ return True
+ except Exception as e:
+ logger.error(f"Failed to save {path}: {e}")
+ return False
+
+
+async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None) -> bool:
+ """Safely edit message, handling cases where message was deleted or not modified."""
+ try:
+ await query.edit_message_text(
+ text, reply_markup=reply_markup, parse_mode=parse_mode
+ )
+ return True
+ except BadRequest as e:
+ err_msg = str(e).lower()
+ if "message is not modified" in err_msg:
+ return True # No change needed, not an error
+ if "message to edit not found" in err_msg or "message can't be edited" in err_msg:
+ logger.warning(f"Cannot edit message: {e}")
+ return False
+ raise # Re-raise unexpected BadRequest
+
+
+async def check_service_status(service: str) -> bool:
+ """Check if systemd service is running."""
+ code, _, _ = await sh("systemctl", "is-active", service)
+ return code == 0
+
+
+async def get_telemt_version() -> str:
+ """Get telemt version."""
+ code, stdout, _ = await sh("telemt", "-v")
+ if code == 0:
+ return stdout.strip().split()[-1] if stdout else "unknown"
+ return "unknown"
+
+
+def is_docker_running() -> bool:
+ """Check if Docker daemon is running."""
+ try:
+ subprocess.run(
+ ["docker", "ps"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=5,
+ )
+ return True
+ except Exception:
+ return False
+
+
+async def check_old_container() -> Optional[str]:
+ """Check for old mtg Docker container (v1 migration)."""
+ if not is_docker_running():
+ return None
+ code, stdout, _ = await sh("docker", "ps", "-a", "--format", "{{.Names}}")
+ if code == 0 and "mtg" in stdout:
+ return "mtg"
+ return None
+
+
+# ============================================================================
+# ACCESS CONTROL
+# ============================================================================
+
+
+def is_user_allowed(user_id: int) -> bool:
+ """Check if user ID is in ALLOWED_IDS."""
+ if not ALLOWED_IDS:
+ return True
+ return user_id in ALLOWED_IDS
+
+
+async def require_auth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
+ """Check authorization and send error if not allowed."""
+ if not is_user_allowed(update.effective_user.id):
+ await update.message.reply_text(
+ f"Access denied. Your ID: {update.effective_user.id}"
+ )
+ logger.warning(
+ f"Unauthorized access attempt from user {update.effective_user.id}"
+ )
+ return False
+ return True
+
+
+# ============================================================================
+# MAIN MENU
+# ============================================================================
+
+
+def get_main_menu() -> InlineKeyboardMarkup:
+ """Generate main menu keyboard."""
+ buttons = [
+ [
+ InlineKeyboardButton("⚙️ Install", callback_data="menu_install"),
+ InlineKeyboardButton("📊 Status", callback_data="menu_status"),
+ ],
+ [
+ InlineKeyboardButton("🔗 Link", callback_data="menu_link"),
+ InlineKeyboardButton("📤 Share", callback_data="menu_share"),
+ ],
+ [
+ InlineKeyboardButton("🔄 Restart", callback_data="menu_restart"),
+ InlineKeyboardButton("📋 Logs", callback_data="menu_logs"),
+ ],
+ [
+ InlineKeyboardButton("⚡ Change Mode/Template", callback_data="menu_change"),
+ InlineKeyboardButton("💾 Backup", callback_data="menu_backup"),
+ ],
+ [
+ InlineKeyboardButton("↩️ Restore", callback_data="menu_restore"),
+ InlineKeyboardButton("📡 Update telemt", callback_data="menu_update"),
+ ],
+ [
+ InlineKeyboardButton("🌐 Website/SSL", callback_data="menu_website"),
+ InlineKeyboardButton("🎁 Promo", callback_data="menu_promo"),
+ ],
+ [
+ InlineKeyboardButton("🗑️ Remove", callback_data="menu_remove"),
+ InlineKeyboardButton("ℹ️ Credits", callback_data="menu_credits"),
+ ],
+ [InlineKeyboardButton("❌ Close", callback_data="close_menu")],
+ ]
+ return InlineKeyboardMarkup(buttons)
+
+
+# ============================================================================
+# COMMANDS
+# ============================================================================
+
+
+async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Start command - show main menu."""
+ if not await require_auth(update, context):
+ return
+
+ welcome = (
+ f"GoTelegram v{GOTELEGRAM_VERSION}\n\n"
+ "🤖 MTProxy Management Bot\n"
+ "Powered by telemt engine\n\n"
+ "Select an action from the menu below:"
+ )
+ await update.message.reply_text(
+ welcome, reply_markup=get_main_menu(), parse_mode="HTML"
+ )
+
+
+async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Help command - show available commands."""
+ if not await require_auth(update, context):
+ return
+
+ help_text = (
+ "GoTelegram Bot Commands\n\n"
+ "/start - Show main menu\n"
+ "/help - Show this help message\n"
+ "/status - Quick status check\n"
+ "/logs - Show recent logs\n\n"
+ "Use the inline menu for all other operations."
+ )
+ await update.message.reply_text(help_text, parse_mode="HTML")
+
+
+async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Quick status check."""
+ if not await require_auth(update, context):
+ return
+
+ await update.message.reply_text("⏳ Checking status...", parse_mode="HTML")
+ status_text = await get_status_text()
+ await update.message.reply_text(status_text, parse_mode="HTML")
+
+
+async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show recent logs."""
+ if not await require_auth(update, context):
+ return
+
+ code, stdout, stderr = await sh(
+ "journalctl", "-u", TELEMT_SERVICE, "-n", "20", "--no-pager"
+ )
+ if code == 0:
+ log_text = stdout[-1500:] if len(stdout) > 1500 else stdout
+ await update.message.reply_text(
+ f"{html.escape(log_text)}",
+ parse_mode="HTML",
+ )
+ else:
+ await update.message.reply_text("Failed to retrieve logs")
+
+
+# ============================================================================
+# STATUS
+# ============================================================================
+
+
+async def get_status_text() -> str:
+ """Generate status report."""
+ lines = ["📊 Current Status\n"]
+
+ # Service status
+ is_running = await check_service_status(TELEMT_SERVICE)
+ lines.append(f"Service: {'✅ Running' if is_running else '❌ Stopped'}")
+
+ # Telemt version
+ version = await get_telemt_version()
+ lines.append(f"Telemt: v{version}")
+
+ # Config status
+ config = load_json(GOTELEGRAM_CONFIG)
+ if config:
+ lines.append(f"Mode: {html.escape(str(config.get('mode', 'unknown')))}")
+ if "template" in config:
+ lines.append(f"Template: {html.escape(str(config['template']))}")
+ if "domain" in config:
+ lines.append(f"Domain: {html.escape(str(config['domain']))}")
+ if "port" in config:
+ lines.append(f"Port: {html.escape(str(config['port']))}")
+
+ # Telemt config
+ telemt_cfg = load_toml(TELEMT_CONFIG)
+ if telemt_cfg:
+ cfg = telemt_cfg.get("config", {})
+ if "listen_port" in cfg:
+ lines.append(f"Listen Port: {cfg['listen_port']}")
+
+ # Backups
+ backup_count = 0
+ try:
+ if os.path.exists(BACKUP_DIR):
+ backup_count = len([f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")])
+ except Exception:
+ pass
+ lines.append(f"Backups: {backup_count}")
+
+ # Old container check
+ old_container = await check_old_container()
+ if old_container:
+ lines.append(f"\n⚠️ Found old container: {html.escape(old_container)}")
+ lines.append("Run 'Install' to migrate")
+
+ return "\n".join(lines)
+
+
+async def cb_menu_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Status callback."""
+ query = update.callback_query
+ await query.answer()
+
+ await safe_edit_message(query,"⏳ Checking status...")
+
+ status_text = await get_status_text()
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,
+ status_text, reply_markup=keyboard, parse_mode="HTML"
+ )
+
+
+# ============================================================================
+# INSTALL
+# ============================================================================
+
+
+def get_install_mode_menu() -> InlineKeyboardMarkup:
+ """Install mode selection menu."""
+ buttons = [
+ [InlineKeyboardButton("⚡ Quick Mode", callback_data="install_mode_quick")],
+ [InlineKeyboardButton("🔒 Stealth Mode", callback_data="install_mode_stealth")],
+ [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ ]
+ return InlineKeyboardMarkup(buttons)
+
+
+async def cb_menu_install(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Install menu callback."""
+ query = update.callback_query
+ await query.answer()
+
+ # Check for old container
+ old_container = await check_old_container()
+ if old_container:
+ text = (
+ f"⚠️ Migration from v1 detected\n\n"
+ f"Found Docker container: {html.escape(old_container)}\n\n"
+ f"Would you like to:\n"
+ f"1. Migrate from v1 (recommended)\n"
+ f"2. Fresh install (will remove old container)\n\n"
+ f"Select below or choose install mode"
+ )
+ buttons = [
+ [InlineKeyboardButton("🔄 Migrate from v1", callback_data="install_migrate")],
+ [InlineKeyboardButton("✨ Fresh Install", callback_data="install_mode_quick")],
+ [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ ]
+ keyboard = InlineKeyboardMarkup(buttons)
+ else:
+ text = "Select installation mode:"
+ keyboard = get_install_mode_menu()
+
+ await safe_edit_message(query,
+ text, reply_markup=keyboard, parse_mode="HTML"
+ )
+
+
+async def cb_install_mode_quick(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Quick mode domain selection."""
+ query = update.callback_query
+ await query.answer()
+
+ # Show domains with pagination (4 per row, 2 rows)
+ buttons = []
+ for i in range(0, len(QUICK_DOMAINS), 2):
+ row = []
+ for j in range(2):
+ if i + j < len(QUICK_DOMAINS):
+ domain = QUICK_DOMAINS[i + j]
+ row.append(
+ InlineKeyboardButton(
+ domain, callback_data=f"quick_dom_{i+j}"
+ )
+ )
+ buttons.append(row)
+
+ buttons.append([InlineKeyboardButton("« Back", callback_data="menu_install")])
+
+ text = "Select a domain for quick mode:"
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,text, reply_markup=keyboard)
+
+
+async def cb_quick_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Quick domain selection callback."""
+ query = update.callback_query
+ data = query.data
+ try:
+ domain_idx = int(data.split("_")[-1])
+ domain = QUICK_DOMAINS[domain_idx]
+ except (ValueError, IndexError):
+ await query.answer("Invalid domain selection")
+ return
+
+ await query.answer()
+ await safe_edit_message(query,f"⏳ Installing with domain: {domain}...")
+
+ # Simulate installation (in real scenario, call install script)
+ config = {
+ "mode": "quick",
+ "domain": domain,
+ "port": 443,
+ "installed_at": datetime.now().isoformat(),
+ }
+
+ if save_json(GOTELEGRAM_CONFIG, config):
+ text = (
+ f"✅ Quick mode installed!\n\n"
+ f"Domain: {domain}\n"
+ f"Mode: Quick\n\n"
+ f"Service starting... Check status in 10 seconds."
+ )
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,
+ text, reply_markup=keyboard, parse_mode="HTML"
+ )
+ else:
+ await safe_edit_message(query,
+ "❌ Failed to save configuration",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_install")]]
+ ),
+ )
+
+
+async def cb_install_mode_stealth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Stealth mode - show template categories."""
+ query = update.callback_query
+ await query.answer()
+
+ catalog = load_json(TEMPLATES_CATALOG)
+ if not catalog or "categories" not in catalog:
+ await safe_edit_message(query,
+ "❌ Template catalog not found",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_install")]]
+ ),
+ )
+ return
+
+ buttons = []
+ for cat in catalog.get("categories", []):
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ f"📁 {cat['name']}", callback_data=f"stealth_cat_{cat['id']}"
+ )
+ ]
+ )
+ buttons.append([InlineKeyboardButton("« Back", callback_data="menu_install")])
+
+ text = "Stealth Mode - Select Template Category:"
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,text, reply_markup=keyboard)
+
+
+async def cb_stealth_category(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show templates in category."""
+ query = update.callback_query
+ data = query.data
+ cat_id = data.removeprefix("stealth_cat_")
+
+ await query.answer()
+
+ catalog = load_json(TEMPLATES_CATALOG)
+ if not catalog:
+ await safe_edit_message(query,"❌ Template catalog not found")
+ return
+
+ # Find category and templates
+ category = None
+ templates = []
+ for cat in catalog.get("categories", []):
+ if cat["id"] == cat_id:
+ category = cat
+ templates = cat.get("templates", [])
+ break
+
+ if not category:
+ await safe_edit_message(query,"❌ Category not found")
+ return
+
+ buttons = []
+ for tpl in templates:
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ f"🎨 {tpl['name']}", callback_data=f"stealth_tpl_{tpl['id']}"
+ )
+ ]
+ )
+ buttons.append([InlineKeyboardButton("« Back", callback_data="install_mode_stealth")])
+
+ text = f"Select template from {html.escape(category['name'])}:"
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_stealth_template(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show template preview and confirm."""
+ query = update.callback_query
+ data = query.data
+ tpl_id = data.removeprefix("stealth_tpl_")
+
+ await query.answer()
+
+ catalog = load_json(TEMPLATES_CATALOG)
+ if not catalog:
+ await safe_edit_message(query,"❌ Template catalog not found")
+ return
+
+ # Find template
+ template = None
+ for cat in catalog.get("categories", []):
+ for tpl in cat.get("templates", []):
+ if tpl["id"] == tpl_id:
+ template = tpl
+ break
+ if template:
+ break
+
+ if not template:
+ await safe_edit_message(query,"❌ Template not found")
+ return
+
+ tpl_name = html.escape(template.get('name', 'Unknown'))
+ tpl_desc = html.escape(template.get('description', 'N/A'))
+ text = (
+ f"🎨 Template Preview\n\n"
+ f"Name: {tpl_name}\n"
+ f"Description: {tpl_desc}\n\n"
+ )
+ if "preview_url" in template:
+ preview_url = html.escape(template['preview_url'], quote=True)
+ text += f'View Live Preview\n\n'
+
+ text += "Confirm installation?"
+
+ buttons = [
+ [
+ InlineKeyboardButton(
+ "✅ Install", callback_data=f"stealth_confirm_{tpl_id}"
+ )
+ ],
+ [InlineKeyboardButton("« Back", callback_data="install_mode_stealth")],
+ ]
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_stealth_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Confirm and install stealth template."""
+ query = update.callback_query
+ data = query.data
+ tpl_id = data.removeprefix("stealth_confirm_")
+
+ await query.answer()
+ await safe_edit_message(query,"⏳ Installing template...")
+
+ config = {
+ "mode": "stealth",
+ "template": tpl_id,
+ "port": 443,
+ "installed_at": datetime.now().isoformat(),
+ }
+
+ if save_json(GOTELEGRAM_CONFIG, config):
+ text = (
+ f"✅ Stealth mode installed!\n\n"
+ f"Template: {html.escape(tpl_id)}\n"
+ f"Mode: Stealth\n\n"
+ f"Service starting... Check status in 10 seconds."
+ )
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,
+ text, reply_markup=keyboard, parse_mode="HTML"
+ )
+ else:
+ await safe_edit_message(query,
+ "❌ Failed to save configuration",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_install")]]
+ ),
+ )
+
+
+# ============================================================================
+# PROXY LINK & SHARE
+# ============================================================================
+
+
+async def get_proxy_link() -> Optional[str]:
+ """Generate proxy link from config."""
+ config = load_json(GOTELEGRAM_CONFIG)
+ if not config:
+ return None
+
+ # Get secret from telemt TOML config
+ secret = config.get("secret", "")
+ if not secret:
+ telemt_cfg = load_toml(TELEMT_CONFIG)
+ if telemt_cfg:
+ users = telemt_cfg.get("users", [])
+ if isinstance(users, list) and users:
+ secret = users[0].get("secret", "")
+ if not secret:
+ return None
+
+ # Get server IP
+ code, stdout, _ = await sh("curl", "-s", "-4", "--max-time", "5", "https://api.ipify.org")
+ server = stdout.strip() if code == 0 and stdout.strip() else "0.0.0.0"
+
+ port = config.get("port", 443)
+
+ return f"tg://proxy?server={server}&port={port}&secret={secret}"
+
+
+async def cb_menu_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Generate and show proxy link."""
+ query = update.callback_query
+ await query.answer()
+
+ link = await get_proxy_link()
+ if not link:
+ text = "❌ Proxy not installed yet. Run install first."
+ else:
+ text = (
+ f"🔗 Proxy Link\n\n"
+ f"{html.escape(link)}\n\n"
+ f"Open in Telegram to connect."
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Share link as QR code."""
+ query = update.callback_query
+ await query.answer()
+
+ link = await get_proxy_link()
+ if not link:
+ text = "❌ Proxy not installed yet."
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard)
+ return
+
+ # Try to generate QR code
+ qr_file = None
+ code, _, _ = await sh("which", "qrencode")
+ if code == 0:
+ qr_path = "/tmp/proxy_qr.png"
+ code, _, _ = await sh("qrencode", "-o", qr_path, link)
+ if code == 0 and os.path.exists(qr_path):
+ qr_file = qr_path
+
+ if qr_file:
+ try:
+ with open(qr_file, "rb") as f:
+ await query.message.reply_photo(
+ photo=f,
+ caption=f"📤 Proxy QR Code\n\n{html.escape(link)}",
+ parse_mode="HTML",
+ )
+ except Exception as e:
+ logger.error(f"Failed to send QR code: {e}")
+ await safe_edit_message(query,
+ f"🔗 Proxy Link\n\n{html.escape(link)}",
+ parse_mode="HTML",
+ )
+ else:
+ await safe_edit_message(query,
+ f"🔗 Proxy Link\n\n{html.escape(link)}",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ ),
+ parse_mode="HTML",
+ )
+
+
+# ============================================================================
+# RESTART & LOGS
+# ============================================================================
+
+
+async def cb_menu_restart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Restart service."""
+ query = update.callback_query
+ await query.answer()
+
+ text = "⏳ Restarting telemt service..."
+ await safe_edit_message(query,text)
+
+ code, _, stderr = await sh("systemctl", "restart", TELEMT_SERVICE)
+ if code == 0:
+ text = "✅ Service restarted successfully"
+ else:
+ text = f"❌ Failed to restart:\n{html.escape(stderr[:500])}"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,
+ text, reply_markup=keyboard, parse_mode="HTML"
+ )
+
+
+async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show recent logs."""
+ query = update.callback_query
+ await query.answer()
+
+ code, stdout, _ = await sh(
+ "journalctl", "-u", TELEMT_SERVICE, "-n", "30", "--no-pager"
+ )
+
+ if code == 0:
+ log_text = stdout[-1000:] if len(stdout) > 1000 else stdout
+ text = f"📋 Recent Logs\n\n{html.escape(log_text)}"
+ else:
+ text = "❌ Failed to retrieve logs"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+# ============================================================================
+# BACKUP & RESTORE
+# ============================================================================
+
+
+async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Backup menu."""
+ query = update.callback_query
+ await query.answer()
+
+ # List existing backups
+ backups = []
+ try:
+ if os.path.exists(BACKUP_DIR):
+ backups = sorted(
+ [f for f in os.listdir(BACKUP_DIR)
+ if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")],
+ reverse=True,
+ )
+ except Exception:
+ pass
+
+ buttons = [[InlineKeyboardButton("💾 Create Backup", callback_data="backup_create")]]
+
+ if backups:
+ buttons.append(
+ [InlineKeyboardButton("📋 List Backups", callback_data="backup_list")]
+ )
+
+ buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")])
+
+ text = f"💾 Backup Management\n\nExisting backups: {len(backups)}"
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_backup_create(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Create backup."""
+ query = update.callback_query
+ await query.answer()
+
+ await safe_edit_message(query,"⏳ Creating backup...")
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ backup_file = os.path.join(BACKUP_DIR, f"backup_{timestamp}.tar.gz")
+
+ os.makedirs(BACKUP_DIR, exist_ok=True)
+ code, _, stderr = await sh(
+ "tar", "-czf", backup_file, GOTELEGRAM_CONFIG, TELEMT_CONFIG
+ )
+
+ if code == 0:
+ text = f"✅ Backup created:\n{html.escape(backup_file)}"
+ else:
+ text = f"❌ Backup failed:\n{html.escape(stderr[:500])}"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_backup")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_backup_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """List backups."""
+ query = update.callback_query
+ await query.answer()
+
+ backups = []
+ try:
+ if os.path.exists(BACKUP_DIR):
+ backups = sorted(
+ [f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")],
+ reverse=True,
+ )
+ except Exception:
+ pass
+
+ if not backups:
+ text = "No backups found"
+ else:
+ text = "📋 Available Backups\n\n"
+ for backup in backups[:10]:
+ path = os.path.join(BACKUP_DIR, backup)
+ size = os.path.getsize(path) / (1024 * 1024)
+ text += f"{html.escape(backup)} ({size:.2f} MB)\n"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_backup")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Restore menu."""
+ query = update.callback_query
+ await query.answer()
+
+ backups = []
+ try:
+ if os.path.exists(BACKUP_DIR):
+ backups = sorted(
+ [f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")],
+ reverse=True,
+ )
+ except Exception:
+ pass
+
+ if not backups:
+ text = "❌ No backups available"
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ else:
+ text = "Select backup to restore:"
+ buttons = []
+ for i, backup in enumerate(backups[:10]):
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ backup, callback_data=f"restore_idx_{i}"
+ )
+ ]
+ )
+ buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")])
+ keyboard = InlineKeyboardMarkup(buttons)
+ # Store backup list in user_data for retrieval
+ context.user_data["backup_list"] = backups[:10]
+
+ await safe_edit_message(query,text, reply_markup=keyboard)
+
+
+async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Execute backup restoration."""
+ query = update.callback_query
+ data = query.data
+
+ try:
+ idx = int(data.removeprefix("restore_idx_"))
+ except ValueError:
+ await query.answer("Invalid backup selection")
+ return
+
+ backup_list = context.user_data.get("backup_list", [])
+ if idx < 0 or idx >= len(backup_list):
+ await query.answer("Backup not found")
+ return
+
+ backup_name = backup_list[idx]
+ backup_path = os.path.join(BACKUP_DIR, backup_name)
+
+ await query.answer()
+ await safe_edit_message(query,f"⏳ Restoring from {html.escape(backup_name)}...")
+
+ if not os.path.exists(backup_path):
+ text = "❌ Backup file not found"
+ else:
+ # Simple restore: extract tar to overwrite configs
+ code, _, stderr = await sh(
+ "tar", "-xzf", backup_path, "-C", "/", timeout=60
+ )
+ if code == 0:
+ # Restart services
+ await sh("systemctl", "restart", TELEMT_SERVICE)
+ text = f"✅ Restored from {html.escape(backup_name)}"
+ else:
+ text = f"❌ Restore failed:\n{html.escape(stderr[:500])}"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+# ============================================================================
+# UPDATE & MODE/TEMPLATE CHANGE
+# ============================================================================
+
+
+async def cb_menu_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Update telemt by re-running the install script's update logic."""
+ query = update.callback_query
+ await query.answer()
+
+ await safe_edit_message(query,"⏳ Checking for telemt updates...")
+
+ # Get current version
+ cur_code, cur_out, _ = await sh("telemt", "--version")
+ current = cur_out.strip() if cur_code == 0 else "unknown"
+
+ # Check latest release from GitHub
+ code, stdout, stderr = await sh(
+ "curl", "-s", "--max-time", "10",
+ "https://api.github.com/repos/telemt/telemt/releases/latest",
+ )
+
+ if code != 0 or not stdout.strip():
+ text = "❌ Failed to check for updates"
+ else:
+ try:
+ release = json.loads(stdout)
+ latest = release.get("tag_name", "unknown")
+ if latest == current:
+ text = f"✅ telemt is already up to date ({html.escape(current)})"
+ else:
+ text = (
+ f"ℹ️ Update available: {html.escape(current)} → {html.escape(latest)}\n\n"
+ f"Run the CLI installer to update:\n"
+ f"sudo bash install.sh → menu item 10"
+ )
+ except json.JSONDecodeError:
+ text = "❌ Failed to parse release info"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_menu_change(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Change mode or template."""
+ query = update.callback_query
+ await query.answer()
+
+ buttons = [
+ [InlineKeyboardButton("⚡ Switch to Quick Mode", callback_data="change_quick")],
+ [InlineKeyboardButton("🔒 Switch to Stealth Mode", callback_data="change_stealth")],
+ [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ ]
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,
+ "Change mode or template:", reply_markup=keyboard
+ )
+
+
+async def cb_change_quick(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Switch to quick mode — show domain selection."""
+ query = update.callback_query
+ await query.answer()
+ # Reuse the quick mode domain selection flow
+ await cb_install_mode_quick(update, context)
+
+
+async def cb_change_stealth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Switch to stealth mode — show template categories."""
+ query = update.callback_query
+ await query.answer()
+ # Reuse the stealth mode template selection flow
+ await cb_install_mode_stealth(update, context)
+
+
+async def cb_install_migrate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Migrate from v1 (mtg Docker) to v2 (telemt)."""
+ query = update.callback_query
+ await query.answer()
+
+ await safe_edit_message(query,"⏳ Migrating from v1...")
+
+ # Stop old mtg container
+ code, _, stderr = await sh("docker", "stop", "mtproto-proxy", timeout=30)
+ if code != 0:
+ code, _, stderr = await sh("docker", "stop", "mtg", timeout=30)
+
+ # Remove old container
+ await sh("docker", "rm", "mtproto-proxy", timeout=15)
+ await sh("docker", "rm", "mtg", timeout=15)
+
+ text = (
+ "✅ v1 container stopped and removed\n\n"
+ "Now select installation mode for v2:"
+ )
+ keyboard = get_install_mode_menu()
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+# ============================================================================
+# WEBSITE & SSL
+# ============================================================================
+
+
+async def cb_menu_website(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Website and SSL management."""
+ query = update.callback_query
+ await query.answer()
+
+ buttons = [
+ [InlineKeyboardButton("🔄 Renew SSL Certificate", callback_data="ssl_renew")],
+ [InlineKeyboardButton("📊 SSL Status", callback_data="ssl_status")],
+ [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ ]
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,
+ "Website & SSL Management:", reply_markup=keyboard
+ )
+
+
+async def cb_ssl_renew(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Renew SSL certificate."""
+ query = update.callback_query
+ await query.answer()
+
+ await safe_edit_message(query,"⏳ Renewing SSL certificate...")
+
+ code, stdout, stderr = await sh("certbot", "renew", timeout=120)
+
+ if code == 0:
+ text = "✅ SSL certificate renewed successfully"
+ else:
+ text = f"❌ Renewal failed:\n{html.escape(stderr[:500])}"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_website")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_ssl_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show SSL status."""
+ query = update.callback_query
+ await query.answer()
+
+ code, stdout, _ = await sh("certbot", "certificates")
+
+ if code == 0:
+ text = f"📊 SSL Certificates\n\n{html.escape(stdout[:1000])}"
+ else:
+ text = "❌ Failed to get SSL status"
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_website")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+# ============================================================================
+# PROMO & CREDITS
+# ============================================================================
+
+
+async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Promo information."""
+ query = update.callback_query
+ await query.answer()
+
+ text = (
+ f"🎁 GoTelegram Promo\n\n"
+ f"Share the love! Invite friends to use GoTelegram.\n\n"
+ f"Promo Link\n\n"
+ f"Support development:\n"
+ f"Send a Tip"
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Credits and acknowledgements."""
+ query = update.callback_query
+ await query.answer()
+
+ text = (
+ f"ℹ️ Credits & Acknowledgements\n\n"
+ f"GoTelegram v{GOTELEGRAM_VERSION}\n\n"
+ f"Built with love for the Telegram community\n\n"
+ f"Special thanks to:\n\n"
+ f"🙏 telemt - MTProxy engine\n"
+ f" High-performance proxy core\n\n"
+ f"🎨 HTML5UP - Beautiful web templates\n"
+ f" Responsive design & themes\n\n"
+ f"📚 Learning Zone - Educational resources\n"
+ f" Community learning support\n\n"
+ f"🚀 Start Bootstrap - Bootstrap templates\n"
+ f" Professional design framework\n\n"
+ f"💬 Community - Your feedback & support\n\n"
+ f"GoTelegram is open-source and community-driven"
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+# ============================================================================
+# REMOVE
+# ============================================================================
+
+
+async def cb_menu_remove(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Remove installation."""
+ query = update.callback_query
+ await query.answer()
+
+ text = (
+ "⚠️ Remove GoTelegram\n\n"
+ "This will completely remove the installation.\n"
+ "Are you sure?"
+ )
+
+ buttons = [
+ [InlineKeyboardButton("❌ Yes, Remove", callback_data="remove_confirm")],
+ [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ ]
+ keyboard = InlineKeyboardMarkup(buttons)
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+async def cb_remove_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Confirm removal."""
+ query = update.callback_query
+ await query.answer()
+
+ await safe_edit_message(query,"⏳ Removing GoTelegram...")
+
+ # Stop service
+ await sh("systemctl", "stop", TELEMT_SERVICE)
+
+ # Remove directories
+ for path in ["/opt/gotelegram", WEBSITE_ROOT]:
+ await sh("rm", "-rf", path)
+
+ text = "✅ GoTelegram removed successfully"
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ )
+ await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
+
+
+# ============================================================================
+# CALLBACK ROUTING
+# ============================================================================
+
+
+async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Route all callbacks."""
+ query = update.callback_query
+ data = query.data
+
+ # Access control
+ if not is_user_allowed(update.effective_user.id):
+ await query.answer("Access denied")
+ return
+
+ # Main menu
+ if data == "menu_main":
+ await query.answer()
+ buttons = get_main_menu()
+ text = (
+ f"GoTelegram v{GOTELEGRAM_VERSION}\n\n"
+ "🤖 MTProxy Management\n"
+ "Select an action:"
+ )
+ await safe_edit_message(query,text, reply_markup=buttons, parse_mode="HTML")
+ return
+
+ if data == "close_menu":
+ await query.answer()
+ await query.delete_message()
+ return
+
+ # Dispatch to handlers
+ handlers = {
+ "menu_install": cb_menu_install,
+ "menu_status": cb_menu_status,
+ "menu_link": cb_menu_link,
+ "menu_share": cb_menu_share,
+ "menu_restart": cb_menu_restart,
+ "menu_logs": cb_menu_logs,
+ "menu_backup": cb_menu_backup,
+ "menu_restore": cb_menu_restore,
+ "menu_update": cb_menu_update,
+ "menu_change": cb_menu_change,
+ "menu_website": cb_menu_website,
+ "menu_promo": cb_menu_promo,
+ "menu_credits": cb_menu_credits,
+ "menu_remove": cb_menu_remove,
+ "install_mode_quick": cb_install_mode_quick,
+ "install_mode_stealth": cb_install_mode_stealth,
+ "backup_create": cb_backup_create,
+ "backup_list": cb_backup_list,
+ "ssl_renew": cb_ssl_renew,
+ "ssl_status": cb_ssl_status,
+ "remove_confirm": cb_remove_confirm,
+ "change_quick": cb_change_quick,
+ "change_stealth": cb_change_stealth,
+ "install_migrate": cb_install_migrate,
+ }
+
+ # Pattern-based handlers
+ if data.startswith("quick_dom_"):
+ await cb_quick_domain(update, context)
+ elif data.startswith("stealth_cat_"):
+ await cb_stealth_category(update, context)
+ elif data.startswith("stealth_tpl_"):
+ await cb_stealth_template(update, context)
+ elif data.startswith("stealth_confirm_"):
+ await cb_stealth_confirm(update, context)
+ elif data.startswith("restore_idx_"):
+ await cb_restore_backup(update, context)
+ elif data in handlers:
+ await handlers[data](update, context)
+ else:
+ await query.answer("Unknown action")
+
+
+# ============================================================================
+# ERROR HANDLERS
+# ============================================================================
+
+
+async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Log errors caused by Updates."""
+ logger.error(f"Exception while handling an update:", exc_info=context.error)
+
+
+# ============================================================================
+# MAIN APPLICATION
+# ============================================================================
+
+
+def main() -> None:
+ """Start the bot."""
+ if not BOT_TOKEN:
+ logger.error("BOT_TOKEN not set in .env file")
+ return
+
+ # Create the Application
+ application = Application.builder().token(BOT_TOKEN).build()
+
+ # Command handlers
+ application.add_handler(CommandHandler("start", cmd_start))
+ application.add_handler(CommandHandler("help", cmd_help))
+ application.add_handler(CommandHandler("status", cmd_status))
+ application.add_handler(CommandHandler("logs", cmd_logs))
+
+ # Callback query handler (buttons)
+ application.add_handler(CallbackQueryHandler(handle_callback))
+
+ # Error handler
+ application.add_error_handler(error_handler)
+
+ # Run the bot
+ logger.info(f"GoTelegram v{GOTELEGRAM_VERSION} bot starting...")
+ application.run_polling(allowed_updates=Update.ALL_TYPES)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/gotelegram-bot/config.example.env b/gotelegram-bot/config.example.env
index f4b44da..59750e9 100644
--- a/gotelegram-bot/config.example.env
+++ b/gotelegram-bot/config.example.env
@@ -1,4 +1,9 @@
-# Скопируйте в .env и заполните
+# GoTelegram v2.2 Bot Configuration
+# Copy this to .env and fill in your values
+
+# Telegram Bot Token from @BotFather
BOT_TOKEN=your_bot_token_from_@BotFather
-# Опционально: список ID пользователей с доступом (через запятую). Пусто = все.
-# ALLOWED_IDS=123456789,987654321
+
+# Comma-separated list of allowed Telegram user IDs
+# Leave empty to allow all users
+# ALLOWED_IDS=123456789,987654321
\ No newline at end of file
diff --git a/gotelegram-bot/requirements.txt b/gotelegram-bot/requirements.txt
index ea4513c..7c6803c 100644
--- a/gotelegram-bot/requirements.txt
+++ b/gotelegram-bot/requirements.txt
@@ -1 +1,3 @@
python-telegram-bot>=21.0
+python-dotenv>=1.0.0
+toml>=0.10.2
\ No newline at end of file
diff --git a/install.sh b/install.sh
index 3f25f48..48acebd 100644
--- a/install.sh
+++ b/install.sh
@@ -1,1320 +1,460 @@
-#!/bin/bash
-# GoTelegram MTProxy — всё в одном файле.
-# Установка: 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
-
-# ── Цвета ────────────────────────────────────────────────────────────────────
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-CYAN='\033[0;36m'
-YELLOW='\033[1;33m'
-MAGENTA='\033[0;35m'
-BLUE='\033[0;34m'
-WHITE='\033[1;37m'
-NC='\033[0m'
-
-# ── Спиннер и прогресс-бар ────────────────────────────────────────────────────
-spin_pid=""
-spinner_start() {
- local msg="${1:-Подождите...}"
- (
- local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
- local i=0
- while true; do
- printf "\r ${CYAN}${frames[$i]}${NC} ${msg}" >&2
- i=$(( (i+1) % ${#frames[@]} ))
- sleep 0.12
- done
- ) &
- spin_pid=$!
-}
-spinner_stop() {
- [ -n "$spin_pid" ] && kill "$spin_pid" 2>/dev/null && wait "$spin_pid" 2>/dev/null
- spin_pid=""
- printf "\r\033[K" >&2
-}
-
-progress_bar() {
- local current="$1" total="$2" label="${3:-}"
- local pct=$(( current * 100 / total ))
- local filled=$(( pct / 2 ))
- local empty=$(( 50 - filled ))
- local bar=""
- for ((i=0; i{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, ctx):
- 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()
- text = f"{html.escape(info['link'])}" if info else "❌ Прокси не запущен."
- 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, ctx):
- 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:
- 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
- tg_link = info["link"]
- share_text = (
- f"🔐 MTProxy для Telegram\n\n"
- f"🌍 Сервер: {html.escape(info['ip'])}\n"
- f"🔌 Порт: {html.escape(info['port'])}\n"
- f"🔑 Secret: {html.escape(info['secret'])}\n\n"
- f"👉 Подключиться одним нажатием:\n"
- f"{html.escape(tg_link)}\n\n"
- f"Просто нажмите на ссылку или перешлите это сообщение.")
- kb = InlineKeyboardMarkup([
- [InlineKeyboardButton("📤 Переслать другу", switch_inline_query=tg_link)],
- [InlineKeyboardButton("◀️ Меню", callback_data="menu_main")],
- ])
- if update.callback_query:
- await update.callback_query.edit_message_text(share_text, parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(share_text, parse_mode="HTML", reply_markup=kb)
-
-async def cmd_remove(update, ctx):
- 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, ctx):
- 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, ctx):
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg: return
- if not _ok(update.effective_user.id): return
- if not await proxy_running():
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query: await update.callback_query.edit_message_text("❌ Прокси не запущен.", reply_markup=kb)
- else: await msg.reply_text("❌ Прокси не запущен.", reply_markup=kb)
- return
- code, out, err = await sh("docker","logs","--tail","40",CONTAINER_NAME, timeout=15)
- text = (out or "") + (("\n" + err) if err else "") or "Нет вывода."
- if len(text) > 4000: text = text[-4000:]
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text(f"{html.escape(text)}", parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(f"{html.escape(text)}", parse_mode="HTML", reply_markup=kb)
-
-async def cmd_promo(update, ctx):
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg: return
- if not _ok(update.effective_user.id): return
- text = ("💰 Хостинг со скидкой до -60%\n\n"
- f"Хостинг #1: {PROMO_LINK}\n"
- "Промокоды: OFF60, antenka20, antenka6, antenka12\n\n"
- "Хостинг #2: https://vk.cc/cUxAhj\n"
- "Промокод: OFF60\n\n"
- f"☕ Донат: {TIP_LINK}")
- kb = InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]])
- if update.callback_query:
- await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
- else:
- await msg.reply_text(text, parse_mode="HTML", reply_markup=kb)
-
-async def install_step_domain(update, ctx):
- msg = update.message or (update.callback_query and update.callback_query.message)
- if not update.effective_user or not msg: return
- if not _ok(update.effective_user.id): return
- buttons, row = [], []
- for i, d in enumerate(DOMAINS):
- row.append(InlineKeyboardButton(d, callback_data=f"dom_{i}"))
- if len(row) == 2: buttons.append(row); row = []
- if row: buttons.append(row)
- text = "🌐 Выберите домен для маскировки (Fake TLS):"
- if update.callback_query:
- await update.callback_query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons))
- else:
- await msg.reply_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(buttons))
-
-async def install_step_port(update, ctx):
- query = update.callback_query
- domain = ctx.user_data.get("install_domain", "google.com")
- busy_443 = await check_port(443)
- busy_8443 = await check_port(8443)
- rows = []
- l443 = "443 (рекомендуется)" if not busy_443 else "443 ⚠️ занят"
- l8443 = "8443" if not busy_8443 else "8443 ⚠️ занят"
- rows.append([InlineKeyboardButton(l443, callback_data="port_443"), InlineKeyboardButton(l8443, callback_data="port_8443")])
- rows.append([InlineKeyboardButton("◀️ Меню", callback_data="menu_main")])
- pi = ""
- if busy_443: pi += f"\n⚠️ Порт 443 занят:\n{html.escape(busy_443[:300])}\n"
- if busy_8443: pi += f"\n⚠️ Порт 8443 занят:\n{html.escape(busy_8443[:300])}\n"
- text = f"Домен: {html.escape(domain)}\n\n🔌 Выберите порт или введите свой (1-65535):{pi}"
- ctx.user_data["install_wait_port"] = True
- await query.edit_message_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(rows))
-
-async def install_port_chosen(update, ctx, port_str):
- port = int(port_str)
- msg = update.callback_query.message if update.callback_query else 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{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, ctx):
- domain = ctx.user_data.get("install_domain") or "google.com"
- port = ctx.user_data.get("install_port") or "443"
- 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 и запуск контейнера...")
- else: return
- chat = msg.chat
- code, _, _ = await sh("docker","info", timeout=10)
- if code != 0:
- await chat.send_message("❌ Docker не запущен.", parse_mode="HTML",
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Меню", callback_data="menu_main")]]))
- return
- 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)
- 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","-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📞 Звонки поддержаны (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)
-
-async def callback_handler(update, ctx):
- query = update.callback_query
- if not query or not update.effective_user: return
- await query.answer()
- if not _ok(update.effective_user.id): await query.edit_message_text("⛔ Доступ запрещён."); return
- data = query.data or ""
- if data == "menu_main": await start(update, ctx)
- elif data == "menu_install": await install_step_domain(update, ctx)
- elif data == "menu_status": await cmd_status(update, ctx)
- elif data == "menu_link": await cmd_link(update, ctx)
- elif data == "menu_share": await cmd_share(update, ctx)
- elif data == "menu_restart": await cmd_restart(update, ctx)
- elif data == "menu_logs": await cmd_logs(update, ctx)
- elif data == "menu_remove": await cmd_remove(update, ctx)
- elif data == "menu_promo": await cmd_promo(update, ctx)
- elif data.startswith("dom_"):
- try: idx = int(data[4:])
- except ValueError: await query.edit_message_text("❌ Ошибка."); return
- if not (0 <= idx < len(DOMAINS)): await query.edit_message_text("❌ Неверный выбор."); 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_"):
- ctx.user_data["install_port"] = data[6:]
- ctx.user_data["install_wait_port"] = False
- await do_install(update, ctx)
- elif data == "reselect_port": await install_step_port(update, ctx)
-
-async def text_handler(update, ctx):
- if not update.message or not ctx.user_data.get("install_wait_port"): return
- text = (update.message.text or "").strip()
- if not re.match(r"^\d+$", text): return
- port = int(text)
- if not (1 <= port <= 65535): await update.message.reply_text("Введите число от 1 до 65535."); return
- await install_port_chosen(update, ctx, str(port))
-
-def main():
- if not BOT_TOKEN: raise SystemExit("Задайте BOT_TOKEN в .env")
- app = Application.builder().token(BOT_TOKEN).build()
- app.add_handler(CommandHandler("start", start))
- app.add_handler(CommandHandler("help", start))
- app.add_handler(CommandHandler("install", install_step_domain))
- app.add_handler(CommandHandler("status", cmd_status))
- app.add_handler(CommandHandler("link", cmd_link))
- app.add_handler(CommandHandler("share", cmd_share))
- app.add_handler(CommandHandler("remove", cmd_remove))
- app.add_handler(CommandHandler("restart", cmd_restart))
- app.add_handler(CommandHandler("logs", cmd_logs))
- app.add_handler(CommandHandler("promo", cmd_promo))
- app.add_handler(CallbackQueryHandler(callback_handler))
- app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler))
- app.run_polling(allowed_updates=Update.ALL_TYPES)
-
-if __name__ == "__main__":
- main()
-BOTEOF
-}
-
-install_bot_deps() {
- local bot_steps=3 bot_cur=0
-
- bot_cur=$((bot_cur+1)); progress_bar $bot_cur $bot_steps "Создание venv..."
-
- # Если venv сломан (нет pip), удаляем и пересоздаём
- if [ -d "$BOT_DIR/venv" ] && [ ! -f "$BOT_DIR/venv/bin/pip" ]; then
- echo -e " ${YELLOW}!${NC} venv повреждён (нет pip), пересоздаю..."
- rm -rf "$BOT_DIR/venv"
- fi
-
- if [ ! -d "$BOT_DIR/venv" ]; then
- # Убеждаемся что ensurepip доступен
- if ! python3 -m ensurepip --version &>/dev/null; then
- local PY_VER
- PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "3")
- echo -e " ${YELLOW}!${NC} Установка python${PY_VER}-venv (с ensurepip)..."
- install_pkg "python${PY_VER}-venv" 2>/dev/null
- install_pkg python3-venv python3-pip 2>/dev/null
- fi
- spinner_start "Создание Python venv..."
- python3 -m venv "$BOT_DIR/venv" 2>/dev/null
- spinner_stop
- if [ ! -f "$BOT_DIR/venv/bin/pip" ]; then
- echo -e " ${RED}✗${NC} venv создан, но pip отсутствует."
- echo -e " ${YELLOW}Выполните вручную: apt install python3-venv && rm -rf $BOT_DIR/venv${NC}"
- return 1
- fi
- fi
- echo -e " ${GREEN}✓${NC} Python venv готов"
-
- bot_cur=$((bot_cur+1)); progress_bar $bot_cur $bot_steps "Обновление pip..."
- spinner_start "Обновление pip..."
- "$BOT_DIR/venv/bin/pip" install --upgrade pip -q 2>/dev/null
- spinner_stop
- echo -e " ${GREEN}✓${NC} pip обновлён"
-
- bot_cur=$((bot_cur+1)); progress_bar $bot_cur $bot_steps "Установка зависимостей..."
- spinner_start "Установка python-telegram-bot (до 1 мин)..."
- "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q 2>/dev/null
- local rc=$?
- spinner_stop
- if [ $rc -ne 0 ]; then
- echo -e " ${RED}✗${NC} pip install не удался."
- return 1
- fi
- # Проверяем что модуль реально доступен
- if ! "$BOT_DIR/venv/bin/python" -c "import telegram" 2>/dev/null; then
- echo -e " ${RED}✗${NC} Модуль telegram не найден после установки."
- return 1
- fi
- echo -e " ${GREEN}✓${NC} Зависимости установлены"
-}
-
-# ── 7) Полное меню удаления ────────────────────────────────────────────────────
-menu_remove() {
- clear
- echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}"
- echo -e "${RED}║ УДАЛЕНИЕ КОМПОНЕНТОВ ║${NC}"
- echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}"
- echo ""
- echo -e " ${YELLOW}1)${NC} Удалить только контейнер MTProxy"
- echo -e " (Docker и другие контейнеры останутся)"
- echo ""
- echo -e " ${RED}2)${NC} Удалить контейнер MTProxy + Docker полностью"
- echo -e " ${RED}⚠ ВСЕ контейнеры и образы будут уничтожены!${NC}"
- echo ""
- echo -e " ${WHITE}0)${NC} Назад"
- echo ""
- local choice
- read -p " Выбор: " choice
-
- case $choice in
- 1) remove_container_only ;;
- 2) remove_with_docker ;;
- *) return ;;
- esac
-}
-
-remove_container_only() {
- clear
- echo -e "${YELLOW}╔══════════════════════════════════════════════════════════════╗${NC}"
- echo -e "${YELLOW}║ Удаление контейнера MTProxy ║${NC}"
- echo -e "${YELLOW}╚══════════════════════════════════════════════════════════════╝${NC}"
- echo ""
- echo -e " Будет удалено:"
- echo -e " • Контейнер ${WHITE}$CONTAINER_NAME${NC}"
- echo -e " • Telegram-бот (сервис ${WHITE}$SERVICE_NAME${NC})"
- echo -e " • Файлы бота (${WHITE}$BOT_DIR${NC})"
- echo -e " • Скрипт ${WHITE}/usr/local/bin/gotelegram${NC}"
- echo ""
- echo -e " Docker и другие контейнеры ${GREEN}НЕ будут затронуты${NC}."
- echo ""
-
- # Подтверждение 1
- local yn
- read -p " Вы уверены? (y/N): " yn
- if [ "$yn" != "y" ] && [ "$yn" != "Y" ]; then
- echo -e " ${GREEN}Отменено.${NC}"
- read -p " Нажмите Enter..."
- return
- fi
-
- # Подтверждение 2 — случайное слово
- local words=("УДАЛИТЬ" "СТЕРЕТЬ" "ПРОКСИ" "ОЧИСТКА" "ФИНАЛ" "СБРОС")
- local confirm_word="${words[$((RANDOM % ${#words[@]}))]}"
- echo ""
- echo -e " ${RED}Для подтверждения введите слово:${NC} ${WHITE}${confirm_word}${NC}"
- local input_word
- read -p " >>> " input_word
- if [ "$input_word" != "$confirm_word" ]; then
- echo -e " ${GREEN}Слово не совпало. Удаление отменено.${NC}"
- read -p " Нажмите Enter..."
- return
- fi
-
- echo ""
- # Удаление
- spinner_start "Остановка и удаление контейнера..."
- docker stop "$CONTAINER_NAME" &>/dev/null
- docker rm "$CONTAINER_NAME" &>/dev/null
- spinner_stop
- echo -e " ${GREEN}✓${NC} Контейнер удалён"
-
- spinner_start "Остановка Telegram-бота..."
- systemctl stop "$SERVICE_NAME" 2>/dev/null
- systemctl disable "$SERVICE_NAME" 2>/dev/null
- rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
- systemctl daemon-reload 2>/dev/null
- spinner_stop
- echo -e " ${GREEN}✓${NC} Сервис бота удалён"
-
- rm -rf "$BOT_DIR"
- echo -e " ${GREEN}✓${NC} Файлы бота удалены"
-
- rm -f /usr/local/bin/gotelegram
- echo -e " ${GREEN}✓${NC} Скрипт gotelegram удалён"
-
- echo ""
- echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
- echo -e "${GREEN} Удаление завершено. Docker остался на месте.${NC}"
- echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
- read -p " Нажмите Enter..."
-}
-
-remove_with_docker() {
- clear
- echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}"
- echo -e "${RED}║ ⚠ ПОЛНОЕ УДАЛЕНИЕ: MTProxy + Docker + всё ⚠ ║${NC}"
- echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}"
- echo ""
- echo -e " ${RED}ВНИМАНИЕ! Будет удалено ВСЁ:${NC}"
- echo -e " • Контейнер ${WHITE}$CONTAINER_NAME${NC}"
- echo -e " • Telegram-бот и файлы"
- echo -e " • Скрипт gotelegram"
- echo -e " • ${RED}Docker Engine полностью${NC}"
- echo -e " • ${RED}ВСЕ Docker-контейнеры, образы и тома${NC}"
- echo ""
-
- # Показываем что ещё есть в Docker
- local other_containers
- other_containers=$(docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null | grep -v "^${CONTAINER_NAME}")
- if [ -n "$other_containers" ]; then
- echo -e " ${RED}⚠ На сервере есть ДРУГИЕ контейнеры, которые тоже будут уничтожены:${NC}"
- echo -e " ${RED}────────────────────────────────────────────────────────────────${NC}"
- echo "$other_containers" | while IFS= read -r line; do
- echo -e " ${WHITE}$line${NC}"
- done
- echo -e " ${RED}────────────────────────────────────────────────────────────────${NC}"
- echo ""
- fi
-
- # Подтверждение 1
- local yn
- echo -e " ${RED}Это действие НЕОБРАТИМО.${NC}"
- read -p " Вы точно уверены? (y/N): " yn
- if [ "$yn" != "y" ] && [ "$yn" != "Y" ]; then
- echo -e " ${GREEN}Отменено.${NC}"
- read -p " Нажмите Enter..."
- return
- fi
-
- # Подтверждение 2 — случайное слово
- local words=("УНИЧТОЖИТЬ" "ПОЛНЫЙ-СБРОС" "СТЕРЕТЬ-ВСЁ" "ПОДТВЕРЖДАЮ" "DOCKER-УДАЛИТЬ" "ТОЧНО-ДА")
- local confirm_word="${words[$((RANDOM % ${#words[@]}))]}"
- echo ""
- echo -e " ${RED}████████████████████████████████████████████████████████████${NC}"
- echo -e " ${RED}██${NC} Для подтверждения введите: ${WHITE}${confirm_word}${NC}"
- echo -e " ${RED}████████████████████████████████████████████████████████████${NC}"
- local input_word
- read -p " >>> " input_word
- if [ "$input_word" != "$confirm_word" ]; then
- echo -e " ${GREEN}Слово не совпало. Удаление отменено.${NC}"
- read -p " Нажмите Enter..."
- return
- fi
-
- echo ""
- # Удаление MTProxy
- spinner_start "Удаление контейнера MTProxy..."
- docker stop "$CONTAINER_NAME" &>/dev/null
- docker rm "$CONTAINER_NAME" &>/dev/null
- spinner_stop
- echo -e " ${GREEN}✓${NC} Контейнер MTProxy удалён"
-
- # Удаление бота
- spinner_start "Удаление Telegram-бота..."
- systemctl stop "$SERVICE_NAME" 2>/dev/null
- systemctl disable "$SERVICE_NAME" 2>/dev/null
- rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
- systemctl daemon-reload 2>/dev/null
- rm -rf "$BOT_DIR"
- spinner_stop
- echo -e " ${GREEN}✓${NC} Telegram-бот удалён"
-
- # Удаление всех контейнеров Docker
- spinner_start "Остановка всех контейнеров Docker..."
- docker stop $(docker ps -aq) &>/dev/null
- docker rm $(docker ps -aq) &>/dev/null
- spinner_stop
- echo -e " ${GREEN}✓${NC} Все контейнеры остановлены и удалены"
-
- # Удаление Docker
- spinner_start "Удаление Docker Engine..."
- systemctl stop docker 2>/dev/null
- if command -v apt-get &>/dev/null; then
- apt-get purge -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>/dev/null
- apt-get autoremove -y -qq 2>/dev/null
- elif command -v dnf &>/dev/null; then
- dnf remove -y docker-ce docker-ce-cli containerd.io 2>/dev/null
- elif command -v yum &>/dev/null; then
- yum remove -y docker-ce docker-ce-cli containerd.io 2>/dev/null
- fi
- rm -rf /var/lib/docker /var/lib/containerd /etc/docker
- spinner_stop
- echo -e " ${GREEN}✓${NC} Docker полностью удалён"
-
- # Удаление скрипта
- rm -f /usr/local/bin/gotelegram
- echo -e " ${GREEN}✓${NC} Скрипт gotelegram удалён"
-
- echo ""
- echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
- echo -e "${GREEN} Полное удаление завершено.${NC}"
- echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
- echo -e " Для повторной установки используйте команду curl."
- read -p " Нажмите Enter для выхода..."
- exit 0
-}
-
-# ── Выход ────────────────────────────────────────────────────────────────────
-show_exit() {
- clear
- show_config
- echo ""
- echo -e "${MAGENTA}Поддержка автора (CloudTips):${NC}"
- echo -e " $TIP_LINK"
- echo -e " YouTube: https://www.youtube.com/@antenkaru"
- if command -v qrencode &>/dev/null; then
- qrencode -t ANSIUTF8 "$TIP_LINK"
- fi
- exit 0
-}
-
-# ══════════════════════════════════════════════════════════════════════════════
-# ██ СТАРТ СКРИПТА
-# ══════════════════════════════════════════════════════════════════════════════
-
-install_base_deps
-
-# Копируем себя в /usr/local/bin/gotelegram (если запущены из другого места)
-SELF="$(realpath "$0")"
-if [ "$SELF" != "/usr/local/bin/gotelegram" ]; then
- cp "$SELF" /usr/local/bin/gotelegram && chmod +x /usr/local/bin/gotelegram
-fi
-
-show_promo
-
-# ── Главное меню (цикл) ─────────────────────────────────────────────────────
-while true; do
- echo ""
- echo -e "${MAGENTA}╔══════════════════════════════════════════════════════════════╗${NC}"
- echo -e "${MAGENTA}║ GoTelegram Manager (by anten-ka) ║${NC}"
- echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════════╝${NC}"
-
- # Статус прокси
- if proxy_is_running; then
- echo -e " Прокси: ${GREEN}работает${NC}"
- else
- echo -e " Прокси: ${RED}не запущен${NC}"
- fi
- # Статус бота
- if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
- echo -e " Telegram-бот: ${GREEN}работает${NC}"
- else
- echo -e " Telegram-бот: ${YELLOW}не настроен${NC}"
- fi
- echo ""
- echo -e " ${GREEN}1)${NC} Установить / Обновить прокси"
- echo -e " ${GREEN}2)${NC} Показать данные подключения"
- echo -e " ${CYAN}3)${NC} Настроить Telegram-бот"
- echo -e " ${GREEN}4)${NC} Перезапустить прокси"
- echo -e " ${GREEN}5)${NC} Логи прокси"
- echo -e " ${YELLOW}6)${NC} Показать PROMO"
- echo -e " ${RED}7)${NC} Удалить (полное меню удаления)"
- echo -e " ${WHITE}0)${NC} Выход"
- echo ""
- read -p " Пункт: " m_idx
- case $m_idx in
- 1) menu_install ;;
- 2) clear; show_config; read -p "Нажмите Enter..." ;;
- 3) menu_setup_bot ;;
- 4)
- if proxy_is_running; then
- docker restart "$CONTAINER_NAME" && echo -e "${GREEN}Перезапущен.${NC}" || echo -e "${RED}Ошибка.${NC}"
- else
- echo -e "${RED}Прокси не запущен.${NC}"
- fi
- read -p "Нажмите Enter..."
- ;;
- 5)
- if proxy_is_running; then
- docker logs --tail 50 "$CONTAINER_NAME" 2>&1
- else
- echo -e "${RED}Прокси не запущен.${NC}"
- fi
- read -p "Нажмите Enter..."
- ;;
- 6) show_promo ;;
- 7) menu_remove ;;
- 0) show_exit ;;
- *) echo -e "${RED}Неверный ввод.${NC}" ;;
- esac
-done
+#!/bin/bash
+# ══════════════════════════════════════════════════════════════════════════════
+# GoTelegram v2.2 — MTProxy на ядре telemt (Rust + Tokio)
+# Anti-DPI • Fake TLS • TCP Splice • JA3/JA4 Resistance
+#
+# Установка:
+# curl -sL URL/install.sh | sudo bash
+# ══════════════════════════════════════════════════════════════════════════════
+
+set -uo pipefail
+
+# Путь к скрипту и библиотекам
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+LIB_DIR="$SCRIPT_DIR/lib"
+
+# Загружаем библиотеки
+source "$LIB_DIR/common.sh"
+source "$LIB_DIR/telemt.sh"
+source "$LIB_DIR/telemt_config.sh"
+source "$LIB_DIR/website.sh"
+source "$LIB_DIR/templates_catalog.sh"
+source "$LIB_DIR/backup.sh"
+
+# ── Главное меню ─────────────────────────────────────────────────────────────
+show_main_menu() {
+ local status
+ status=$(telemt_status)
+
+ local status_badge
+ case "$status" in
+ running) status_badge="${GREEN}● Работает${NC}" ;;
+ stopped) status_badge="${YELLOW}○ Остановлен${NC}" ;;
+ *) status_badge="${RED}✗ Не установлен${NC}" ;;
+ esac
+
+ echo ""
+ echo -e " ${BOLD}${WHITE}Главное меню${NC} │ ${status_badge}"
+ echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
+ echo -e " ${CYAN} 1)${NC} 🔧 Установить / Обновить прокси"
+ echo -e " ${CYAN} 2)${NC} 📊 Статус"
+ echo -e " ${CYAN} 3)${NC} 🔗 Ссылка для подключения"
+ echo -e " ${CYAN} 4)${NC} 📤 Поделиться ключом"
+ echo -e " ${CYAN} 5)${NC} 🔄 Перезапуск"
+ echo -e " ${CYAN} 6)${NC} 📋 Логи"
+ echo -e " ${CYAN} 7)${NC} 🎭 Сменить режим / шаблон"
+ echo -e " ${CYAN} 8)${NC} 💾 Бекап конфигурации"
+ echo -e " ${CYAN} 9)${NC} 📦 Восстановить из бекапа"
+ echo -e " ${CYAN}10)${NC} ⬆️ Обновить telemt"
+ echo -e " ${CYAN}11)${NC} 🌐 Управление сайтом (SSL)"
+ echo -e " ${CYAN}12)${NC} 🗑 Удалить прокси"
+ echo -e " ${CYAN}13)${NC} 🏷 Промо"
+ echo -e " ${CYAN} 0)${NC} 🚪 Выход"
+ echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
+ echo -ne " ${WHITE}Выбор:${NC} "
+}
+
+# ── Установка: выбор режима ──────────────────────────────────────────────────
+menu_install() {
+ # Проверяем v1
+ if detect_v1_installation; then
+ echo ""
+ echo -e " ${YELLOW}⚠️ Обнаружена установка GoTelegram v1 (mtg)${NC}"
+ echo -e " ${DIM}Контейнер: ${V1_CONTAINER_NAME}${NC}"
+ echo ""
+ if ! migrate_v1_to_v2; then
+ return
+ fi
+ fi
+
+ echo ""
+ echo -e " ${BOLD}${WHITE}🎭 Выберите режим маскировки:${NC}"
+ echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
+ echo -e " ${CYAN}1)${NC} ${GREEN}⚡ Quick${NC} — маскировка под популярный сайт"
+ echo -e " ${DIM}Быстро, без домена. telemt маскирует трафик${NC}"
+ echo -e " ${DIM}под выбранный сайт (google.com и т.д.)${NC}"
+ echo ""
+ echo -e " ${CYAN}2)${NC} ${MAGENTA}🛡 Stealth${NC} — свой сайт + полная маскировка"
+ echo -e " ${DIM}nginx + SSL + HTML-шаблон + telemt.${NC}"
+ echo -e " ${DIM}DPI видит реальный сайт с реальным сертификатом.${NC}"
+ echo -e " ${DIM}Требует: домен, направленный на этот сервер.${NC}"
+ echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
+ echo -ne " ${WHITE}Выбор (1/2):${NC} "
+ read -r mode_choice
+ mode_choice="${mode_choice:-}"
+
+ case "$mode_choice" in
+ 1) install_quick_mode ;;
+ 2) install_stealth_mode ;;
+ *) log_error "Неверный выбор: ${mode_choice:-<пусто>}" ;;
+ esac
+}
+
+# ── Quick-режим ──────────────────────────────────────────────────────────────
+install_quick_mode() {
+ log_step "Установка Quick-режима"
+
+ # Выбор домена
+ local domain
+ domain=$(select_quick_domain)
+ [ $? -ne 0 ] && return
+
+ # Выбор порта
+ local port
+ port=$(select_port)
+ [ $? -ne 0 ] && return
+
+ # Генерация секрета
+ local secret
+ secret=$(generate_hex 32)
+
+ # Подтверждение
+ local ip
+ ip=$(get_server_ip)
+ echo ""
+ echo -e " ${BOLD}${WHITE}📋 Конфигурация:${NC}"
+ echo -e " IP: ${CYAN}${ip}${NC}"
+ echo -e " Порт: ${CYAN}${port}${NC}"
+ echo -e " Маскировка: ${CYAN}${domain}${NC}"
+ echo -e " Режим: ${GREEN}Quick${NC}"
+ echo ""
+
+ if ! confirm "Установить прокси?"; then
+ return
+ fi
+
+ # Установка
+ ensure_deps
+ install_telemt_full || return
+
+ # Генерируем конфиг telemt
+ generate_telemt_toml "$secret" "$port" "quick" "$domain" "443"
+
+ # Валидация
+ validate_telemt_config || return
+
+ # Запуск
+ start_telemt || return
+
+ # Сохраняем GoTelegram конфиг
+ save_gotelegram_config "telemt" "quick" "$port" "$secret" "$domain" "" ""
+
+ # Благодарности
+ show_credits
+
+ # Результат
+ show_proxy_info
+ log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Quick-режим)"
+}
+
+# ── Stealth-режим ────────────────────────────────────────────────────────────
+install_stealth_mode() {
+ log_step "Установка Stealth-режима"
+
+ # Ввод домена
+ echo ""
+ echo -ne " ${WHITE}Введите ваш домен (например, example.com):${NC} "
+ read -r user_domain
+
+ if [ -z "$user_domain" ] || ! validate_domain "$user_domain"; then
+ log_error "Некорректный домен: ${user_domain:-<пусто>}"
+ return
+ fi
+
+ # Проверяем DNS
+ local resolved_ip server_ip
+ resolved_ip=$(dig +short "$user_domain" A 2>/dev/null | head -1)
+ server_ip=$(get_server_ip)
+
+ if [ -n "$resolved_ip" ] && [ "$resolved_ip" != "$server_ip" ]; then
+ log_warning "Домен $user_domain указывает на $resolved_ip, а не на $server_ip"
+ if ! confirm "Продолжить всё равно?"; then
+ return
+ fi
+ fi
+
+ # Email для Let's Encrypt
+ echo -ne " ${WHITE}Email для SSL (Enter = без email):${NC} "
+ read -r ssl_email
+
+ # Выбор шаблона
+ local template_dir
+ template_dir=$(interactive_template_selection)
+ [ $? -ne 0 ] && return
+
+ # Выбор порта для telemt (внутренний)
+ local telemt_port=8443
+ echo ""
+ echo -e " ${DIM}telemt будет слушать на порту $telemt_port (внутренний)${NC}"
+ echo -e " ${DIM}nginx будет на 443 (внешний) и проксировать трафик${NC}"
+
+ # Генерация секрета
+ local secret
+ secret=$(generate_hex 32)
+
+ # Подтверждение
+ echo ""
+ echo -e " ${BOLD}${WHITE}📋 Конфигурация:${NC}"
+ echo -e " Домен: ${CYAN}${user_domain}${NC}"
+ echo -e " Порт: ${CYAN}443 (nginx) → $telemt_port (telemt)${NC}"
+ echo -e " Режим: ${MAGENTA}Stealth${NC}"
+ echo ""
+
+ if ! confirm "Установить прокси + сайт?"; then
+ return
+ fi
+
+ # Установка
+ ensure_deps
+ install_telemt_full || return
+
+ # Конфиг telemt: маскировка на localhost (nginx)
+ generate_telemt_toml "$secret" "$telemt_port" "stealth" "127.0.0.1" "443"
+
+ # Настройка сайта (nginx + certbot + шаблон)
+ setup_stealth_mode "$user_domain" "$template_dir" "$telemt_port" "$ssl_email" || return
+
+ # Запуск telemt
+ start_telemt || return
+
+ # Сохраняем конфиг
+ local tpl_id
+ tpl_id=$(basename "$template_dir")
+ save_gotelegram_config "telemt" "stealth" "443" "$secret" "127.0.0.1" "$user_domain" "$tpl_id"
+
+ # Результат
+ show_proxy_info
+ echo -e " ${WHITE}Сайт:${NC} ${GREEN}https://${user_domain}${NC}"
+ log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Stealth-режим)"
+}
+
+# ── Статус ───────────────────────────────────────────────────────────────────
+menu_status() {
+ show_proxy_info
+
+ # Дополнительно для stealth
+ local mode
+ mode=$(config_get mode 2>/dev/null)
+ if [ "$mode" = "stealth" ]; then
+ local domain
+ domain=$(config_get domain 2>/dev/null)
+ if [ -n "$domain" ]; then
+ local ssl_expiry
+ ssl_expiry=$(get_ssl_expiry "$domain")
+ local nginx_st
+ nginx_st=$(nginx_status)
+ echo -e " ${WHITE}nginx:${NC} ${nginx_st}"
+ echo -e " ${WHITE}SSL до:${NC} ${ssl_expiry}"
+ echo -e " ${WHITE}Сайт:${NC} https://${domain}"
+ echo ""
+ fi
+ fi
+}
+
+# ── Ссылка ───────────────────────────────────────────────────────────────────
+menu_link() {
+ local secret port ip link
+ secret=$(get_config_value secret)
+ port=$(get_config_value port)
+ ip=$(get_server_ip)
+ link=$(generate_proxy_link "$ip" "$port" "$secret")
+
+ echo ""
+ echo -e " ${BOLD}${WHITE}🔗 Ссылка для подключения:${NC}"
+ echo ""
+ echo -e " ${GREEN}${link}${NC}"
+ echo ""
+
+ if command -v qrencode &>/dev/null; then
+ qrencode -t UTF8 -m 2 "$link" 2>/dev/null
+ fi
+}
+
+# ── Поделиться ───────────────────────────────────────────────────────────────
+menu_share() {
+ local secret port ip link
+ secret=$(get_config_value secret)
+ port=$(get_config_value port)
+ ip=$(get_server_ip)
+ link=$(generate_proxy_link "$ip" "$port" "$secret")
+
+ echo ""
+ echo -e " ${BOLD}📤 Перешлите это сообщение:${NC}"
+ echo ""
+ echo "🔐 MTProxy для Telegram (GoTelegram v${GOTELEGRAM_VERSION})"
+ echo ""
+ echo "🌍 Сервер: $ip"
+ echo "🔌 Порт: $port"
+ echo ""
+ echo "👉 Подключиться одним нажатием:"
+ echo "$link"
+ echo ""
+ echo "Просто нажмите на ссылку или настройте вручную."
+ echo ""
+}
+
+# ── Перезапуск ───────────────────────────────────────────────────────────────
+menu_restart() {
+ restart_telemt
+ local mode
+ mode=$(config_get mode 2>/dev/null)
+ if [ "$mode" = "stealth" ]; then
+ restart_nginx
+ fi
+}
+
+# ── Логи ─────────────────────────────────────────────────────────────────────
+menu_logs() {
+ echo ""
+ echo -e " ${BOLD}${WHITE}📋 Логи telemt (последние 40 строк):${NC}"
+ echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
+ telemt_logs 40
+ echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
+}
+
+# ── Смена режима / шаблона ───────────────────────────────────────────────────
+menu_change_mode() {
+ local current_mode
+ current_mode=$(config_get mode 2>/dev/null)
+ echo ""
+ echo -e " ${WHITE}Текущий режим:${NC} ${CYAN}${current_mode}${NC}"
+ echo ""
+ echo -e " ${CYAN}1)${NC} Сменить шаблон сайта (только stealth)"
+ echo -e " ${CYAN}2)${NC} Переключить режим (quick ↔ stealth)"
+ echo -e " ${CYAN}0)${NC} Назад"
+ echo -ne " ${WHITE}Выбор:${NC} "
+ read -r ch
+
+ case "$ch" in
+ 1)
+ if [ "$current_mode" != "stealth" ]; then
+ log_error "Смена шаблона доступна только в stealth-режиме"
+ return
+ fi
+ local template_dir
+ template_dir=$(interactive_template_selection)
+ [ $? -ne 0 ] && return
+ switch_template "$template_dir"
+ ;;
+ 2)
+ log_warning "Переключение режима требует переустановки."
+ if confirm "Переустановить прокси?"; then
+ menu_install
+ fi
+ ;;
+ esac
+}
+
+# ── Управление сайтом ───────────────────────────────────────────────────────
+menu_website() {
+ local mode
+ mode=$(config_get mode 2>/dev/null)
+ if [ "$mode" != "stealth" ]; then
+ log_info "Управление сайтом доступно только в stealth-режиме"
+ return
+ fi
+
+ local domain
+ domain=$(config_get domain 2>/dev/null)
+
+ echo ""
+ echo -e " ${BOLD}${WHITE}🌐 Управление сайтом${NC}"
+ echo -e " Домен: ${CYAN}${domain}${NC}"
+ echo -e " SSL до: $(get_ssl_expiry "$domain")"
+ echo ""
+ echo -e " ${CYAN}1)${NC} Обновить SSL сертификат"
+ echo -e " ${CYAN}2)${NC} Перезапустить nginx"
+ echo -e " ${CYAN}3)${NC} Сменить шаблон"
+ echo -e " ${CYAN}0)${NC} Назад"
+ echo -ne " ${WHITE}Выбор:${NC} "
+ read -r ch
+
+ case "$ch" in
+ 1) renew_ssl_certificate ;;
+ 2) restart_nginx ;;
+ 3)
+ local template_dir
+ template_dir=$(interactive_template_selection)
+ [ $? -ne 0 ] && return
+ switch_template "$template_dir"
+ ;;
+ esac
+}
+
+# ── Удаление ─────────────────────────────────────────────────────────────────
+menu_remove() {
+ echo ""
+ log_warning "Это удалит прокси и все настройки."
+ if ! confirm "Вы уверены?"; then
+ return
+ fi
+
+ # Предлагаем бекап
+ if confirm "Сделать бекап перед удалением?"; then
+ interactive_backup
+ fi
+
+ remove_telemt
+
+ local mode
+ mode=$(config_get mode 2>/dev/null)
+ if [ "$mode" = "stealth" ]; then
+ remove_stealth_mode
+ fi
+
+ rm -f "$GOTELEGRAM_CONFIG"
+ log_success "GoTelegram полностью удалён"
+}
+
+# ── Промо ────────────────────────────────────────────────────────────────────
+menu_promo() {
+ echo ""
+ echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}"
+ echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ СО СКИДКОЙ ДО -60%${NC} ${YELLOW}║${NC}"
+ echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/ct29NQ${NC} ${YELLOW}║${NC}"
+ echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
+ echo -e " ${YELLOW}║${NC} Промокоды: OFF60, antenka20, antenka6, antenka12 ${YELLOW}║${NC}"
+ echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
+ echo -e " ${YELLOW}║${NC} Донат: ${CYAN}https://pay.cloudtips.ru/p/7410814f${NC} ${YELLOW}║${NC}"
+ echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}"
+ echo ""
+}
+
+# ── Точка входа ──────────────────────────────────────────────────────────────
+main() {
+ check_root
+ init_dirs
+ show_banner
+
+ # Pre-flight
+ check_os
+ check_disk_space 500
+
+ while true; do
+ show_main_menu
+ read -r choice
+ case "$choice" in
+ 1) menu_install ;;
+ 2) menu_status ;;
+ 3) menu_link ;;
+ 4) menu_share ;;
+ 5) menu_restart ;;
+ 6) menu_logs ;;
+ 7) menu_change_mode ;;
+ 8) interactive_backup ;;
+ 9) interactive_restore ;;
+ 10) update_telemt ;;
+ 11) menu_website ;;
+ 12) menu_remove ;;
+ 13) menu_promo ;;
+ 0|q|exit) echo ""; log_info "До встречи! 👋"; exit 0 ;;
+ *) log_error "Неверный выбор" ;;
+ esac
+
+ echo ""
+ echo -ne " ${DIM}Нажмите Enter для возврата в меню...${NC}"
+ read -r
+ done
+}
+
+main "$@"
diff --git a/install_gotelegram_bot.sh b/install_gotelegram_bot.sh
index 8960fae..c5d4306 100644
--- a/install_gotelegram_bot.sh
+++ b/install_gotelegram_bot.sh
@@ -1,139 +1,132 @@
-#!/bin/bash
-# Установка GoTelegram MTProxy Bot (по образцу gokaskad).
-# Запуск: curl -sL URL/install_gotelegram_bot.sh -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh
-# Или с токеном GitHub (приватный репо): curl -sL -H "Authorization: token YOUR_GITHUB_TOKEN" URL -o /tmp/install_gotelegram_bot.sh && sudo bash /tmp/install_gotelegram_bot.sh
-
-set -e
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-NC='\033[0m'
-
-if [ "$EUID" -ne 0 ]; then
- echo -e "${RED}Запустите скрипт с sudo.${NC}"
- exit 1
-fi
-
-BOT_DIR="/opt/gotelegram-bot"
-SERVICE_NAME="gotelegram-bot"
-REPO_URL="${GOTETELEGRAM_BOT_REPO:-https://github.com/anten-ka/gotelegram_pro}"
-# Для приватного репо задайте GITHUB_TOKEN перед запуском или в одной команде:
-# GITHUB_TOKEN=ghp_xxx curl -sL -H "Authorization: token $GITHUB_TOKEN" ...
-
-echo -e "${GREEN}[*] Установка GoTelegram Bot...${NC}"
-
-# Зависимости
-if ! command -v python3 &>/dev/null; then
- if command -v apt-get &>/dev/null; then
- apt-get update && apt-get install -y python3 python3-pip python3-venv
- elif command -v dnf &>/dev/null; then
- dnf install -y python3 python3-pip python3-virtualenv 2>/dev/null || dnf install -y python3 python3-pip
- elif command -v yum &>/dev/null; then
- yum install -y python3 python3-pip
- else
- echo -e "${RED}Установите python3 вручную.${NC}"
- exit 1
- fi
-fi
-if ! command -v docker &>/dev/null; then
- echo -e "${YELLOW}[!] Docker не найден. Установите Docker для работы /install.${NC}"
-fi
-
-# Каталог бота
-mkdir -p "$BOT_DIR"
-cd "$BOT_DIR"
-
-# Откуда брать файлы: локальная папка (клонированный репо) или скачивание
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-
-if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then
- echo -e "${GREEN}[*] Копирование файлов из $SCRIPT_DIR/gotelegram-bot${NC}"
- cp -r "$SCRIPT_DIR/gotelegram-bot"/* "$BOT_DIR/"
-else
- # Приватный репо: клонируем по токену (raw URL с токеном для приватного не работает)
- if [ -n "$GITHUB_TOKEN" ] && command -v git &>/dev/null; then
- echo -e "${GREEN}[*] Клонирование приватного репозитория (HTTPS + токен)...${NC}"
- CLONE_DIR="/tmp/gotelegram_pro_fetch_$$"
- trap "rm -rf '$CLONE_DIR'" EXIT
- REPO_PATH="${REPO_URL#https://}"
- REPO_PATH="${REPO_PATH#http://}"
- git clone --depth 1 --branch "${GIT_BRANCH:-main}" "https://${GITHUB_TOKEN}@${REPO_PATH}" "$CLONE_DIR" 2>/dev/null || {
- echo -e "${RED}Клонирование не удалось. Проверьте GITHUB_TOKEN и доступ к репо.${NC}"
- exit 1
- }
- if [ -f "$CLONE_DIR/gotelegram-bot/bot.py" ]; then
- cp -r "$CLONE_DIR/gotelegram-bot"/* "$BOT_DIR/"
- fi
- rm -rf "$CLONE_DIR"
- trap - EXIT
- fi
- # Если файлов всё ещё нет — пробуем raw (только для публичного репо)
- if [ ! -f "$BOT_DIR/bot.py" ]; then
- echo -e "${YELLOW}[*] Скачивание файлов из репозитория (публичный доступ)...${NC}"
- for f in bot.py requirements.txt config.example.env; do
- if [ -n "$GITHUB_TOKEN" ]; then
- curl -sL -f -H "Authorization: token $GITHUB_TOKEN" \
- "$REPO_URL/raw/main/gotelegram-bot/$f" -o "$BOT_DIR/$f" 2>/dev/null || true
- fi
- [ ! -f "$BOT_DIR/$f" ] && curl -sL -f "$REPO_URL/raw/main/gotelegram-bot/$f" -o "$BOT_DIR/$f" 2>/dev/null || true
- done
- fi
- if [ ! -f "$BOT_DIR/bot.py" ]; then
- echo -e "${RED}Не удалось получить файлы бота.${NC}"
- echo -e " Для приватного репо: задайте GITHUB_TOKEN и убедитесь, что установлен git."
- echo -e " Или клонируйте репо вручную и запустите: sudo ./install_gotelegram_bot.sh"
- exit 1
- fi
-fi
-
-# venv и зависимости
-if [ ! -d "$BOT_DIR/venv" ]; then
- python3 -m venv "$BOT_DIR/venv"
-fi
-"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q
-
-# Конфиг
-if [ ! -f "$BOT_DIR/.env" ]; then
- echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}"
- TOKEN=""
- while [ -z "$TOKEN" ]; do
- read -r TOKEN
- TOKEN=$(echo "$TOKEN" | tr -d '[:space:]')
- [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым. Повторите:${NC}"
- done
- {
- echo "BOT_TOKEN=$TOKEN"
- [ -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
-cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
-[Unit]
-Description=GoTelegram MTProxy Bot
-After=network.target docker.service
-
-[Service]
-Type=simple
-WorkingDirectory=$BOT_DIR
-ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py
-Restart=always
-RestartSec=5
-Environment=PATH=$BOT_DIR/venv/bin:/usr/bin
-
-[Install]
-WantedBy=multi-user.target
-EOF
-
-systemctl daemon-reload
-systemctl enable "$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 -e "Логи: journalctl -u $SERVICE_NAME -f"
-echo -e "Настройки: $BOT_DIR/.env (BOT_TOKEN, ALLOWED_IDS)"
-exit 0
+#!/bin/bash
+# GoTelegram v2.2 — Установка Telegram-бота
+# Создаёт venv, ставит зависимости, настраивает systemd
+
+set -e
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+BOT_DIR="/opt/gotelegram-bot"
+SERVICE_NAME="gotelegram-bot"
+GOTELEGRAM_DIR="/opt/gotelegram"
+
+if [ "$EUID" -ne 0 ]; then
+ echo -e "${RED}Запустите с sudo.${NC}"
+ exit 1
+fi
+
+echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}"
+echo -e "${CYAN}║${NC} ${GREEN}GoTelegram v2.2 — Установка бота${NC} ${CYAN}║${NC}"
+echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}"
+echo ""
+
+# ── Python ───────────────────────────────────────────────────────────────────
+if ! command -v python3 &>/dev/null; then
+ echo -e "${YELLOW}[*] Установка python3...${NC}"
+ if command -v apt-get &>/dev/null; then
+ apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv
+ elif command -v dnf &>/dev/null; then
+ dnf install -y -q python3 python3-pip
+ elif command -v yum &>/dev/null; then
+ yum install -y -q python3 python3-pip
+ fi
+fi
+
+# ── Каталог бота ─────────────────────────────────────────────────────────────
+mkdir -p "$BOT_DIR"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then
+ echo -e "${GREEN}[*] Копирование файлов бота...${NC}"
+ cp "$SCRIPT_DIR/gotelegram-bot/bot.py" "$BOT_DIR/"
+ cp "$SCRIPT_DIR/gotelegram-bot/requirements.txt" "$BOT_DIR/"
+ [ -f "$SCRIPT_DIR/gotelegram-bot/config.example.env" ] && cp "$SCRIPT_DIR/gotelegram-bot/config.example.env" "$BOT_DIR/"
+else
+ echo -e "${RED}Файлы бота не найдены в $SCRIPT_DIR/gotelegram-bot/${NC}"
+ exit 1
+fi
+
+# Копируем каталог шаблонов
+if [ -f "$SCRIPT_DIR/templates_catalog.json" ]; then
+ mkdir -p "$GOTELEGRAM_DIR"
+ cp "$SCRIPT_DIR/templates_catalog.json" "$GOTELEGRAM_DIR/"
+ echo -e "${GREEN}[*] Каталог шаблонов скопирован${NC}"
+fi
+
+# ── Virtual environment ──────────────────────────────────────────────────────
+if [ ! -d "$BOT_DIR/venv" ]; then
+ echo -e "${GREEN}[*] Создание виртуального окружения...${NC}"
+ python3 -m venv "$BOT_DIR/venv"
+fi
+
+echo -e "${GREEN}[*] Установка зависимостей...${NC}"
+"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q
+
+# ── Конфигурация ─────────────────────────────────────────────────────────────
+if [ ! -f "$BOT_DIR/.env" ]; then
+ echo ""
+ echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}"
+ TOKEN=""
+ while [ -z "$TOKEN" ]; do
+ read -r TOKEN
+ TOKEN=$(echo "$TOKEN" | tr -d '[:space:]')
+ [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым.${NC}"
+ done
+
+ echo -ne "${YELLOW}ID администратора (Enter = доступ для всех):${NC} "
+ read -r ADMIN_ID
+
+ {
+ echo "BOT_TOKEN=$TOKEN"
+ [ -n "$ADMIN_ID" ] && echo "ALLOWED_IDS=$ADMIN_ID"
+ } > "$BOT_DIR/.env"
+
+ chmod 600 "$BOT_DIR/.env"
+ echo -e "${GREEN}[*] .env создан${NC}"
+else
+ echo -e "${GREEN}[*] .env уже существует${NC}"
+fi
+
+# ── Systemd ──────────────────────────────────────────────────────────────────
+cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF
+[Unit]
+Description=GoTelegram v2.2 Telegram Bot
+After=network.target
+
+[Service]
+Type=simple
+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
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+systemctl daemon-reload
+systemctl enable "$SERVICE_NAME"
+systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME"
+
+echo ""
+echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}"
+echo -e "${GREEN}║ ✅ Бот установлен и запущен! ║${NC}"
+echo -e "${GREEN}╚═══════════════════════════════════════════╝${NC}"
+echo ""
+echo -e "Проверка: ${CYAN}systemctl status $SERVICE_NAME${NC}"
+echo -e "Логи: ${CYAN}journalctl -u $SERVICE_NAME -f${NC}"
+echo -e "Настройки: ${CYAN}$BOT_DIR/.env${NC}"
+echo ""
+
+# Благодарности
+echo -e "${CYAN}─────────────────────────────────────────────${NC}"
+echo -e "💜 Спасибо авторам открытых проектов:"
+echo -e " ${CYAN}telemt${NC} — MTProxy engine (Rust)"
+echo -e " ${CYAN}HTML5 UP${NC} — шаблоны сайтов (CC BY 3.0)"
+echo -e " ${CYAN}learning-zone${NC} — 150+ HTML5 шаблонов"
+echo -e " ${CYAN}Start Bootstrap${NC} — Bootstrap шаблоны (MIT)"
+echo -e "${CYAN}─────────────────────────────────────────────${NC}"
diff --git a/lib/backup.sh b/lib/backup.sh
new file mode 100644
index 0000000..de60d8a
--- /dev/null
+++ b/lib/backup.sh
@@ -0,0 +1,341 @@
+#!/bin/bash
+# GoTelegram v2.2 — Бекап и восстановление конфигурации
+
+# ── Создание бекапа ──────────────────────────────────────────────────────────
+create_backup() {
+ local password="$1"
+ local output_dir="${2:-$BACKUP_DIR}"
+ local timestamp
+ timestamp=$(date +%Y%m%d_%H%M%S)
+ local backup_name="gotelegram_backup_${timestamp}"
+ local tmp_dir="/tmp/${backup_name}"
+
+ mkdir -p "$tmp_dir" "$output_dir"
+
+ # Собираем файлы
+ log_info "Собираю конфигурацию..."
+
+ # telemt конфиг
+ if [ -f "$TELEMT_CONFIG" ]; then
+ cp "$TELEMT_CONFIG" "$tmp_dir/config.toml"
+ fi
+
+ # GoTelegram конфиг
+ if [ -f "$GOTELEGRAM_CONFIG" ]; then
+ cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram.json"
+ fi
+
+ # nginx конфиг (stealth mode)
+ if [ -f "$NGINX_SITE_CONF" ]; then
+ cp "$NGINX_SITE_CONF" "$tmp_dir/nginx.conf"
+ fi
+
+ # SSL сертификаты
+ local domain
+ domain=$(config_get domain 2>/dev/null)
+ if [ -n "$domain" ] && [ -d "/etc/letsencrypt/live/$domain" ]; then
+ mkdir -p "$tmp_dir/certs"
+ cp "/etc/letsencrypt/live/$domain/fullchain.pem" "$tmp_dir/certs/" 2>/dev/null
+ cp "/etc/letsencrypt/live/$domain/privkey.pem" "$tmp_dir/certs/" 2>/dev/null
+ log_dim "SSL сертификаты включены"
+ fi
+
+ # Шаблон сайта (если есть)
+ if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then
+ mkdir -p "$tmp_dir/site"
+ cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/"
+ log_dim "Шаблон сайта включён"
+ fi
+
+ # Метаданные
+ local ip mode engine
+ ip=$(get_server_ip)
+ mode=$(config_get mode 2>/dev/null || echo "unknown")
+ engine=$(config_get engine 2>/dev/null || echo "telemt")
+
+ cat > "$tmp_dir/metadata.json" << EOMETA
+{
+ "backup_version": "1.0",
+ "gotelegram_version": "$GOTELEGRAM_VERSION",
+ "created_at": "$(date -Iseconds)",
+ "hostname": "$(hostname)",
+ "ip": "$ip",
+ "engine": "$engine",
+ "mode": "$mode",
+ "port": $(config_get port 2>/dev/null || echo "443"),
+ "domain": "$(config_get domain 2>/dev/null)"
+}
+EOMETA
+
+ # Архивируем
+ local tar_file="/tmp/${backup_name}.tar.gz"
+ if ! tar czf "$tar_file" -C /tmp "$backup_name" 2>/dev/null; then
+ log_error "Ошибка создания архива"
+ rm -rf "$tmp_dir"
+ rm -f "$tar_file"
+ return 1
+ fi
+
+ if [ ! -f "$tar_file" ]; then
+ log_error "Архив не создан"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ # Шифруем если задан пароль
+ local final_file=""
+ if [ -n "$password" ]; then
+ final_file="${output_dir}/${backup_name}.tar.gz.enc"
+ openssl enc -aes-256-cbc -salt -pbkdf2 -in "$tar_file" -out "$final_file" -pass "pass:${password}" 2>/dev/null
+ if [ $? -ne 0 ]; then
+ log_error "Ошибка шифрования"
+ rm -f "$tar_file"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+ rm -f "$tar_file"
+ log_success "Бекап зашифрован (AES-256-CBC)"
+ else
+ final_file="${output_dir}/${backup_name}.tar.gz"
+ mv "$tar_file" "$final_file"
+ fi
+
+ # SHA256 подпись
+ sha256sum "$final_file" > "${final_file}.sha256" 2>/dev/null
+
+ # Очистка
+ rm -rf "$tmp_dir"
+
+ local size
+ size=$(du -h "$final_file" | cut -f1)
+ log_success "Бекап создан: $final_file ($size)"
+ echo "$final_file"
+ return 0
+}
+
+# ── Восстановление из бекапа ────────────────────────────────────────────────
+restore_backup() {
+ local backup_file="$1"
+ local password="$2"
+
+ if [ ! -f "$backup_file" ]; then
+ log_error "Файл не найден: $backup_file"
+ return 1
+ fi
+
+ local tmp_dir="/tmp/gotelegram_restore_$$"
+ mkdir -p "$tmp_dir"
+
+ # Расшифровываем если нужно
+ local tar_file=""
+ if echo "$backup_file" | grep -q '\.enc$'; then
+ if [ -z "$password" ]; then
+ echo -ne " Введите пароль от бекапа: "
+ read -rs password
+ echo ""
+ fi
+ tar_file="/tmp/gotelegram_restore_$$.tar.gz"
+ openssl enc -aes-256-cbc -d -pbkdf2 -in "$backup_file" -out "$tar_file" -pass "pass:${password}" 2>/dev/null
+ if [ $? -ne 0 ]; then
+ log_error "Неверный пароль или повреждённый файл"
+ rm -rf "$tmp_dir" "$tar_file"
+ return 1
+ fi
+ else
+ tar_file="$backup_file"
+ fi
+
+ # Распаковываем
+ tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null
+ if [ $? -ne 0 ]; then
+ log_error "Ошибка распаковки архива"
+ rm -rf "$tmp_dir"
+ return 1
+ fi
+
+ # Находим папку бекапа
+ local backup_dir
+ backup_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "gotelegram_backup_*" | head -1)
+ [ -z "$backup_dir" ] && backup_dir="$tmp_dir"
+
+ # Проверяем метаданные
+ if [ -f "$backup_dir/metadata.json" ]; then
+ local bk_version bk_mode bk_ip
+ bk_version=$(jq -r '.gotelegram_version // "unknown"' "$backup_dir/metadata.json")
+ bk_mode=$(jq -r '.mode // "unknown"' "$backup_dir/metadata.json")
+ bk_ip=$(jq -r '.ip // "unknown"' "$backup_dir/metadata.json")
+ echo ""
+ echo -e " ${BOLD}${WHITE}📦 Бекап:${NC}"
+ echo -e " Версия: $bk_version | Режим: $bk_mode | IP: $bk_ip"
+ echo -e " Дата: $(jq -r '.created_at' "$backup_dir/metadata.json")"
+ echo ""
+ fi
+
+ if ! confirm "Восстановить конфигурацию? Текущие настройки будут перезаписаны."; then
+ rm -rf "$tmp_dir"
+ return 0
+ fi
+
+ # Останавливаем сервисы
+ stop_telemt 2>/dev/null
+ systemctl stop nginx 2>/dev/null
+
+ # Восстанавливаем telemt конфиг
+ if [ -f "$backup_dir/config.toml" ]; then
+ mkdir -p /etc/telemt
+ cp "$backup_dir/config.toml" "$TELEMT_CONFIG"
+ chmod 600 "$TELEMT_CONFIG"
+ log_success "telemt конфиг восстановлен"
+ fi
+
+ # Восстанавливаем GoTelegram конфиг
+ if [ -f "$backup_dir/gotelegram.json" ]; then
+ mkdir -p "$GOTELEGRAM_DIR"
+ cp "$backup_dir/gotelegram.json" "$GOTELEGRAM_CONFIG"
+ log_success "GoTelegram конфиг восстановлен"
+ fi
+
+ # Восстанавливаем nginx конфиг
+ if [ -f "$backup_dir/nginx.conf" ]; then
+ mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
+ cp "$backup_dir/nginx.conf" "$NGINX_SITE_CONF"
+ ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK"
+ log_success "nginx конфиг восстановлен"
+ fi
+
+ # Восстанавливаем SSL
+ if [ -d "$backup_dir/certs" ]; then
+ local domain
+ domain=$(config_get domain 2>/dev/null)
+ if [ -n "$domain" ]; then
+ local cert_dir="/etc/letsencrypt/live/$domain"
+ mkdir -p "$cert_dir"
+ cp "$backup_dir/certs/"* "$cert_dir/" 2>/dev/null
+ log_success "SSL сертификаты восстановлены"
+ fi
+ fi
+
+ # Восстанавливаем шаблон сайта
+ if [ -d "$backup_dir/site" ]; then
+ mkdir -p "$WEBSITE_ROOT"
+ cp -r "$backup_dir/site"/* "$WEBSITE_ROOT/"
+ chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null
+ log_success "Шаблон сайта восстановлен"
+ fi
+
+ # Запускаем сервисы
+ if is_telemt_installed; then
+ start_telemt
+ fi
+ systemctl start nginx 2>/dev/null
+
+ # Очистка
+ rm -rf "$tmp_dir"
+ [ "$tar_file" != "$backup_file" ] && rm -f "$tar_file"
+
+ log_success "Восстановление завершено!"
+ show_proxy_info
+ return 0
+}
+
+# ── Список бекапов ───────────────────────────────────────────────────────────
+list_backups() {
+ if [ ! -d "$BACKUP_DIR" ] || [ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then
+ log_info "Бекапов нет"
+ return 1
+ fi
+
+ echo ""
+ echo -e " ${BOLD}${WHITE}📦 Доступные бекапы:${NC}"
+ echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}"
+
+ local i=1
+ for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do
+ [ -f "$f" ] || continue
+ [[ "$f" == *.sha256 ]] && continue
+ local size date_str name
+ size=$(du -h "$f" | cut -f1)
+ name=$(basename "$f")
+ date_str=$(echo "$name" | grep -oE '[0-9]{8}_[0-9]{6}' | head -1)
+ local encrypted=""
+ [[ "$f" == *.enc ]] && encrypted=" 🔒"
+ echo -e " ${CYAN}${i})${NC} ${name} (${size})${encrypted}"
+ ((i++))
+ done
+ echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}"
+}
+
+# ── Очистка старых бекапов ───────────────────────────────────────────────────
+cleanup_old_backups() {
+ local keep="${1:-5}"
+ local count
+ count=$(find "$BACKUP_DIR" -name "gotelegram_backup_*.tar.gz*" ! -name "*.sha256" 2>/dev/null | wc -l)
+
+ if [ "$count" -gt "$keep" ]; then
+ local to_delete=$((count - keep))
+ find "$BACKUP_DIR" -name "gotelegram_backup_*.tar.gz*" ! -name "*.sha256" 2>/dev/null | sort | head -n "$to_delete" | while read -r f; do
+ rm -f "$f" "${f}.sha256"
+ done
+ log_dim "Удалено $to_delete старых бекапов (оставлено $keep)"
+ fi
+}
+
+# ── Интерактивный бекап ──────────────────────────────────────────────────────
+interactive_backup() {
+ echo ""
+ echo -e " ${BOLD}${WHITE}💾 Создание бекапа${NC}"
+ echo -ne " Зашифровать бекап паролем? [Y/n]: "
+ read -r use_pass
+
+ local password=""
+ if [[ ! "$use_pass" =~ ^[Nn] ]]; then
+ echo -ne " Введите пароль: "
+ read -rs password
+ echo ""
+ echo -ne " Повторите пароль: "
+ read -rs password2
+ echo ""
+ if [ "$password" != "$password2" ]; then
+ log_error "Пароли не совпадают"
+ return 1
+ fi
+ if [ ${#password} -lt 6 ]; then
+ log_error "Пароль слишком короткий (минимум 6 символов)"
+ return 1
+ fi
+ fi
+
+ create_backup "$password"
+ cleanup_old_backups
+}
+
+# ── Интерактивное восстановление ─────────────────────────────────────────────
+interactive_restore() {
+ list_backups || return 1
+
+ echo -ne " Номер бекапа (или путь к файлу): "
+ read -r choice
+
+ local backup_file=""
+ if [[ "$choice" =~ ^[0-9]+$ ]]; then
+ local i=1
+ for f in "$BACKUP_DIR"/gotelegram_backup_*.tar.gz*; do
+ [ -f "$f" ] || continue
+ [[ "$f" == *.sha256 ]] && continue
+ if [ "$i" -eq "$choice" ]; then
+ backup_file="$f"
+ break
+ fi
+ ((i++))
+ done
+ elif [ -f "$choice" ]; then
+ backup_file="$choice"
+ fi
+
+ if [ -z "$backup_file" ]; then
+ log_error "Бекап не найден"
+ return 1
+ fi
+
+ restore_backup "$backup_file"
+}
diff --git a/lib/common.sh b/lib/common.sh
new file mode 100644
index 0000000..2804498
--- /dev/null
+++ b/lib/common.sh
@@ -0,0 +1,456 @@
+#!/bin/bash
+# GoTelegram v2.2 — Общие утилиты
+# Цвета, логирование, спиннер, системные функции, совместимость с v1
+
+# ── Версия ────────────────────────────────────────────────────────────────────
+GOTELEGRAM_VERSION="2.2.0"
+GOTELEGRAM_NAME="GoTelegram"
+
+# ── Пути ──────────────────────────────────────────────────────────────────────
+GOTELEGRAM_DIR="/opt/gotelegram"
+GOTELEGRAM_CONFIG="$GOTELEGRAM_DIR/config.json"
+TELEMT_CONFIG="/etc/telemt/config.toml"
+TELEMT_BIN="/usr/local/bin/telemt"
+TELEMT_SERVICE="telemt"
+NGINX_SITE_CONF="/etc/nginx/sites-available/gotelegram"
+NGINX_SITE_LINK="/etc/nginx/sites-enabled/gotelegram"
+WEBSITE_ROOT="/var/www/gotelegram-site"
+BACKUP_DIR="$GOTELEGRAM_DIR/backups"
+LOG_FILE="/var/log/gotelegram.log"
+BOT_DIR="/opt/gotelegram-bot"
+
+# ── V1 совместимость ─────────────────────────────────────────────────────────
+V1_CONTAINER_NAME="mtproto-proxy"
+V1_CONFIG_FILE="/opt/gotelegram-bot/proxy.json"
+V1_SERVICE_NAME="gotelegram-bot"
+
+# ── Цвета ────────────────────────────────────────────────────────────────────
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+CYAN='\033[0;36m'
+YELLOW='\033[1;33m'
+MAGENTA='\033[0;35m'
+BLUE='\033[0;34m'
+WHITE='\033[1;37m'
+BOLD='\033[1m'
+DIM='\033[2m'
+NC='\033[0m'
+
+# ── Логирование ──────────────────────────────────────────────────────────────
+log_info() { echo -e " ${CYAN}ℹ${NC} $*"; }
+log_success() { echo -e " ${GREEN}✓${NC} $*"; }
+log_warning() { echo -e " ${YELLOW}⚠${NC} $*"; }
+log_error() { echo -e " ${RED}✗${NC} $*"; }
+log_step() { echo -e "\n${BOLD}${WHITE} $*${NC}"; }
+log_dim() { echo -e " ${DIM}$*${NC}"; }
+
+log_to_file() {
+ local ts; ts=$(date '+%Y-%m-%d %H:%M:%S')
+ echo "[$ts] $*" >> "$LOG_FILE" 2>/dev/null
+}
+
+# ── Спиннер ──────────────────────────────────────────────────────────────────
+_spin_pid=""
+spinner_start() {
+ local msg="${1:-Подождите...}"
+ (
+ local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
+ local i=0
+ while true; do
+ printf "\r ${CYAN}${frames[$i]}${NC} ${msg}" >&2
+ i=$(( (i+1) % ${#frames[@]} ))
+ sleep 0.1
+ done
+ ) &
+ _spin_pid=$!
+}
+
+spinner_stop() {
+ [ -n "$_spin_pid" ] && kill "$_spin_pid" 2>/dev/null && wait "$_spin_pid" 2>/dev/null
+ _spin_pid=""
+ printf "\r\033[K" >&2
+}
+
+# ── Прогресс-бар ─────────────────────────────────────────────────────────────
+progress_bar() {
+ local current="$1" total="$2" label="${3:-}"
+ local pct=$(( current * 100 / total ))
+ local filled=$(( pct / 2 ))
+ local empty=$(( 50 - filled ))
+ local bar=""
+ for ((i=0; i