v2.4.0 — internationalization (EN/RU) + custom git templates

- i18n engine (lib/i18n.sh, lib/lang/en.sh, lib/lang/ru.sh)
- first-run language picker, persisted to .language + config.json
- install.sh, common.sh, backup.sh, templates_catalog.sh wired through t()/tf()
- backup.sh preserves .language marker and records language in metadata.json
- custom git template feature (first item in pro template picker)
  * validates HTTPS URLs, rejects shell metachars
  * 100MB size guard, 90s clone timeout
  * auto-detects index.html in dist/public/build/_site/site/docs/out/www
- bot v2.4.0: i18n.py + lang/{en,ru}.json, /lang command, language toggle button
- bot: custom git template via text input with waiter gating
This commit is contained in:
anten-ka
2026-04-10 11:26:02 +03:00
parent 9c084f37ec
commit 0d087831d8
13 changed files with 2489 additions and 1002 deletions

29
.gitignore vendored
View File

@@ -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

View File

@@ -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"<b>👋 Привет, {html.escape(name)}!</b>\n\n"
f"Бот ещё не настроен.\n"
f"Ваш Telegram ID: <code>{user_id}</code>\n\n"
f"Назначить вас администратором?"
)
title = _tf(user_id, "waiting_admin_title", html.escape(name))
body = _tf(user_id, "waiting_admin_body", user_id)
text = f"<b>{title}</b>\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: <code>{user_id}</code>",
_tf(user_id, "access_denied", user_id),
parse_mode="HTML",
)
return
welcome = (
f"<b>GoTelegram v{GOTELEGRAM_VERSION}</b>\n\n"
"🤖 MTProxy Management Bot\n"
"Powered by telemt engine\n\n"
"Select an action from the menu below:"
f"<b>{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}</b>\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 = (
"<b>GoTelegram Bot — Команды</b>\n\n"
"/start — Главное меню\n"
"/help — Эта справка\n"
"/status — Быстрый статус\n"
"/logs — Последние логи\n"
"/addadmin ID — Добавить админа\n"
"/deladmin ID — Удалить админа\n\n"
"Используйте кнопки меню для остальных операций."
f"<b>{_t(user_id, 'help_title')}</b>\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"<b>{title}</b>\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 = ["<b>📊 Current Status</b>\n"]
async def get_status_text(user_id: Optional[int] = None) -> str:
"""Generate status report (localized)."""
lines = [f"<b>{_t(user_id, 'status_title')}</b>\n"]
# Service status
is_running = await check_service_status(TELEMT_SERVICE)
lines.append(f"<b>Service:</b> {'✅ Running' if is_running else '❌ Stopped'}")
running = _t(user_id, "status_running") if is_running else _t(user_id, "status_stopped")
lines.append(f"<b>{_t(user_id, 'status_service')}:</b> {running}")
# Telemt version
version = await get_telemt_version()
lines.append(f"<b>Telemt:</b> v{version}")
lines.append(f"<b>{_t(user_id, 'status_telemt')}:</b> v{version}")
# Config status
config = load_json(GOTELEGRAM_CONFIG)
if config:
lines.append(f"<b>Mode:</b> {html.escape(str(config.get('mode', 'unknown')))}")
lines.append(f"<b>{_t(user_id, 'status_mode')}:</b> {html.escape(str(config.get('mode', 'unknown')))}")
if "template" in config:
lines.append(f"<b>Template:</b> {html.escape(str(config['template']))}")
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(config['template']))}")
if "domain" in config:
lines.append(f"<b>Domain:</b> {html.escape(str(config['domain']))}")
lines.append(f"<b>{_t(user_id, 'status_domain')}:</b> {html.escape(str(config['domain']))}")
if "port" in config:
lines.append(f"<b>Port:</b> {html.escape(str(config['port']))}")
lines.append(f"<b>{_t(user_id, 'status_port')}:</b> {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"<b>Listen Port:</b> {server_cfg['port']}")
lines.append(f"<b>{_t(user_id, 'status_listen_port')}:</b> {server_cfg['port']}")
censor_cfg = telemt_cfg.get("censorship", {})
if "tls_domain" in censor_cfg:
lines.append(f"<b>TLS Domain:</b> {html.escape(str(censor_cfg['tls_domain']))}")
lines.append(f"<b>{_t(user_id, 'status_tls_domain')}:</b> {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"<b>🔗 Proxy Link</b>\n\n<code>{html.escape(link)}</code>",
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<code>{html.escape(stderr[:500])}</code>"
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"<b>💾 Backup Management</b>\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<code>{html.escape(stderr[:500])}</code>"
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"<b>GoTelegram v{GOTELEGRAM_VERSION}</b>\n\n"
"🤖 MTProxy Management\n"
"Select an action:"
f"<b>{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}</b>\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"<b>{title}</b>\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"<b>{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}</b>\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"<b>{title}</b>\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)

142
gotelegram-bot/i18n.py Normal file
View File

@@ -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/<code>.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/<code>.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

116
gotelegram-bot/lang/en.json Normal file
View File

@@ -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: <code>%s</code>\n\nAssign you as administrator?",
"btn_yes": "✅ Yes",
"btn_no": "❌ No",
"access_denied": "⛔ Access denied.\nYour ID: <code>%s</code>",
"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 <code>@branch</code>.",
"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"
}

116
gotelegram-bot/lang/ru.json Normal file
View File

@@ -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: <code>%s</code>\n\nНазначить вас администратором?",
"btn_yes": "✅ Да",
"btn_no": "❌ Нет",
"access_denied": "⛔ Доступ запрещён.\nВаш ID: <code>%s</code>",
"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При желании добавьте <code>@branch</code>.",
"cg_cloning": "⏳ Клонирую %s ...",
"cg_invalid": "❌ Неверный URL. Разрешены только HTTPS git-URL.",
"cg_timeout": "❌ Таймаут клонирования (репозиторий слишком большой или медленный)",
"cg_too_big": "❌ Репозиторий слишком большой (>100МБ)",
"cg_no_index": "❌ В репозитории не найден index.html",
"cg_ok_fmt": "✅ Свой шаблон загружен: %s"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# GoTelegram v2.2Бекап и восстановление конфигурации
# GoTelegram v2.4backup 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

View File

@@ -1,9 +1,9 @@
#!/bin/bash
# GoTelegram v2.3Общие утилиты
# Цвета, логирование, спиннер, системные функции, совместимость с v1
# GoTelegram v2.4common 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"
}

140
lib/i18n.sh Executable file
View File

@@ -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 <key> → 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 <key> <arg1> <arg2> ...
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
}

375
lib/lang/en.sh Executable file
View File

@@ -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."

375
lib/lang/ru.sh Executable file
View File

@@ -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 освобождён."

View File

@@ -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 <<EOF
{"ts":$ts,"proxy_bytes":$proxy_bytes,"proxy_pkts":$proxy_pkts,"site_bytes":$site_bytes,"site_pkts":$site_pkts}
EOF
fi
# Save snapshot for rate calculation (one per minute)
local minute_key
minute_key=$(date +%Y%m%d%H%M 2>/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

View File

@@ -1,20 +1,29 @@
#!/bin/bash
# GoTelegram v2.2Каталог шаблонов сайтов
# Выбор из ~200 шаблонов, превью-ссылки, скачивание через git sparse-checkout
# GoTelegram v2.4website 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_<hash>, 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