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)