mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 14:36:05 +00:00
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:
@@ -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
142
gotelegram-bot/i18n.py
Normal 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
116
gotelegram-bot/lang/en.json
Normal 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
116
gotelegram-bot/lang/ru.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user