mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 14:46:01 +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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user