@@ -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 \n API: " + " , " . 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 ) ,