v2.5.0: maintenance and bot user management

This commit is contained in:
Codex
2026-04-24 18:50:43 +03:00
parent b10ea54ce9
commit 7afeb59261
21 changed files with 618 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
# GoTelegram v2.2 Bot
# GoTelegram v2.5.0 Bot
Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux servers.
@@ -19,6 +19,7 @@ Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux se
- Promotional links
- **Template Browsing** - Browse categories → templates → preview → install
- **Per-user MTProxy Keys** - Manage telemt `[access.users]` from inline bot menus
- **V1 Migration** - Detects old mtg Docker container and offers migration
- **Access Control** - ALLOWED_IDS from .env
- **Async/Await** - Full async support via python-telegram-bot v21+
@@ -144,4 +145,4 @@ code, stdout, stderr = await sh("command", "arg1", "arg2")
## License
GoTelegram v2.2 - Open source community project
GoTelegram v2.5.0 - Open source community project

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
GoTelegram v2.4 Bot - MTProxy Management for Linux
GoTelegram v2.5.0 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.
@@ -23,6 +23,7 @@ from datetime import datetime
from io import StringIO
from pathlib import Path
from typing import Tuple, Optional, List, Dict, Any
from urllib.parse import quote
from dotenv import load_dotenv
from telegram import (
@@ -100,7 +101,7 @@ logger = logging.getLogger(__name__)
# CONFIGURATION
# ============================================================================
GOTELEGRAM_VERSION = "2.4.6"
GOTELEGRAM_VERSION = "2.5.0"
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt"
@@ -256,6 +257,7 @@ _DOMAIN_RE = re.compile(
r"^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+"
r"(?!-)[A-Za-z0-9-]{2,63}(?<!-)$"
)
_USER_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$")
async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict:
@@ -342,6 +344,22 @@ def save_json(path: str, data: Dict) -> bool:
return False
def template_display_name(template_id: str) -> str:
"""Resolve a template id to a human-friendly name from catalog/config."""
if not template_id:
return ""
if template_id.startswith("custom_"):
config = load_json(GOTELEGRAM_CONFIG) or {}
source = config.get("template_source", "")
return f"{template_id} ({source})" if source else template_id
catalog = load_json(TEMPLATES_CATALOG) or {}
for cat in catalog.get("categories", []):
for tpl in cat.get("templates", []):
if tpl.get("id") == template_id:
return f"{tpl.get('name', template_id)} ({template_id})"
return template_id
async def safe_edit_message(
query,
text: str,
@@ -498,14 +516,17 @@ def get_main_menu(user_id: Optional[int] = None) -> InlineKeyboardMarkup:
],
[
InlineKeyboardButton(_t(user_id, "menu_stats"), callback_data="menu_stats"),
InlineKeyboardButton(_t(user_id, "menu_users"), callback_data="menu_users"),
],
[
InlineKeyboardButton(_t(user_id, "menu_remove"), callback_data="menu_remove"),
],
[
InlineKeyboardButton(_t(user_id, "menu_admins"), callback_data="menu_admins"),
InlineKeyboardButton(_t(user_id, "menu_credits"), callback_data="menu_credits"),
],
[
InlineKeyboardButton(_t(user_id, "menu_credits"), callback_data="menu_credits"),
InlineKeyboardButton(_t(user_id, "menu_language"), callback_data="menu_lang"),
],
[
InlineKeyboardButton(_t(user_id, "menu_close"), callback_data="close_menu"),
],
]
@@ -653,7 +674,7 @@ async def get_status_text(user_id: Optional[int] = None) -> str:
# install.sh/save_gotelegram_config uses "template_id" (not "template")
tpl = config.get("template_id") or config.get("template")
if tpl:
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(tpl))}")
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(template_display_name(str(tpl)))}")
if config.get("domain"):
lines.append(f"<b>{_t(user_id, 'status_domain')}:</b> {html.escape(str(config['domain']))}")
if config.get("port"):
@@ -689,6 +710,15 @@ async def get_status_text(user_id: Optional[int] = None) -> str:
async def get_traffic_stats() -> str:
"""Get formatted traffic statistics."""
await sh(
"bash",
"-lc",
"source /opt/gotelegram/lib/common.sh; "
"source /opt/gotelegram/lib/stats.sh; "
"stats_init >/dev/null 2>&1 || true; stats_collect >/dev/null 2>&1 || true",
timeout=15,
)
# Read current snapshot
current_file = "/run/gotelegram/stats_current.json"
history_file = "/opt/gotelegram/stats_history.csv"
@@ -706,6 +736,8 @@ async def get_traffic_stats() -> str:
reader = csv.reader(f)
for row in reader:
if len(row) >= 3:
if not row[0].isdigit():
continue
history.append({
"ts": int(row[0]),
"proxy": int(row[1]),
@@ -824,12 +856,12 @@ async def cb_menu_status(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# ============================================================================
def get_install_mode_menu() -> InlineKeyboardMarkup:
def get_install_mode_menu(user_id: Optional[int] = None) -> InlineKeyboardMarkup:
"""Install mode selection menu."""
buttons = [
[InlineKeyboardButton("⚡ Lite", callback_data="install_mode_lite")],
[InlineKeyboardButton("🛡 Pro", callback_data="install_mode_pro")],
[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")],
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")],
]
return InlineKeyboardMarkup(buttons)
@@ -858,7 +890,7 @@ async def cb_menu_install(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
keyboard = InlineKeyboardMarkup(buttons)
else:
text = "Select installation mode:"
keyboard = get_install_mode_menu()
keyboard = get_install_mode_menu(_uid(update))
await safe_edit_message(query,
text, reply_markup=keyboard, parse_mode="HTML"
@@ -1362,6 +1394,96 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# PROXY LINK & SHARE
# ============================================================================
def load_telemt_users() -> Dict[str, str]:
"""Return users from [access.users] in telemt config."""
telemt_cfg = load_toml(TELEMT_CONFIG) or {}
users = telemt_cfg.get("access", {}).get("users", {})
if not isinstance(users, dict):
return {}
return {
str(name): str(secret)
for name, secret in users.items()
if isinstance(name, str) and isinstance(secret, str)
}
def save_telemt_users(users: Dict[str, str]) -> bool:
"""Persist [access.users] while keeping the rest of the TOML structure."""
telemt_cfg = load_toml(TELEMT_CONFIG) or {}
access = telemt_cfg.setdefault("access", {})
access["users"] = dict(sorted(users.items()))
try:
os.makedirs(os.path.dirname(TELEMT_CONFIG), exist_ok=True)
with open(TELEMT_CONFIG, "w") as f:
toml.dump(telemt_cfg, f)
os.chmod(TELEMT_CONFIG, 0o600)
return True
except Exception as e:
logger.error(f"Failed to save telemt users: {e}")
return False
async def refresh_telemt_after_user_change() -> bool:
"""Restart telemt after config user changes."""
code, _, _ = await sh("systemctl", "restart", TELEMT_SERVICE, timeout=20)
return code == 0
async def telemt_api_get(path: str) -> Optional[Dict[str, Any]]:
"""Read telemt local API if it is enabled in config."""
code, stdout, _ = await sh(
"curl",
"-sS",
"--max-time",
"3",
f"http://127.0.0.1:9091{path}",
timeout=5,
)
if code != 0 or not stdout.strip():
return None
try:
data = json.loads(stdout)
return data if isinstance(data, dict) else None
except json.JSONDecodeError:
return None
def _extract_traffic_value(data: Any, keys: List[str]) -> int:
if isinstance(data, dict):
total = 0
for key, value in data.items():
if key in keys and isinstance(value, (int, float)):
total += int(value)
elif isinstance(value, (dict, list)):
total += _extract_traffic_value(value, keys)
return total
if isinstance(data, list):
return sum(_extract_traffic_value(item, keys) for item in data)
return 0
async def get_proxy_link_for_secret(secret: str) -> Optional[str]:
"""Generate a fake-TLS proxy link for an arbitrary telemt user secret."""
config = load_json(GOTELEGRAM_CONFIG) or {}
if not secret:
return None
mode = config.get("mode", "lite")
domain = config.get("domain", "")
port = config.get("port", 443)
if mode == "pro" and domain:
domain_hex = str(domain).encode().hex()
return f"tg://proxy?server={domain}&port={port}&secret=ee{secret}{domain_hex}"
code, stdout, _ = await sh("curl", "-s", "-4", "--max-time", "5", "https://api.ipify.org")
server = stdout.strip() if code == 0 and stdout.strip() else "0.0.0.0"
mask_host = config.get("mask_host", "")
if mask_host:
domain_hex = str(mask_host).encode().hex()
return f"tg://proxy?server={server}&port={port}&secret=ee{secret}{domain_hex}"
return f"tg://proxy?server={server}&port={port}&secret={secret}"
async def get_proxy_link() -> Optional[str]:
"""Generate proxy link from config. Pro-mode uses domain + fake-TLS secret."""
@@ -1381,27 +1503,7 @@ async def get_proxy_link() -> Optional[str]:
if not secret:
return None
mode = config.get("mode", "lite")
domain = config.get("domain", "")
port = config.get("port", 443)
# Pro-режим: ссылка с доменом и fake-TLS секретом (ee + secret + hex domain)
if mode == "pro" and domain:
domain_hex = domain.encode().hex()
faketls_secret = f"ee{secret}{domain_hex}"
return f"tg://proxy?server={domain}&port={port}&secret={faketls_secret}"
# Lite-режим: IP + fake-TLS с mask_host
code, stdout, _ = await sh("curl", "-s", "-4", "--max-time", "5", "https://api.ipify.org")
server = stdout.strip() if code == 0 and stdout.strip() else "0.0.0.0"
mask_host = config.get("mask_host", "")
if mask_host:
domain_hex = mask_host.encode().hex()
faketls_secret = f"ee{secret}{domain_hex}"
return f"tg://proxy?server={server}&port={port}&secret={faketls_secret}"
return f"tg://proxy?server={server}&port={port}&secret={secret}"
return await get_proxy_link_for_secret(secret)
async def cb_menu_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -1477,6 +1579,201 @@ async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
)
# ============================================================================
# TELEMT USERS
# ============================================================================
def _users_keyboard(users: Dict[str, str], user_id: Optional[int]) -> InlineKeyboardMarkup:
rows = []
for name in sorted(users):
rows.append([InlineKeyboardButton(f"👤 {name}", callback_data=f"user_view_{name}")])
rows.append([InlineKeyboardButton(" Добавить ключ", callback_data="user_add")])
rows.append([
InlineKeyboardButton(_t(user_id, "btn_refresh"), callback_data="menu_users"),
InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main"),
])
return InlineKeyboardMarkup(rows)
async def cb_menu_users(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
users = load_telemt_users()
if users:
user_lines = "\n".join(f"• <code>{html.escape(name)}</code>" for name in sorted(users))
else:
user_lines = "<i>Ключей пока нет</i>"
api_summary = await telemt_api_get("/v1/stats/summary")
api_note = ""
if api_summary and isinstance(api_summary.get("data"), dict):
data = api_summary["data"]
configured = data.get("configured_users")
active = data.get("active_connections") or data.get("connections_active")
bits = []
if configured is not None:
bits.append(f"users: <code>{configured}</code>")
if active is not None:
bits.append(f"active: <code>{active}</code>")
if bits:
api_note = "\n\nAPI: " + ", ".join(bits)
text = (
"<b>🔑 Ключи пользователей</b>\n\n"
f"{user_lines}"
f"{api_note}\n\n"
"<i>Нажмите на пользователя, чтобы увидеть ссылку, статистику и действия.</i>"
)
await safe_edit_message(query, text, reply_markup=_users_keyboard(users, user_id), parse_mode="HTML")
async def _user_detail_text(name: str, secret: str) -> str:
link = await get_proxy_link_for_secret(secret)
api = await telemt_api_get(f"/v1/users/{quote(name, safe='')}")
details = ""
if api:
data = api.get("data", api)
up = _extract_traffic_value(data, ["upload_bytes", "uplink_bytes", "tx_bytes", "sent_bytes", "up"])
down = _extract_traffic_value(data, ["download_bytes", "downlink_bytes", "rx_bytes", "received_bytes", "down"])
active_ips = _extract_traffic_value(data, ["active_ips", "unique_ips"])
parts = []
if up:
parts.append(f"{up} B")
if down:
parts.append(f"{down} B")
if active_ips:
parts.append(f"active IPs: {active_ips}")
if parts:
details = "\n" + "\n".join(parts)
else:
compact = json.dumps(data, ensure_ascii=False)[:600]
details = f"\n<pre>{html.escape(compact)}</pre>"
else:
details = "\n<i>Runtime API недоступен. Новые установки GoTelegram включают его автоматически.</i>"
link_line = html.escape(link) if link else "link unavailable"
return (
f"<b>👤 {html.escape(name)}</b>\n\n"
f"Secret: <code>{html.escape(secret)}</code>\n\n"
f"<b>Ссылка:</b>\n<code>{link_line}</code>\n"
f"{details}"
)
async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
name = query.data.removeprefix("user_view_")
users = load_telemt_users()
secret = users.get(name)
if not secret:
await safe_edit_message(
query,
"❌ Пользователь не найден.",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")]]),
)
return
buttons = [
[InlineKeyboardButton("🗑 Удалить", callback_data=f"user_del_{name}")],
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
]
await safe_edit_message(
query,
await _user_detail_text(name, secret),
reply_markup=InlineKeyboardMarkup(buttons),
parse_mode="HTML",
disable_web_page_preview=True,
)
async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
context.user_data["awaiting_user_name"] = True
text = (
"<b> Новый ключ</b>\n\n"
"Отправьте имя пользователя: латиница, цифры, <code>_ . -</code>, до 48 символов.\n"
"Пример: <code>ivan</code> или <code>family-1</code>."
)
await safe_edit_message(
query,
text,
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data="menu_users")]]),
parse_mode="HTML",
)
async def cb_user_delete(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
name = query.data.removeprefix("user_del_")
if name == "main":
await query.answer("main нельзя удалить", show_alert=True)
return
text = f"Удалить ключ <code>{html.escape(name)}</code>?"
buttons = [
[InlineKeyboardButton("✅ Удалить", callback_data=f"user_del_yes_{name}")],
[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data=f"user_view_{name}")],
]
await safe_edit_message(query, text, reply_markup=InlineKeyboardMarkup(buttons), parse_mode="HTML")
async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
name = query.data.removeprefix("user_del_yes_")
users = load_telemt_users()
if name == "main" or name not in users:
await query.answer("Нельзя удалить этот ключ", show_alert=True)
return
users.pop(name, None)
if not save_telemt_users(users):
await safe_edit_message(query, "Не удалось сохранить config.toml")
return
await refresh_telemt_after_user_change()
await safe_edit_message(
query,
f"✅ Ключ <code>{html.escape(name)}</code> удалён.",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")]]),
parse_mode="HTML",
)
async def create_user_from_text(update: Update, context: ContextTypes.DEFAULT_TYPE, name: str) -> None:
user_id = update.effective_user.id
if not _USER_NAME_RE.match(name):
await update.message.reply_text("❌ Некорректное имя. Используйте латиницу, цифры, _ . - и до 48 символов.")
return
users = load_telemt_users()
if name in users:
await update.message.reply_text("❌ Такой пользователь уже есть.")
return
secret = hashlib.sha256(f"{name}:{time.time()}:{os.urandom(16).hex()}".encode()).hexdigest()[:32]
users[name] = secret
if not save_telemt_users(users):
await update.message.reply_text("Не удалось сохранить /etc/telemt/config.toml")
return
await refresh_telemt_after_user_change()
link = await get_proxy_link_for_secret(secret)
await update.message.reply_text(
f"✅ <b>Ключ создан</b>\n\n"
f"Пользователь: <code>{html.escape(name)}</code>\n"
f"Secret: <code>{secret}</code>\n\n"
f"<code>{html.escape(link or '')}</code>",
reply_markup=_users_keyboard(load_telemt_users(), user_id),
parse_mode="HTML",
disable_web_page_preview=True,
)
# ============================================================================
# RESTART & LOGS
# ============================================================================
@@ -1795,7 +2092,7 @@ async def cb_install_migrate(update: Update, context: ContextTypes.DEFAULT_TYPE)
"✅ <b>v1 container stopped and removed</b>\n\n"
"Now select installation mode for v2:"
)
keyboard = get_install_mode_menu()
keyboard = get_install_mode_menu(_uid(update))
await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML")
@@ -2218,6 +2515,7 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
"menu_promo": cb_menu_promo,
"menu_credits": cb_menu_credits,
"menu_admins": cb_menu_admins,
"menu_users": cb_menu_users,
"menu_remove": cb_menu_remove,
"install_mode_lite": cb_install_mode_lite,
"install_mode_pro": cb_install_mode_pro,
@@ -2251,6 +2549,14 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# Pattern-based handlers
if data.startswith("lite_dom_"):
await cb_lite_domain(update, context)
elif data == "user_add":
await cb_user_add(update, context)
elif data.startswith("user_view_"):
await cb_user_view(update, context)
elif data.startswith("user_del_yes_"):
await cb_user_delete_confirm(update, context)
elif data.startswith("user_del_"):
await cb_user_delete(update, context)
elif data.startswith("pro_cat_"):
await cb_pro_category(update, context)
elif data.startswith("pro_tpl_"):
@@ -2277,6 +2583,10 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
if not is_user_allowed(update.effective_user.id):
return
user_id = update.effective_user.id
if context.user_data.pop("awaiting_user_name", False):
await create_user_from_text(update, context, update.message.text.strip())
return
# Only act when we're explicitly waiting for a custom-git URL
if not _CUSTOM_GIT_WAITERS.pop(user_id, False):
return
@@ -2295,6 +2605,24 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
config["template_id"] = tpl_id
config["template_source"] = url
save_json(GOTELEGRAM_CONFIG, config)
if config.get("mode") == "pro" and os.path.isdir(info):
try:
os.makedirs(WEBSITE_ROOT, exist_ok=True)
for entry in os.listdir(WEBSITE_ROOT):
path = os.path.join(WEBSITE_ROOT, entry)
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path)
else:
os.remove(path)
for entry in os.listdir(info):
src = os.path.join(info, entry)
dst = os.path.join(WEBSITE_ROOT, entry)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
except OSError as e:
logger.error("custom template deploy failed: %s", e)
await update.message.reply_text(
_tf(user_id, "cg_ok_fmt", html.escape(tpl_id)),
reply_markup=get_main_menu(user_id),

View File

@@ -1,4 +1,4 @@
# GoTelegram v2.2 Bot Configuration
# GoTelegram v2.5.0 Bot Configuration
# Copy this to .env and fill in your values
# Telegram Bot Token from @BotFather

View File

@@ -1,5 +1,5 @@
"""
GoTelegram v2.4 Bot — i18n module
GoTelegram v2.5.0 Bot — i18n module
Provides per-user language preferences and a simple t()/tf() API.
Usage:

View File

@@ -33,6 +33,7 @@
"menu_website": "🌐 Website/SSL",
"menu_promo": "🎁 Promo",
"menu_stats": "📊 Traffic Stats",
"menu_users": "🔑 Keys",
"menu_remove": "🗑️ Remove",
"menu_admins": "👤 Admins",
"menu_credits": " Credits",

View File

@@ -33,6 +33,7 @@
"menu_website": "🌐 Сайт/SSL",
"menu_promo": "🎁 Промо",
"menu_stats": "📊 Трафик",
"menu_users": "🔑 Ключи",
"menu_remove": "🗑️ Удалить",
"menu_admins": "👤 Админы",
"menu_credits": " О проекте",