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

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"
}