diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 80b8654..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,29 +0,0 @@
-# Environment
-.env
-*.env.local
-
-# Python
-__pycache__/
-*.pyc
-venv/
-.venv/
-
-# Backups (contain secrets)
-backups/
-*.tar.gz
-*.tar.gz.enc
-*.sha256
-
-# Temp
-/tmp/
-*.tmp
-*.swp
-
-# IDE
-.vscode/
-.idea/
-*.code-workspace
-
-# OS
-.DS_Store
-Thumbs.db
diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py
index 4e73940..97fadbb 100644
--- a/gotelegram-bot/bot.py
+++ b/gotelegram-bot/bot.py
@@ -1,18 +1,22 @@
#!/usr/bin/env python3
"""
-GoTelegram v2.2 Bot - MTProxy Management for Linux
+GoTelegram v2.4 Bot - MTProxy Management for Linux
Manages telemt engine via Telegram interface with full CLI feature parity
Uses python-telegram-bot v21+
+Supports EN/RU UI with per-user language preferences.
"""
import asyncio
import csv
+import hashlib
import html
import json
import logging
import os
import re
+import shutil
import subprocess
+import sys
import time
import toml
from datetime import datetime
@@ -32,10 +36,56 @@ from telegram.ext import (
CommandHandler,
CallbackQueryHandler,
ContextTypes,
+ MessageHandler,
filters,
)
from telegram.error import TelegramError, BadRequest
+# i18n — loaded from the bot directory next to this file
+_BOT_DIR = Path(__file__).resolve().parent
+if str(_BOT_DIR) not in sys.path:
+ sys.path.insert(0, str(_BOT_DIR))
+try:
+ from i18n import (
+ t as _t,
+ tf as _tf,
+ get_user_lang,
+ set_user_lang,
+ get_language_name,
+ SUPPORTED_LANGS,
+ )
+except Exception as _i18n_err: # pragma: no cover — defensive fallback
+ logging.warning("i18n module not available: %s", _i18n_err)
+
+ def _t(user_id, key, default=None):
+ return default if default is not None else key
+
+ def _tf(user_id, key, *args, default=None):
+ template = default if default is not None else key
+ try:
+ return template % args if args else template
+ except Exception:
+ return template
+
+ def get_user_lang(user_id):
+ return "en"
+
+ def set_user_lang(user_id, code):
+ return False
+
+ def get_language_name(code):
+ return code
+
+ SUPPORTED_LANGS = ("en",)
+
+
+def _uid(update: Optional[Update]) -> Optional[int]:
+ """Extract user id from an update (if any)."""
+ if update is None:
+ return None
+ user = getattr(update, "effective_user", None)
+ return user.id if user else None
+
# Load environment variables
load_dotenv()
@@ -50,7 +100,7 @@ logger = logging.getLogger(__name__)
# CONFIGURATION
# ============================================================================
-GOTELEGRAM_VERSION = "2.3.1"
+GOTELEGRAM_VERSION = "2.4.0"
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt"
@@ -326,43 +376,44 @@ async def require_auth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bo
# ============================================================================
-def get_main_menu() -> InlineKeyboardMarkup:
- """Generate main menu keyboard."""
+def get_main_menu(user_id: Optional[int] = None) -> InlineKeyboardMarkup:
+ """Generate main menu keyboard localized for the given user."""
buttons = [
[
- InlineKeyboardButton("⚙️ Install", callback_data="menu_install"),
- InlineKeyboardButton("📊 Status", callback_data="menu_status"),
+ InlineKeyboardButton(_t(user_id, "menu_install"), callback_data="menu_install"),
+ InlineKeyboardButton(_t(user_id, "menu_status"), callback_data="menu_status"),
],
[
- InlineKeyboardButton("🔗 Link", callback_data="menu_link"),
- InlineKeyboardButton("📤 Share", callback_data="menu_share"),
+ InlineKeyboardButton(_t(user_id, "menu_link"), callback_data="menu_link"),
+ InlineKeyboardButton(_t(user_id, "menu_share"), callback_data="menu_share"),
],
[
- InlineKeyboardButton("🔄 Restart", callback_data="menu_restart"),
- InlineKeyboardButton("📋 Logs", callback_data="menu_logs"),
+ InlineKeyboardButton(_t(user_id, "menu_restart"), callback_data="menu_restart"),
+ InlineKeyboardButton(_t(user_id, "menu_logs"), callback_data="menu_logs"),
],
[
- InlineKeyboardButton("⚡ Change Mode/Template", callback_data="menu_change"),
- InlineKeyboardButton("💾 Backup", callback_data="menu_backup"),
+ InlineKeyboardButton(_t(user_id, "menu_change"), callback_data="menu_change"),
+ InlineKeyboardButton(_t(user_id, "menu_backup"), callback_data="menu_backup"),
],
[
- InlineKeyboardButton("↩️ Restore", callback_data="menu_restore"),
- InlineKeyboardButton("📡 Update telemt", callback_data="menu_update"),
+ InlineKeyboardButton(_t(user_id, "menu_restore"), callback_data="menu_restore"),
+ InlineKeyboardButton(_t(user_id, "menu_update"), callback_data="menu_update"),
],
[
- InlineKeyboardButton("🌐 Website/SSL", callback_data="menu_website"),
- InlineKeyboardButton("🎁 Промо", callback_data="menu_promo"),
+ InlineKeyboardButton(_t(user_id, "menu_website"), callback_data="menu_website"),
+ InlineKeyboardButton(_t(user_id, "menu_promo"), callback_data="menu_promo"),
],
[
- InlineKeyboardButton("📊 Traffic Stats", callback_data="menu_stats"),
- InlineKeyboardButton("🗑️ Remove", callback_data="menu_remove"),
+ InlineKeyboardButton(_t(user_id, "menu_stats"), callback_data="menu_stats"),
+ InlineKeyboardButton(_t(user_id, "menu_remove"), callback_data="menu_remove"),
],
[
- InlineKeyboardButton("👤 Админы", callback_data="menu_admins"),
- InlineKeyboardButton("ℹ️ Credits", callback_data="menu_credits"),
+ InlineKeyboardButton(_t(user_id, "menu_admins"), callback_data="menu_admins"),
+ InlineKeyboardButton(_t(user_id, "menu_credits"), callback_data="menu_credits"),
],
[
- InlineKeyboardButton("❌ Close", callback_data="close_menu"),
+ InlineKeyboardButton(_t(user_id, "menu_language"), callback_data="menu_lang"),
+ InlineKeyboardButton(_t(user_id, "menu_close"), callback_data="close_menu"),
],
]
return InlineKeyboardMarkup(buttons)
@@ -384,16 +435,13 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# ── Режим ожидания первого админа ──
if _WAITING_FOR_ADMIN:
name = user.full_name or user.username or str(user_id)
- text = (
- f"👋 Привет, {html.escape(name)}!\n\n"
- f"Бот ещё не настроен.\n"
- f"Ваш Telegram ID: {user_id}\n\n"
- f"Назначить вас администратором?"
- )
+ title = _tf(user_id, "waiting_admin_title", html.escape(name))
+ body = _tf(user_id, "waiting_admin_body", user_id)
+ text = f"{title}\n\n{body}"
keyboard = InlineKeyboardMarkup([
[
- InlineKeyboardButton("✅ Да", callback_data=f"admin_confirm_{user_id}"),
- InlineKeyboardButton("❌ Нет", callback_data="admin_cancel"),
+ InlineKeyboardButton(_t(user_id, "btn_yes"), callback_data=f"admin_confirm_{user_id}"),
+ InlineKeyboardButton(_t(user_id, "btn_no"), callback_data="admin_cancel"),
]
])
await update.message.reply_text(text, reply_markup=keyboard, parse_mode="HTML")
@@ -402,19 +450,19 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# ── Проверка доступа ──
if not is_user_allowed(user_id):
await update.message.reply_text(
- f"⛔ Доступ запрещён.\nВаш ID: {user_id}",
+ _tf(user_id, "access_denied", user_id),
parse_mode="HTML",
)
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:"
+ f"{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}\n\n"
+ f"{_t(user_id, 'welcome_subtitle')}\n"
+ f"{_t(user_id, 'welcome_powered')}\n\n"
+ f"{_t(user_id, 'welcome_prompt')}"
)
await update.message.reply_text(
- welcome, reply_markup=get_main_menu(), parse_mode="HTML"
+ welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML"
)
# Промо раз в сутки
@@ -429,27 +477,41 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Help command - show available commands."""
if not await require_auth(update, context):
return
-
+ user_id = _uid(update)
help_text = (
- "GoTelegram Bot — Команды\n\n"
- "/start — Главное меню\n"
- "/help — Эта справка\n"
- "/status — Быстрый статус\n"
- "/logs — Последние логи\n"
- "/addadmin ID — Добавить админа\n"
- "/deladmin ID — Удалить админа\n\n"
- "Используйте кнопки меню для остальных операций."
+ f"{_t(user_id, 'help_title')}\n\n"
+ f"{_t(user_id, 'help_lines')}"
)
await update.message.reply_text(help_text, parse_mode="HTML")
+async def cmd_lang(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show language picker."""
+ if not await require_auth(update, context):
+ return
+ user_id = _uid(update)
+ current = get_user_lang(user_id)
+ title = _t(user_id, "lang_title")
+ curr_line = _tf(user_id, "lang_current", get_language_name(current))
+ prompt = _t(user_id, "lang_choose")
+ text = f"{title}\n\n{curr_line}\n\n{prompt}"
+ keyboard = InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("🇬🇧 English", callback_data="lang_set_en"),
+ InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_set_ru"),
+ ],
+ [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")],
+ ])
+ await update.message.reply_text(text, reply_markup=keyboard, 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()
+ user_id = _uid(update)
+ await update.message.reply_text(_t(user_id, "status_checking"), parse_mode="HTML")
+ status_text = await get_status_text(user_id)
await update.message.reply_text(status_text, parse_mode="HTML")
@@ -457,6 +519,7 @@ async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show recent logs."""
if not await require_auth(update, context):
return
+ user_id = _uid(update)
code, stdout, stderr = await sh(
"journalctl", "-u", TELEMT_SERVICE, "-n", "20", "--no-pager"
@@ -468,7 +531,7 @@ async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
parse_mode="HTML",
)
else:
- await update.message.reply_text("Failed to retrieve logs")
+ await update.message.reply_text(_t(user_id, "logs_failed"))
# ============================================================================
@@ -476,38 +539,39 @@ async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# ============================================================================
-async def get_status_text() -> str:
- """Generate status report."""
- lines = ["📊 Current Status\n"]
+async def get_status_text(user_id: Optional[int] = None) -> str:
+ """Generate status report (localized)."""
+ lines = [f"{_t(user_id, 'status_title')}\n"]
# Service status
is_running = await check_service_status(TELEMT_SERVICE)
- lines.append(f"Service: {'✅ Running' if is_running else '❌ Stopped'}")
+ running = _t(user_id, "status_running") if is_running else _t(user_id, "status_stopped")
+ lines.append(f"{_t(user_id, 'status_service')}: {running}")
# Telemt version
version = await get_telemt_version()
- lines.append(f"Telemt: v{version}")
+ lines.append(f"{_t(user_id, 'status_telemt')}: v{version}")
# Config status
config = load_json(GOTELEGRAM_CONFIG)
if config:
- lines.append(f"Mode: {html.escape(str(config.get('mode', 'unknown')))}")
+ lines.append(f"{_t(user_id, 'status_mode')}: {html.escape(str(config.get('mode', 'unknown')))}")
if "template" in config:
- lines.append(f"Template: {html.escape(str(config['template']))}")
+ lines.append(f"{_t(user_id, 'status_template')}: {html.escape(str(config['template']))}")
if "domain" in config:
- lines.append(f"Domain: {html.escape(str(config['domain']))}")
+ lines.append(f"{_t(user_id, 'status_domain')}: {html.escape(str(config['domain']))}")
if "port" in config:
- lines.append(f"Port: {html.escape(str(config['port']))}")
+ lines.append(f"{_t(user_id, 'status_port')}: {html.escape(str(config['port']))}")
# Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...)
telemt_cfg = load_toml(TELEMT_CONFIG)
if telemt_cfg:
server_cfg = telemt_cfg.get("server", {})
if "port" in server_cfg:
- lines.append(f"Listen Port: {server_cfg['port']}")
+ lines.append(f"{_t(user_id, 'status_listen_port')}: {server_cfg['port']}")
censor_cfg = telemt_cfg.get("censorship", {})
if "tls_domain" in censor_cfg:
- lines.append(f"TLS Domain: {html.escape(str(censor_cfg['tls_domain']))}")
+ lines.append(f"{_t(user_id, 'status_tls_domain')}: {html.escape(str(censor_cfg['tls_domain']))}")
# Backups
backup_count = 0
@@ -627,7 +691,7 @@ async def cb_menu_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
keyboard = [
[InlineKeyboardButton("🔄 Обновить", callback_data="menu_stats")],
- [InlineKeyboardButton("« Меню", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
]
await safe_edit_message(
@@ -646,10 +710,10 @@ async def cb_menu_status(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
if not await require_auth(update, context):
return
- text = await get_status_text()
+ text = await get_status_text(_uid(update))
keyboard = [
[InlineKeyboardButton("🔄 Обновить", callback_data="menu_status")],
- [InlineKeyboardButton("« Меню", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
]
await safe_edit_message(
query,
@@ -669,7 +733,7 @@ def get_install_mode_menu() -> InlineKeyboardMarkup:
buttons = [
[InlineKeyboardButton("⚡ Lite", callback_data="install_mode_lite")],
[InlineKeyboardButton("🛡 Pro", callback_data="install_mode_pro")],
- [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
]
return InlineKeyboardMarkup(buttons)
@@ -693,7 +757,7 @@ async def cb_menu_install(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
buttons = [
[InlineKeyboardButton("🔄 Migrate from v1", callback_data="install_migrate")],
[InlineKeyboardButton("✨ Fresh Install", callback_data="install_mode_lite")],
- [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
]
keyboard = InlineKeyboardMarkup(buttons)
else:
@@ -761,7 +825,7 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
f"Service starting... Check status in 10 seconds."
)
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
@@ -790,7 +854,12 @@ async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE
)
return
+ user_id = _uid(update)
buttons = []
+ # First item: custom git template (matches CLI behaviour)
+ buttons.append([InlineKeyboardButton(
+ _t(user_id, "cg_title"), callback_data="pro_custom_git"
+ )])
for cat in catalog.get("categories", []):
buttons.append(
[
@@ -799,11 +868,134 @@ async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE
)
]
)
- buttons.append([InlineKeyboardButton("« Back", callback_data="menu_install")])
+ buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_install")])
- text = "Pro Mode - Select Template Category:"
+ text = "Pro Mode — Select Template Category:"
keyboard = InlineKeyboardMarkup(buttons)
- await safe_edit_message(query,text, reply_markup=keyboard)
+ await safe_edit_message(query, text, reply_markup=keyboard)
+
+
+# ── Custom git template input flow ──────────────────────────────────────────
+_CUSTOM_GIT_WAITERS: Dict[int, bool] = {}
+_CUSTOM_GIT_URL_RE = re.compile(r'^https://[A-Za-z0-9._~:/\-?#\[\]@!$&\'()*+,;=%]+(@[A-Za-z0-9._\-/]+)?$')
+_CUSTOM_GIT_MAX_MB = 100
+_CUSTOM_GIT_CLONE_TIMEOUT = 90
+
+
+def _validate_custom_git_url(url: str) -> bool:
+ if not url or len(url) > 512:
+ return False
+ # Block shell metacharacters explicitly
+ for bad in (" ", "`", "$", "(", ")", "<", ">", "|", "\\", "\t", "\n", "\r", ";", "&"):
+ if bad in url:
+ return False
+ if not url.lower().startswith("https://"):
+ return False
+ return True
+
+
+async def _download_custom_git_template(url_with_branch: str) -> Tuple[bool, str, str]:
+ """Clone a custom git repo and stage its static site under WEBSITE_ROOT.
+
+ Returns (ok, tpl_id, message_key_or_path).
+ """
+ # Parse @branch suffix (only when the branch appears on the last path segment)
+ branch = None
+ url = url_with_branch
+ if "@" in url_with_branch.rsplit("/", 1)[-1]:
+ base, _, maybe_branch = url_with_branch.rpartition("@")
+ if (
+ base.lower().startswith("https://")
+ and maybe_branch # reject empty branch after `@`
+ and "/" not in maybe_branch
+ and re.match(r'^[A-Za-z0-9._/\-]+$', maybe_branch)
+ ):
+ url = base
+ branch = maybe_branch
+
+ tpl_id = "custom_" + hashlib.md5(url_with_branch.encode("utf-8")).hexdigest()[:10]
+ target_dir = f"/opt/gotelegram/custom_templates/{tpl_id}"
+
+ # Clean previous copy
+ if os.path.isdir(target_dir):
+ shutil.rmtree(target_dir, ignore_errors=True)
+
+ tmp_dir = f"/tmp/{tpl_id}_clone"
+ if os.path.isdir(tmp_dir):
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+ cmd = ["git", "clone", "--depth", "1"]
+ if branch:
+ cmd += ["--branch", branch]
+ cmd += [url, tmp_dir]
+
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ _, err = await asyncio.wait_for(proc.communicate(), timeout=_CUSTOM_GIT_CLONE_TIMEOUT)
+ except asyncio.TimeoutError:
+ try:
+ proc.kill()
+ except ProcessLookupError:
+ pass
+ return False, tpl_id, "cg_timeout"
+ if proc.returncode != 0:
+ return False, tpl_id, "cg_invalid"
+ except Exception as e:
+ logger.warning("custom git clone failed: %s", e)
+ return False, tpl_id, "cg_invalid"
+
+ # Remove .git to enforce size guard and avoid leaking repo history
+ git_dir = os.path.join(tmp_dir, ".git")
+ if os.path.isdir(git_dir):
+ shutil.rmtree(git_dir, ignore_errors=True)
+
+ # Size guard
+ total = 0
+ for root, _dirs, files in os.walk(tmp_dir):
+ for f in files:
+ try:
+ total += os.path.getsize(os.path.join(root, f))
+ except OSError:
+ pass
+ if total > _CUSTOM_GIT_MAX_MB * 1024 * 1024:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ return False, tpl_id, "cg_too_big"
+
+ # Locate index.html in priority order
+ found_root = None
+ for sub in ("", "dist", "public", "build", "_site", "site", "docs", "out", "www"):
+ cand = os.path.join(tmp_dir, sub) if sub else tmp_dir
+ if os.path.isfile(os.path.join(cand, "index.html")):
+ found_root = cand
+ break
+ if not found_root:
+ # Fallback: search maxdepth 4
+ for root, _dirs, files in os.walk(tmp_dir):
+ depth = root[len(tmp_dir):].count(os.sep)
+ if depth > 4:
+ continue
+ if "index.html" in files:
+ found_root = root
+ break
+ if not found_root:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ return False, tpl_id, "cg_no_index"
+
+ # Stage final template dir
+ os.makedirs(os.path.dirname(target_dir), exist_ok=True)
+ shutil.copytree(found_root, target_dir)
+ try:
+ with open(os.path.join(target_dir, ".custom_git_source"), "w", encoding="utf-8") as f:
+ f.write(url_with_branch + "\n")
+ except OSError:
+ pass
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ return True, tpl_id, target_dir
async def cb_pro_category(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -924,7 +1116,7 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
f"Service starting... Check status in 10 seconds."
)
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
@@ -1000,7 +1192,7 @@ async def cb_menu_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
)
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -1014,7 +1206,7 @@ async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
if not link:
text = "❌ Proxy not installed yet."
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard)
return
@@ -1051,7 +1243,7 @@ async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
await safe_edit_message(query,
f"🔗 Proxy Link\n\n{html.escape(link)}",
reply_markup=InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
),
parse_mode="HTML",
)
@@ -1077,7 +1269,7 @@ async def cb_menu_restart(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
text = f"❌ Failed to restart:\n{html.escape(stderr[:500])}"
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
@@ -1100,7 +1292,7 @@ async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
text = "❌ Failed to retrieve logs"
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -1134,7 +1326,7 @@ async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
[InlineKeyboardButton("📋 List Backups", callback_data="backup_list")]
)
- buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")])
+ buttons.append([InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")])
text = f"💾 Backup Management\n\nExisting backups: {len(backups)}"
keyboard = InlineKeyboardMarkup(buttons)
@@ -1215,7 +1407,7 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
if not backups:
text = "❌ No backups available"
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
else:
text = "Select backup to restore:"
@@ -1228,7 +1420,7 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
)
]
)
- buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")])
+ buttons.append([InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")])
keyboard = InlineKeyboardMarkup(buttons)
# Store backup list in user_data for retrieval
context.user_data["backup_list"] = backups[:10]
@@ -1273,7 +1465,7 @@ async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE)
text = f"❌ Restore failed:\n{html.escape(stderr[:500])}"
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -1318,7 +1510,7 @@ async def cb_menu_update(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
text = "❌ Failed to parse release info"
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -1331,7 +1523,7 @@ async def cb_menu_change(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
buttons = [
[InlineKeyboardButton("⚡ Switch to Lite Mode", callback_data="change_lite")],
[InlineKeyboardButton("🛡 Switch to Pro Mode", callback_data="change_pro")],
- [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
]
keyboard = InlineKeyboardMarkup(buttons)
await safe_edit_message(query,
@@ -1392,7 +1584,7 @@ async def cb_menu_website(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
buttons = [
[InlineKeyboardButton("🔄 Renew SSL Certificate", callback_data="ssl_renew")],
[InlineKeyboardButton("📊 SSL Status", callback_data="ssl_status")],
- [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
]
keyboard = InlineKeyboardMarkup(buttons)
await safe_edit_message(query,
@@ -1464,7 +1656,7 @@ async def cb_menu_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
)
keyboard = InlineKeyboardMarkup([
- [InlineKeyboardButton("« Назад", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
])
await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
@@ -1599,7 +1791,7 @@ async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
await query.answer()
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Назад", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query, get_promo_text(), reply_markup=keyboard, parse_mode="HTML")
@@ -1627,7 +1819,7 @@ async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
)
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -1650,7 +1842,7 @@ async def cb_menu_remove(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
buttons = [
[InlineKeyboardButton("❌ Yes, Remove", callback_data="remove_confirm")],
- [InlineKeyboardButton("« Back", callback_data="menu_main")],
+ [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
]
keyboard = InlineKeyboardMarkup(buttons)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -1672,7 +1864,7 @@ async def cb_remove_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE)
text = "✅ GoTelegram removed successfully"
keyboard = InlineKeyboardMarkup(
- [[InlineKeyboardButton("« Back", callback_data="menu_main")]]
+ [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -1726,16 +1918,18 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer("Доступ запрещён")
return
+ user_id = update.effective_user.id
+
# Main menu
if data == "menu_main":
await query.answer()
- buttons = get_main_menu()
+ buttons = get_main_menu(user_id)
text = (
- f"GoTelegram v{GOTELEGRAM_VERSION}\n\n"
- "🤖 MTProxy Management\n"
- "Select an action:"
+ f"{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}\n\n"
+ f"{_t(user_id, 'welcome_subtitle')}\n"
+ f"{_t(user_id, 'welcome_prompt')}"
)
- await safe_edit_message(query,text, reply_markup=buttons, parse_mode="HTML")
+ await safe_edit_message(query, text, reply_markup=buttons, parse_mode="HTML")
return
if data == "close_menu":
@@ -1743,6 +1937,41 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.delete_message()
return
+ # Language picker
+ if data == "menu_lang":
+ await query.answer()
+ current = get_user_lang(user_id)
+ title = _t(user_id, "lang_title")
+ curr_line = _tf(user_id, "lang_current", get_language_name(current))
+ prompt = _t(user_id, "lang_choose")
+ text = f"{title}\n\n{curr_line}\n\n{prompt}"
+ keyboard = InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("🇬🇧 English", callback_data="lang_set_en"),
+ InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_set_ru"),
+ ],
+ [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")],
+ ])
+ await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML")
+ return
+
+ if data.startswith("lang_set_"):
+ code = data.replace("lang_set_", "", 1)
+ if code in SUPPORTED_LANGS:
+ set_user_lang(user_id, code)
+ await query.answer(_tf(user_id, "lang_saved", get_language_name(code)))
+ # Re-render main menu in the new language
+ buttons = get_main_menu(user_id)
+ text = (
+ f"{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}\n\n"
+ f"{_t(user_id, 'welcome_subtitle')}\n"
+ f"{_t(user_id, 'welcome_prompt')}"
+ )
+ await safe_edit_message(query, text, reply_markup=buttons, parse_mode="HTML")
+ else:
+ await query.answer("Unsupported language")
+ return
+
# Dispatch to handlers
handlers = {
"menu_install": cb_menu_install,
@@ -1773,6 +2002,22 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
"menu_stats": cb_menu_stats,
}
+ # Custom git template URL prompt
+ if data == "pro_custom_git":
+ await query.answer()
+ _CUSTOM_GIT_WAITERS[user_id] = True
+ title = _t(user_id, "cg_title")
+ body = _t(user_id, "cg_ask_url")
+ await safe_edit_message(
+ query,
+ f"{title}\n\n{body}",
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data="menu_main")]]
+ ),
+ parse_mode="HTML",
+ )
+ return
+
# Pattern-based handlers
if data.startswith("lite_dom_"):
await cb_lite_domain(update, context)
@@ -1795,6 +2040,37 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# ============================================================================
+async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle free-text input. Currently used for custom git template URLs."""
+ if update.message is None or update.message.text is None:
+ return
+ if not is_user_allowed(update.effective_user.id):
+ return
+ user_id = update.effective_user.id
+ # Only act when we're explicitly waiting for a custom-git URL
+ if not _CUSTOM_GIT_WAITERS.pop(user_id, False):
+ return
+ url = update.message.text.strip()
+ if not _validate_custom_git_url(url):
+ await update.message.reply_text(_t(user_id, "cg_invalid"), parse_mode="HTML")
+ return
+ await update.message.reply_text(_tf(user_id, "cg_cloning", html.escape(url)), parse_mode="HTML")
+ ok, tpl_id, info = await _download_custom_git_template(url)
+ if not ok:
+ await update.message.reply_text(_t(user_id, info), parse_mode="HTML")
+ return
+ # Success — record in GoTelegram config
+ config = load_json(GOTELEGRAM_CONFIG) or {}
+ config["template"] = tpl_id
+ config["template_source"] = url
+ save_json(GOTELEGRAM_CONFIG, config)
+ await update.message.reply_text(
+ _tf(user_id, "cg_ok_fmt", html.escape(tpl_id)),
+ reply_markup=get_main_menu(user_id),
+ parse_mode="HTML",
+ )
+
+
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)
@@ -1819,12 +2095,18 @@ def main() -> None:
application.add_handler(CommandHandler("help", cmd_help))
application.add_handler(CommandHandler("status", cmd_status))
application.add_handler(CommandHandler("logs", cmd_logs))
+ application.add_handler(CommandHandler("lang", cmd_lang))
application.add_handler(CommandHandler("addadmin", cmd_addadmin))
application.add_handler(CommandHandler("deladmin", cmd_deladmin))
# Callback query handler (buttons)
application.add_handler(CallbackQueryHandler(handle_callback))
+ # Text message handler (for custom git URL input)
+ application.add_handler(MessageHandler(
+ filters.TEXT & ~filters.COMMAND, handle_text_message
+ ))
+
# Error handler
application.add_error_handler(error_handler)
diff --git a/gotelegram-bot/i18n.py b/gotelegram-bot/i18n.py
new file mode 100644
index 0000000..83151fe
--- /dev/null
+++ b/gotelegram-bot/i18n.py
@@ -0,0 +1,142 @@
+"""
+GoTelegram v2.4 Bot — i18n module
+Provides per-user language preferences and a simple t()/tf() API.
+
+Usage:
+ from i18n import t, tf, set_user_lang, get_user_lang, get_language_name
+
+ msg = t(user_id, "menu_status")
+ msg = tf(user_id, "backup_created_fmt", filename)
+
+Language files live next to this module in lang/.json.
+Per-user choices are persisted to USER_LANG_FILE (one JSON dict: user_id -> code).
+"""
+
+import json
+import logging
+import os
+from pathlib import Path
+from typing import Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+# ── Paths ─────────────────────────────────────────────────────────────────
+_MODULE_DIR = Path(__file__).resolve().parent
+LANG_DIR = _MODULE_DIR / "lang"
+USER_LANG_FILE = Path("/opt/gotelegram-bot/user_langs.json")
+
+# Supported codes; keep in sync with lang/*.json
+SUPPORTED_LANGS = ("en", "ru")
+DEFAULT_LANG = os.getenv("BOT_LANG", "en").strip().lower() or "en"
+if DEFAULT_LANG not in SUPPORTED_LANGS:
+ DEFAULT_LANG = "en"
+
+LANG_NAMES = {
+ "en": "English",
+ "ru": "Русский",
+}
+
+# ── Caches ────────────────────────────────────────────────────────────────
+_LANG_CACHE: Dict[str, Dict[str, str]] = {}
+_USER_LANGS: Dict[int, str] = {}
+_USER_LANGS_LOADED = False
+
+
+def _load_lang_file(code: str) -> Dict[str, str]:
+ """Load lang/.json into the cache and return it."""
+ if code in _LANG_CACHE:
+ return _LANG_CACHE[code]
+ path = LANG_DIR / f"{code}.json"
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ if not isinstance(data, dict):
+ raise ValueError("lang file must contain a top-level object")
+ _LANG_CACHE[code] = data
+ return data
+ except FileNotFoundError:
+ logger.warning("lang file not found: %s", path)
+ except Exception as e:
+ logger.warning("failed to load %s: %s", path, e)
+ _LANG_CACHE[code] = {}
+ return _LANG_CACHE[code]
+
+
+def _load_user_langs() -> None:
+ """Load per-user language preferences from USER_LANG_FILE."""
+ global _USER_LANGS, _USER_LANGS_LOADED
+ _USER_LANGS_LOADED = True
+ try:
+ if USER_LANG_FILE.exists():
+ with open(USER_LANG_FILE, "r", encoding="utf-8") as f:
+ raw = json.load(f)
+ if isinstance(raw, dict):
+ _USER_LANGS = {
+ int(k): v for k, v in raw.items()
+ if isinstance(v, str) and v in SUPPORTED_LANGS
+ }
+ except Exception as e:
+ logger.warning("failed to load user_langs: %s", e)
+ _USER_LANGS = {}
+
+
+def _save_user_langs() -> None:
+ """Persist per-user language preferences."""
+ try:
+ USER_LANG_FILE.parent.mkdir(parents=True, exist_ok=True)
+ with open(USER_LANG_FILE, "w", encoding="utf-8") as f:
+ json.dump(
+ {str(k): v for k, v in _USER_LANGS.items()},
+ f, ensure_ascii=False, indent=2,
+ )
+ except Exception as e:
+ logger.warning("failed to save user_langs: %s", e)
+
+
+# ── Public API ────────────────────────────────────────────────────────────
+
+def get_user_lang(user_id: Optional[int]) -> str:
+ """Return the language code for the given user (or DEFAULT_LANG)."""
+ if not _USER_LANGS_LOADED:
+ _load_user_langs()
+ if user_id is None:
+ return DEFAULT_LANG
+ return _USER_LANGS.get(int(user_id), DEFAULT_LANG)
+
+
+def set_user_lang(user_id: int, code: str) -> bool:
+ """Set the per-user language preference and persist it."""
+ if not _USER_LANGS_LOADED:
+ _load_user_langs()
+ code = (code or "").strip().lower()
+ if code not in SUPPORTED_LANGS:
+ return False
+ _USER_LANGS[int(user_id)] = code
+ _save_user_langs()
+ return True
+
+
+def get_language_name(code: str) -> str:
+ return LANG_NAMES.get(code, code)
+
+
+def t(user_id: Optional[int], key: str, default: Optional[str] = None) -> str:
+ """Translate key for the given user. Falls back to English, then default/key."""
+ code = get_user_lang(user_id)
+ table = _load_lang_file(code)
+ if key in table:
+ return table[key]
+ if code != "en":
+ en_table = _load_lang_file("en")
+ if key in en_table:
+ return en_table[key]
+ return default if default is not None else key
+
+
+def tf(user_id: Optional[int], key: str, *args, default: Optional[str] = None) -> str:
+ """Format a translated string with positional args using %-formatting."""
+ template = t(user_id, key, default=default)
+ try:
+ return template % args if args else template
+ except (TypeError, ValueError):
+ return template
diff --git a/gotelegram-bot/lang/en.json b/gotelegram-bot/lang/en.json
new file mode 100644
index 0000000..5ae9002
--- /dev/null
+++ b/gotelegram-bot/lang/en.json
@@ -0,0 +1,116 @@
+{
+ "lang_english": "English",
+ "lang_russian": "Русский",
+ "lang_title": "🌐 Language",
+ "lang_current": "Current language: %s",
+ "lang_saved": "Language saved: %s",
+ "lang_choose": "Choose your language:",
+
+ "welcome_title": "GoTelegram v%s",
+ "welcome_subtitle": "🤖 MTProxy Management Bot",
+ "welcome_powered": "Powered by telemt engine",
+ "welcome_prompt": "Select an action from the menu below:",
+
+ "waiting_admin_title": "👋 Hi, %s!",
+ "waiting_admin_body": "The bot is not configured yet.\nYour Telegram ID: %s\n\nAssign you as administrator?",
+ "btn_yes": "✅ Yes",
+ "btn_no": "❌ No",
+
+ "access_denied": "⛔ Access denied.\nYour ID: %s",
+ "help_title": "GoTelegram Bot — Commands",
+ "help_lines": "/start — Main menu\n/help — This help\n/status — Quick status\n/logs — Latest logs\n/lang — Change language\n/addadmin ID — Add admin\n/deladmin ID — Remove admin\n\nUse the menu buttons for other operations.",
+
+ "menu_install": "⚙️ Install",
+ "menu_status": "📊 Status",
+ "menu_link": "🔗 Link",
+ "menu_share": "📤 Share",
+ "menu_restart": "🔄 Restart",
+ "menu_logs": "📋 Logs",
+ "menu_change": "⚡ Change Mode/Template",
+ "menu_backup": "💾 Backup",
+ "menu_restore": "↩️ Restore",
+ "menu_update": "📡 Update telemt",
+ "menu_website": "🌐 Website/SSL",
+ "menu_promo": "🎁 Promo",
+ "menu_stats": "📊 Traffic Stats",
+ "menu_remove": "🗑️ Remove",
+ "menu_admins": "👤 Admins",
+ "menu_credits": "ℹ️ Credits",
+ "menu_language": "🌐 Language",
+ "menu_close": "❌ Close",
+ "btn_back": "⬅️ Back",
+ "btn_refresh": "🔄 Refresh",
+ "btn_cancel": "❌ Cancel",
+ "btn_confirm": "✅ Confirm",
+
+ "status_checking": "⏳ Checking status...",
+ "status_title": "📊 Current Status",
+ "status_service": "Service",
+ "status_running": "✅ Running",
+ "status_stopped": "❌ Stopped",
+ "status_telemt": "Telemt",
+ "status_mode": "Mode",
+ "status_template": "Template",
+ "status_domain": "Domain",
+ "status_port": "Port",
+ "status_listen_port": "Listen Port",
+ "status_tls_domain": "TLS Domain",
+
+ "logs_failed": "Failed to retrieve logs",
+ "link_fetching": "⏳ Fetching link...",
+ "link_unavailable": "Link unavailable (proxy not installed?)",
+ "share_title": "📤 Share Proxy",
+ "share_body": "Send this link to your client:",
+
+ "restart_title": "🔄 Restart",
+ "restart_progress": "⏳ Restarting telemt...",
+ "restart_ok": "✅ telemt restarted",
+ "restart_fail": "❌ Restart failed",
+
+ "install_title": "⚙️ Install / Update",
+ "install_pick_mode": "Select installation mode:",
+ "install_mode_lite": "🚀 Lite (quick, no site)",
+ "install_mode_pro": "🎨 Pro (stealth + website)",
+
+ "backup_title": "💾 Backup",
+ "backup_creating": "⏳ Creating backup...",
+ "backup_created_fmt": "✅ Backup created: %s",
+ "backup_failed": "❌ Backup creation failed",
+ "backup_list_title": "Available backups:",
+ "backup_none": "No backups yet",
+ "backup_restore_title": "↩️ Restore backup",
+ "backup_restoring": "⏳ Restoring...",
+ "backup_restored": "✅ Backup restored",
+
+ "update_title": "📡 Update telemt",
+ "update_progress": "⏳ Updating telemt binary...",
+ "update_ok": "✅ telemt updated",
+ "update_fail": "❌ Update failed",
+
+ "website_title": "🌐 Website / SSL",
+ "ssl_renew_progress": "⏳ Renewing SSL...",
+ "ssl_renewed": "✅ SSL renewed",
+ "ssl_renew_fail": "❌ SSL renew failed",
+ "ssl_status_title": "🔒 SSL Status",
+
+ "remove_title": "🗑️ Remove",
+ "remove_warn": "⚠️ This will stop and remove telemt, nginx site and configs. Continue?",
+ "remove_progress": "⏳ Removing...",
+ "remove_done": "✅ Removed",
+
+ "admins_title": "👤 Administrators",
+ "admins_list": "Current admin IDs:",
+ "admins_empty": "No admins configured",
+
+ "promo_title": "🎁 Promo",
+ "credits_title": "ℹ️ Credits",
+
+ "cg_title": "🔗 Custom Git Template",
+ "cg_ask_url": "Send me the HTTPS git URL of a static site repository.\nOptionally append @branch.",
+ "cg_cloning": "⏳ Cloning %s ...",
+ "cg_invalid": "❌ Invalid URL. Only HTTPS git URLs are allowed.",
+ "cg_timeout": "❌ Clone timeout (repository too large or slow)",
+ "cg_too_big": "❌ Repository too large (>100MB)",
+ "cg_no_index": "❌ No index.html found in repository",
+ "cg_ok_fmt": "✅ Custom template downloaded: %s"
+}
diff --git a/gotelegram-bot/lang/ru.json b/gotelegram-bot/lang/ru.json
new file mode 100644
index 0000000..a8a195f
--- /dev/null
+++ b/gotelegram-bot/lang/ru.json
@@ -0,0 +1,116 @@
+{
+ "lang_english": "English",
+ "lang_russian": "Русский",
+ "lang_title": "🌐 Язык",
+ "lang_current": "Текущий язык: %s",
+ "lang_saved": "Язык сохранён: %s",
+ "lang_choose": "Выберите язык:",
+
+ "welcome_title": "GoTelegram v%s",
+ "welcome_subtitle": "🤖 Бот управления MTProxy",
+ "welcome_powered": "На базе движка telemt",
+ "welcome_prompt": "Выберите действие в меню ниже:",
+
+ "waiting_admin_title": "👋 Привет, %s!",
+ "waiting_admin_body": "Бот ещё не настроен.\nВаш Telegram ID: %s\n\nНазначить вас администратором?",
+ "btn_yes": "✅ Да",
+ "btn_no": "❌ Нет",
+
+ "access_denied": "⛔ Доступ запрещён.\nВаш ID: %s",
+ "help_title": "GoTelegram Bot — Команды",
+ "help_lines": "/start — Главное меню\n/help — Эта справка\n/status — Быстрый статус\n/logs — Последние логи\n/lang — Сменить язык\n/addadmin ID — Добавить админа\n/deladmin ID — Удалить админа\n\nИспользуйте кнопки меню для остальных операций.",
+
+ "menu_install": "⚙️ Установить",
+ "menu_status": "📊 Статус",
+ "menu_link": "🔗 Ссылка",
+ "menu_share": "📤 Поделиться",
+ "menu_restart": "🔄 Перезапуск",
+ "menu_logs": "📋 Логи",
+ "menu_change": "⚡ Сменить режим/шаблон",
+ "menu_backup": "💾 Бекап",
+ "menu_restore": "↩️ Восстановить",
+ "menu_update": "📡 Обновить telemt",
+ "menu_website": "🌐 Сайт/SSL",
+ "menu_promo": "🎁 Промо",
+ "menu_stats": "📊 Трафик",
+ "menu_remove": "🗑️ Удалить",
+ "menu_admins": "👤 Админы",
+ "menu_credits": "ℹ️ О проекте",
+ "menu_language": "🌐 Язык",
+ "menu_close": "❌ Закрыть",
+ "btn_back": "⬅️ Назад",
+ "btn_refresh": "🔄 Обновить",
+ "btn_cancel": "❌ Отмена",
+ "btn_confirm": "✅ Подтвердить",
+
+ "status_checking": "⏳ Проверяю статус...",
+ "status_title": "📊 Текущий статус",
+ "status_service": "Сервис",
+ "status_running": "✅ Работает",
+ "status_stopped": "❌ Остановлен",
+ "status_telemt": "Telemt",
+ "status_mode": "Режим",
+ "status_template": "Шаблон",
+ "status_domain": "Домен",
+ "status_port": "Порт",
+ "status_listen_port": "Порт прослушивания",
+ "status_tls_domain": "TLS домен",
+
+ "logs_failed": "Не удалось получить логи",
+ "link_fetching": "⏳ Получаю ссылку...",
+ "link_unavailable": "Ссылка недоступна (прокси не установлен?)",
+ "share_title": "📤 Поделиться прокси",
+ "share_body": "Отправьте эту ссылку клиенту:",
+
+ "restart_title": "🔄 Перезапуск",
+ "restart_progress": "⏳ Перезапускаю telemt...",
+ "restart_ok": "✅ telemt перезапущен",
+ "restart_fail": "❌ Ошибка перезапуска",
+
+ "install_title": "⚙️ Установка / Обновление",
+ "install_pick_mode": "Выберите режим установки:",
+ "install_mode_lite": "🚀 Lite (быстро, без сайта)",
+ "install_mode_pro": "🎨 Pro (stealth + сайт)",
+
+ "backup_title": "💾 Бекап",
+ "backup_creating": "⏳ Создаю бекап...",
+ "backup_created_fmt": "✅ Бекап создан: %s",
+ "backup_failed": "❌ Не удалось создать бекап",
+ "backup_list_title": "Доступные бекапы:",
+ "backup_none": "Бекапов пока нет",
+ "backup_restore_title": "↩️ Восстановление бекапа",
+ "backup_restoring": "⏳ Восстанавливаю...",
+ "backup_restored": "✅ Бекап восстановлен",
+
+ "update_title": "📡 Обновление telemt",
+ "update_progress": "⏳ Обновляю telemt...",
+ "update_ok": "✅ telemt обновлён",
+ "update_fail": "❌ Ошибка обновления",
+
+ "website_title": "🌐 Сайт / SSL",
+ "ssl_renew_progress": "⏳ Обновляю SSL...",
+ "ssl_renewed": "✅ SSL обновлён",
+ "ssl_renew_fail": "❌ Ошибка обновления SSL",
+ "ssl_status_title": "🔒 Статус SSL",
+
+ "remove_title": "🗑️ Удаление",
+ "remove_warn": "⚠️ Это остановит и удалит telemt, сайт nginx и конфиги. Продолжить?",
+ "remove_progress": "⏳ Удаляю...",
+ "remove_done": "✅ Удалено",
+
+ "admins_title": "👤 Администраторы",
+ "admins_list": "Текущие ID админов:",
+ "admins_empty": "Админы не настроены",
+
+ "promo_title": "🎁 Промо",
+ "credits_title": "ℹ️ О проекте",
+
+ "cg_title": "🔗 Свой git-шаблон",
+ "cg_ask_url": "Отправьте HTTPS git-URL репозитория со статическим сайтом.\nПри желании добавьте @branch.",
+ "cg_cloning": "⏳ Клонирую %s ...",
+ "cg_invalid": "❌ Неверный URL. Разрешены только HTTPS git-URL.",
+ "cg_timeout": "❌ Таймаут клонирования (репозиторий слишком большой или медленный)",
+ "cg_too_big": "❌ Репозиторий слишком большой (>100МБ)",
+ "cg_no_index": "❌ В репозитории не найден index.html",
+ "cg_ok_fmt": "✅ Свой шаблон загружен: %s"
+}
diff --git a/install.sh b/install.sh
index 33a54c0..0bab9c0 100755
--- a/install.sh
+++ b/install.sh
@@ -1,20 +1,21 @@
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════════════
-# GoTelegram v2.3.0 — MTProxy на ядре telemt (Rust + Tokio)
-# Anti-DPI • Fake TLS • TCP Splice • JA3/JA4 Resistance
+# GoTelegram v2.4.0 — MTProxy powered by telemt (Rust + Tokio)
+# Anti-DPI • Fake TLS • TCP Splice • JA3/JA4 Resistance • i18n (EN/RU)
#
-# Установка:
+# Install:
# curl -sL URL/install.sh | sudo bash
# ══════════════════════════════════════════════════════════════════════════════
set -uo pipefail
-# Путь к скрипту и библиотекам
+# Script path and libraries
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
LIB_DIR="$SCRIPT_DIR/lib"
-# Загружаем библиотеки
+# Load libraries
source "$LIB_DIR/common.sh"
+source "$LIB_DIR/i18n.sh"
source "$LIB_DIR/telemt.sh"
source "$LIB_DIR/telemt_config.sh"
source "$LIB_DIR/website.sh"
@@ -22,6 +23,9 @@ source "$LIB_DIR/templates_catalog.sh"
source "$LIB_DIR/backup.sh"
[ -f "$LIB_DIR/stats.sh" ] && source "$LIB_DIR/stats.sh"
+# Load language (from config.json or marker file, default en)
+load_language "$(detect_language)"
+
# ── Главное меню (Compact Dashboard + 5 Top-Level Items) ──────────────────────
show_main_menu() {
local proxy_status bot_status nginx_st mode domain secret port ip link ssl_expiry
@@ -38,13 +42,13 @@ show_main_menu() {
local line; line=$(printf '━%.0s' $(seq 1 $W))
local line2; line2=$(printf '─%.0s' $(seq 1 $W))
- # ── Заголовок (без правого бордера — ANSI ломает выравнивание) ──
+ # ── Header (no right border — ANSI breaks alignment) ──
echo ""
echo -e " ${BOLD}${CYAN}━${line}━${NC}"
- echo -e " ${BOLD}${WHITE} GoTelegram v${GOTELEGRAM_VERSION}${NC} ${DIM}— Панель управления${NC}"
+ echo -e " ${BOLD}${WHITE} GoTelegram v${GOTELEGRAM_VERSION}${NC} ${DIM}— $(t dashboard_title)${NC}"
echo -e " ${BOLD}${CYAN}━${line}━${NC}"
- # ── Здоровье сервисов ──
+ # ── Service health ──
echo ""
echo -e " ${DIM}${line2}${NC}"
@@ -55,7 +59,7 @@ show_main_menu() {
stopped) proxy_icon="○"; proxy_color="${YELLOW}" ;;
*) proxy_icon="✗"; proxy_color="${RED}" ;;
esac
- echo -e " ${proxy_color}${proxy_icon}${NC} Прокси ${proxy_color}${proxy_status}${NC} ${DIM}(telemt ${mode})${NC}"
+ echo -e " ${proxy_color}${proxy_icon}${NC} $(t svc_proxy) ${proxy_color}${proxy_status}${NC} ${DIM}(telemt ${mode})${NC}"
# nginx
local nginx_icon nginx_color
@@ -63,7 +67,7 @@ show_main_menu() {
running) nginx_icon="●"; nginx_color="${GREEN}" ;;
*) nginx_icon="✗"; nginx_color="${RED}" ;;
esac
- echo -e " ${nginx_icon}${nginx_color}${NC} nginx ${nginx_color}${nginx_st}${NC} ${DIM}(127.0.0.1:8443)${NC}"
+ echo -e " ${nginx_icon}${nginx_color}${NC} $(t svc_nginx) ${nginx_color}${nginx_st}${NC} ${DIM}(127.0.0.1:8443)${NC}"
# Site (pro)
if [ "$mode" = "pro" ] && [ -n "$domain" ]; then
@@ -73,29 +77,29 @@ show_main_menu() {
else
site_icon="✗"; site_color="${RED}"
fi
- echo -e " ${site_color}${site_icon}${NC} Сайт ${site_color}https://${domain}${NC}"
+ echo -e " ${site_color}${site_icon}${NC} $(t svc_site) ${site_color}https://${domain}${NC}"
ssl_expiry=$(get_ssl_expiry "$domain" 2>/dev/null || echo "N/A")
- echo -e " ${GREEN}●${NC} SSL ${DIM}до ${ssl_expiry}${NC}"
+ echo -e " ${GREEN}●${NC} $(t svc_ssl) ${DIM}$(tf ssl_until "$ssl_expiry")${NC}"
fi
# Bot
case "$bot_status" in
- running) echo -e " ${GREEN}●${NC} Бот ${GREEN}running${NC}" ;;
- stopped) echo -e " ${YELLOW}○${NC} Бот ${YELLOW}stopped${NC}" ;;
+ running) echo -e " ${GREEN}●${NC} $(t svc_bot) ${GREEN}$(t running)${NC}" ;;
+ stopped) echo -e " ${YELLOW}○${NC} $(t svc_bot) ${YELLOW}$(t stopped)${NC}" ;;
esac
echo -e " ${DIM}${line2}${NC}"
- # ── Сетевые параметры ──
- echo -e " ${WHITE}IP:${NC} ${CYAN}${ip}${NC} ${WHITE}Порт:${NC} ${CYAN}${port}${NC} ${WHITE}Режим:${NC} ${CYAN}${mode}${NC}"
+ # ── Network parameters ──
+ echo -e " ${WHITE}$(t net_ip)${NC} ${CYAN}${ip}${NC} ${WHITE}$(t net_port)${NC} ${CYAN}${port}${NC} ${WHITE}$(t net_mode)${NC} ${CYAN}${mode}${NC}"
if [ -n "$domain" ]; then
- echo -e " ${WHITE}Домен:${NC} ${CYAN}${domain}${NC}"
+ echo -e " ${WHITE}$(t net_domain)${NC} ${CYAN}${domain}${NC}"
fi
echo -e " ${DIM}${line2}${NC}"
- # ── Прокси-ссылка + QR ──
+ # ── Proxy link + QR ──
local mask_host
mask_host=$(config_get mask_host 2>/dev/null || echo "")
if [ -n "$secret" ] && [ "$proxy_status" = "running" ]; then
@@ -105,7 +109,7 @@ show_main_menu() {
link=$(generate_proxy_link "$ip" "$port" "$secret" "$mask_host")
fi
- echo -e " ${BOLD}${WHITE}Ссылка для Telegram:${NC}"
+ echo -e " ${BOLD}${WHITE}$(t connection_link)${NC}"
echo -e " ${GREEN}${link}${NC}"
if command -v qrencode &>/dev/null; then
@@ -116,39 +120,39 @@ show_main_menu() {
echo ""
fi
else
- echo -e " ${DIM}Прокси не настроен. Выберите пункт 1.${NC}"
+ echo -e " ${DIM}$(t proxy_not_configured)${NC}"
echo ""
fi
- # ── Меню ──
+ # ── Menu ──
echo -e " ${DIM}${line2}${NC}"
- echo -e " ${CYAN}1${NC}) Прокси ▸"
- echo -e " ${CYAN}2${NC}) Статистика ▸"
- echo -e " ${CYAN}3${NC}) Управление ▸"
- echo -e " ${CYAN}4${NC}) Telegram-бот ▸"
- echo -e " ${CYAN}5${NC}) О программе ▸"
- echo -e " ${CYAN}0${NC}) ${DIM}Выход${NC}"
+ echo -e " ${CYAN}1${NC}) $(t menu_proxy)"
+ echo -e " ${CYAN}2${NC}) $(t menu_stats)"
+ echo -e " ${CYAN}3${NC}) $(t menu_manage)"
+ echo -e " ${CYAN}4${NC}) $(t menu_telegram_bot)"
+ echo -e " ${CYAN}5${NC}) $(t menu_about)"
+ echo -e " ${CYAN}0${NC}) ${DIM}$(t exit)${NC}"
echo -e " ${DIM}${line2}${NC}"
- echo -e " ${DIM}Обновление через 30 сек${NC}"
+ echo -e " ${DIM}$(t auto_refresh_30s)${NC}"
echo -ne " ${WHITE}▸ ${NC}"
}
-# ── Подменю: Прокси ──────────────────────────────────────────────────────────
+# ── Submenu: Proxy ──────────────────────────────────────────────────────────
submenu_proxy() {
while true; do
echo ""
- echo -e " ${BOLD}${WHITE}🚀 ПРОКСИ${NC}"
+ echo -e " ${BOLD}${WHITE}$(t submenu_proxy_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${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}0${NC}) « Назад"
+ echo -e " ${CYAN}1${NC}) $(t proxy_install_update)"
+ echo -e " ${CYAN}2${NC}) $(t proxy_status_detail)"
+ echo -e " ${CYAN}3${NC}) $(t proxy_copy_link)"
+ echo -e " ${CYAN}4${NC}) $(t proxy_share)"
+ echo -e " ${CYAN}5${NC}) $(t proxy_restart)"
+ echo -e " ${CYAN}6${NC}) $(t proxy_logs)"
+ echo -e " ${CYAN}7${NC}) $(t proxy_change_mode)"
+ echo -e " ${CYAN}0${NC}) $(t back)"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}"
- echo -ne " ${WHITE}Выбор:${NC} "
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r ch
case "$ch" in
@@ -160,29 +164,30 @@ submenu_proxy() {
6) menu_logs ;;
7) menu_change_mode ;;
0) break ;;
- *) log_error "Неверный выбор" ;;
+ *) log_error "$(t invalid_choice)" ;;
esac
echo ""
- echo -ne " ${DIM}Нажмите Enter...${NC}"
+ echo -ne " ${DIM}$(t press_enter)${NC}"
read -r
done
}
-# ── Подменю: Управление ──────────────────────────────────────────────────────
+# ── Submenu: Management ─────────────────────────────────────────────────────
submenu_manage() {
while true; do
echo ""
- echo -e " ${BOLD}${WHITE}⚙️ УПРАВЛЕНИЕ${NC}"
+ echo -e " ${BOLD}${WHITE}$(t submenu_manage_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}"
- echo -e " ${CYAN}1${NC}) Бекап"
- echo -e " ${CYAN}2${NC}) Восстановить"
- echo -e " ${CYAN}3${NC}) Обновить telemt"
- echo -e " ${CYAN}4${NC}) Сайт / SSL"
- echo -e " ${CYAN}5${NC}) Удалить"
- echo -e " ${CYAN}0${NC}) « Назад"
+ echo -e " ${CYAN}1${NC}) $(t manage_backup)"
+ echo -e " ${CYAN}2${NC}) $(t manage_restore)"
+ echo -e " ${CYAN}3${NC}) $(t manage_update_telemt)"
+ echo -e " ${CYAN}4${NC}) $(t manage_site_ssl)"
+ echo -e " ${CYAN}5${NC}) $(t manage_remove)"
+ echo -e " ${CYAN}6${NC}) $(t manage_language)"
+ echo -e " ${CYAN}0${NC}) $(t back)"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}"
- echo -ne " ${WHITE}Выбор:${NC} "
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r ch
case "$ch" in
@@ -191,61 +196,62 @@ submenu_manage() {
3) update_telemt ;;
4) menu_website ;;
5) menu_remove ;;
+ 6) menu_language ;;
0) break ;;
- *) log_error "Неверный выбор" ;;
+ *) log_error "$(t invalid_choice)" ;;
esac
echo ""
- echo -ne " ${DIM}Нажмите Enter...${NC}"
+ echo -ne " ${DIM}$(t press_enter)${NC}"
read -r
done
}
-# ── Подменю: О программе ─────────────────────────────────────────────────────
+# ── Submenu: About ──────────────────────────────────────────────────────────
submenu_about() {
while true; do
echo ""
- echo -e " ${BOLD}${WHITE}ℹ️ О ПРОГРАММЕ${NC}"
+ echo -e " ${BOLD}${WHITE}$(t submenu_about_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}"
- echo -e " ${CYAN}1${NC}) Информация о версии"
- echo -e " ${CYAN}2${NC}) Промо / Донат"
- echo -e " ${CYAN}0${NC}) « Назад"
+ echo -e " ${CYAN}1${NC}) $(t about_version_info)"
+ echo -e " ${CYAN}2${NC}) $(t about_promo)"
+ echo -e " ${CYAN}0${NC}) $(t back)"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}"
- echo -ne " ${WHITE}Выбор:${NC} "
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r ch
case "$ch" in
1) menu_version ;;
2) menu_promo ;;
0) break ;;
- *) log_error "Неверный выбор" ;;
+ *) log_error "$(t invalid_choice)" ;;
esac
echo ""
- echo -ne " ${DIM}Нажмите Enter...${NC}"
+ echo -ne " ${DIM}$(t press_enter)${NC}"
read -r
done
}
-# ── Информация о версии ──────────────────────────────────────────────────────
+# ── Version info ────────────────────────────────────────────────────────────
menu_version() {
echo ""
- echo -e " ${BOLD}${WHITE}🔍 Информация${NC}"
+ echo -e " ${BOLD}${WHITE}$(t version_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}"
- echo -e " ${WHITE}GoTelegram:${NC} v${GOTELEGRAM_VERSION}"
- echo -e " ${WHITE}Ядро:${NC} telemt (Rust + Tokio)"
- echo -e " ${WHITE}Технология:${NC} Anti-DPI, Fake TLS, TCP Splice"
- echo -e " ${WHITE}Лицензия:${NC} MIT"
+ echo -e " ${WHITE}$(t version_label)${NC} v${GOTELEGRAM_VERSION}"
+ echo -e " ${WHITE}$(t version_engine)${NC} telemt (Rust + Tokio)"
+ echo -e " ${WHITE}$(t version_tech)${NC} Anti-DPI, Fake TLS, TCP Splice"
+ echo -e " ${WHITE}$(t version_license)${NC} MIT"
echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}"
}
-# ── Установка: выбор режима ──────────────────────────────────────────────────
+# ── Install: mode selection ─────────────────────────────────────────────────
menu_install() {
- # Проверяем v1
+ # Check for v1
if detect_v1_installation; then
echo ""
- echo -e " ${YELLOW}⚠️ Обнаружена установка GoTelegram v1 (mtg)${NC}"
- echo -e " ${DIM}Контейнер: ${V1_CONTAINER_NAME}${NC}"
+ echo -e " ${YELLOW}$(t v1_detected)${NC}"
+ echo -e " ${DIM}$(tf v1_container "$V1_CONTAINER_NAME")${NC}"
echo ""
if ! migrate_v1_to_v2; then
return
@@ -253,185 +259,185 @@ menu_install() {
fi
echo ""
- echo -e " ${BOLD}${WHITE}🎭 Выберите режим маскировки:${NC}"
+ echo -e " ${BOLD}${WHITE}$(t install_select_mode)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
- echo -e " ${CYAN}1)${NC} ${GREEN}⚡ Lite${NC} — маскировка под популярный сайт"
- echo -e " ${DIM}Быстро, без домена. telemt маскирует трафик${NC}"
- echo -e " ${DIM}под выбранный сайт (google.com и т.д.)${NC}"
+ echo -e " ${CYAN}1)${NC} ${GREEN}$(t install_lite_title)${NC}"
+ echo -e " ${DIM}$(t install_lite_desc1)${NC}"
+ echo -e " ${DIM}$(t install_lite_desc2)${NC}"
echo ""
- echo -e " ${CYAN}2)${NC} ${MAGENTA}🛡 Pro${NC} — свой сайт + полная маскировка"
- echo -e " ${DIM}nginx + SSL + HTML-шаблон + telemt.${NC}"
- echo -e " ${DIM}DPI видит реальный сайт с реальным сертификатом.${NC}"
- echo -e " ${DIM}Требует: домен, направленный на этот сервер.${NC}"
+ echo -e " ${CYAN}2)${NC} ${MAGENTA}$(t install_pro_title)${NC}"
+ echo -e " ${DIM}$(t install_pro_desc1)${NC}"
+ echo -e " ${DIM}$(t install_pro_desc2)${NC}"
+ echo -e " ${DIM}$(t install_pro_desc3)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
- echo -ne " ${WHITE}Выбор (1/2):${NC} "
+ echo -ne " ${WHITE}$(t install_mode_choice)${NC} "
read -r mode_choice
mode_choice="${mode_choice:-}"
case "$mode_choice" in
1) install_lite_mode ;;
2) install_pro_mode ;;
- *) log_error "Неверный выбор: ${mode_choice:-<пусто>}" ;;
+ *) log_error "$(tf install_bad_choice "${mode_choice:-}")" ;;
esac
}
-# ── Lite-режим ───────────────────────────────────────────────────────────────
+# ── Lite mode ───────────────────────────────────────────────────────────────
install_lite_mode() {
- log_step "Установка Lite-режима"
+ log_step "$(t install_lite_step)"
- # Выбор домена
+ # Domain selection
local domain
domain=$(select_quick_domain)
[ $? -ne 0 ] && return
- # Выбор порта
+ # Port selection
local port
port=$(select_port)
[ $? -ne 0 ] && return
- # Генерация секрета
+ # Generate secret
local secret
secret=$(generate_hex 32)
- # Подтверждение
+ # Confirm
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}Lite${NC}"
+ echo -e " ${BOLD}${WHITE}$(t install_config_title)${NC}"
+ echo -e " $(t install_cfg_ip) ${CYAN}${ip}${NC}"
+ echo -e " $(t install_cfg_port) ${CYAN}${port}${NC}"
+ echo -e " $(t install_cfg_mask) ${CYAN}${domain}${NC}"
+ echo -e " $(t install_cfg_mode) ${GREEN}Lite${NC}"
echo ""
- if ! confirm "Установить прокси?"; then
+ if ! confirm "$(t install_confirm_proxy)"; then
return
fi
- # Установка
+ # Install
ensure_deps
install_telemt_full || return
- # Генерируем конфиг telemt
+ # Generate telemt config
generate_telemt_toml "$secret" "$port" "lite" "$domain" "443"
- # Валидация
+ # Validate
validate_telemt_config || return
- # Запуск
+ # Start
start_telemt || return
- # Сохраняем GoTelegram конфиг
+ # Save GoTelegram config
save_gotelegram_config "telemt" "lite" "$port" "$secret" "$domain" "" ""
- # Благодарности
+ # Credits
show_credits
- # Результат
+ # Result
show_proxy_info
- log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Lite-режим)"
+ log_success "$(tf install_done "$GOTELEGRAM_VERSION" "Lite")"
}
-# ── Pro-режим ────────────────────────────────────────────────────────────────
+# ── Pro mode ────────────────────────────────────────────────────────────────
install_pro_mode() {
- log_step "Установка Pro-режима"
+ log_step "$(t install_pro_step)"
- # Ввод домена
+ # Enter domain
echo ""
- echo -ne " ${WHITE}Введите ваш домен (например, example.com):${NC} "
+ echo -ne " ${WHITE}$(t install_enter_domain)${NC} "
read -r user_domain
if [ -z "$user_domain" ] || ! validate_domain "$user_domain"; then
- log_error "Некорректный домен: ${user_domain:-<пусто>}"
+ log_error "$(tf install_bad_domain "${user_domain:-}")"
return
fi
- # Проверяем DNS
+ # Check 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
+ log_warning "$(tf install_dns_mismatch "$user_domain" "$resolved_ip" "$server_ip")"
+ if ! confirm "$(t install_continue_anyway)"; then
return
fi
fi
- # Email для Let's Encrypt
- echo -ne " ${WHITE}Email для SSL (Enter = без email):${NC} "
+ # Email for Let's Encrypt
+ echo -ne " ${WHITE}$(t install_enter_email)${NC} "
read -r ssl_email
- # Выбор шаблона
+ # Template selection
local template_dir
template_dir=$(interactive_template_selection)
[ $? -ne 0 ] && return
- # Архитектура Pro:
- # telemt слушает на 0.0.0.0:443 (принимает ВСЕ подключения)
- # nginx слушает на 127.0.0.1:8443 с SSL (обслуживает сайт)
- # MTProxy клиент → :443 → telemt (проксирует)
- # Обычный браузер → :443 → telemt → 127.0.0.1:8443 → nginx (сайт)
- # Провайдер видит только HTTPS на 443 к домену
+ # Pro architecture:
+ # telemt listens on 0.0.0.0:443 (accepts ALL connections)
+ # nginx listens on 127.0.0.1:8443 with SSL (serves website)
+ # MTProxy client → :443 → telemt (proxies)
+ # Regular browser → :443 → telemt → 127.0.0.1:8443 → nginx (website)
+ # ISP only sees HTTPS on 443 to domain
local nginx_internal_port=8443
echo ""
- echo -e " ${DIM}telemt принимает весь трафик на 443 (маскировка под HTTPS)${NC}"
- echo -e " ${DIM}nginx обслуживает сайт на внутреннем порту $nginx_internal_port${NC}"
- echo -e " ${DIM}Провайдер видит только HTTPS-трафик к ${user_domain}:443${NC}"
+ echo -e " ${DIM}$(t install_arch_desc1)${NC}"
+ echo -e " ${DIM}$(tf install_arch_desc2 "$nginx_internal_port")${NC}"
+ echo -e " ${DIM}$(tf install_arch_desc3 "$user_domain")${NC}"
- # Генерация fake-TLS секрета (ee + secret + hex domain)
- # Префикс ee говорит Telegram-клиенту маскировать трафик под TLS к домену
+ # Generate fake-TLS secret (ee + secret + hex domain)
+ # ee prefix tells Telegram client to masquerade traffic as TLS to domain
local raw_secret
raw_secret=$(generate_hex 32)
local domain_hex
domain_hex=$(printf '%s' "$user_domain" | xxd -p | tr -d '\n')
local faketls_secret="ee${raw_secret}${domain_hex}"
- # Подтверждение
+ # Confirmation
echo ""
- echo -e " ${BOLD}${WHITE}📋 Конфигурация:${NC}"
- echo -e " Домен: ${CYAN}${user_domain}${NC}"
- echo -e " Порт: ${CYAN}443 (telemt + nginx внутри)${NC}"
- echo -e " Режим: ${MAGENTA}Pro (fake-TLS)${NC}"
+ echo -e " ${BOLD}${WHITE}$(t install_config_title)${NC}"
+ echo -e " $(t install_cfg_domain) ${CYAN}${user_domain}${NC}"
+ echo -e " $(t install_cfg_port) ${CYAN}443 (telemt + nginx)${NC}"
+ echo -e " $(t install_cfg_mode) ${MAGENTA}Pro (fake-TLS)${NC}"
echo ""
- if ! confirm "Установить прокси + сайт?"; then
+ if ! confirm "$(t install_confirm_proxy_site)"; then
return
fi
- # Установка
+ # Install
ensure_deps
install_telemt_full || return
- # Конфиг telemt: слушает 443, маскировка на локальный nginx через dns_override
+ # telemt config: listen 443, masquerade to local nginx via dns_override
generate_telemt_toml "$raw_secret" "443" "pro" "$user_domain" "$nginx_internal_port"
- # Настройка сайта (nginx на внутреннем порту + certbot + шаблон)
+ # Website setup (nginx on internal port + certbot + template)
setup_pro_mode "$user_domain" "$template_dir" "$nginx_internal_port" "$ssl_email" || return
- # Останавливаем nginx на 443 перед запуском telemt (telemt займёт 443)
- # nginx уже перенастроен на внутренний порт
+ # Stop nginx on 443 before starting telemt (telemt will take 443)
+ # nginx already reconfigured to internal port
systemctl restart nginx 2>/dev/null
- # Запуск telemt
+ # Start telemt
start_telemt || return
- # Сохраняем конфиг
+ # Save config
local tpl_id
tpl_id=$(basename "$template_dir")
save_gotelegram_config "telemt" "pro" "443" "$raw_secret" "$user_domain" "$user_domain" "$tpl_id"
- # Результат — используем домен и fake-TLS ссылку
+ # Result — use domain and fake-TLS link
show_proxy_info_pro "$user_domain" "$faketls_secret"
- echo -e " ${WHITE}Сайт:${NC} ${GREEN}https://${user_domain}${NC}"
- log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Pro-режим)"
+ echo -e " ${WHITE}$(t svc_site):${NC} ${GREEN}https://${user_domain}${NC}"
+ log_success "$(tf install_done "$GOTELEGRAM_VERSION" "Pro")"
}
# ── Статус ───────────────────────────────────────────────────────────────────
menu_status() {
show_proxy_info
- # Дополнительно для pro
+ # Extras for pro
local mode
mode=$(config_get mode 2>/dev/null)
if [ "$mode" = "pro" ]; then
@@ -442,9 +448,9 @@ menu_status() {
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 -e " ${WHITE}$(t svc_nginx):${NC} ${nginx_st}"
+ echo -e " ${WHITE}$(t website_ssl_until)${NC} ${ssl_expiry}"
+ echo -e " ${WHITE}$(t svc_site):${NC} https://${domain}"
echo ""
fi
fi
@@ -467,7 +473,7 @@ menu_link() {
fi
echo ""
- echo -e " ${BOLD}${WHITE}🔗 Ссылка для подключения:${NC}"
+ echo -e " ${BOLD}${WHITE}$(t link_title)${NC}"
echo ""
echo -e " ${GREEN}${link}${NC}"
echo ""
@@ -496,17 +502,17 @@ menu_share() {
fi
echo ""
- echo -e " ${BOLD}📤 Перешлите это сообщение:${NC}"
+ echo -e " ${BOLD}$(t share_title)${NC}"
echo ""
- echo "🔐 MTProxy для Telegram (GoTelegram v${GOTELEGRAM_VERSION})"
+ printf "$(t share_line1)\n" "$GOTELEGRAM_VERSION"
echo ""
- echo "🌍 Сервер: $server_display"
- echo "🔌 Порт: $port"
+ printf "$(t share_server)\n" "$server_display"
+ printf "$(t share_port)\n" "$port"
echo ""
- echo "👉 Подключиться одним нажатием:"
+ echo "$(t share_connect_cta)"
echo "$link"
echo ""
- echo "Просто нажмите на ссылку или настройте вручную."
+ echo "$(t share_footer)"
echo ""
}
@@ -520,32 +526,32 @@ menu_restart() {
fi
}
-# ── Логи ─────────────────────────────────────────────────────────────────────
+# ── Logs ────────────────────────────────────────────────────────────────────
menu_logs() {
echo ""
- echo -e " ${BOLD}${WHITE}📋 Логи telemt (последние 40 строк):${NC}"
+ echo -e " ${BOLD}${WHITE}$(tf logs_telemt_title 40)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
telemt_logs 40
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
}
-# ── Смена режима / шаблона ───────────────────────────────────────────────────
+# ── Change mode / template ──────────────────────────────────────────────────
menu_change_mode() {
local current_mode
current_mode=$(config_get mode 2>/dev/null)
echo ""
- echo -e " ${WHITE}Текущий режим:${NC} ${CYAN}${current_mode}${NC}"
+ echo -e " ${WHITE}$(t change_current_mode)${NC} ${CYAN}${current_mode}${NC}"
echo ""
- echo -e " ${CYAN}1${NC}) Сменить шаблон сайта (только pro)"
- echo -e " ${CYAN}2${NC}) Переключить режим (lite ↔ pro)"
- echo -e " ${CYAN}0${NC}) Назад"
- echo -ne " ${WHITE}Выбор:${NC} "
+ echo -e " ${CYAN}1${NC}) $(t change_template)"
+ echo -e " ${CYAN}2${NC}) $(t change_mode_switch)"
+ echo -e " ${CYAN}0${NC}) $(t back)"
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r ch
case "$ch" in
1)
if [ "$current_mode" != "pro" ]; then
- log_error "Смена шаблона доступна только в pro-режиме"
+ log_error "$(t change_only_pro)"
return
fi
local template_dir
@@ -554,21 +560,21 @@ menu_change_mode() {
switch_template "$template_dir"
;;
2)
- log_warning "Переключение режима требует переустановки."
- if confirm "Переустановить прокси?"; then
+ log_warning "$(t change_requires_reinstall)"
+ if confirm "$(t change_reinstall_confirm)"; then
menu_install
fi
;;
esac
}
-# ── Управление сайтом ────────────────────────────────────────────────────────
+# ── Website management ─────────────────────────────────────────────────────
menu_website() {
local mode
mode=$(config_get mode 2>/dev/null)
if [ "$mode" != "pro" ]; then
- log_info "Управление сайтом доступно только в pro-режиме"
+ log_info "$(t website_only_pro)"
return
fi
@@ -576,15 +582,15 @@ menu_website() {
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 -e " ${BOLD}${WHITE}$(t website_title)${NC}"
+ echo -e " $(t website_domain) ${CYAN}${domain}${NC}"
+ echo -e " $(t website_ssl_until) $(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} "
+ echo -e " ${CYAN}1${NC}) $(t website_renew_ssl)"
+ echo -e " ${CYAN}2${NC}) $(t website_restart_nginx)"
+ echo -e " ${CYAN}3${NC}) $(t website_change_template)"
+ echo -e " ${CYAN}0${NC}) $(t back)"
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r ch
case "$ch" in
@@ -599,23 +605,23 @@ menu_website() {
esac
}
-# ── Удаление ─────────────────────────────────────────────────────────────────
+# ── Remove ─────────────────────────────────────────────────────────────────
menu_remove() {
echo ""
- echo -e " ${BOLD}${RED}🗑 Удаление GoTelegram${NC}"
+ echo -e " ${BOLD}${RED}$(t remove_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
- echo -e " ${CYAN}1${NC}) Удалить только прокси (telemt)"
- echo -e " ${CYAN}2${NC}) Удалить только Telegram-бота"
- echo -e " ${CYAN}3${NC}) Удалить всё (прокси + бот + настройки)"
- echo -e " ${CYAN}0${NC}) Назад"
- echo -ne " ${WHITE}Выбор:${NC} "
+ echo -e " ${CYAN}1${NC}) $(t remove_proxy_only)"
+ echo -e " ${CYAN}2${NC}) $(t remove_bot_only)"
+ echo -e " ${CYAN}3${NC}) $(t remove_all)"
+ echo -e " ${CYAN}0${NC}) $(t back)"
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r rm_choice
case "$rm_choice" in
1)
- log_warning "Это удалит прокси и все его настройки."
- if ! confirm "Удалить прокси?"; then return; fi
- if confirm "Сделать бекап перед удалением?"; then
+ log_warning "$(t remove_warn_proxy)"
+ if ! confirm "$(t remove_confirm_proxy)"; then return; fi
+ if confirm "$(t remove_backup_before)"; then
interactive_backup
fi
remove_telemt
@@ -625,18 +631,18 @@ menu_remove() {
remove_pro_mode
fi
rm -f "$GOTELEGRAM_CONFIG"
- log_success "Прокси удалён"
+ log_success "$(t remove_proxy_done)"
;;
2)
bot_remove
;;
3)
- log_warning "Это удалит ВСЁ: прокси, бот, сайт, настройки."
- if ! confirm "Вы точно уверены?"; then return; fi
- if confirm "Сделать бекап перед удалением?"; then
+ log_warning "$(t remove_warn_all)"
+ if ! confirm "$(t remove_confirm_all)"; then return; fi
+ if confirm "$(t remove_backup_before)"; then
interactive_backup
fi
- # Прокси
+ # Proxy
remove_telemt
local mode
mode=$(config_get mode 2>/dev/null)
@@ -644,7 +650,7 @@ menu_remove() {
remove_pro_mode
fi
rm -f "$GOTELEGRAM_CONFIG"
- # Бот
+ # Bot
if [ "$(bot_service_status)" != "not_installed" ]; then
systemctl stop "$BOT_SERVICE" 2>/dev/null
systemctl disable "$BOT_SERVICE" 2>/dev/null
@@ -652,7 +658,7 @@ menu_remove() {
systemctl daemon-reload
rm -rf "$BOT_DIR"
fi
- log_success "GoTelegram полностью удалён (прокси + бот)"
+ log_success "$(t remove_all_done)"
;;
esac
}
@@ -676,42 +682,42 @@ menu_bot() {
st=$(bot_service_status)
echo ""
- echo -e " ${BOLD}${WHITE}🤖 Telegram-бот${NC}"
+ echo -e " ${BOLD}${WHITE}$(t bot_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
case "$st" in
running)
- echo -e " Статус: ${GREEN}● Работает${NC}"
+ echo -e " $(t bot_status_colon) ${GREEN}$(t bot_status_running)${NC}"
echo ""
- 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}) ⚙️ Настройки (.env)"
- echo -e " ${CYAN}6${NC}) 🗑 Удалить бота"
+ echo -e " ${CYAN}1${NC}) $(t bot_menu_status)"
+ echo -e " ${CYAN}2${NC}) $(t bot_menu_logs)"
+ echo -e " ${CYAN}3${NC}) $(t bot_menu_restart)"
+ echo -e " ${CYAN}4${NC}) $(t bot_menu_stop)"
+ echo -e " ${CYAN}5${NC}) $(t bot_menu_settings)"
+ echo -e " ${CYAN}6${NC}) $(t bot_menu_remove)"
;;
stopped)
- echo -e " Статус: ${YELLOW}○ Остановлен${NC}"
+ echo -e " $(t bot_status_colon) ${YELLOW}$(t bot_status_stopped)${NC}"
echo ""
- echo -e " ${CYAN}1${NC}) 📊 Статус бота"
- echo -e " ${CYAN}2${NC}) 📋 Логи бота"
- echo -e " ${CYAN}3${NC}) ▶️ Запустить бота"
- echo -e " ${CYAN}5${NC}) ⚙️ Настройки (.env)"
- echo -e " ${CYAN}6${NC}) 🗑 Удалить бота"
+ echo -e " ${CYAN}1${NC}) $(t bot_menu_status)"
+ echo -e " ${CYAN}2${NC}) $(t bot_menu_logs)"
+ echo -e " ${CYAN}3${NC}) $(t bot_menu_start)"
+ echo -e " ${CYAN}5${NC}) $(t bot_menu_settings)"
+ echo -e " ${CYAN}6${NC}) $(t bot_menu_remove)"
;;
*)
- echo -e " Статус: ${RED}✗ Не установлен${NC}"
+ echo -e " $(t bot_status_colon) ${RED}$(t bot_status_not_installed)${NC}"
echo ""
- echo -e " ${DIM}Бот позволяет управлять прокси прямо из Telegram:${NC}"
- echo -e " ${DIM}статус, перезапуск, смена режима, бекап, QR-код.${NC}"
+ echo -e " ${DIM}$(t bot_intro1)${NC}"
+ echo -e " ${DIM}$(t bot_intro2)${NC}"
echo ""
- echo -e " ${CYAN}1${NC}) 🔧 Установить бота"
+ echo -e " ${CYAN}1${NC}) $(t bot_menu_install)"
;;
esac
- echo -e " ${CYAN}0${NC}) « Назад"
+ echo -e " ${CYAN}0${NC}) $(t back)"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
- echo -ne " ${WHITE}Выбор:${NC} "
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r ch
case "$st" in
@@ -719,8 +725,8 @@ menu_bot() {
case "$ch" in
1) bot_show_status ;;
2) bot_show_logs ;;
- 3) systemctl restart "$BOT_SERVICE" && log_success "Бот перезапущен" ;;
- 4) systemctl stop "$BOT_SERVICE" && log_info "Бот остановлен" ;;
+ 3) systemctl restart "$BOT_SERVICE" && log_success "$(t bot_restarted)" ;;
+ 4) systemctl stop "$BOT_SERVICE" && log_info "$(t bot_stopped)" ;;
5) bot_edit_config ;;
6) bot_remove ;;
esac
@@ -729,7 +735,7 @@ menu_bot() {
case "$ch" in
1) bot_show_status ;;
2) bot_show_logs ;;
- 3) systemctl start "$BOT_SERVICE" && log_success "Бот запущен" ;;
+ 3) systemctl start "$BOT_SERVICE" && log_success "$(t bot_started)" ;;
5) bot_edit_config ;;
6) bot_remove ;;
esac
@@ -743,11 +749,11 @@ menu_bot() {
}
bot_install() {
- log_step "Установка Telegram-бота"
+ log_step "$(t bot_install_step)"
# Python
if ! command -v python3 &>/dev/null; then
- log_info "Установка Python3..."
+ log_info "$(t bot_install_python)"
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
@@ -757,65 +763,76 @@ bot_install() {
fi
fi
- # Копируем файлы бота
+ # Copy bot files
mkdir -p "$BOT_DIR"
if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then
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/"
+ # Copy i18n language files for bot
+ if [ -d "$SCRIPT_DIR/gotelegram-bot/lang" ]; then
+ mkdir -p "$BOT_DIR/lang"
+ cp -f "$SCRIPT_DIR/gotelegram-bot/lang/"*.json "$BOT_DIR/lang/" 2>/dev/null
+ fi
+ [ -f "$SCRIPT_DIR/gotelegram-bot/i18n.py" ] && \
+ cp "$SCRIPT_DIR/gotelegram-bot/i18n.py" "$BOT_DIR/"
else
- log_error "Файлы бота не найдены в $SCRIPT_DIR/gotelegram-bot/"
+ log_error "$(tf bot_files_not_found "$SCRIPT_DIR/gotelegram-bot/")"
return 1
fi
- # Каталог шаблонов
+ # Templates catalog
[ -f "$SCRIPT_DIR/templates_catalog.json" ] && \
cp "$SCRIPT_DIR/templates_catalog.json" "$GOTELEGRAM_DIR/"
# Venv
if [ ! -d "$BOT_DIR/venv" ]; then
- log_info "Создание виртуального окружения..."
+ log_info "$(t bot_create_venv)"
python3 -m venv "$BOT_DIR/venv"
fi
- log_info "Установка зависимостей..."
+ log_info "$(t bot_install_deps)"
"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q
- # Конфигурация
+ # Configuration
if [ ! -f "$BOT_DIR/.env" ]; then
echo ""
- echo -e " ${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}"
+ echo -e " ${YELLOW}$(t bot_enter_token)${NC}"
local token=""
while [ -z "$token" ]; do
- echo -ne " ${WHITE}Token:${NC} "
+ echo -ne " ${WHITE}$(t bot_token)${NC} "
read -r token
token=$(echo "$token" | tr -d '[:space:]')
- [ -z "$token" ] && log_error "Токен не может быть пустым"
+ [ -z "$token" ] && log_error "$(t bot_token_empty)"
done
echo ""
- echo -e " ${WHITE}Как добавить администратора?${NC}"
- echo -e " ${CYAN}1${NC}) Автоматически — бот определит ID при первом /start"
- echo -e " ${CYAN}2${NC}) Вручную — ввести ID сейчас"
- echo -ne " ${WHITE}Выбор [1]:${NC} "
+ echo -e " ${WHITE}$(t bot_add_admin_how)${NC}"
+ echo -e " ${CYAN}1${NC}) $(t bot_admin_auto)"
+ echo -e " ${CYAN}2${NC}) $(t bot_admin_manual)"
+ echo -ne " ${WHITE}$(t choose) [1]:${NC} "
read -r admin_mode
admin_mode="${admin_mode:-1}"
local admin_ids=""
if [ "$admin_mode" = "2" ]; then
- echo -ne " ${WHITE}ID администраторов (через пробел/запятую):${NC} "
+ echo -ne " ${WHITE}$(t bot_admin_ids_prompt)${NC} "
read -r admin_ids
admin_ids=$(echo "$admin_ids" | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')
fi
+ # Propagate selected language to bot so UI matches
+ local bot_lang
+ bot_lang=$(get_language 2>/dev/null || echo en)
{
echo "BOT_TOKEN=$token"
[ -n "$admin_ids" ] && echo "ALLOWED_IDS=$admin_ids"
+ echo "BOT_LANG=$bot_lang"
} > "$BOT_DIR/.env"
chmod 600 "$BOT_DIR/.env"
- log_success ".env создан"
+ log_success "$(t bot_env_created)"
else
- log_info ".env уже существует, настройки сохранены"
+ log_info "$(t bot_env_exists)"
fi
# Systemd
@@ -840,37 +857,37 @@ SVCEOF
systemctl enable "$BOT_SERVICE" &>/dev/null
systemctl restart "$BOT_SERVICE" 2>/dev/null || systemctl start "$BOT_SERVICE"
- # Если авто-режим — ждём пока бот словит первого админа
+ # If auto mode — wait until bot captures first admin
local has_ids
has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2)
if [ -z "$has_ids" ]; then
echo ""
echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}"
- echo -e " ${YELLOW}║${NC} ${BOLD}Ожидание администратора${NC} ${YELLOW}║${NC}"
+ printf " ${YELLOW}║${NC} ${BOLD}%-52s${NC} ${YELLOW}║${NC}\n" "$(t bot_wait_admin_title)"
echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} Откройте бота в Telegram и отправьте ${CYAN}/start${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} Бот автоматически назначит вас администратором ${YELLOW}║${NC}"
+ printf " ${YELLOW}║${NC} %s ${CYAN}/start${NC}%*s${YELLOW}║${NC}\n" "$(t bot_wait_admin_msg1)" 0 ""
+ printf " ${YELLOW}║${NC} %-52s ${YELLOW}║${NC}\n" "$(t bot_wait_admin_msg2)"
echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${DIM}Нажмите Ctrl+C чтобы пропустить${NC} ${YELLOW}║${NC}"
+ printf " ${YELLOW}║${NC} ${DIM}%-52s${NC} ${YELLOW}║${NC}\n" "$(t bot_wait_admin_skip)"
echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
local waited=0
- local max_wait=300 # 5 минут максимум
+ local max_wait=300 # 5 min max
- # Ловим Ctrl+C чтобы выйти из ожидания без убийства скрипта
+ # Catch Ctrl+C to skip waiting without killing the script
local interrupted=0
trap 'interrupted=1' INT
while [ $waited -lt $max_wait ] && [ $interrupted -eq 0 ]; do
- printf "\r ${CYAN}${frames[$i]}${NC} Ожидание... напишите /start боту (%d сек) " "$waited" >&2
+ printf "\r ${CYAN}${frames[$i]}${NC} $(tf bot_wait_spinner "$waited") " >&2
i=$(( (i+1) % ${#frames[@]} ))
sleep 1
waited=$((waited + 1))
- # Проверяем появился ли ALLOWED_IDS
+ # Check if ALLOWED_IDS has appeared
has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2)
if [ -n "$has_ids" ]; then
break
@@ -878,30 +895,30 @@ SVCEOF
done
trap - INT
- printf "\r\033[K" >&2 # очистить строку со спиннером
+ printf "\r\033[K" >&2 # clear spinner line
if [ -n "$has_ids" ]; then
echo ""
- log_success "Администратор назначен!"
+ log_success "$(t bot_admin_assigned)"
echo -e " ${WHITE}ID:${NC} ${GREEN}${has_ids}${NC}"
elif [ $interrupted -eq 1 ]; then
echo ""
- log_warning "Пропущено. Добавить админа позже: меню → Telegram-бот → Настройки"
+ log_warning "$(t bot_wait_skipped)"
else
echo ""
- log_warning "Таймаут (5 мин). Добавить админа: меню → Telegram-бот → Настройки"
+ log_warning "$(t bot_wait_timeout)"
fi
fi
echo ""
- log_success "Бот установлен и запущен!"
- echo -e " ${DIM}Проверка: systemctl status $BOT_SERVICE${NC}"
- echo -e " ${DIM}Логи: journalctl -u $BOT_SERVICE -f${NC}"
+ log_success "$(t bot_installed)"
+ echo -e " ${DIM}systemctl status $BOT_SERVICE${NC}"
+ echo -e " ${DIM}journalctl -u $BOT_SERVICE -f${NC}"
}
bot_show_status() {
echo ""
- echo -e " ${BOLD}${WHITE}📊 Статус Telegram-бота${NC}"
+ echo -e " ${BOLD}${WHITE}$(t bot_status_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
systemctl status "$BOT_SERVICE" --no-pager -l 2>/dev/null | head -15 | while IFS= read -r line; do
echo " $line"
@@ -912,14 +929,20 @@ bot_show_status() {
local has_token has_ids
has_token=$(grep -c "BOT_TOKEN=" "$BOT_DIR/.env" 2>/dev/null || echo 0)
has_ids=$(grep "ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2)
- echo -e " Token: ${has_token:+${GREEN}✓ настроен${NC}}"
- echo -e " Доступ: ${has_ids:+ID: $has_ids}${has_ids:-${YELLOW}все пользователи${NC}}"
+ if [ "${has_token:-0}" -gt 0 ]; then
+ echo -e " $(t bot_token) ${GREEN}✓ $(t bot_token_configured)${NC}"
+ fi
+ if [ -n "$has_ids" ]; then
+ echo -e " $(t bot_access_colon) $(tf bot_access_ids_fmt "$has_ids")"
+ else
+ echo -e " $(t bot_access_colon) ${YELLOW}$(t bot_access_open)${NC}"
+ fi
fi
}
bot_show_logs() {
echo ""
- echo -e " ${BOLD}${WHITE}📋 Логи бота (последние 30 строк):${NC}"
+ echo -e " ${BOLD}${WHITE}$(t bot_logs_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
journalctl -u "$BOT_SERVICE" --no-pager -n 30 2>/dev/null | while IFS= read -r line; do
echo " $line"
@@ -929,13 +952,13 @@ bot_show_logs() {
bot_edit_config() {
echo ""
- echo -e " ${BOLD}${WHITE}⚙️ Настройки бота${NC}"
+ echo -e " ${BOLD}${WHITE}$(t bot_settings_title)${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
if [ -f "$BOT_DIR/.env" ]; then
- echo -e " ${DIM}Текущий .env:${NC}"
+ echo -e " ${DIM}$(t bot_current_env)${NC}"
while IFS= read -r line; do
- # Маскируем токен для безопасности
+ # Mask token for security
if [[ "$line" == BOT_TOKEN=* ]]; then
local tok="${line#BOT_TOKEN=}"
echo -e " BOT_TOKEN=${tok:0:10}...${tok: -5}"
@@ -946,29 +969,29 @@ bot_edit_config() {
fi
echo ""
- echo -e " ${CYAN}1${NC}) Сменить BOT_TOKEN"
- echo -e " ${CYAN}2${NC}) Изменить ALLOWED_IDS"
- echo -e " ${CYAN}0${NC}) Назад"
- echo -ne " ${WHITE}Выбор:${NC} "
+ echo -e " ${CYAN}1${NC}) $(t bot_change_token)"
+ echo -e " ${CYAN}2${NC}) $(t bot_change_allowed)"
+ echo -e " ${CYAN}0${NC}) $(t back)"
+ echo -ne " ${WHITE}$(t choose):${NC} "
read -r ch
case "$ch" in
1)
- echo -ne " ${WHITE}Новый BOT_TOKEN:${NC} "
+ echo -ne " ${WHITE}$(t bot_new_token)${NC} "
read -r new_token
new_token=$(echo "$new_token" | tr -d '[:space:]')
if [ -n "$new_token" ]; then
sed -i "s|^BOT_TOKEN=.*|BOT_TOKEN=$new_token|" "$BOT_DIR/.env"
systemctl restart "$BOT_SERVICE"
- log_success "Токен обновлён, бот перезапущен"
+ log_success "$(t bot_token_updated)"
else
- log_error "Пустой токен"
+ log_error "$(t bot_token_empty_err)"
fi
;;
2)
- echo -ne " ${WHITE}ALLOWED_IDS (через пробел/запятую, пусто = авто):${NC} "
+ echo -ne " ${WHITE}$(t bot_allowed_prompt)${NC} "
read -r new_ids
- # Нормализуем: пробелы и запятые → запятые, убираем лишнее
+ # Normalize: spaces and commas → commas, strip extras
new_ids=$(echo "$new_ids" | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')
if grep -q "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null; then
if [ -n "$new_ids" ]; then
@@ -980,15 +1003,15 @@ bot_edit_config() {
[ -n "$new_ids" ] && echo "ALLOWED_IDS=$new_ids" >> "$BOT_DIR/.env"
fi
systemctl restart "$BOT_SERVICE"
- log_success "Доступ обновлён, бот перезапущен"
+ log_success "$(t bot_access_updated)"
;;
esac
}
bot_remove() {
echo ""
- log_warning "Это удалит Telegram-бота и все его настройки."
- if ! confirm "Удалить бота?"; then
+ log_warning "$(t bot_remove_warn)"
+ if ! confirm "$(t bot_remove_confirm)"; then
return
fi
@@ -997,29 +1020,33 @@ bot_remove() {
rm -f "/etc/systemd/system/${BOT_SERVICE}.service"
systemctl daemon-reload
rm -rf "$BOT_DIR"
- log_success "Бот полностью удалён"
+ log_success "$(t bot_removed)"
+}
+
+# ── Promo ────────────────────────────────────────────────────────────────────
+_promo_block() {
+ # Print a promo section without width-fragile box borders (i18n safe)
+ local line2; line2=$(printf '─%.0s' {1..54})
+ echo ""
+ echo -e " ${DIM}${line2}${NC}"
+ echo -e " ${BOLD}${YELLOW}$(t promo_host1_title)${NC}"
+ echo -e " $(t promo_link_label) ${CYAN}https://vk.cc/ct29NQ${NC}"
+ echo -e " ${WHITE}OFF60${NC} — $(tf promo_off60)"
+ echo -e " ${WHITE}antenka20${NC} — $(tf promo_ant20)"
+ echo -e " ${WHITE}antenka6${NC} — $(tf promo_ant6)"
+ echo -e " ${DIM}${line2}${NC}"
+ echo -e " ${BOLD}${YELLOW}$(t promo_host2_title)${NC}"
+ echo -e " $(t promo_link_label) ${CYAN}https://vk.cc/cUxAhj${NC}"
+ echo -e " ${WHITE}OFF60${NC} — $(tf promo_off60)"
+ echo -e " ${DIM}${line2}${NC}"
+ echo -e " ${BOLD}${YELLOW}$(t promo_tips_title)${NC}"
+ echo -e " ${CYAN}https://pay.cloudtips.ru/p/7410814f${NC}"
+ echo -e " ${DIM}${line2}${NC}"
+ echo ""
}
-# ── Промо ────────────────────────────────────────────────────────────────────
menu_promo() {
- echo ""
- echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}"
- echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #1 — СКИДКА ДО 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} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${WHITE}antenka20${NC} — 20% + 3% при оплате за 3 месяца ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${WHITE}antenka6${NC} — 15% + 5% при оплате за 6 месяцев ${YELLOW}║${NC}"
- echo -e " ${YELLOW}╟──────────────────────────────────────────────────────╢${NC}"
- echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #2 — СКИДКА ДО 60%${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/cUxAhj${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}"
- echo -e " ${YELLOW}╟──────────────────────────────────────────────────────╢${NC}"
- echo -e " ${YELLOW}║${NC} ${BOLD}☕ Донат / Чаевые${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${CYAN}https://pay.cloudtips.ru/p/7410814f${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}"
- echo ""
+ _promo_block
}
# ── Проверка: показывать ли промо (раз в сутки) ────────────────────────────
@@ -1043,37 +1070,23 @@ mark_promo_shown() {
date +%s > "$GOTELEGRAM_DIR/.promo_last_shown"
}
-# ── Промо с QR и задержкой (при установке + раз в сутки) ───────────────────
+# ── Promo with QR + delay (on install + once per day) ───────────────────
show_promo_with_qr() {
- echo ""
- echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}"
- echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #1 — СКИДКА ДО 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} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${WHITE}antenka20${NC} — 20% + 3% при оплате за 3 месяца ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${WHITE}antenka6${NC} — 15% + 5% при оплате за 6 месяцев ${YELLOW}║${NC}"
- echo -e " ${YELLOW}╟──────────────────────────────────────────────────────╢${NC}"
- echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #2 — СКИДКА ДО 60%${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/cUxAhj${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}"
- echo -e " ${YELLOW}║${NC} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}"
- echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}"
- echo ""
+ _promo_block
- # QR-коды
+ # QR codes
if command -v qrencode &>/dev/null; then
- echo -e " ${DIM}── QR: Хостинг #1 ──${NC}"
+ echo -e " ${DIM}$(t promo_qr_host1)${NC}"
qrencode -t UTF8 -m 1 "https://vk.cc/ct29NQ" 2>/dev/null | while IFS= read -r qr_line; do
echo " $qr_line"
done
echo ""
- echo -e " ${DIM}── QR: Хостинг #2 ──${NC}"
+ echo -e " ${DIM}$(t promo_qr_host2)${NC}"
qrencode -t UTF8 -m 1 "https://vk.cc/cUxAhj" 2>/dev/null | while IFS= read -r qr_line; do
echo " $qr_line"
done
echo ""
- echo -e " ${DIM}── QR: Чаевые / Донат ──${NC}"
+ echo -e " ${DIM}$(t promo_qr_tips)${NC}"
qrencode -t UTF8 -m 1 "https://pay.cloudtips.ru/p/7410814f" 2>/dev/null | while IFS= read -r qr_line; do
echo " $qr_line"
done
@@ -1081,25 +1094,63 @@ show_promo_with_qr() {
mark_promo_shown
- # 5-секундная задержка с обратным отсчётом
+ # 5-second countdown
for i in 5 4 3 2 1; do
- echo -ne "\r ${DIM}Меню через ${i} сек...${NC} "
+ echo -ne "\r ${DIM}$(tf promo_menu_in "$i")${NC} "
sleep 1
done
- echo -ne "\r \r"
+ echo -ne "\r \r"
}
-# ── Точка входа ──────────────────────────────────────────────────────────────
+# ── First-run: pick language ─────────────────────────────────────────────────
+first_run_language_picker() {
+ # Show picker only if language not yet saved
+ local marker="${GOTELEGRAM_DIR:-/opt/gotelegram}/.language"
+ local cfg_lang=""
+ if [ -f "$GOTELEGRAM_CONFIG" ] && command -v jq >/dev/null 2>&1; then
+ cfg_lang=$(jq -r '.language // empty' "$GOTELEGRAM_CONFIG" 2>/dev/null)
+ fi
+ if [ -f "$marker" ] || [ -n "$cfg_lang" ]; then
+ return 0
+ fi
+
+ local chosen
+ chosen=$(pick_language_interactive)
+ save_language "$chosen"
+ load_language "$chosen"
+}
+
+# ── Change language on demand ────────────────────────────────────────────────
+menu_language() {
+ echo ""
+ echo -e " ${BOLD}${WHITE}$(t lang_change_prompt)${NC}"
+ echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}"
+ echo -e " ${CYAN}1${NC}) English"
+ echo -e " ${CYAN}2${NC}) Русский"
+ echo -e " ${CYAN}0${NC}) $(t back)"
+ echo -ne " ${WHITE}$(t choose):${NC} "
+ read -r ch
+ case "$ch" in
+ 1) save_language "en"; load_language "en"; log_success "$(tf lang_saved English)" ;;
+ 2) save_language "ru"; load_language "ru"; log_success "$(tf lang_saved Русский)" ;;
+ esac
+}
+
+# ── Точка входа / Entry point ───────────────────────────────────────────────
main() {
check_root
init_dirs
+
+ # First-run language picker (before banner so banner appears in chosen lang)
+ first_run_language_picker
+
show_banner
# Pre-flight
check_os
check_disk_space 500
- # Промо раз в сутки
+ # Promo once per day
if should_show_promo; then
show_promo_with_qr
fi
@@ -1115,14 +1166,14 @@ main() {
3) submenu_manage ;;
4) menu_bot ;;
5) submenu_about ;;
- 0|q|exit) echo ""; log_info "До встречи! 👋"; exit 0 ;;
- *) log_error "Неверный выбор" ;;
+ 0|q|exit) echo ""; log_info "$(t bye)"; exit 0 ;;
+ *) log_error "$(t invalid_choice)" ;;
esac
- # Пауза после подменю (кроме статистики — у неё свой цикл)
+ # Pause after submenu (except stats — it has its own loop)
if [ "$choice" != "2" ]; then
echo ""
- echo -ne " ${DIM}Нажмите Enter для возврата в меню...${NC}"
+ echo -ne " ${DIM}$(t press_enter_to_return)${NC}"
read -r
fi
fi
@@ -1155,33 +1206,34 @@ submenu_stats() {
tput cup 0 0 2>/dev/null || printf '\033[H'
fi
- # Рисуем весь экран поверх старого содержимого
- echo -e "\033[J" # очищаем от курсора до конца (убирает хвосты)
- echo -e " ${BOLD}${WHITE}📊 Статистика трафика${NC}"
+ # Draw the whole screen over the previous content
+ echo -e "\033[J" # erase from cursor to end (removes trails)
+ echo -e " ${BOLD}${WHITE}$(t stats_title)${NC}"
echo -e " ${DIM}${line2}${NC}"
if type show_traffic_stats &>/dev/null; then
show_traffic_stats
else
- echo -e " ${DIM}Модуль статистики не загружен.${NC}"
- echo -e " ${DIM}Файл lib/stats.sh не найден.${NC}"
+ echo -e " ${DIM}$(t stats_module_missing)${NC}"
+ echo -e " ${DIM}$(t stats_file_missing)${NC}"
echo ""
fi
echo -e " ${DIM}${line2}${NC}"
- local stats_on="вкл"
+ local stats_on
+ stats_on=$(t stats_on)
if type toggle_stats &>/dev/null; then
local cfg_val
cfg_val=$(config_get stats_enabled 2>/dev/null || echo "true")
- [ "$cfg_val" = "false" ] && stats_on="выкл"
+ [ "$cfg_val" = "false" ] && stats_on=$(t stats_off)
fi
- echo -e " ${CYAN}1${NC}) Вкл/Выкл подсчёт (сейчас: ${stats_on})"
- echo -e " ${CYAN}2${NC}) Установить/обновить сборщик статистики"
- echo -e " ${CYAN}0${NC}) ${DIM}← Назад${NC}"
+ echo -e " ${CYAN}1${NC}) $(tf stats_toggle "$stats_on")"
+ echo -e " ${CYAN}2${NC}) $(t stats_install_collector)"
+ echo -e " ${CYAN}0${NC}) ${DIM}$(t back)${NC}"
echo -e " ${DIM}${line2}${NC}"
- echo -e " ${DIM}Обновление каждые 3 сек${NC}"
+ echo -e " ${DIM}$(t stats_auto_refresh)${NC}"
- # Показываем курсор для ввода, потом снова скрываем
+ # Show cursor for input, then hide again
tput cnorm 2>/dev/null
echo -ne " ${WHITE}▸ ${NC}"
@@ -1191,14 +1243,14 @@ submenu_stats() {
1)
if type toggle_stats &>/dev/null; then
toggle_stats
- echo -ne " ${DIM}Нажмите Enter...${NC}"; read -r
- first_draw=1 # полная перерисовка после действия
+ echo -ne " ${DIM}$(t press_enter)${NC}"; read -r
+ first_draw=1 # full redraw after action
fi
;;
2)
if type install_stats_collector &>/dev/null; then
install_stats_collector
- echo -ne " ${DIM}Нажмите Enter...${NC}"; read -r
+ echo -ne " ${DIM}$(t press_enter)${NC}"; read -r
first_draw=1
fi
;;
diff --git a/lib/backup.sh b/lib/backup.sh
index 07854ef..f5d3cd6 100755
--- a/lib/backup.sh
+++ b/lib/backup.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-# GoTelegram v2.2 — Бекап и восстановление конфигурации
+# GoTelegram v2.4 — backup and restore (i18n-aware)
# ── Создание бекапа ──────────────────────────────────────────────────────────
create_backup() {
@@ -13,7 +13,7 @@ create_backup() {
mkdir -p "$tmp_dir" "$output_dir"
# Собираем файлы
- log_info "Собираю конфигурацию..."
+ log_info "$(_t_or backup_collecting 'Собираю конфигурацию...')"
# telemt конфиг
if [ -f "$TELEMT_CONFIG" ]; then
@@ -25,6 +25,11 @@ create_backup() {
cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram.json"
fi
+ # Language marker (i18n)
+ if [ -f "$GOTELEGRAM_DIR/.language" ]; then
+ cp "$GOTELEGRAM_DIR/.language" "$tmp_dir/.language"
+ fi
+
# nginx конфиг (stealth mode)
if [ -f "$NGINX_SITE_CONF" ]; then
cp "$NGINX_SITE_CONF" "$tmp_dir/nginx.conf"
@@ -44,40 +49,46 @@ create_backup() {
if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then
mkdir -p "$tmp_dir/site"
cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/"
- log_dim "Шаблон сайта включён"
+ log_dim "$(_t_or backup_site_included 'Шаблон сайта включён')"
fi
# Метаданные
- local ip mode engine
+ local ip mode engine lang port domain
ip=$(get_server_ip)
mode=$(config_get mode 2>/dev/null || echo "unknown")
engine=$(config_get engine 2>/dev/null || echo "telemt")
+ lang=$(type get_language &>/dev/null && get_language 2>/dev/null || echo "en")
+ port=$(config_get port 2>/dev/null || echo "443")
+ # Ensure port is numeric; fall back to 443 if garbage
+ [[ "$port" =~ ^[0-9]+$ ]] || port=443
+ domain=$(config_get domain 2>/dev/null || echo "")
cat > "$tmp_dir/metadata.json" << EOMETA
{
- "backup_version": "1.0",
+ "backup_version": "1.1",
"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)"
+ "language": "$lang",
+ "port": $port,
+ "domain": "$domain"
}
EOMETA
# Архивируем
local tar_file="/tmp/${backup_name}.tar.gz"
if ! tar czf "$tar_file" -C /tmp "$backup_name" 2>/dev/null; then
- log_error "Ошибка создания архива"
+ log_error "$(_t_or backup_archive_err 'Ошибка создания архива')"
rm -rf "$tmp_dir"
rm -f "$tar_file"
return 1
fi
if [ ! -f "$tar_file" ]; then
- log_error "Архив не создан"
+ log_error "$(_t_or backup_archive_missing 'Архив не создан')"
rm -rf "$tmp_dir"
return 1
fi
@@ -88,13 +99,13 @@ EOMETA
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 "Ошибка шифрования"
+ log_error "$(_t_or backup_encrypt_err 'Ошибка шифрования')"
rm -f "$tar_file"
rm -rf "$tmp_dir"
return 1
fi
rm -f "$tar_file"
- log_success "Бекап зашифрован (AES-256-CBC)"
+ log_success "$(_t_or backup_encrypted 'Бекап зашифрован (AES-256-CBC)')"
else
final_file="${output_dir}/${backup_name}.tar.gz"
mv "$tar_file" "$final_file"
@@ -108,7 +119,11 @@ EOMETA
local size
size=$(du -h "$final_file" | cut -f1)
- log_success "Бекап создан: $final_file ($size)"
+ if type tf &>/dev/null; then
+ log_success "$(tf backup_created_fmt "$final_file" "$size")"
+ else
+ log_success "Бекап создан: $final_file ($size)"
+ fi
echo "$final_file"
return 0
}
@@ -119,7 +134,11 @@ restore_backup() {
local password="$2"
if [ ! -f "$backup_file" ]; then
- log_error "Файл не найден: $backup_file"
+ if type tf &>/dev/null; then
+ log_error "$(tf backup_file_not_found_fmt "$backup_file")"
+ else
+ log_error "Файл не найден: $backup_file"
+ fi
return 1
fi
@@ -130,14 +149,14 @@ restore_backup() {
local tar_file=""
if echo "$backup_file" | grep -q '\.enc$'; then
if [ -z "$password" ]; then
- echo -ne " Введите пароль от бекапа: "
+ echo -ne " $(_t_or backup_enter_pass 'Введите пароль от бекапа'): "
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 "Неверный пароль или повреждённый файл"
+ log_error "$(_t_or backup_bad_pass 'Неверный пароль или повреждённый файл')"
rm -rf "$tmp_dir" "$tar_file"
return 1
fi
@@ -148,7 +167,7 @@ restore_backup() {
# Распаковываем
tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null
if [ $? -ne 0 ]; then
- log_error "Ошибка распаковки архива"
+ log_error "$(_t_or backup_extract_err 'Ошибка распаковки архива')"
rm -rf "$tmp_dir"
return 1
fi
@@ -160,18 +179,20 @@ restore_backup() {
# Проверяем метаданные
if [ -f "$backup_dir/metadata.json" ]; then
- local bk_version bk_mode bk_ip
+ local bk_version bk_mode bk_ip bk_lang bk_date
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")
+ bk_lang=$(jq -r '.language // "-"' "$backup_dir/metadata.json")
+ bk_date=$(jq -r '.created_at // "-"' "$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 -e " ${BOLD}${WHITE}📦 $(_t_or backup_label 'Бекап'):${NC}"
+ echo -e " $(_t_or backup_version_label 'Версия'): $bk_version | $(_t_or backup_mode_label 'Режим'): $bk_mode | IP: $bk_ip | $(_t_or backup_lang_label 'Язык'): $bk_lang"
+ echo -e " $(_t_or backup_date_label 'Дата'): $bk_date"
echo ""
fi
- if ! confirm "Восстановить конфигурацию? Текущие настройки будут перезаписаны."; then
+ if ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then
rm -rf "$tmp_dir"
return 0
fi
@@ -185,14 +206,21 @@ restore_backup() {
mkdir -p /etc/telemt
cp "$backup_dir/config.toml" "$TELEMT_CONFIG"
chmod 600 "$TELEMT_CONFIG"
- log_success "telemt конфиг восстановлен"
+ log_success "$(_t_or backup_restored_telemt 'telemt конфиг восстановлен')"
fi
# Восстанавливаем GoTelegram конфиг
if [ -f "$backup_dir/gotelegram.json" ]; then
mkdir -p "$GOTELEGRAM_DIR"
cp "$backup_dir/gotelegram.json" "$GOTELEGRAM_CONFIG"
- log_success "GoTelegram конфиг восстановлен"
+ log_success "$(_t_or backup_restored_gotelegram 'GoTelegram конфиг восстановлен')"
+ fi
+
+ # Восстанавливаем language marker (i18n)
+ if [ -f "$backup_dir/.language" ]; then
+ mkdir -p "$GOTELEGRAM_DIR"
+ cp "$backup_dir/.language" "$GOTELEGRAM_DIR/.language"
+ log_success "$(_t_or backup_restored_lang 'Язык интерфейса восстановлен')"
fi
# Восстанавливаем nginx конфиг
@@ -200,7 +228,7 @@ restore_backup() {
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 конфиг восстановлен"
+ log_success "$(_t_or backup_restored_nginx 'nginx конфиг восстановлен')"
fi
# Восстанавливаем SSL
@@ -211,7 +239,7 @@ restore_backup() {
local cert_dir="/etc/letsencrypt/live/$domain"
mkdir -p "$cert_dir"
cp "$backup_dir/certs/"* "$cert_dir/" 2>/dev/null
- log_success "SSL сертификаты восстановлены"
+ log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')"
fi
fi
@@ -220,7 +248,7 @@ restore_backup() {
mkdir -p "$WEBSITE_ROOT"
cp -r "$backup_dir/site"/* "$WEBSITE_ROOT/"
chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null
- log_success "Шаблон сайта восстановлен"
+ log_success "$(_t_or backup_restored_site 'Шаблон сайта восстановлен')"
fi
# Запускаем сервисы
@@ -233,7 +261,7 @@ restore_backup() {
rm -rf "$tmp_dir"
[ "$tar_file" != "$backup_file" ] && rm -f "$tar_file"
- log_success "Восстановление завершено!"
+ log_success "$(_t_or backup_restore_done 'Восстановление завершено!')"
show_proxy_info
return 0
}
@@ -241,12 +269,12 @@ restore_backup() {
# ── Список бекапов ───────────────────────────────────────────────────────────
list_backups() {
if [ ! -d "$BACKUP_DIR" ] || [ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then
- log_info "Бекапов нет"
+ log_info "$(_t_or backup_none 'Бекапов нет')"
return 1
fi
echo ""
- echo -e " ${BOLD}${WHITE}📦 Доступные бекапы:${NC}"
+ echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_list_title 'Доступные бекапы'):${NC}"
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}"
local i=1
@@ -276,31 +304,35 @@ cleanup_old_backups() {
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)"
+ if type tf &>/dev/null; then
+ log_dim "$(tf backup_cleanup_fmt "$to_delete" "$keep")"
+ else
+ log_dim "Удалено $to_delete старых бекапов (оставлено $keep)"
+ fi
fi
}
# ── Интерактивный бекап ──────────────────────────────────────────────────────
interactive_backup() {
echo ""
- echo -e " ${BOLD}${WHITE}💾 Создание бекапа${NC}"
- echo -ne " Зашифровать бекап паролем? [Y/n]: "
+ echo -e " ${BOLD}${WHITE}💾 $(_t_or backup_create_title 'Создание бекапа')${NC}"
+ echo -ne " $(_t_or backup_encrypt_prompt 'Зашифровать бекап паролем?') [Y/n]: "
read -r use_pass
local password=""
if [[ ! "$use_pass" =~ ^[Nn] ]]; then
- echo -ne " Введите пароль: "
+ echo -ne " $(_t_or backup_enter_pass 'Введите пароль'): "
read -rs password
echo ""
- echo -ne " Повторите пароль: "
+ echo -ne " $(_t_or backup_repeat_pass 'Повторите пароль'): "
read -rs password2
echo ""
if [ "$password" != "$password2" ]; then
- log_error "Пароли не совпадают"
+ log_error "$(_t_or backup_pass_mismatch 'Пароли не совпадают')"
return 1
fi
if [ ${#password} -lt 6 ]; then
- log_error "Пароль слишком короткий (минимум 6 символов)"
+ log_error "$(_t_or backup_pass_short 'Пароль слишком короткий (минимум 6 символов)')"
return 1
fi
fi
@@ -313,7 +345,7 @@ interactive_backup() {
interactive_restore() {
list_backups || return 1
- echo -ne " Номер бекапа (или путь к файлу): "
+ echo -ne " $(_t_or backup_pick_prompt 'Номер бекапа (или путь к файлу)'): "
read -r choice
local backup_file=""
@@ -333,7 +365,7 @@ interactive_restore() {
fi
if [ -z "$backup_file" ]; then
- log_error "Бекап не найден"
+ log_error "$(_t_or backup_not_found 'Бекап не найден')"
return 1
fi
diff --git a/lib/common.sh b/lib/common.sh
index ce5c649..a4d35ea 100755
--- a/lib/common.sh
+++ b/lib/common.sh
@@ -1,9 +1,9 @@
#!/bin/bash
-# GoTelegram v2.3 — Общие утилиты
-# Цвета, логирование, спиннер, системные функции, совместимость с v1
+# GoTelegram v2.4 — common utilities
+# Colors, logging, spinner, system helpers, v1 compat, i18n-aware
-# ── Версия ────────────────────────────────────────────────────────────────────
-GOTELEGRAM_VERSION="2.3.1"
+# ── Version ───────────────────────────────────────────────────────────────────
+GOTELEGRAM_VERSION="2.4.0"
GOTELEGRAM_NAME="GoTelegram"
# ── Пути ──────────────────────────────────────────────────────────────────────
@@ -49,10 +49,12 @@ log_to_file() {
echo "[$ts] $*" >> "$LOG_FILE" 2>/dev/null
}
-# ── Спиннер ──────────────────────────────────────────────────────────────────
+# ── Spinner ──────────────────────────────────────────────────────────────────
_spin_pid=""
spinner_start() {
- local msg="${1:-Подождите...}"
+ local default_msg
+ default_msg=$(type t &>/dev/null && t wait || echo "Please wait...")
+ local msg="${1:-$default_msg}"
(
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
@@ -95,7 +97,9 @@ run_with_spinner() {
if [ $rc -eq 0 ]; then
log_success "$label"
else
- log_error "$label ${RED}(ошибка, код: $rc)${NC}"
+ local err_label
+ err_label=$(type t &>/dev/null && t error || echo "error")
+ log_error "$label ${RED}(${err_label}, code: $rc)${NC}"
if [ -s "$err_file" ]; then
log_dim " $(head -3 "$err_file")"
fi
@@ -104,35 +108,45 @@ run_with_spinner() {
return $rc
}
-# ── Баннер ───────────────────────────────────────────────────────────────────
+# ── Banner ───────────────────────────────────────────────────────────────────
show_banner() {
+ local line
+ line=$(printf '━%.0s' $(seq 1 60))
echo ""
- echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
- echo -e "${CYAN}║${NC} ${BOLD}${WHITE}🚀 GoTelegram v${GOTELEGRAM_VERSION}${NC} ${CYAN}║${NC}"
- echo -e "${CYAN}║${NC} ${DIM}MTProxy на ядре telemt (Rust + Tokio)${NC} ${CYAN}║${NC}"
- echo -e "${CYAN}║${NC} ${DIM}Anti-DPI • Fake TLS • TCP Splice • JA3/JA4${NC} ${CYAN}║${NC}"
- echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
+ echo -e "${CYAN}${line}${NC}"
+ if type tf &>/dev/null; then
+ echo -e " ${BOLD}${WHITE}🚀 $(tf banner_title "$GOTELEGRAM_VERSION")${NC}"
+ echo -e " ${DIM}$(t banner_subtitle)${NC}"
+ echo -e " ${DIM}$(t banner_features)${NC}"
+ else
+ echo -e " ${BOLD}${WHITE}🚀 GoTelegram v${GOTELEGRAM_VERSION}${NC}"
+ echo -e " ${DIM}MTProxy powered by telemt (Rust + Tokio)${NC}"
+ echo -e " ${DIM}Anti-DPI • Fake TLS • TCP Splice • JA3/JA4${NC}"
+ fi
+ echo -e "${CYAN}${line}${NC}"
echo ""
}
-# ── Благодарности ────────────────────────────────────────────────────────────
+# ── Credits ──────────────────────────────────────────────────────────────────
show_credits() {
+ local line
+ line=$(printf '─%.0s' $(seq 1 60))
echo ""
- echo -e "${MAGENTA}╔══════════════════════════════════════════════════════════╗${NC}"
- echo -e "${MAGENTA}║${NC} ${BOLD}Благодарности / Credits${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}╟──────────────────────────────────────────────────────────╢${NC}"
- echo -e "${MAGENTA}║${NC} ${WHITE}telemt${NC} — MTProxy engine (Rust) ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${DIM}github.com/telemt/telemt${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${WHITE}HTML5 UP${NC} — адаптивные HTML/CSS шаблоны ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${DIM}html5up.net • CC BY 3.0 • @ajlkn${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${WHITE}learning-zone${NC} — 150+ HTML5 шаблонов ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${DIM}github.com/learning-zone/website-templates${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${WHITE}Start Bootstrap${NC} — MIT лицензия ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}║${NC} ${DIM}startbootstrap.com${NC} ${MAGENTA}║${NC}"
- echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════╝${NC}"
+ echo -e "${MAGENTA}${line}${NC}"
+ echo -e " ${BOLD}$(type t &>/dev/null && t credits_title || echo 'Credits')${NC}"
+ echo -e "${MAGENTA}${line}${NC}"
+ echo -e " ${WHITE}telemt${NC} — MTProxy engine (Rust)"
+ echo -e " ${DIM}github.com/telemt/telemt${NC}"
+ echo ""
+ echo -e " ${WHITE}HTML5 UP${NC} — responsive HTML/CSS templates"
+ echo -e " ${DIM}html5up.net • CC BY 3.0 • @ajlkn${NC}"
+ echo ""
+ echo -e " ${WHITE}learning-zone${NC} — 150+ HTML5 templates"
+ echo -e " ${DIM}github.com/learning-zone/website-templates${NC}"
+ echo ""
+ echo -e " ${WHITE}Start Bootstrap${NC} — MIT license"
+ echo -e " ${DIM}startbootstrap.com${NC}"
+ echo -e "${MAGENTA}${line}${NC}"
echo ""
}
@@ -164,31 +178,41 @@ get_server_ip() {
return 1
}
+_t_or() {
+ # Helper: translate if i18n available, otherwise return fallback
+ local key="$1" fallback="$2"
+ if type t &>/dev/null; then
+ t "$key"
+ else
+ echo "$fallback"
+ fi
+}
+
check_root() {
if [ "$EUID" -ne 0 ]; then
- log_error "Запустите скрипт с sudo / от root"
+ log_error "$(_t_or err_need_root 'Run the script with sudo / as root')"
exit 1
fi
}
check_os() {
if [ ! -f /etc/os-release ]; then
- log_error "Не удалось определить ОС. Требуется Linux."
+ log_error "$(_t_or err_os_unknown 'Failed to detect OS. Linux is required.')"
return 1
fi
# Validate os-release before sourcing (reject command injection: ;, backticks, $())
if grep -qE '(;|`|\$\(|\$\{)' /etc/os-release 2>/dev/null; then
- log_warning "/etc/os-release содержит подозрительные строки, пропускаем"
+ log_warning "/etc/os-release contains suspicious strings, skipping"
return 0
fi
. /etc/os-release
case "$ID" in
ubuntu|debian|centos|rocky|almalinux|fedora|rhel)
- log_dim "ОС: $PRETTY_NAME"
+ log_dim "OS: $PRETTY_NAME"
return 0
;;
*)
- log_warning "ОС $ID может быть несовместима. Поддерживаются: Ubuntu, Debian, CentOS, Rocky."
+ log_warning "OS $ID may be incompatible. Supported: Ubuntu, Debian, CentOS, Rocky."
return 0
;;
esac
@@ -219,7 +243,7 @@ install_pkg() {
apt) apt-get install -y -qq "$pkg" ;;
dnf) dnf install -y -q "$pkg" ;;
yum) yum install -y -q "$pkg" ;;
- *) log_error "Неизвестный пакетный менеджер"; return 1 ;;
+ *) log_error "$(_t_or err_bad_pkg_mgr 'Unknown package manager')"; return 1 ;;
esac
}
@@ -231,7 +255,11 @@ ensure_deps() {
fi
done
if [ ${#missing[@]} -gt 0 ]; then
- log_step "Установка зависимостей: ${missing[*]}"
+ if type tf &>/dev/null; then
+ log_step "$(tf deps_installing "${missing[*]}")"
+ else
+ log_step "Installing dependencies: ${missing[*]}"
+ fi
case "$(get_pkg_manager)" in
apt) apt-get update -qq && apt-get install -y -qq "${missing[@]}" ;;
dnf) dnf install -y -q "${missing[@]}" ;;
@@ -257,7 +285,11 @@ check_disk_space() {
local avail_mb
avail_mb=$(df -m / | awk 'NR==2 {print $4}')
if [ "$avail_mb" -lt "$min_mb" ]; then
- log_error "Мало места на диске: ${avail_mb}MB (нужно ${min_mb}MB+)"
+ if type tf &>/dev/null; then
+ log_error "$(tf err_low_disk "$avail_mb" "$min_mb")"
+ else
+ log_error "Low disk space: ${avail_mb}MB (need ${min_mb}MB+)"
+ fi
return 1
fi
return 0
@@ -266,6 +298,8 @@ check_disk_space() {
# ── Конфигурация GoTelegram (JSON) ──────────────────────────────────────────
save_gotelegram_config() {
mkdir -p "$(dirname "$GOTELEGRAM_CONFIG")"
+ local cur_lang
+ cur_lang=$(type get_language &>/dev/null && get_language || echo en)
cat > "$GOTELEGRAM_CONFIG" << EOJSON
{
"version": "$GOTELEGRAM_VERSION",
@@ -276,6 +310,7 @@ save_gotelegram_config() {
"mask_host": "${5:-google.com}",
"domain": "${6:-}",
"template_id": "${7:-}",
+ "language": "${cur_lang}",
"installed_at": "$(date -Iseconds)",
"updated_at": "$(date -Iseconds)"
}
@@ -295,13 +330,11 @@ load_gotelegram_config() {
config_get() {
local key="$1"
if [ ! -f "$GOTELEGRAM_CONFIG" ]; then
- log_dim "Конфиг не найден: $GOTELEGRAM_CONFIG" >&2
return 2 # file missing
fi
local val
val=$(jq -r ".$key // empty" "$GOTELEGRAM_CONFIG" 2>/dev/null)
if [ $? -ne 0 ]; then
- log_dim "Ошибка чтения JSON: $GOTELEGRAM_CONFIG" >&2
return 3 # invalid JSON
fi
if [ -z "$val" ]; then
@@ -361,7 +394,7 @@ get_v1_config() {
}
migrate_v1_to_v2() {
- log_step "Миграция с v1 (mtg) на v2 (telemt)"
+ log_step "$(_t_or v1_migration_step 'Migrating from v1 (mtg) to v2 (telemt)')"
local v1_config
v1_config=$(get_v1_config)
@@ -371,44 +404,59 @@ migrate_v1_to_v2() {
old_secret=$(echo "$v1_config" | jq -r '.secret // empty')
if [ -z "$old_secret" ]; then
- log_warning "Не удалось извлечь secret из v1. Будет создан новый."
+ log_warning "Failed to extract secret from v1. A new one will be generated."
return 1
fi
echo ""
- echo -e " ${WHITE}Найдена установка v1 (mtg):${NC}"
- echo -e " Порт: ${CYAN}${old_port}${NC}"
- echo -e " Secret: ${CYAN}${old_secret:0:16}...${NC}"
+ echo -e " ${WHITE}$(_t_or v1_found_title 'Found v1 (mtg) installation:')${NC}"
+ if type tf &>/dev/null; then
+ echo -e " $(tf v1_port "$old_port")"
+ echo -e " $(tf v1_secret "${old_secret:0:16}")"
+ else
+ echo -e " Port: ${CYAN}${old_port}${NC}"
+ echo -e " Secret: ${CYAN}${old_secret:0:16}...${NC}"
+ fi
echo ""
- echo -e " ${YELLOW}Внимание:${NC} секрет mtg НЕ совместим с telemt напрямую."
- echo -e " Клиентам потребуется новая ссылка."
+ echo -e " ${YELLOW}$(_t_or warning 'Warning'):${NC} $(_t_or v1_incompatible 'mtg secret is NOT directly compatible with telemt.')"
+ echo -e " $(_t_or v1_new_link 'Clients will need a new link.')"
echo ""
- echo -ne " Остановить v1 контейнер и перейти на v2? [Y/n]: "
+ echo -ne " $(_t_or v1_stop_migrate 'Stop v1 container and migrate to v2? [Y/n]:') "
read -r ans
if [[ "$ans" =~ ^[Nn] ]]; then
- log_info "Миграция отменена. v1 оставлен без изменений."
+ log_info "$(_t_or v1_migration_cancelled 'Migration cancelled. v1 left intact.')"
return 1
fi
- # Останавливаем v1
- log_info "Остановка v1 контейнера..."
+ # Stop v1
+ log_info "$(_t_or v1_stopping 'Stopping v1 container...')"
docker stop "$V1_CONTAINER_NAME" 2>/dev/null
docker rm "$V1_CONTAINER_NAME" 2>/dev/null
- # Бекапим v1 конфиг
+ # Backup v1 config
if [ -f "$V1_CONFIG_FILE" ]; then
mkdir -p "$GOTELEGRAM_DIR"
cp "$V1_CONFIG_FILE" "$GOTELEGRAM_DIR/v1_backup_proxy.json" 2>/dev/null
- log_success "Конфиг v1 сохранён в $GOTELEGRAM_DIR/v1_backup_proxy.json"
+ if type tf &>/dev/null; then
+ log_success "$(tf v1_config_saved "$GOTELEGRAM_DIR/v1_backup_proxy.json")"
+ else
+ log_success "v1 config saved to $GOTELEGRAM_DIR/v1_backup_proxy.json"
+ fi
fi
- log_success "v1 остановлен. Порт $old_port освобождён."
+ if type tf &>/dev/null; then
+ log_success "$(tf v1_port_freed "$old_port")"
+ else
+ log_success "v1 stopped. Port $old_port freed."
+ fi
return 0
}
-# ── Подтверждение ────────────────────────────────────────────────────────────
+# ── Confirm prompt ───────────────────────────────────────────────────────────
confirm() {
- local msg="${1:-Продолжить?}"
+ local default_msg
+ default_msg=$(_t_or install_continue_anyway 'Continue?')
+ local msg="${1:-$default_msg}"
echo -ne " ${msg} [Y/n]: " >&2
read -r ans
[[ ! "$ans" =~ ^[Nn] ]]
@@ -429,7 +477,7 @@ select_option() {
((i++))
done
echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2
- echo -ne " ${WHITE}Выбор:${NC} " >&2
+ echo -ne " ${WHITE}$(_t_or choose 'Choose'):${NC} " >&2
read -r choice
echo "$choice"
}
diff --git a/lib/i18n.sh b/lib/i18n.sh
new file mode 100755
index 0000000..ca8107a
--- /dev/null
+++ b/lib/i18n.sh
@@ -0,0 +1,140 @@
+#!/bin/bash
+# GoTelegram v2.4 — i18n engine
+# Internationalization support: EN (English) / RU (Русский)
+#
+# Usage:
+# source lib/i18n.sh
+# load_language "ru" # or "en"
+# echo "$(t menu_install)" # translated string
+# printf "$(t greeting)\n" "$name" # with format args
+
+# ── Global i18n state ──
+declare -gA I18N
+LANG_CODE="${LANG_CODE:-en}"
+LANG_FILE=""
+
+# ── Load a language ──
+# Sources lib/lang/${lang}.sh into the I18N associative array.
+# Falls back to English if requested language file is missing.
+load_language() {
+ local lang="${1:-en}"
+ # Sanitize: only allow [a-z]{2} codes
+ if ! [[ "$lang" =~ ^[a-z]{2}$ ]]; then
+ lang="en"
+ fi
+
+ local lang_dir
+ lang_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/lang"
+ local lang_file="${lang_dir}/${lang}.sh"
+
+ if [ ! -f "$lang_file" ]; then
+ lang_file="${lang_dir}/en.sh"
+ lang="en"
+ fi
+
+ if [ -f "$lang_file" ]; then
+ # Clear previous keys then source the new language
+ I18N=()
+ # shellcheck disable=SC1090
+ source "$lang_file"
+ LANG_CODE="$lang"
+ LANG_FILE="$lang_file"
+ return 0
+ fi
+ return 1
+}
+
+# ── Translate: fetch value by key ──
+# t → echoes translation (or key if missing)
+t() {
+ local key="$1"
+ local val="${I18N[$key]:-}"
+ if [ -z "$val" ]; then
+ # Fallback to key name so missing translations are visible
+ echo "$key"
+ else
+ echo "$val"
+ fi
+}
+
+# ── Translate + printf-style formatting ──
+# tf ...
+tf() {
+ local key="$1"
+ shift
+ local fmt="${I18N[$key]:-$key}"
+ # shellcheck disable=SC2059
+ printf "$fmt" "$@"
+}
+
+# ── Get current language code ──
+get_language() {
+ echo "$LANG_CODE"
+}
+
+# ── Detect saved language from config.json, default en ──
+detect_language() {
+ local cfg="${GOTELEGRAM_CONFIG:-/opt/gotelegram/config.json}"
+ local lang=""
+ if [ -f "$cfg" ] && command -v jq >/dev/null 2>&1; then
+ lang=$(jq -r '.language // empty' "$cfg" 2>/dev/null)
+ fi
+ # Also check marker file (language set before config.json exists)
+ if [ -z "$lang" ]; then
+ local marker="${GOTELEGRAM_DIR:-/opt/gotelegram}/.language"
+ if [ -f "$marker" ]; then
+ lang=$(head -c 2 "$marker" 2>/dev/null | tr -d '[:space:]')
+ fi
+ fi
+ # Sanitize
+ if ! [[ "$lang" =~ ^(en|ru)$ ]]; then
+ lang="en"
+ fi
+ echo "$lang"
+}
+
+# ── Persist selected language ──
+# Saves to config.json if present, otherwise to marker file
+save_language() {
+ local lang="$1"
+ if ! [[ "$lang" =~ ^(en|ru)$ ]]; then
+ return 1
+ fi
+ mkdir -p "${GOTELEGRAM_DIR:-/opt/gotelegram}" 2>/dev/null
+ # Always write marker for early-access (before config.json exists)
+ echo "$lang" > "${GOTELEGRAM_DIR:-/opt/gotelegram}/.language" 2>/dev/null
+
+ local cfg="${GOTELEGRAM_CONFIG:-/opt/gotelegram/config.json}"
+ if [ -f "$cfg" ] && command -v jq >/dev/null 2>&1; then
+ local tmp
+ tmp=$(mktemp) || return 1
+ if jq --arg lang "$lang" '. + {language: $lang}' "$cfg" > "$tmp" 2>/dev/null; then
+ mv "$tmp" "$cfg"
+ chmod 600 "$cfg"
+ else
+ rm -f "$tmp"
+ fi
+ fi
+ return 0
+}
+
+# ── First-run interactive language picker ──
+# Shows a minimal, language-agnostic picker (keeps it culture-neutral).
+# Returns the chosen code via echo.
+pick_language_interactive() {
+ echo "" >&2
+ echo " ┌──────────────────────────────────────────┐" >&2
+ echo " │ Select language / Выберите язык │" >&2
+ echo " ├──────────────────────────────────────────┤" >&2
+ echo " │ 1) English │" >&2
+ echo " │ 2) Русский │" >&2
+ echo " └──────────────────────────────────────────┘" >&2
+ echo -n " > " >&2
+ local ch
+ read -r ch
+ case "$ch" in
+ 1|en|EN|english|English) echo "en" ;;
+ 2|ru|RU|russian|Russian|русский) echo "ru" ;;
+ *) echo "en" ;;
+ esac
+}
diff --git a/lib/lang/en.sh b/lib/lang/en.sh
new file mode 100755
index 0000000..fd3046e
--- /dev/null
+++ b/lib/lang/en.sh
@@ -0,0 +1,375 @@
+#!/bin/bash
+# GoTelegram v2.4 — English translations
+# shellcheck disable=SC2034,SC2148
+
+# ── Common words ────────────────────────────────────────────────────────
+I18N[yes]="Yes"
+I18N[no]="No"
+I18N[ok]="OK"
+I18N[cancel]="Cancel"
+I18N[back]="« Back"
+I18N[exit]="Exit"
+I18N[skip]="Skip"
+I18N[choose]="Choose"
+I18N[press_enter]="Press Enter..."
+I18N[press_enter_to_return]="Press Enter to return to menu..."
+I18N[invalid_choice]="Invalid choice"
+I18N[running]="running"
+I18N[stopped]="stopped"
+I18N[not_installed]="not installed"
+I18N[unknown]="unknown"
+I18N[error]="Error"
+I18N[warning]="Warning"
+I18N[info]="Info"
+I18N[success]="Done"
+I18N[wait]="Please wait..."
+
+# ── Banner ──────────────────────────────────────────────────────────────
+I18N[banner_title]="GoTelegram v%s"
+I18N[banner_subtitle]="MTProxy powered by telemt (Rust + Tokio)"
+I18N[banner_features]="Anti-DPI • Fake TLS • TCP Splice • JA3/JA4"
+I18N[credits_title]="Credits / Thanks"
+
+# ── Main menu (dashboard) ───────────────────────────────────────────────
+I18N[dashboard_title]="Control panel"
+I18N[svc_proxy]="Proxy"
+I18N[svc_nginx]="nginx"
+I18N[svc_site]="Site"
+I18N[svc_ssl]="SSL"
+I18N[svc_bot]="Bot"
+I18N[ssl_until]="until %s"
+I18N[net_ip]="IP:"
+I18N[net_port]="Port:"
+I18N[net_mode]="Mode:"
+I18N[net_domain]="Domain:"
+I18N[connection_link]="Telegram connection link:"
+I18N[proxy_not_configured]="Proxy is not configured. Select option 1."
+I18N[menu_proxy]="Proxy ▸"
+I18N[menu_stats]="Statistics ▸"
+I18N[menu_manage]="Management ▸"
+I18N[menu_telegram_bot]="Telegram bot ▸"
+I18N[menu_about]="About ▸"
+I18N[auto_refresh_30s]="Refresh in 30 sec"
+
+# ── Submenu: Proxy ──────────────────────────────────────────────────────
+I18N[submenu_proxy_title]="🚀 PROXY"
+I18N[proxy_install_update]="Install / Update"
+I18N[proxy_status_detail]="Detailed status"
+I18N[proxy_copy_link]="Copy link"
+I18N[proxy_share]="Share key"
+I18N[proxy_restart]="Restart"
+I18N[proxy_logs]="Logs"
+I18N[proxy_change_mode]="Change mode / template"
+
+# ── Submenu: Manage ─────────────────────────────────────────────────────
+I18N[submenu_manage_title]="⚙️ MANAGEMENT"
+I18N[manage_backup]="Backup"
+I18N[manage_restore]="Restore"
+I18N[manage_update_telemt]="Update telemt"
+I18N[manage_site_ssl]="Site / SSL"
+I18N[manage_remove]="Remove"
+I18N[manage_language]="Language / Язык"
+
+# ── Submenu: About ──────────────────────────────────────────────────────
+I18N[submenu_about_title]="ℹ️ ABOUT"
+I18N[about_version_info]="Version info"
+I18N[about_promo]="Promo / Donate"
+I18N[version_title]="🔍 Information"
+I18N[version_label]="GoTelegram:"
+I18N[version_engine]="Engine:"
+I18N[version_tech]="Technology:"
+I18N[version_license]="License:"
+
+# ── Install flow ────────────────────────────────────────────────────────
+I18N[install_select_mode]="🎭 Select masquerade mode:"
+I18N[install_lite_title]="⚡ Lite — masquerade as popular website"
+I18N[install_lite_desc1]="Fast, no domain needed. telemt disguises traffic"
+I18N[install_lite_desc2]="as the chosen site (google.com etc.)"
+I18N[install_pro_title]="🛡 Pro — your own site + full masquerade"
+I18N[install_pro_desc1]="nginx + SSL + HTML template + telemt."
+I18N[install_pro_desc2]="DPI sees a real website with a real certificate."
+I18N[install_pro_desc3]="Requires: a domain pointing to this server."
+I18N[install_mode_choice]="Choice (1/2):"
+I18N[install_bad_choice]="Invalid choice: %s"
+I18N[install_lite_step]="Installing Lite mode"
+I18N[install_pro_step]="Installing Pro mode"
+I18N[install_enter_domain]="Enter your domain (e.g. example.com):"
+I18N[install_bad_domain]="Invalid domain: %s"
+I18N[install_dns_mismatch]="Domain %s points to %s, not to %s"
+I18N[install_continue_anyway]="Continue anyway?"
+I18N[install_enter_email]="Email for SSL (Enter = no email):"
+I18N[install_config_title]="📋 Configuration:"
+I18N[install_cfg_ip]="IP:"
+I18N[install_cfg_port]="Port:"
+I18N[install_cfg_mask]="Masquerade:"
+I18N[install_cfg_mode]="Mode:"
+I18N[install_cfg_domain]="Domain:"
+I18N[install_confirm_proxy]="Install proxy?"
+I18N[install_confirm_proxy_site]="Install proxy + website?"
+I18N[install_done]="GoTelegram v%s installed! (%s mode)"
+I18N[install_arch_desc1]="telemt accepts all traffic on 443 (HTTPS masquerade)"
+I18N[install_arch_desc2]="nginx serves the site on internal port %s"
+I18N[install_arch_desc3]="ISP only sees HTTPS traffic to %s:443"
+
+# ── Change mode/template ────────────────────────────────────────────────
+I18N[change_current_mode]="Current mode:"
+I18N[change_template]="Change site template (pro only)"
+I18N[change_mode_switch]="Switch mode (lite ↔ pro)"
+I18N[change_only_pro]="Template change is available in pro mode only"
+I18N[change_requires_reinstall]="Mode switch requires reinstall."
+I18N[change_reinstall_confirm]="Reinstall proxy?"
+
+# ── Logs ────────────────────────────────────────────────────────────────
+I18N[logs_telemt_title]="📋 telemt logs (last %s lines):"
+
+# ── Link / Share ────────────────────────────────────────────────────────
+I18N[link_title]="🔗 Connection link:"
+I18N[share_title]="📤 Forward this message:"
+I18N[share_line1]="🔐 MTProxy for Telegram (GoTelegram v%s)"
+I18N[share_server]="🌍 Server: %s"
+I18N[share_port]="🔌 Port: %s"
+I18N[share_connect_cta]="👉 Connect with one tap:"
+I18N[share_footer]="Just tap the link or configure manually."
+
+# ── Website ─────────────────────────────────────────────────────────────
+I18N[website_title]="🌐 Website management"
+I18N[website_domain]="Domain:"
+I18N[website_ssl_until]="SSL until:"
+I18N[website_only_pro]="Website management is available in pro mode only"
+I18N[website_renew_ssl]="Renew SSL certificate"
+I18N[website_restart_nginx]="Restart nginx"
+I18N[website_change_template]="Change template"
+
+# ── Remove ──────────────────────────────────────────────────────────────
+I18N[remove_title]="🗑 Remove GoTelegram"
+I18N[remove_proxy_only]="Remove proxy only (telemt)"
+I18N[remove_bot_only]="Remove Telegram bot only"
+I18N[remove_all]="Remove everything (proxy + bot + settings)"
+I18N[remove_warn_proxy]="This will remove the proxy and all its settings."
+I18N[remove_confirm_proxy]="Remove proxy?"
+I18N[remove_backup_before]="Create a backup before removal?"
+I18N[remove_warn_all]="This will remove EVERYTHING: proxy, bot, site, settings."
+I18N[remove_confirm_all]="Are you absolutely sure?"
+I18N[remove_proxy_done]="Proxy removed"
+I18N[remove_all_done]="GoTelegram fully removed (proxy + bot)"
+
+# ── Telegram bot submenu ────────────────────────────────────────────────
+I18N[bot_title]="🤖 Telegram bot"
+I18N[bot_status_running]="● Running"
+I18N[bot_status_stopped]="○ Stopped"
+I18N[bot_status_not_installed]="✗ Not installed"
+I18N[bot_menu_status]="📊 Bot status"
+I18N[bot_menu_logs]="📋 Bot logs"
+I18N[bot_menu_restart]="🔄 Restart bot"
+I18N[bot_menu_stop]="⏹ Stop bot"
+I18N[bot_menu_start]="▶️ Start bot"
+I18N[bot_menu_settings]="⚙️ Settings (.env)"
+I18N[bot_menu_remove]="🗑 Remove bot"
+I18N[bot_menu_install]="🔧 Install bot"
+I18N[bot_intro1]="The bot lets you manage the proxy from Telegram:"
+I18N[bot_intro2]="status, restart, change mode, backup, QR code."
+I18N[bot_install_step]="Installing Telegram bot"
+I18N[bot_install_python]="Installing Python3..."
+I18N[bot_files_not_found]="Bot files not found in %s"
+I18N[bot_create_venv]="Creating virtual environment..."
+I18N[bot_install_deps]="Installing dependencies..."
+I18N[bot_enter_token]="Enter BOT_TOKEN from @BotFather:"
+I18N[bot_token_empty]="Token cannot be empty"
+I18N[bot_token]="Token:"
+I18N[bot_add_admin_how]="How to add the administrator?"
+I18N[bot_admin_auto]="Auto — bot will capture the ID on first /start"
+I18N[bot_admin_manual]="Manual — enter the ID now"
+I18N[bot_admin_ids_prompt]="Admin IDs (space or comma separated):"
+I18N[bot_env_created]=".env created"
+I18N[bot_env_exists]=".env already exists, settings preserved"
+I18N[bot_wait_admin_title]="Waiting for administrator"
+I18N[bot_wait_admin_msg1]="Open the bot in Telegram and send"
+I18N[bot_wait_admin_msg2]="The bot will automatically make you an admin"
+I18N[bot_wait_admin_skip]="Press Ctrl+C to skip"
+I18N[bot_wait_spinner]="Waiting... send /start to the bot (%d sec)"
+I18N[bot_admin_assigned]="Administrator assigned!"
+I18N[bot_wait_skipped]="Skipped. Add admin later via: menu → Telegram bot → Settings"
+I18N[bot_wait_timeout]="Timeout (5 min). Add admin via: menu → Telegram bot → Settings"
+I18N[bot_installed]="Bot installed and running!"
+I18N[bot_status_title]="📊 Telegram bot status"
+I18N[bot_token_configured]="configured"
+I18N[bot_access_open]="all users"
+I18N[bot_logs_title]="📋 Bot logs (last 30 lines):"
+I18N[bot_settings_title]="⚙️ Bot settings"
+I18N[bot_current_env]="Current .env:"
+I18N[bot_change_token]="Change BOT_TOKEN"
+I18N[bot_change_allowed]="Change ALLOWED_IDS"
+I18N[bot_new_token]="New BOT_TOKEN:"
+I18N[bot_token_empty_err]="Empty token"
+I18N[bot_token_updated]="Token updated, bot restarted"
+I18N[bot_allowed_prompt]="ALLOWED_IDS (space or comma separated, empty = auto):"
+I18N[bot_access_updated]="Access updated, bot restarted"
+I18N[bot_remove_warn]="This will remove the Telegram bot and all its settings."
+I18N[bot_remove_confirm]="Remove bot?"
+I18N[bot_removed]="Bot fully removed"
+I18N[bot_restarted]="Bot restarted"
+I18N[bot_stopped]="Bot stopped"
+I18N[bot_started]="Bot started"
+I18N[bot_status_colon]="Status:"
+I18N[bot_access_colon]="Access:"
+I18N[bot_access_ids_fmt]="ID: %s"
+
+# ── Promo / Donate ──────────────────────────────────────────────────────
+I18N[promo_host1_title]="💰 HOSTING #1 — UP TO 60% OFF"
+I18N[promo_host2_title]="💰 HOSTING #2 — UP TO 60% OFF"
+I18N[promo_tips_title]="☕ Donate / Tips"
+I18N[promo_link_label]="Link:"
+I18N[promo_off60]="60%% discount on the first month"
+I18N[promo_ant20]="20%% + 3%% when paid for 3 months"
+I18N[promo_ant6]="15%% + 5%% when paid for 6 months"
+I18N[promo_qr_host1]="── QR: Hosting #1 ──"
+I18N[promo_qr_host2]="── QR: Hosting #2 ──"
+I18N[promo_qr_tips]="── QR: Donate / Tips ──"
+I18N[promo_menu_in]="Menu in %d sec..."
+
+# ── Stats ───────────────────────────────────────────────────────────────
+I18N[stats_title]="📊 Traffic statistics"
+I18N[stats_module_missing]="Statistics module not loaded."
+I18N[stats_file_missing]="File lib/stats.sh not found."
+I18N[stats_toggle]="Toggle counter (now: %s)"
+I18N[stats_install_collector]="Install/update stats collector"
+I18N[stats_auto_refresh]="Refresh every 3 sec"
+I18N[stats_on]="on"
+I18N[stats_off]="off"
+
+# ── Templates catalog ───────────────────────────────────────────────────
+I18N[templates_categories]="📂 Site template categories:"
+I18N[templates_custom_git]="📎 Custom template from git URL"
+I18N[templates_random]="🎲 Random template"
+I18N[templates_count_fmt]="(%d templates)"
+I18N[templates_list]="📋 %s — available templates:"
+I18N[templates_preview_title]="🔍 Template preview:"
+I18N[templates_name]="Name:"
+I18N[templates_source]="Source:"
+I18N[templates_description]="Description:"
+I18N[templates_preview]="👁 Preview:"
+I18N[templates_preview_hint]="Open the link in a browser to preview the template"
+I18N[templates_repo]="📦 Repo:"
+I18N[templates_thanks]="💜 Thanks to the authors of %s for the open source code!"
+I18N[templates_install_this]="Install this template?"
+I18N[templates_cat_empty]="No templates in this category"
+I18N[templates_downloading]="Downloading template \"%s\"..."
+I18N[templates_downloaded]="Template \"%s\" downloaded"
+I18N[templates_downloaded_subfolder]="Template \"%s\" downloaded (from subfolder)"
+I18N[templates_no_index]="Template does not contain index.html"
+I18N[templates_path]="Path: %s"
+I18N[templates_catalog_not_found]="Templates catalog not found: %s"
+
+# ── Custom git template ─────────────────────────────────────────────────
+I18N[custom_git_title]="📎 CUSTOM TEMPLATE FROM GIT URL"
+I18N[custom_git_help_1]="You can use ANY public static HTML repository as a template."
+I18N[custom_git_help_2]="The repository must be public and contain a ready-made"
+I18N[custom_git_help_3]="index.html (build via npm is NOT performed)."
+I18N[custom_git_formats]="Supported URL formats:"
+I18N[custom_git_fmt_github]=" • https://github.com/user/repo"
+I18N[custom_git_fmt_gitlab]=" • https://gitlab.com/user/repo"
+I18N[custom_git_fmt_gitext]=" • https://example.com/user/repo.git"
+I18N[custom_git_fmt_branch]=" • https://github.com/user/repo@branch (branch after @)"
+I18N[custom_git_auto_detect]="Repository structure (auto-detection):"
+I18N[custom_git_auto_1]=" 1. index.html in repo root"
+I18N[custom_git_auto_2]=" 2. dist/index.html (StartBootstrap, Vite, webpack)"
+I18N[custom_git_auto_3]=" 3. public/ or build/ or _site/ or site/ or docs/"
+I18N[custom_git_auto_4]=" 4. Fallback: search index.html across whole repo"
+I18N[custom_git_requirements]="Requirements:"
+I18N[custom_git_req_1]=" • HTTPS only (ssh:// and git:// are blocked)"
+I18N[custom_git_req_2]=" • Public repositories only"
+I18N[custom_git_req_3]=" • Repo size up to 100 MB"
+I18N[custom_git_req_4]=" • Static HTML (no PHP/Python/Node server code)"
+I18N[custom_git_examples]="Tested example repos:"
+I18N[custom_git_ex_1]=" • https://github.com/html5up-collective/strata"
+I18N[custom_git_ex_2]=" • https://github.com/StartBootstrap/startbootstrap-landing-page"
+I18N[custom_git_enter_url]="Paste git URL (or Enter to cancel):"
+I18N[custom_git_empty]="No URL provided, cancelled"
+I18N[custom_git_bad_url]="Invalid URL. Only https:// addresses are accepted"
+I18N[custom_git_cloning]="Cloning repository..."
+I18N[custom_git_clone_failed]="Failed to clone repository: %s"
+I18N[custom_git_too_big]="Repository is too large: %s (limit 100MB)"
+I18N[custom_git_scanning]="Scanning for index.html..."
+I18N[custom_git_found_at]="✓ Found index.html in: %s"
+I18N[custom_git_no_index]="index.html not found in the repository"
+I18N[custom_git_installed]="Custom template installed from %s"
+I18N[custom_git_saved]="Template URL saved in config (menu → Site → Update from git)"
+
+# ── First-run language picker ───────────────────────────────────────────
+I18N[lang_picker_title]="Select language / Выберите язык"
+I18N[lang_english]="English"
+I18N[lang_russian]="Русский"
+I18N[lang_saved]="Language saved: %s"
+I18N[lang_change_prompt]="Select a new language:"
+
+# ── Backup ──────────────────────────────────────────────────────────────
+I18N[backup_title]="💾 Backup"
+I18N[backup_creating]="Creating backup..."
+I18N[backup_created]="Backup created: %s"
+I18N[backup_failed]="Backup creation failed"
+I18N[backup_restore_title]="↩️ Restore from backup"
+I18N[backup_no_files]="No backup files"
+I18N[backup_select]="Select a backup to restore:"
+I18N[backup_restoring]="Restoring..."
+I18N[backup_restored]="Backup restored"
+I18N[backup_collecting]="Collecting configuration..."
+I18N[backup_site_included]="Website template included"
+I18N[backup_archive_err]="Archive creation failed"
+I18N[backup_archive_missing]="Archive not created"
+I18N[backup_encrypt_err]="Encryption failed"
+I18N[backup_encrypted]="Backup encrypted (AES-256-CBC)"
+I18N[backup_created_fmt]="Backup created: %s (%s)"
+I18N[backup_file_not_found_fmt]="File not found: %s"
+I18N[backup_enter_pass]="Enter backup password"
+I18N[backup_bad_pass]="Wrong password or corrupted file"
+I18N[backup_extract_err]="Archive extraction failed"
+I18N[backup_label]="Backup"
+I18N[backup_version_label]="Version"
+I18N[backup_mode_label]="Mode"
+I18N[backup_lang_label]="Language"
+I18N[backup_date_label]="Date"
+I18N[backup_confirm_restore]="Restore configuration? Current settings will be overwritten."
+I18N[backup_restored_telemt]="telemt config restored"
+I18N[backup_restored_gotelegram]="GoTelegram config restored"
+I18N[backup_restored_lang]="Interface language restored"
+I18N[backup_restored_nginx]="nginx config restored"
+I18N[backup_restored_ssl]="SSL certificates restored"
+I18N[backup_restored_site]="Website template restored"
+I18N[backup_restore_done]="Restore completed!"
+I18N[backup_none]="No backups"
+I18N[backup_list_title]="Available backups"
+I18N[backup_cleanup_fmt]="Removed %s old backups (kept %s)"
+I18N[backup_create_title]="Create backup"
+I18N[backup_encrypt_prompt]="Encrypt backup with a password?"
+I18N[backup_repeat_pass]="Repeat password"
+I18N[backup_pass_mismatch]="Passwords do not match"
+I18N[backup_pass_short]="Password too short (minimum 6 characters)"
+I18N[backup_pick_prompt]="Backup number (or path to file)"
+I18N[backup_not_found]="Backup not found"
+
+# ── Errors / misc ───────────────────────────────────────────────────────
+I18N[err_need_root]="Run the script with sudo / as root"
+I18N[err_os_unknown]="Failed to detect OS. Linux is required."
+I18N[err_low_disk]="Low disk space: %sMB (need %sMB+)"
+I18N[err_bad_pkg_mgr]="Unknown package manager"
+I18N[err_unexpected]="Unexpected error"
+I18N[bye]="See you later! 👋"
+I18N[auto_refresh]="Refresh in 30 sec"
+
+# ── Deps ────────────────────────────────────────────────────────────────
+I18N[deps_installing]="Installing dependencies: %s"
+
+# ── Migration ───────────────────────────────────────────────────────────
+I18N[v1_detected]="⚠️ GoTelegram v1 (mtg) installation detected"
+I18N[v1_container]="Container: %s"
+I18N[v1_migration_step]="Migrating from v1 (mtg) to v2 (telemt)"
+I18N[v1_found_title]="Found v1 (mtg) installation:"
+I18N[v1_port]="Port: %s"
+I18N[v1_secret]="Secret: %s..."
+I18N[v1_incompatible]="mtg secret is NOT directly compatible with telemt."
+I18N[v1_new_link]="Clients will need a new link."
+I18N[v1_stop_migrate]="Stop v1 container and migrate to v2? [Y/n]:"
+I18N[v1_migration_cancelled]="Migration cancelled. v1 left intact."
+I18N[v1_stopping]="Stopping v1 container..."
+I18N[v1_config_saved]="v1 config saved to %s"
+I18N[v1_port_freed]="v1 stopped. Port %s freed."
diff --git a/lib/lang/ru.sh b/lib/lang/ru.sh
new file mode 100755
index 0000000..6101c23
--- /dev/null
+++ b/lib/lang/ru.sh
@@ -0,0 +1,375 @@
+#!/bin/bash
+# GoTelegram v2.4 — Russian translations
+# shellcheck disable=SC2034,SC2148
+
+# ── Common words ────────────────────────────────────────────────────────
+I18N[yes]="Да"
+I18N[no]="Нет"
+I18N[ok]="OK"
+I18N[cancel]="Отмена"
+I18N[back]="« Назад"
+I18N[exit]="Выход"
+I18N[skip]="Пропустить"
+I18N[choose]="Выбор"
+I18N[press_enter]="Нажмите Enter..."
+I18N[press_enter_to_return]="Нажмите Enter для возврата в меню..."
+I18N[invalid_choice]="Неверный выбор"
+I18N[running]="работает"
+I18N[stopped]="остановлен"
+I18N[not_installed]="не установлен"
+I18N[unknown]="неизвестно"
+I18N[error]="Ошибка"
+I18N[warning]="Внимание"
+I18N[info]="Инфо"
+I18N[success]="Готово"
+I18N[wait]="Подождите..."
+
+# ── Banner ──────────────────────────────────────────────────────────────
+I18N[banner_title]="GoTelegram v%s"
+I18N[banner_subtitle]="MTProxy на ядре telemt (Rust + Tokio)"
+I18N[banner_features]="Anti-DPI • Fake TLS • TCP Splice • JA3/JA4"
+I18N[credits_title]="Благодарности / Credits"
+
+# ── Main menu (dashboard) ───────────────────────────────────────────────
+I18N[dashboard_title]="Панель управления"
+I18N[svc_proxy]="Прокси"
+I18N[svc_nginx]="nginx"
+I18N[svc_site]="Сайт"
+I18N[svc_ssl]="SSL"
+I18N[svc_bot]="Бот"
+I18N[ssl_until]="до %s"
+I18N[net_ip]="IP:"
+I18N[net_port]="Порт:"
+I18N[net_mode]="Режим:"
+I18N[net_domain]="Домен:"
+I18N[connection_link]="Ссылка для Telegram:"
+I18N[proxy_not_configured]="Прокси не настроен. Выберите пункт 1."
+I18N[menu_proxy]="Прокси ▸"
+I18N[menu_stats]="Статистика ▸"
+I18N[menu_manage]="Управление ▸"
+I18N[menu_telegram_bot]="Telegram-бот ▸"
+I18N[menu_about]="О программе ▸"
+I18N[auto_refresh_30s]="Обновление через 30 сек"
+
+# ── Submenu: Proxy ──────────────────────────────────────────────────────
+I18N[submenu_proxy_title]="🚀 ПРОКСИ"
+I18N[proxy_install_update]="Установить / Обновить"
+I18N[proxy_status_detail]="Статус подробно"
+I18N[proxy_copy_link]="Скопировать ссылку"
+I18N[proxy_share]="Поделиться ключом"
+I18N[proxy_restart]="Перезапуск"
+I18N[proxy_logs]="Логи"
+I18N[proxy_change_mode]="Сменить режим / шаблон"
+
+# ── Submenu: Manage ─────────────────────────────────────────────────────
+I18N[submenu_manage_title]="⚙️ УПРАВЛЕНИЕ"
+I18N[manage_backup]="Бекап"
+I18N[manage_restore]="Восстановить"
+I18N[manage_update_telemt]="Обновить telemt"
+I18N[manage_site_ssl]="Сайт / SSL"
+I18N[manage_remove]="Удалить"
+I18N[manage_language]="Язык / Language"
+
+# ── Submenu: About ──────────────────────────────────────────────────────
+I18N[submenu_about_title]="ℹ️ О ПРОГРАММЕ"
+I18N[about_version_info]="Информация о версии"
+I18N[about_promo]="Промо / Донат"
+I18N[version_title]="🔍 Информация"
+I18N[version_label]="GoTelegram:"
+I18N[version_engine]="Ядро:"
+I18N[version_tech]="Технология:"
+I18N[version_license]="Лицензия:"
+
+# ── Install flow ────────────────────────────────────────────────────────
+I18N[install_select_mode]="🎭 Выберите режим маскировки:"
+I18N[install_lite_title]="⚡ Lite — маскировка под популярный сайт"
+I18N[install_lite_desc1]="Быстро, без домена. telemt маскирует трафик"
+I18N[install_lite_desc2]="под выбранный сайт (google.com и т.д.)"
+I18N[install_pro_title]="🛡 Pro — свой сайт + полная маскировка"
+I18N[install_pro_desc1]="nginx + SSL + HTML-шаблон + telemt."
+I18N[install_pro_desc2]="DPI видит реальный сайт с реальным сертификатом."
+I18N[install_pro_desc3]="Требует: домен, направленный на этот сервер."
+I18N[install_mode_choice]="Выбор (1/2):"
+I18N[install_bad_choice]="Неверный выбор: %s"
+I18N[install_lite_step]="Установка Lite-режима"
+I18N[install_pro_step]="Установка Pro-режима"
+I18N[install_enter_domain]="Введите ваш домен (например, example.com):"
+I18N[install_bad_domain]="Некорректный домен: %s"
+I18N[install_dns_mismatch]="Домен %s указывает на %s, а не на %s"
+I18N[install_continue_anyway]="Продолжить всё равно?"
+I18N[install_enter_email]="Email для SSL (Enter = без email):"
+I18N[install_config_title]="📋 Конфигурация:"
+I18N[install_cfg_ip]="IP:"
+I18N[install_cfg_port]="Порт:"
+I18N[install_cfg_mask]="Маскировка:"
+I18N[install_cfg_mode]="Режим:"
+I18N[install_cfg_domain]="Домен:"
+I18N[install_confirm_proxy]="Установить прокси?"
+I18N[install_confirm_proxy_site]="Установить прокси + сайт?"
+I18N[install_done]="GoTelegram v%s установлен! (%s-режим)"
+I18N[install_arch_desc1]="telemt принимает весь трафик на 443 (маскировка под HTTPS)"
+I18N[install_arch_desc2]="nginx обслуживает сайт на внутреннем порту %s"
+I18N[install_arch_desc3]="Провайдер видит только HTTPS-трафик к %s:443"
+
+# ── Change mode/template ────────────────────────────────────────────────
+I18N[change_current_mode]="Текущий режим:"
+I18N[change_template]="Сменить шаблон сайта (только pro)"
+I18N[change_mode_switch]="Переключить режим (lite ↔ pro)"
+I18N[change_only_pro]="Смена шаблона доступна только в pro-режиме"
+I18N[change_requires_reinstall]="Переключение режима требует переустановки."
+I18N[change_reinstall_confirm]="Переустановить прокси?"
+
+# ── Logs ────────────────────────────────────────────────────────────────
+I18N[logs_telemt_title]="📋 Логи telemt (последние %s строк):"
+
+# ── Link / Share ────────────────────────────────────────────────────────
+I18N[link_title]="🔗 Ссылка для подключения:"
+I18N[share_title]="📤 Перешлите это сообщение:"
+I18N[share_line1]="🔐 MTProxy для Telegram (GoTelegram v%s)"
+I18N[share_server]="🌍 Сервер: %s"
+I18N[share_port]="🔌 Порт: %s"
+I18N[share_connect_cta]="👉 Подключиться одним нажатием:"
+I18N[share_footer]="Просто нажмите на ссылку или настройте вручную."
+
+# ── Website ─────────────────────────────────────────────────────────────
+I18N[website_title]="🌐 Управление сайтом"
+I18N[website_domain]="Домен:"
+I18N[website_ssl_until]="SSL до:"
+I18N[website_only_pro]="Управление сайтом доступно только в pro-режиме"
+I18N[website_renew_ssl]="Обновить SSL сертификат"
+I18N[website_restart_nginx]="Перезапустить nginx"
+I18N[website_change_template]="Сменить шаблон"
+
+# ── Remove ──────────────────────────────────────────────────────────────
+I18N[remove_title]="🗑 Удаление GoTelegram"
+I18N[remove_proxy_only]="Удалить только прокси (telemt)"
+I18N[remove_bot_only]="Удалить только Telegram-бота"
+I18N[remove_all]="Удалить всё (прокси + бот + настройки)"
+I18N[remove_warn_proxy]="Это удалит прокси и все его настройки."
+I18N[remove_confirm_proxy]="Удалить прокси?"
+I18N[remove_backup_before]="Сделать бекап перед удалением?"
+I18N[remove_warn_all]="Это удалит ВСЁ: прокси, бот, сайт, настройки."
+I18N[remove_confirm_all]="Вы точно уверены?"
+I18N[remove_proxy_done]="Прокси удалён"
+I18N[remove_all_done]="GoTelegram полностью удалён (прокси + бот)"
+
+# ── Telegram bot submenu ────────────────────────────────────────────────
+I18N[bot_title]="🤖 Telegram-бот"
+I18N[bot_status_running]="● Работает"
+I18N[bot_status_stopped]="○ Остановлен"
+I18N[bot_status_not_installed]="✗ Не установлен"
+I18N[bot_menu_status]="📊 Статус бота"
+I18N[bot_menu_logs]="📋 Логи бота"
+I18N[bot_menu_restart]="🔄 Перезапустить бота"
+I18N[bot_menu_stop]="⏹ Остановить бота"
+I18N[bot_menu_start]="▶️ Запустить бота"
+I18N[bot_menu_settings]="⚙️ Настройки (.env)"
+I18N[bot_menu_remove]="🗑 Удалить бота"
+I18N[bot_menu_install]="🔧 Установить бота"
+I18N[bot_intro1]="Бот позволяет управлять прокси прямо из Telegram:"
+I18N[bot_intro2]="статус, перезапуск, смена режима, бекап, QR-код."
+I18N[bot_install_step]="Установка Telegram-бота"
+I18N[bot_install_python]="Установка Python3..."
+I18N[bot_files_not_found]="Файлы бота не найдены в %s"
+I18N[bot_create_venv]="Создание виртуального окружения..."
+I18N[bot_install_deps]="Установка зависимостей..."
+I18N[bot_enter_token]="Введите BOT_TOKEN от @BotFather:"
+I18N[bot_token_empty]="Токен не может быть пустым"
+I18N[bot_token]="Token:"
+I18N[bot_add_admin_how]="Как добавить администратора?"
+I18N[bot_admin_auto]="Автоматически — бот определит ID при первом /start"
+I18N[bot_admin_manual]="Вручную — ввести ID сейчас"
+I18N[bot_admin_ids_prompt]="ID администраторов (через пробел/запятую):"
+I18N[bot_env_created]=".env создан"
+I18N[bot_env_exists]=".env уже существует, настройки сохранены"
+I18N[bot_wait_admin_title]="Ожидание администратора"
+I18N[bot_wait_admin_msg1]="Откройте бота в Telegram и отправьте"
+I18N[bot_wait_admin_msg2]="Бот автоматически назначит вас администратором"
+I18N[bot_wait_admin_skip]="Нажмите Ctrl+C чтобы пропустить"
+I18N[bot_wait_spinner]="Ожидание... напишите /start боту (%d сек)"
+I18N[bot_admin_assigned]="Администратор назначен!"
+I18N[bot_wait_skipped]="Пропущено. Добавить админа позже: меню → Telegram-бот → Настройки"
+I18N[bot_wait_timeout]="Таймаут (5 мин). Добавить админа: меню → Telegram-бот → Настройки"
+I18N[bot_installed]="Бот установлен и запущен!"
+I18N[bot_status_title]="📊 Статус Telegram-бота"
+I18N[bot_token_configured]="настроен"
+I18N[bot_access_open]="все пользователи"
+I18N[bot_logs_title]="📋 Логи бота (последние 30 строк):"
+I18N[bot_settings_title]="⚙️ Настройки бота"
+I18N[bot_current_env]="Текущий .env:"
+I18N[bot_change_token]="Сменить BOT_TOKEN"
+I18N[bot_change_allowed]="Изменить ALLOWED_IDS"
+I18N[bot_new_token]="Новый BOT_TOKEN:"
+I18N[bot_token_empty_err]="Пустой токен"
+I18N[bot_token_updated]="Токен обновлён, бот перезапущен"
+I18N[bot_allowed_prompt]="ALLOWED_IDS (через пробел/запятую, пусто = авто):"
+I18N[bot_access_updated]="Доступ обновлён, бот перезапущен"
+I18N[bot_remove_warn]="Это удалит Telegram-бота и все его настройки."
+I18N[bot_remove_confirm]="Удалить бота?"
+I18N[bot_removed]="Бот полностью удалён"
+I18N[bot_restarted]="Бот перезапущен"
+I18N[bot_stopped]="Бот остановлен"
+I18N[bot_started]="Бот запущен"
+I18N[bot_status_colon]="Статус:"
+I18N[bot_access_colon]="Доступ:"
+I18N[bot_access_ids_fmt]="ID: %s"
+
+# ── Promo / Donate ──────────────────────────────────────────────────────
+I18N[promo_host1_title]="💰 ХОСТИНГ #1 — СКИДКА ДО 60%"
+I18N[promo_host2_title]="💰 ХОСТИНГ #2 — СКИДКА ДО 60%"
+I18N[promo_tips_title]="☕ Донат / Чаевые"
+I18N[promo_link_label]="Ссылка:"
+I18N[promo_off60]="60%% скидки на первый месяц"
+I18N[promo_ant20]="20%% + 3%% при оплате за 3 месяца"
+I18N[promo_ant6]="15%% + 5%% при оплате за 6 месяцев"
+I18N[promo_qr_host1]="── QR: Хостинг #1 ──"
+I18N[promo_qr_host2]="── QR: Хостинг #2 ──"
+I18N[promo_qr_tips]="── QR: Чаевые / Донат ──"
+I18N[promo_menu_in]="Меню через %d сек..."
+
+# ── Stats ───────────────────────────────────────────────────────────────
+I18N[stats_title]="📊 Статистика трафика"
+I18N[stats_module_missing]="Модуль статистики не загружен."
+I18N[stats_file_missing]="Файл lib/stats.sh не найден."
+I18N[stats_toggle]="Вкл/Выкл подсчёт (сейчас: %s)"
+I18N[stats_install_collector]="Установить/обновить сборщик статистики"
+I18N[stats_auto_refresh]="Обновление каждые 3 сек"
+I18N[stats_on]="вкл"
+I18N[stats_off]="выкл"
+
+# ── Templates catalog ───────────────────────────────────────────────────
+I18N[templates_categories]="📂 Категории шаблонов сайтов:"
+I18N[templates_custom_git]="📎 Свой шаблон по git URL"
+I18N[templates_random]="🎲 Случайный шаблон"
+I18N[templates_count_fmt]="(%d шаблонов)"
+I18N[templates_list]="📋 %s — доступные шаблоны:"
+I18N[templates_preview_title]="🔍 Превью шаблона:"
+I18N[templates_name]="Название:"
+I18N[templates_source]="Источник:"
+I18N[templates_description]="Описание:"
+I18N[templates_preview]="👁 Превью:"
+I18N[templates_preview_hint]="Откройте ссылку в браузере для просмотра шаблона"
+I18N[templates_repo]="📦 Репо:"
+I18N[templates_thanks]="💜 Спасибо авторам %s за открытый код!"
+I18N[templates_install_this]="Установить этот шаблон?"
+I18N[templates_cat_empty]="В этой категории нет шаблонов"
+I18N[templates_downloading]="Скачивание шаблона \"%s\"..."
+I18N[templates_downloaded]="Шаблон \"%s\" скачан"
+I18N[templates_downloaded_subfolder]="Шаблон \"%s\" скачан (из подпапки)"
+I18N[templates_no_index]="Шаблон не содержит index.html"
+I18N[templates_path]="Путь: %s"
+I18N[templates_catalog_not_found]="Каталог шаблонов не найден: %s"
+
+# ── Custom git template ─────────────────────────────────────────────────
+I18N[custom_git_title]="📎 СВОЙ ШАБЛОН ПО GIT URL"
+I18N[custom_git_help_1]="Вы можете использовать ЛЮБОЙ репозиторий со статическим HTML-сайтом"
+I18N[custom_git_help_2]="в качестве шаблона. Репозиторий должен быть публичным и содержать"
+I18N[custom_git_help_3]="готовый index.html (сборка через npm НЕ выполняется)."
+I18N[custom_git_formats]="Поддерживаемые форматы URL:"
+I18N[custom_git_fmt_github]=" • https://github.com/user/repo"
+I18N[custom_git_fmt_gitlab]=" • https://gitlab.com/user/repo"
+I18N[custom_git_fmt_gitext]=" • https://example.com/user/repo.git"
+I18N[custom_git_fmt_branch]=" • https://github.com/user/repo@branch (ветка после @)"
+I18N[custom_git_auto_detect]="Структура репозитория (авто-определение):"
+I18N[custom_git_auto_1]=" 1. index.html в корне репозитория"
+I18N[custom_git_auto_2]=" 2. dist/index.html (StartBootstrap, Vite, webpack)"
+I18N[custom_git_auto_3]=" 3. public/ или build/ или _site/ или site/ или docs/"
+I18N[custom_git_auto_4]=" 4. Fallback: поиск index.html по всему репозиторию"
+I18N[custom_git_requirements]="Требования:"
+I18N[custom_git_req_1]=" • Только HTTPS (ssh:// и git:// блокируются)"
+I18N[custom_git_req_2]=" • Только публичные репозитории"
+I18N[custom_git_req_3]=" • Размер репо не более 100 МБ"
+I18N[custom_git_req_4]=" • Статический HTML (без серверного кода PHP/Python/Node)"
+I18N[custom_git_examples]="Примеры проверенных репо:"
+I18N[custom_git_ex_1]=" • https://github.com/html5up-collective/strata"
+I18N[custom_git_ex_2]=" • https://github.com/StartBootstrap/startbootstrap-landing-page"
+I18N[custom_git_enter_url]="Вставьте git URL (или Enter для отмены):"
+I18N[custom_git_empty]="URL не указан, отмена"
+I18N[custom_git_bad_url]="Недопустимый URL. Принимаются только https:// адреса"
+I18N[custom_git_cloning]="Клонирование репозитория..."
+I18N[custom_git_clone_failed]="Не удалось клонировать репозиторий: %s"
+I18N[custom_git_too_big]="Репозиторий слишком большой: %s (лимит 100MB)"
+I18N[custom_git_scanning]="Поиск index.html в структуре..."
+I18N[custom_git_found_at]="✓ Найден index.html в: %s"
+I18N[custom_git_no_index]="index.html не найден в репозитории"
+I18N[custom_git_installed]="Свой шаблон установлен из %s"
+I18N[custom_git_saved]="URL шаблона сохранён в конфиге (меню → Сайт → Обновить из git)"
+
+# ── First-run language picker ───────────────────────────────────────────
+I18N[lang_picker_title]="Выберите язык / Select language"
+I18N[lang_english]="English"
+I18N[lang_russian]="Русский"
+I18N[lang_saved]="Язык сохранён: %s"
+I18N[lang_change_prompt]="Выберите новый язык:"
+
+# ── Backup ──────────────────────────────────────────────────────────────
+I18N[backup_title]="💾 Бекап"
+I18N[backup_creating]="Создание бекапа..."
+I18N[backup_created]="Бекап создан: %s"
+I18N[backup_failed]="Ошибка создания бекапа"
+I18N[backup_restore_title]="↩️ Восстановление из бекапа"
+I18N[backup_no_files]="Нет файлов бекапа"
+I18N[backup_select]="Выберите бекап для восстановления:"
+I18N[backup_restoring]="Восстановление..."
+I18N[backup_restored]="Бекап восстановлен"
+I18N[backup_collecting]="Собираю конфигурацию..."
+I18N[backup_site_included]="Шаблон сайта включён"
+I18N[backup_archive_err]="Ошибка создания архива"
+I18N[backup_archive_missing]="Архив не создан"
+I18N[backup_encrypt_err]="Ошибка шифрования"
+I18N[backup_encrypted]="Бекап зашифрован (AES-256-CBC)"
+I18N[backup_created_fmt]="Бекап создан: %s (%s)"
+I18N[backup_file_not_found_fmt]="Файл не найден: %s"
+I18N[backup_enter_pass]="Введите пароль от бекапа"
+I18N[backup_bad_pass]="Неверный пароль или повреждённый файл"
+I18N[backup_extract_err]="Ошибка распаковки архива"
+I18N[backup_label]="Бекап"
+I18N[backup_version_label]="Версия"
+I18N[backup_mode_label]="Режим"
+I18N[backup_lang_label]="Язык"
+I18N[backup_date_label]="Дата"
+I18N[backup_confirm_restore]="Восстановить конфигурацию? Текущие настройки будут перезаписаны."
+I18N[backup_restored_telemt]="telemt конфиг восстановлен"
+I18N[backup_restored_gotelegram]="GoTelegram конфиг восстановлен"
+I18N[backup_restored_lang]="Язык интерфейса восстановлен"
+I18N[backup_restored_nginx]="nginx конфиг восстановлен"
+I18N[backup_restored_ssl]="SSL сертификаты восстановлены"
+I18N[backup_restored_site]="Шаблон сайта восстановлен"
+I18N[backup_restore_done]="Восстановление завершено!"
+I18N[backup_none]="Бекапов нет"
+I18N[backup_list_title]="Доступные бекапы"
+I18N[backup_cleanup_fmt]="Удалено %s старых бекапов (оставлено %s)"
+I18N[backup_create_title]="Создание бекапа"
+I18N[backup_encrypt_prompt]="Зашифровать бекап паролем?"
+I18N[backup_repeat_pass]="Повторите пароль"
+I18N[backup_pass_mismatch]="Пароли не совпадают"
+I18N[backup_pass_short]="Пароль слишком короткий (минимум 6 символов)"
+I18N[backup_pick_prompt]="Номер бекапа (или путь к файлу)"
+I18N[backup_not_found]="Бекап не найден"
+
+# ── Errors / misc ───────────────────────────────────────────────────────
+I18N[err_need_root]="Запустите скрипт с sudo / от root"
+I18N[err_os_unknown]="Не удалось определить ОС. Требуется Linux."
+I18N[err_low_disk]="Мало места на диске: %sMB (нужно %sMB+)"
+I18N[err_bad_pkg_mgr]="Неизвестный пакетный менеджер"
+I18N[err_unexpected]="Неожиданная ошибка"
+I18N[bye]="До встречи! 👋"
+I18N[auto_refresh]="Обновление через 30 сек"
+
+# ── Deps ────────────────────────────────────────────────────────────────
+I18N[deps_installing]="Установка зависимостей: %s"
+
+# ── Migration ───────────────────────────────────────────────────────────
+I18N[v1_detected]="⚠️ Обнаружена установка GoTelegram v1 (mtg)"
+I18N[v1_container]="Контейнер: %s"
+I18N[v1_migration_step]="Миграция с v1 (mtg) на v2 (telemt)"
+I18N[v1_found_title]="Найдена установка v1 (mtg):"
+I18N[v1_port]="Порт: %s"
+I18N[v1_secret]="Secret: %s..."
+I18N[v1_incompatible]="секрет mtg НЕ совместим с telemt напрямую."
+I18N[v1_new_link]="Клиентам потребуется новая ссылка."
+I18N[v1_stop_migrate]="Остановить v1 контейнер и перейти на v2? [Y/n]:"
+I18N[v1_migration_cancelled]="Миграция отменена. v1 оставлен без изменений."
+I18N[v1_stopping]="Остановка v1 контейнера..."
+I18N[v1_config_saved]="Конфиг v1 сохранён в %s"
+I18N[v1_port_freed]="v1 остановлен. Порт %s освобождён."
diff --git a/lib/stats.sh b/lib/stats.sh
deleted file mode 100755
index adcee19..0000000
--- a/lib/stats.sh
+++ /dev/null
@@ -1,406 +0,0 @@
-#!/bin/bash
-# stats.sh — Traffic statistics module for GoTelegram
-# Tracks proxy (telemt port 443) and site (nginx port 8443) traffic
-# Uses iptables counters + real-time snapshots + historical CSV
-
-# Color codes (from common.sh)
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-STATS_DIR="/run/gotelegram"
-HISTORY_FILE="/opt/gotelegram/stats_history.csv"
-SNAPSHOTS_DIR="$STATS_DIR/snapshots"
-CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json"
-CONFIG_FILE="/opt/gotelegram/config.json"
-
-# Initialize stats infrastructure
-stats_init() {
- # Create runtime directory
- mkdir -p "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null
- chmod 755 "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null
-
- # Create iptables chain if not exists
- if ! iptables -L GOTELEGRAM_STATS -n >/dev/null 2>&1; then
- iptables -N GOTELEGRAM_STATS 2>/dev/null
- fi
-
- # Add chain to INPUT if not already present
- if ! iptables -C INPUT -j GOTELEGRAM_STATS 2>/dev/null; then
- iptables -I INPUT -j GOTELEGRAM_STATS 2>/dev/null
- fi
-
- # Add rule for proxy traffic (port 443, TCP)
- if ! iptables -C GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null; then
- iptables -A GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null
- fi
-
- # Add rule for site traffic (loopback, port 8443, TCP)
- if ! iptables -C GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null; then
- iptables -A GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null
- fi
-
- # Initialize CSV header if file doesn't exist
- if [[ ! -f "$HISTORY_FILE" ]]; then
- echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null
- fi
-
- # Write initial snapshot
- stats_collect
-}
-
-# Collect current traffic statistics from iptables
-stats_collect() {
- local proxy_bytes=0 proxy_pkts=0 site_bytes=0 site_pkts=0
- local ts=$(date +%s)
- local temp_file=$(mktemp)
-
- # Parse iptables output: format is "pkts bytes target"
- # We need to extract bytes (2nd column) for each rule
- local iptables_output=$(iptables -L GOTELEGRAM_STATS -v -n -x 2>/dev/null)
-
- # Extract counters for port 443 (proxy)
- proxy_bytes=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $2}')
- proxy_pkts=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $1}')
-
- # Extract counters for port 8443 on loopback (site)
- site_bytes=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $2}')
- site_pkts=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $1}')
-
- # Default to 0 if not found
- proxy_bytes=${proxy_bytes:-0}
- proxy_pkts=${proxy_pkts:-0}
- site_bytes=${site_bytes:-0}
- site_pkts=${site_pkts:-0}
-
- # Write current snapshot as JSON
- if command -v jq &>/dev/null; then
- echo "{\"ts\":$ts,\"proxy_bytes\":$proxy_bytes,\"proxy_pkts\":$proxy_pkts,\"site_bytes\":$site_bytes,\"site_pkts\":$site_pkts}" > "$CURRENT_SNAPSHOT" 2>/dev/null
- else
- cat > "$CURRENT_SNAPSHOT" 2>/dev/null </dev/null)
- local snapshot_file="$SNAPSHOTS_DIR/snap_${minute_key}.json"
- cp "$CURRENT_SNAPSHOT" "$snapshot_file" 2>/dev/null
-
- # Append to history CSV (once per minute, check if last entry is fresh)
- if [[ -f "$HISTORY_FILE" ]]; then
- local last_ts
- last_ts=$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f1)
- last_ts="${last_ts:-0}"
- local current_minute=$((ts - (ts % 60)))
-
- if [[ "$last_ts" -eq 0 ]] || [[ $((current_minute - last_ts)) -ge 60 ]]; then
- echo "$current_minute,$proxy_bytes,$site_bytes" >> "$HISTORY_FILE" 2>/dev/null
-
- # Cleanup old entries (keep only 365 days)
- stats_cleanup_history
- fi
- fi
-
- rm -f "$temp_file" 2>/dev/null
-}
-
-# Read current snapshot as JSON
-stats_read_current() {
- if [[ -f "$CURRENT_SNAPSHOT" ]]; then
- cat "$CURRENT_SNAPSHOT"
- else
- echo "{}"
- fi
-}
-
-# Extract value from JSON (fallback if jq not available)
-json_get() {
- local json="$1"
- local key="$2"
-
- if command -v jq &>/dev/null; then
- echo "$json" | jq -r ".${key}" 2>/dev/null || echo "0"
- else
- echo "$json" | grep -o "\"$key\":[^,}]*" | cut -d: -f2 | tr -d ' "' || echo "0"
- fi
-}
-
-# Convert bytes to human-readable format
-format_bytes() {
- local bytes=$1
-
- if (( bytes < 1024 )); then
- printf "%.0f B" "$bytes"
- elif (( bytes < 1024 * 1024 )); then
- printf "%.1f KB" "$(echo "scale=1; $bytes / 1024" | bc 2>/dev/null || echo "$((bytes / 1024))")"
- elif (( bytes < 1024 * 1024 * 1024 )); then
- printf "%.1f MB" "$(echo "scale=1; $bytes / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024))")"
- elif (( bytes < 1024 * 1024 * 1024 * 1024 )); then
- printf "%.1f GB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024))")"
- else
- printf "%.1f TB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024 / 1024))")"
- fi
-}
-
-# Convert bytes/sec to human-readable rate
-format_rate() {
- local bytes_per_sec=$1
-
- if (( bytes_per_sec < 1024 )); then
- printf "%.0f B/s" "$bytes_per_sec"
- elif (( bytes_per_sec < 1024 * 1024 )); then
- printf "%.1f KB/s" "$(echo "scale=1; $bytes_per_sec / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024))")"
- elif (( bytes_per_sec < 1024 * 1024 * 1024 )); then
- printf "%.1f MB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024))")"
- else
- printf "%.1f GB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024 / 1024))")"
- fi
-}
-
-# Safely convert value to integer (returns 0 for empty/non-numeric)
-_to_int() {
- local val="${1:-0}"
- # Strip non-numeric chars, default to 0
- val="${val//[^0-9]/}"
- echo "${val:-0}"
-}
-
-# Calculate diff safely (never negative, never crashes on empty)
-_safe_diff() {
- local a=$(_to_int "$1")
- local b=$(_to_int "$2")
- local d=$((a - b))
- (( d < 0 )) && d=0
- echo "$d"
-}
-
-# Calculate traffic rates and totals from history
-stats_calculate_rates() {
- local traffic_type="$1" # "proxy" or "site"
- local col_idx=2 # proxy_bytes is column 2
- [[ "$traffic_type" == "site" ]] && col_idx=3
-
- local now
- now=$(date +%s)
-
- # Get latest data line (skip header with grep -E '^[0-9]')
- local bytes_now
- bytes_now=$(_to_int "$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")")
-
- local periods="60 300 3600 86400 604800 2592000 31536000"
- local results=""
-
- for secs in $periods; do
- local target_ts=$((now - secs))
- # Find closest entry at or after target timestamp (skip header)
- local old_val
- old_val=$(_to_int "$(awk -F, -v ts="$target_ts" '$1 ~ /^[0-9]/ && $1 <= ts' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")")
-
- local diff
- diff=$(_safe_diff "$bytes_now" "$old_val")
- local rate=$(( secs > 0 ? diff / secs : 0 ))
-
- local bytes_fmt rate_fmt
- bytes_fmt=$(format_bytes "$diff")
- rate_fmt=$(format_rate "$rate")
-
- if [ -z "$results" ]; then
- results="${bytes_fmt}|${rate_fmt}"
- else
- results="${results}|${bytes_fmt}|${rate_fmt}"
- fi
- done
-
- echo "$results"
-}
-
-# Main display function for traffic statistics
-show_traffic_stats() {
- # Ensure stats are collected
- stats_collect
-
- # Get current counters
- local current_json=$(stats_read_current)
- local proxy_pkts=$(json_get "$current_json" "proxy_pkts")
- local site_pkts=$(json_get "$current_json" "site_pkts")
-
- # Calculate rates for proxy
- local proxy_rates=$(stats_calculate_rates "proxy")
- IFS='|' read -r p1m p1mr p5m p5mr p60m p60mr p1d p1dr p7d p7dr p30d p30dr p365d p365dr <<< "$proxy_rates"
-
- # Calculate rates for site
- local site_rates=$(stats_calculate_rates "site")
- IFS='|' read -r s1m s1mr s5m s5mr s60m s60mr s1d s1dr s7d s7dr s30d s30dr s365d s365dr <<< "$site_rates"
-
- # Display proxy stats
- {
- echo ""
- echo -e "${BLUE} Proxy (telemt, порт 443):${NC}"
- echo -e "${BLUE} ─────────────────────────────────────────${NC}"
- echo -e "${BLUE} Период │ Входящий │ Скорость${NC}"
- echo -e "${BLUE} ─────────────────────────────────────────${NC}"
- printf " %-9s │ %14s │ %s\n" "1 мин" "$p1m" "$p1mr"
- printf " %-9s │ %14s │ %s\n" "5 мин" "$p5m" "$p5mr"
- printf " %-9s │ %14s │ %s\n" "60 мин" "$p60m" "$p60mr"
- printf " %-9s │ %14s │ %s\n" "1 день" "$p1d" "$p1dr"
- printf " %-9s │ %14s │ %s\n" "7 дней" "$p7d" "$p7dr"
- printf " %-9s │ %14s │ %s\n" "30 дней" "$p30d" "$p30dr"
- printf " %-9s │ %14s │ %s\n" "365 дней" "$p365d" "$p365dr"
- echo -e "${BLUE} ─────────────────────────────────────────${NC}"
- printf " Пакетов: %d\n\n" "$proxy_pkts"
-
- echo -e "${BLUE} Сайт (nginx, порт 8443):${NC}"
- echo -e "${BLUE} ─────────────────────────────────────────${NC}"
- echo -e "${BLUE} Период │ Входящий │ Скорость${NC}"
- echo -e "${BLUE} ─────────────────────────────────────────${NC}"
- printf " %-9s │ %14s │ %s\n" "1 мин" "$s1m" "$s1mr"
- printf " %-9s │ %14s │ %s\n" "5 мин" "$s5m" "$s5mr"
- printf " %-9s │ %14s │ %s\n" "60 мин" "$s60m" "$s60mr"
- printf " %-9s │ %14s │ %s\n" "1 день" "$s1d" "$s1dr"
- printf " %-9s │ %14s │ %s\n" "7 дней" "$s7d" "$s7dr"
- printf " %-9s │ %14s │ %s\n" "30 дней" "$s30d" "$s30dr"
- printf " %-9s │ %14s │ %s\n" "365 дней" "$s365d" "$s365dr"
- echo -e "${BLUE} ─────────────────────────────────────────${NC}"
- printf " Пакетов: %d\n" "$site_pkts"
- echo ""
- } >&2
-}
-
-# Clean up history older than 365 days
-stats_cleanup_history() {
- if [[ ! -f "$HISTORY_FILE" ]]; then
- return
- fi
-
- local now=$(date +%s)
- local ts_365d=$((now - 31536000))
- local temp_file=$(mktemp)
-
- # Keep header + entries from last 365 days
- {
- head -1 "$HISTORY_FILE"
- awk -F, -v ts="$ts_365d" '$1 >= ts' "$HISTORY_FILE" | tail -n +2
- } > "$temp_file" 2>/dev/null
-
- mv "$temp_file" "$HISTORY_FILE" 2>/dev/null
-}
-
-# Toggle stats collection on/off
-toggle_stats() {
- local current_state="false"
-
- # Read current state from config
- if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then
- current_state=$(jq -r '.stats_enabled // false' "$CONFIG_FILE" 2>/dev/null)
- fi
-
- # Toggle
- if [[ "$current_state" == "true" ]]; then
- # Disable stats
- if [[ -f "$CONFIG_FILE" ]]; then
- if command -v jq &>/dev/null; then
- jq '.stats_enabled = false' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null
- mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
- fi
- fi
-
- # Remove iptables rules
- iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null
- iptables -F GOTELEGRAM_STATS 2>/dev/null
- iptables -X GOTELEGRAM_STATS 2>/dev/null
-
- # Clean up directories
- rm -rf "$STATS_DIR" 2>/dev/null
-
- echo "Сбор статистики ОТКЛЮЧЕН" >&2
- else
- # Enable stats
- if [[ -f "$CONFIG_FILE" ]]; then
- if command -v jq &>/dev/null; then
- jq '.stats_enabled = true' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null
- mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
- fi
- fi
-
- # Initialize stats collection
- stats_init
-
- echo "Сбор статистики ВКЛЮЧЕН" >&2
- fi
-}
-
-# Install systemd service for stats collection
-install_stats_collector() {
- local service_file="/etc/systemd/system/gotelegram-stats.service"
-
- # Check if running as root
- if [[ $EUID -ne 0 ]]; then
- echo "Требуется root для установки сервиса" >&2
- return 1
- fi
-
- # Get script directory (resolve symlinks)
- local script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
- local lib_dir=$(dirname "$script_dir")
-
- # Create systemd service file
- cat > "$service_file" <<'EOF'
-[Unit]
-Description=GoTelegram Traffic Stats Collector
-After=network.target
-Wants=network-online.target
-
-[Service]
-Type=simple
-User=root
-ExecStart=/bin/bash -c 'source /opt/gotelegram/lib/common.sh; source /opt/gotelegram/lib/stats.sh; stats_init; while true; do stats_collect; sleep 1; done'
-Restart=always
-RestartSec=5
-StandardOutput=journal
-StandardError=journal
-
-[Install]
-WantedBy=multi-user.target
-EOF
-
- chmod 644 "$service_file"
- systemctl daemon-reload
- systemctl enable gotelegram-stats.service
- systemctl start gotelegram-stats.service
-
- echo "Сервис gotelegram-stats установлен и запущен" >&2
-}
-
-# Remove stats collector service
-remove_stats_collector() {
- if [[ $EUID -ne 0 ]]; then
- echo "Требуется root для удаления сервиса" >&2
- return 1
- fi
-
- systemctl stop gotelegram-stats.service 2>/dev/null
- systemctl disable gotelegram-stats.service 2>/dev/null
- rm -f /etc/systemd/system/gotelegram-stats.service
- systemctl daemon-reload
-
- # Remove iptables rules
- iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null
- iptables -F GOTELEGRAM_STATS 2>/dev/null
- iptables -X GOTELEGRAM_STATS 2>/dev/null
-
- # Clean up directories and files
- rm -rf "$STATS_DIR" 2>/dev/null
- rm -f "$HISTORY_FILE" 2>/dev/null
-
- echo "Сервис статистики удалён" >&2
-}
-
-# Export functions for external use
-export -f stats_init stats_collect stats_read_current stats_calculate_rates
-export -f show_traffic_stats format_bytes format_rate toggle_stats
-export -f stats_cleanup_history install_stats_collector remove_stats_collector
-export -f json_get
diff --git a/lib/templates_catalog.sh b/lib/templates_catalog.sh
index 0060493..01503a1 100755
--- a/lib/templates_catalog.sh
+++ b/lib/templates_catalog.sh
@@ -1,20 +1,29 @@
#!/bin/bash
-# GoTelegram v2.2 — Каталог шаблонов сайтов
-# Выбор из ~200 шаблонов, превью-ссылки, скачивание через git sparse-checkout
+# GoTelegram v2.4 — website templates catalog
+# Pick from ~1800 templates, preview links, git sparse-checkout downloads,
+# + custom git URL templates (user-supplied public repos)
CATALOG_FILE="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")/templates_catalog.json"
TEMPLATES_CACHE="/tmp/gotelegram_templates"
-# ── Загрузка каталога ────────────────────────────────────────────────────────
+# Custom git template limits
+CUSTOM_GIT_MAX_SIZE_MB=100
+CUSTOM_GIT_CLONE_TIMEOUT=90
+
+# ── Catalog loading ────────────────────────────────────────────────────
load_catalog() {
if [ ! -f "$CATALOG_FILE" ]; then
- log_error "Каталог шаблонов не найден: $CATALOG_FILE"
+ if type tf &>/dev/null; then
+ log_error "$(tf templates_catalog_not_found "$CATALOG_FILE")"
+ else
+ log_error "Templates catalog not found: $CATALOG_FILE"
+ fi
return 1
fi
return 0
}
-# ── Категории ────────────────────────────────────────────────────────────────
+# ── Categories ─────────────────────────────────────────────────────────
get_categories() {
jq -r '.categories[] | "\(.id)|\(.name)|\(.icon)|\(.templates | length)"' "$CATALOG_FILE" 2>/dev/null
}
@@ -24,13 +33,13 @@ get_category_name() {
jq -r ".categories[] | select(.id == \"$cat_id\") | .name" "$CATALOG_FILE" 2>/dev/null
}
-# ── Шаблоны по категории ────────────────────────────────────────────────────
+# ── Templates in a category ────────────────────────────────────────────
get_templates_by_category() {
local cat_id="$1"
jq -r ".categories[] | select(.id == \"$cat_id\") | .templates[] | \"\(.id)|\(.name)|\(.source)|\(.preview_url)\"" "$CATALOG_FILE" 2>/dev/null
}
-# ── Информация о шаблоне ────────────────────────────────────────────────────
+# ── Template info ──────────────────────────────────────────────────────
get_template_info() {
local tpl_id="$1"
jq ".categories[].templates[] | select(.id == \"$tpl_id\")" "$CATALOG_FILE" 2>/dev/null
@@ -42,16 +51,19 @@ get_template_field() {
jq -r ".categories[].templates[] | select(.id == \"$tpl_id\") | .$field" "$CATALOG_FILE" 2>/dev/null
}
-# ── Интерактивный выбор категории ────────────────────────────────────────────
+# ── Interactive category picker (returns category id or special __custom_git__/__random__) ──
select_category() {
load_catalog || return 1
echo "" >&2
- echo -e " ${BOLD}${WHITE}📂 Категории шаблонов сайтов:${NC}" >&2
+ echo -e " ${BOLD}${WHITE}$(t templates_categories)${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2
+ # First item: custom git URL template
+ printf " ${CYAN}%2d)${NC} ${GREEN}%s${NC}\n" 1 "$(t templates_custom_git)" >&2
+
local cats=()
- local i=1
+ local i=2
while IFS='|' read -r id name icon count; do
[ "$count" -eq 0 ] && continue
local emoji
@@ -67,40 +79,52 @@ select_category() {
chart-bar) emoji="🔧" ;;
*) emoji="📄" ;;
esac
- printf " ${CYAN}%2d)${NC} ${emoji} %-30s ${DIM}(%d шаблонов)${NC}\n" "$i" "$name" "$count" >&2
+ printf " ${CYAN}%2d)${NC} ${emoji} %-30s ${DIM}$(tf templates_count_fmt "$count")${NC}\n" "$i" "$name" >&2
cats+=("$id")
((i++))
done < <(get_categories)
- printf " ${CYAN}%2d)${NC} 🎲 Случайный шаблон\n" "$i" >&2
+ printf " ${CYAN}%2d)${NC} %s\n" "$i" "$(t templates_random)" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2
- echo -ne " ${WHITE}Выбор:${NC} " >&2
+ echo -ne " ${WHITE}$(t choose):${NC} " >&2
read -r choice
- # Случайный
- if [ "$choice" -eq "$i" ] 2>/dev/null; then
+ if ! [[ "$choice" =~ ^[0-9]+$ ]]; then
+ log_error "$(t invalid_choice)"
+ return 1
+ fi
+
+ # Custom git URL
+ if [ "$choice" -eq 1 ]; then
+ echo "__custom_git__"
+ return 0
+ fi
+
+ # Random
+ if [ "$choice" -eq "$i" ]; then
local random_cat="${cats[$((RANDOM % ${#cats[@]}))]}"
echo "$random_cat"
return 0
fi
- if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -lt "$i" ]; then
- echo "${cats[$((choice-1))]}"
+ # Regular category (offset by 1 because item 1 is custom git)
+ if [ "$choice" -ge 2 ] && [ "$choice" -lt "$i" ]; then
+ echo "${cats[$((choice-2))]}"
return 0
fi
- log_error "Неверный выбор"
+ log_error "$(t invalid_choice)"
return 1
}
-# ── Интерактивный выбор шаблона ──────────────────────────────────────────────
+# ── Interactive template picker ────────────────────────────────────────
select_template() {
local cat_id="$1"
local cat_name
cat_name=$(get_category_name "$cat_id")
echo "" >&2
- echo -e " ${BOLD}${WHITE}📋 $cat_name — доступные шаблоны:${NC}" >&2
+ echo -e " ${BOLD}${WHITE}$(tf templates_list "$cat_name")${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
local tpls=()
@@ -112,29 +136,29 @@ select_template() {
done < <(get_templates_by_category "$cat_id")
if [ ${#tpls[@]} -eq 0 ]; then
- log_info "В этой категории нет шаблонов"
+ log_info "$(t templates_cat_empty)"
return 1
fi
echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2
- echo -ne " ${WHITE}Выбор (1-$((i-1))):${NC} " >&2
+ echo -ne " ${WHITE}$(t choose) (1-$((i-1))):${NC} " >&2
read -r choice
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -lt "$i" ]; then
local selected_id="${tpls[$((choice-1))]}"
- # Показываем превью
- show_template_preview "$selected_id"
+ # Show preview
+ show_template_preview "$selected_id" || return 1
echo "$selected_id"
return 0
fi
- log_error "Неверный выбор"
+ log_error "$(t invalid_choice)"
return 1
}
-# ── Показ превью шаблона ────────────────────────────────────────────────────
+# ── Template preview ───────────────────────────────────────────────────
show_template_preview() {
local tpl_id="$1"
local info
@@ -148,36 +172,36 @@ show_template_preview() {
description=$(echo "$info" | jq -r '.description // "—"')
echo "" >&2
- echo -e " ${BOLD}${WHITE}🔍 Превью шаблона:${NC}" >&2
+ echo -e " ${BOLD}${WHITE}$(t templates_preview_title)${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2
- echo -e " ${WHITE}Название:${NC} $name" >&2
- echo -e " ${WHITE}Источник:${NC} $source" >&2
- echo -e " ${WHITE}Описание:${NC} $description" >&2
+ echo -e " ${WHITE}$(t templates_name)${NC} $name" >&2
+ echo -e " ${WHITE}$(t templates_source)${NC} $source" >&2
+ echo -e " ${WHITE}$(t templates_description)${NC} $description" >&2
if [ -n "$preview_url" ]; then
echo "" >&2
- echo -e " ${GREEN}👁 Превью:${NC} ${CYAN}${preview_url}${NC}" >&2
- echo -e " ${DIM}Откройте ссылку в браузере для просмотра шаблона${NC}" >&2
+ echo -e " ${GREEN}$(t templates_preview)${NC} ${CYAN}${preview_url}${NC}" >&2
+ echo -e " ${DIM}$(t templates_preview_hint)${NC}" >&2
fi
if [ -n "$repo_url" ]; then
- echo -e " ${DIM}📦 Репо: ${repo_url}${NC}" >&2
+ echo -e " ${DIM}$(t templates_repo) ${repo_url}${NC}" >&2
fi
- # Благодарность автору
+ # Thanks
echo "" >&2
- echo -e " ${MAGENTA}💜 Спасибо авторам ${source} за открытый код!${NC}" >&2
+ echo -e " ${MAGENTA}$(tf templates_thanks "$source")${NC}" >&2
echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2
echo "" >&2
- if ! confirm "Установить этот шаблон?"; then
+ if ! confirm "$(t templates_install_this)"; then
return 1
fi
return 0
}
-# ── Скачивание шаблона ───────────────────────────────────────────────────────
+# ── Template download (from catalog) ───────────────────────────────────
download_template() {
local tpl_id="$1"
local output_dir="${2:-$TEMPLATES_CACHE}"
@@ -194,9 +218,9 @@ download_template() {
rm -rf "$clone_dir"
mkdir -p "$clone_dir"
- log_info "Скачивание шаблона \"$name\"..."
+ log_info "$(tf templates_downloading "$name")"
- # Для HTML5 UP — отдельный репо с папками
+ # HTML5 UP — one repo with folders
if [ "$source" = "html5up" ]; then
local tmp_clone="/tmp/html5up_clone_$$"
rm -rf "$tmp_clone"
@@ -204,7 +228,7 @@ download_template() {
# Sparse checkout
git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$tmp_clone" 2>/dev/null
if [ $? -ne 0 ]; then
- # Fallback: полный clone
+ # Fallback: full clone
git clone --depth 1 "$repo_url" "$tmp_clone" 2>/dev/null
fi
@@ -217,7 +241,7 @@ download_template() {
fi
rm -rf "$tmp_clone"
- # Для learning-zone — один большой репо
+ # learning-zone — one big repo
elif [ "$source" = "learning-zone" ]; then
local tmp_clone="/tmp/lz_clone_$$"
rm -rf "$tmp_clone"
@@ -236,14 +260,14 @@ download_template() {
fi
rm -rf "$tmp_clone"
- # Для StartBootstrap — каждый шаблон в своём репо
+ # StartBootstrap — each template in its own repo
elif [ "$source" = "startbootstrap" ]; then
local sb_tmp="/tmp/sb_clone_$$"
rm -rf "$sb_tmp"
git clone --depth 1 "$repo_url" "$sb_tmp" 2>/dev/null
if [ -d "$sb_tmp" ]; then
rm -rf "$sb_tmp/.git"
- # StartBootstrap хранит production-файлы в dist/
+ # StartBootstrap stores production files in dist/
if [ -f "$sb_tmp/dist/index.html" ]; then
cp -r "$sb_tmp/dist/"* "$clone_dir/"
elif [ -f "$sb_tmp/index.html" ]; then
@@ -260,7 +284,7 @@ download_template() {
fi
rm -rf "$sb_tmp"
- # Для ThemeWagon / ColorlibHQ — каждый шаблон в отдельном репо
+ # ThemeWagon / ColorlibHQ — each template in its own repo
elif [ "$source" = "themewagon" ] || [ "$source" = "colorlib" ]; then
local tw_tmp="/tmp/tw_clone_$$"
rm -rf "$tw_tmp"
@@ -283,7 +307,7 @@ download_template() {
fi
rm -rf "$tw_tmp"
- # Для dawidolko — один большой репо с папками (как learning-zone)
+ # dawidolko — one big repo with folders (similar to learning-zone)
elif [ "$source" = "dawidolko" ]; then
local tmp_clone="/tmp/dw_clone_$$"
rm -rf "$tmp_clone"
@@ -301,13 +325,13 @@ download_template() {
rm -rf "$tmp_clone"
fi
- # Проверяем результат
+ # Check result
if [ -f "$clone_dir/index.html" ]; then
- log_success "Шаблон \"$name\" скачан"
+ log_success "$(tf templates_downloaded "$name")"
echo "$clone_dir"
return 0
else
- # fallback: ищем index.html в подпапках (нестандартная структура)
+ # fallback: find index.html in subfolders (non-standard structure)
local fallback_index
fallback_index=$(find "$clone_dir" -name "index.html" -type f 2>/dev/null | head -1)
if [ -n "$fallback_index" ]; then
@@ -315,33 +339,253 @@ download_template() {
fallback_dir=$(dirname "$fallback_index")
if [ "$fallback_dir" != "$clone_dir" ]; then
cp -r "$fallback_dir/"* "$clone_dir/"
- log_success "Шаблон \"$name\" скачан (из подпапки)"
+ log_success "$(tf templates_downloaded_subfolder "$name")"
echo "$clone_dir"
return 0
fi
fi
- log_error "Шаблон не содержит index.html"
- log_dim "Путь: $clone_dir"
+ log_error "$(t templates_no_index)"
+ log_dim "$(tf templates_path "$clone_dir")"
ls -la "$clone_dir" 2>/dev/null >&2
return 1
fi
}
-# ── Полный интерактивный процесс выбора ──────────────────────────────────────
+# ── Custom git URL helpers ─────────────────────────────────────────────
+
+# Validate a user-supplied git URL
+# Accepts: https://host/path[.git][@branch]
+# Rejects: ssh://, git://, file://, absolute file paths
+_validate_custom_git_url() {
+ local url="$1"
+ # Must begin with https://
+ [[ "$url" =~ ^https:// ]] || return 1
+ # Reject shell metacharacters that could be exploited
+ [[ "$url" =~ [[:space:]\;\`\$\(\)\<\>\|\\\&] ]] && return 1
+ # Reasonable length limit
+ [ "${#url}" -gt 512 ] && return 1
+ return 0
+}
+
+# Parse URL → sets CUSTOM_GIT_CLEAN and CUSTOM_GIT_BRANCH globals
+_parse_custom_git_url() {
+ local url="$1"
+ CUSTOM_GIT_CLEAN=""
+ CUSTOM_GIT_BRANCH=""
+ # Handle trailing @branch
+ if [[ "$url" =~ ^(https://[^@]+)@([A-Za-z0-9._/-]+)$ ]]; then
+ CUSTOM_GIT_CLEAN="${BASH_REMATCH[1]}"
+ CUSTOM_GIT_BRANCH="${BASH_REMATCH[2]}"
+ else
+ CUSTOM_GIT_CLEAN="$url"
+ fi
+ # Strip trailing slash
+ CUSTOM_GIT_CLEAN="${CUSTOM_GIT_CLEAN%/}"
+ # Append .git if missing (works better with git clone on some hosts)
+ if [[ ! "$CUSTOM_GIT_CLEAN" =~ \.git$ ]]; then
+ CUSTOM_GIT_CLEAN="${CUSTOM_GIT_CLEAN}.git"
+ fi
+}
+
+# Check repo size (in MB) by inspecting cloned directory
+_clone_dir_size_mb() {
+ local dir="$1"
+ du -sm "$dir" 2>/dev/null | awk '{print $1}'
+}
+
+# ── Show detailed help for custom git template ─────────────────────────
+show_custom_git_help() {
+ local line
+ line=$(printf '─%.0s' $(seq 1 60))
+ echo "" >&2
+ echo -e " ${BOLD}${GREEN}$(t custom_git_title)${NC}" >&2
+ echo -e " ${DIM}${line}${NC}" >&2
+ echo -e " $(t custom_git_help_1)" >&2
+ echo -e " $(t custom_git_help_2)" >&2
+ echo -e " $(t custom_git_help_3)" >&2
+ echo "" >&2
+ echo -e " ${BOLD}${WHITE}$(t custom_git_formats)${NC}" >&2
+ echo -e " ${CYAN}$(t custom_git_fmt_github)${NC}" >&2
+ echo -e " ${CYAN}$(t custom_git_fmt_gitlab)${NC}" >&2
+ echo -e " ${CYAN}$(t custom_git_fmt_gitext)${NC}" >&2
+ echo -e " ${CYAN}$(t custom_git_fmt_branch)${NC}" >&2
+ echo "" >&2
+ echo -e " ${BOLD}${WHITE}$(t custom_git_auto_detect)${NC}" >&2
+ echo -e " $(t custom_git_auto_1)" >&2
+ echo -e " $(t custom_git_auto_2)" >&2
+ echo -e " $(t custom_git_auto_3)" >&2
+ echo -e " $(t custom_git_auto_4)" >&2
+ echo "" >&2
+ echo -e " ${BOLD}${WHITE}$(t custom_git_requirements)${NC}" >&2
+ echo -e " ${YELLOW}$(t custom_git_req_1)${NC}" >&2
+ echo -e " ${YELLOW}$(t custom_git_req_2)${NC}" >&2
+ echo -e " ${YELLOW}$(t custom_git_req_3)${NC}" >&2
+ echo -e " ${YELLOW}$(t custom_git_req_4)${NC}" >&2
+ echo "" >&2
+ echo -e " ${BOLD}${WHITE}$(t custom_git_examples)${NC}" >&2
+ echo -e " ${DIM}$(t custom_git_ex_1)${NC}" >&2
+ echo -e " ${DIM}$(t custom_git_ex_2)${NC}" >&2
+ echo -e " ${DIM}${line}${NC}" >&2
+ echo "" >&2
+}
+
+# ── Download a custom git template ─────────────────────────────────────
+# Prompts user for a URL (unless passed), clones, detects index.html,
+# copies result into $output_dir/custom_, echoes the final path.
+download_custom_git_template() {
+ local url="${1:-}"
+ local output_dir="${2:-$TEMPLATES_CACHE}"
+
+ show_custom_git_help
+
+ if [ -z "$url" ]; then
+ echo -ne " ${WHITE}$(t custom_git_enter_url)${NC} " >&2
+ read -r url
+ url=$(echo "$url" | tr -d '\r\n[:space:]')
+ fi
+
+ if [ -z "$url" ]; then
+ log_error "$(t custom_git_empty)"
+ return 1
+ fi
+
+ if ! _validate_custom_git_url "$url"; then
+ log_error "$(t custom_git_bad_url)"
+ return 1
+ fi
+
+ _parse_custom_git_url "$url"
+ local clean_url="$CUSTOM_GIT_CLEAN"
+ local branch="$CUSTOM_GIT_BRANCH"
+
+ # Stable-ish directory name from a hash of the original URL
+ local hash
+ hash=$(echo -n "$url" | md5sum 2>/dev/null | awk '{print $1}' | head -c 10)
+ [ -z "$hash" ] && hash=$(date +%s)
+ local tpl_id="custom_${hash}"
+ local clone_dir="$output_dir/${tpl_id}"
+ local tmp_clone="/tmp/custom_git_clone_$$"
+
+ rm -rf "$clone_dir" "$tmp_clone"
+ mkdir -p "$clone_dir"
+
+ log_info "$(t custom_git_cloning)"
+
+ # Clone with timeout so a hung server can't freeze the installer
+ local clone_status=0
+ local git_args=("clone" "--depth" "1")
+ [ -n "$branch" ] && git_args+=("--branch" "$branch")
+ git_args+=("$clean_url" "$tmp_clone")
+
+ if command -v timeout &>/dev/null; then
+ timeout "$CUSTOM_GIT_CLONE_TIMEOUT" git "${git_args[@]}" 2>/tmp/custom_git_err_$$
+ clone_status=$?
+ else
+ git "${git_args[@]}" 2>/tmp/custom_git_err_$$
+ clone_status=$?
+ fi
+
+ if [ $clone_status -ne 0 ] || [ ! -d "$tmp_clone" ]; then
+ local err_msg
+ err_msg=$(head -3 "/tmp/custom_git_err_$$" 2>/dev/null | tr '\n' ' ')
+ rm -f "/tmp/custom_git_err_$$"
+ rm -rf "$tmp_clone" "$clone_dir"
+ log_error "$(tf custom_git_clone_failed "${err_msg:-$clone_status}")"
+ return 1
+ fi
+ rm -f "/tmp/custom_git_err_$$"
+
+ # Drop .git before measuring size (we only care about payload)
+ rm -rf "$tmp_clone/.git"
+
+ # Size guard
+ local size_mb
+ size_mb=$(_clone_dir_size_mb "$tmp_clone")
+ if [ -n "$size_mb" ] && [ "$size_mb" -gt "$CUSTOM_GIT_MAX_SIZE_MB" ]; then
+ rm -rf "$tmp_clone" "$clone_dir"
+ log_error "$(tf custom_git_too_big "${size_mb}MB")"
+ return 1
+ fi
+
+ log_info "$(t custom_git_scanning)"
+
+ # Priority list of common static-site output folders
+ local candidates=("" "dist" "public" "build" "_site" "site" "docs" "out" "www")
+ local found_dir=""
+ for sub in "${candidates[@]}"; do
+ local try_dir="$tmp_clone"
+ [ -n "$sub" ] && try_dir="$tmp_clone/$sub"
+ if [ -f "$try_dir/index.html" ]; then
+ found_dir="$try_dir"
+ break
+ fi
+ done
+
+ # Fallback: search for any index.html in the repo (shallow depth first)
+ if [ -z "$found_dir" ]; then
+ local fallback_index
+ fallback_index=$(find "$tmp_clone" -maxdepth 4 -name "index.html" -type f 2>/dev/null | head -1)
+ if [ -n "$fallback_index" ]; then
+ found_dir=$(dirname "$fallback_index")
+ fi
+ fi
+
+ if [ -z "$found_dir" ] || [ ! -f "$found_dir/index.html" ]; then
+ rm -rf "$tmp_clone" "$clone_dir"
+ log_error "$(t custom_git_no_index)"
+ return 1
+ fi
+
+ # Show what we found (human-friendly relative path)
+ local rel_path="${found_dir#$tmp_clone}"
+ rel_path="${rel_path#/}"
+ [ -z "$rel_path" ] && rel_path="(root)"
+ log_dim "$(tf custom_git_found_at "$rel_path")"
+
+ # Copy the detected directory as the new template
+ cp -r "$found_dir"/* "$clone_dir/" 2>/dev/null
+ cp -r "$found_dir"/.[!.]* "$clone_dir/" 2>/dev/null
+
+ rm -rf "$tmp_clone"
+
+ if [ ! -f "$clone_dir/index.html" ]; then
+ rm -rf "$clone_dir"
+ log_error "$(t custom_git_no_index)"
+ return 1
+ fi
+
+ # Remember the URL so users can see what template they used
+ echo "$url" > "$clone_dir/.custom_git_source" 2>/dev/null
+
+ log_success "$(tf custom_git_installed "$url")"
+ echo "$clone_dir"
+ return 0
+}
+
+# ── Full interactive template selection ───────────────────────────────
interactive_template_selection() {
load_catalog || return 1
- # Выбор категории
+ # Category selection
local cat_id
cat_id=$(select_category)
[ $? -ne 0 ] && return 1
- # Выбор шаблона
+ # Custom git URL path
+ if [ "$cat_id" = "__custom_git__" ]; then
+ local template_dir
+ template_dir=$(download_custom_git_template)
+ [ $? -ne 0 ] && return 1
+ echo "$template_dir"
+ return 0
+ fi
+
+ # Template selection
local tpl_id
tpl_id=$(select_template "$cat_id")
[ $? -ne 0 ] && return 1
- # Скачивание
+ # Download
local template_dir
template_dir=$(download_template "$tpl_id")
[ $? -ne 0 ] && return 1