2026-06-23 00:44:35 +00:00
#!/usr/bin/env python3
import asyncio
import html
import json
import os
import select
import signal
import re
import subprocess
import tempfile
import time
from datetime import datetime
import urllib . request
import shutil
import shlex
import socket
from pathlib import Path
from typing import Iterable
from collections import OrderedDict
from urllib . parse import urlparse
import urllib . error
from PIL import Image , ImageDraw , ImageFont
from auth import inventory_defaults , resolve_ssh , scp_from_args , ssh_args as build_ssh_args
from telegram import BotCommand , CopyTextButton , InlineKeyboardButton , InlineKeyboardMarkup , Update
from telegram . constants import ChatAction , ParseMode
from telegram . error import BadRequest
from telegram . ext import Application , CallbackQueryHandler , CommandHandler , ContextTypes , MessageHandler , filters
GUKO_VERSION = os . environ . get ( ' GUKO_VERSION ' , ' 0.1.24 ' ) . strip ( ) or ' 0.1.24 '
DATA_DIR = Path ( os . environ . get ( ' DATA_DIR ' , ' /data ' ) )
SERVERS_JSON = Path ( os . environ . get ( ' GUKO_INV ' ) or os . environ . get ( ' VPSPILOT_INV ' ) or DATA_DIR / ' servers.json ' )
KULIN_BASE_URL = os . environ . get ( ' KULIN_BASE_URL ' ) or os . environ . get ( ' KOMARI_BASE_URL ' ) or ' '
KULIN_USERNAME = os . environ . get ( ' KULIN_USERNAME ' ) or os . environ . get ( ' KOMARI_USERNAME ' ) or ' '
KULIN_PASSWORD = os . environ . get ( ' KULIN_PASSWORD ' ) or os . environ . get ( ' KOMARI_PASSWORD ' ) or ' '
KULIN_API_CACHE_TTL = int ( os . environ . get ( ' KULIN_API_CACHE_TTL ' , ' 300 ' ) )
KULIN_GEO_CACHE = { ' ts ' : 0.0 , ' by_ip ' : { } }
MEDIA_DIR = Path ( os . environ . get ( ' MEDIA_DIR ' , DATA_DIR / ' media ' ) )
TMP_DIR = Path ( os . environ . get ( ' TMP_DIR ' , DATA_DIR / ' tmp ' ) )
KEYS_DIR = Path ( os . environ . get ( ' KEYS_DIR ' , DATA_DIR / ' keys ' ) )
RENDER_CHECKPLACE = Path ( os . environ . get ( ' RENDER_CHECKPLACE ' , ' /app/render_checkplace.py ' ) )
BGP_FETCH = Path ( os . environ . get ( ' BGP_FETCH ' , DATA_DIR / ' tools/bgp_fetch.py ' ) )
IPPURE_DOWNLOAD = Path ( os . environ . get ( ' IPPURE_DOWNLOAD ' , DATA_DIR / ' tools/download_ippure.js ' ) )
BGP_OUT_ROOT = Path ( os . environ . get ( ' BGP_OUT_ROOT ' , MEDIA_DIR / ' guko-bgp ' ) )
IPPURE_TMP_ROOT = Path ( os . environ . get ( ' IPPURE_TMP_ROOT ' , TMP_DIR / ' guko-ippure ' ) )
BOT_TOKEN = os . environ . get ( ' BOT_TOKEN ' , ' ' ) . strip ( )
ALLOWED_USERS = { x . strip ( ) for x in os . environ . get ( ' ALLOWED_USERS ' , ' ' ) . split ( ' , ' ) if x . strip ( ) }
ADMIN_USERS = { x . strip ( ) for x in os . environ . get ( ' ADMIN_USERS ' , ' ' ) . split ( ' , ' ) if x . strip ( ) } or set ( ALLOWED_USERS )
ALLOW_INSECURE_STARTUP = os . environ . get ( ' ALLOW_INSECURE_STARTUP ' , ' false ' ) . strip ( ) . lower ( ) in ( ' 1 ' , ' true ' , ' yes ' , ' on ' )
SCRIPT_SOURCES = {
' nexttrace ' : ( ' NextTrace ' , ' https://github.com/nxtrace/NTrace-core ' ) ,
' stream ' : ( ' RegionRestrictionCheck ' , ' https://github.com/lmc999/RegionRestrictionCheck ' ) ,
' ipq ' : ( ' Check.Place ' , ' https://github.com/xykt/NetQuality ' ) ,
' nq ' : ( ' NodeQuality / Check.Place ' , ' https://github.com/xykt/NodeQuality ' ) ,
' ss ' : ( ' SS-Rust-Manager ' , ' https://github.com/shuijiao1/SS-Rust-Manager ' ) ,
' anytls ' : ( ' AnyTLS-Manager ' , ' https://github.com/shuijiao1/AnyTLS-Manager ' ) ,
' vless ' : ( ' Xray-VLESS-Manager ' , ' https://github.com/shuijiao1/Xray-VLESS-Manager ' ) ,
' snell ' : ( ' Snell-Manager ' , ' https://github.com/shuijiao1/Snell-Manager ' ) ,
}
PROXY_TOOLS = {
' ss ' : {
' name ' : ' SS-Rust ' ,
' service ' : ' ss-rust ' ,
' script_url ' : ' https://ss.shuijiao.de ' ,
' install_arg ' : ' install ' ,
' view_arg ' : ' view ' ,
' button ' : ' 🔐 SS ' ,
} ,
' anytls ' : {
' name ' : ' AnyTLS ' ,
' service ' : ' anytls ' ,
' script_url ' : ' https://anytls.shuijiao.de ' ,
' install_arg ' : ' install ' ,
' view_arg ' : ' view ' ,
' button ' : ' 🛡 AnyTLS ' ,
} ,
' vless ' : {
' name ' : ' VLESS ' ,
' service ' : ' xray ' ,
' script_url ' : ' https://xray.shuijiao.de ' ,
' install_arg ' : ' install ' ,
' view_arg ' : ' view ' ,
' button ' : ' ⚡ VLESS ' ,
} ,
' snell ' : {
' name ' : ' Snell ' ,
' service ' : ' snell ' ,
' script_url ' : ' https://snell.shuijiao.de ' ,
' install_arg ' : ' install ' ,
' view_arg ' : ' view ' ,
' button ' : ' 🌀 Snell ' ,
} ,
}
GB5_VERSION = ' 5.5.1 '
GB5_URL = f ' https://cdn.geekbench.com/Geekbench- { GB5_VERSION } -Linux.tar.gz '
JOBS = { }
RUNNING = set ( )
PENDING_NEXTTRACE = { }
ADD_SESSIONS = { }
HISTORY_JSON = Path ( os . environ . get ( ' HISTORY_JSON ' , DATA_DIR / ' history.json ' ) )
RESULTS_DIR = Path ( os . environ . get ( ' RESULTS_DIR ' , DATA_DIR / ' results ' ) )
# Each server/test kind only keeps the latest finished result; this stays intentionally small.
HISTORY_LIMIT = int ( os . environ . get ( ' HISTORY_LIMIT ' , ' 500 ' ) )
def startup_check ( ) :
problems = [ ]
if not BOT_TOKEN or BOT_TOKEN in { ' 123456:replace-me ' , ' 123:abc ' , ' CHANGE_ME ' } :
problems . append ( ' BOT_TOKEN is empty or still an example value ' )
if not ALLOWED_USERS :
problems . append ( ' ALLOWED_USERS is empty; GUKO requires whitelist mode ' )
if ' * ' in ALLOWED_USERS or ' 0 ' in ALLOWED_USERS :
problems . append ( ' ALLOWED_USERS contains unsafe wildcard-like value ' )
for d in ( DATA_DIR , MEDIA_DIR , TMP_DIR , RESULTS_DIR ) :
d . mkdir ( parents = True , exist_ok = True )
KEYS_DIR . mkdir ( parents = True , exist_ok = True )
try :
os . chmod ( KEYS_DIR , 0o700 )
for key_file in KEYS_DIR . iterdir ( ) :
if key_file . is_file ( ) :
os . chmod ( key_file , 0o600 )
except Exception as e :
problems . append ( f ' failed to tighten key permissions: { e } ' )
HISTORY_JSON . parent . mkdir ( parents = True , exist_ok = True )
if SERVERS_JSON . exists ( ) :
try :
inv = json . loads ( SERVERS_JSON . read_text ( ) or ' {} ' )
leaked = [ ]
blob = json . dumps ( inv , ensure_ascii = False )
for marker in ( ' BOT_TOKEN ' , ' CHANGE_ME ' , ' PRIVATE KEY ' ) :
if marker in blob :
leaked . append ( marker )
if leaked :
problems . append ( ' servers.json appears to contain private/example markers: ' + ' , ' . join ( leaked ) )
except Exception as e :
problems . append ( f ' cannot parse servers inventory: { e } ' )
if problems and not ALLOW_INSECURE_STARTUP :
raise SystemExit ( ' 安全启动检查失败: \n - ' + ' \n - ' . join ( problems ) + ' \n \n 请配置 .env; 如确实要临时跳过, 设置 ALLOW_INSECURE_STARTUP=true ' )
if problems :
print ( ' WARNING: insecure startup allowed: \n - ' + ' \n - ' . join ( problems ) , flush = True )
def allowed ( update : Update ) - > bool :
user = update . effective_user
return bool ( user and str ( user . id ) in ALLOWED_USERS )
def is_admin ( update : Update ) - > bool :
user = update . effective_user
return bool ( user and str ( user . id ) in ADMIN_USERS )
async def guard ( update : Update ) - > bool :
if allowed ( update ) :
return True
user = update . effective_user
uid = user . id if user else ' unknown '
if update . callback_query :
await update . callback_query . answer ( ' 无权限 ' , show_alert = True )
elif update . effective_message :
await update . effective_message . reply_text ( f ' 无权限使用这个GUKO bot。你的 ID: { uid } ' )
return False
async def admin_guard ( update : Update ) - > bool :
if not await guard ( update ) :
return False
if is_admin ( update ) :
return True
if update . callback_query :
await update . callback_query . answer ( ' 需要管理员权限 ' , show_alert = True )
elif update . effective_message :
await update . effective_message . reply_text ( ' 需要管理员权限。 ' )
return False
def load_inventory ( ) - > dict :
if not SERVERS_JSON . exists ( ) :
inv = {
' updated_at ' : datetime . now ( ) . astimezone ( ) . isoformat ( timespec = ' seconds ' ) ,
' source ' : ' local ' ,
' defaults ' : {
' ssh ' : {
' user ' : os . environ . get ( ' GUKO_DEFAULT_USER ' ) or os . environ . get ( ' VPSPILOT_DEFAULT_USER ' ) or os . environ . get ( ' JIAOOPS_DEFAULT_USER ' , ' root ' ) ,
' port ' : int ( os . environ . get ( ' GUKO_DEFAULT_PORT ' ) or os . environ . get ( ' VPSPILOT_DEFAULT_PORT ' ) or os . environ . get ( ' JIAOOPS_DEFAULT_PORT ' , ' 22 ' ) ) ,
' key ' : os . environ . get ( ' GUKO_DEFAULT_KEY ' ) or os . environ . get ( ' VPSPILOT_DEFAULT_KEY ' ) or os . environ . get ( ' JIAOOPS_DEFAULT_KEY ' , ' /data/keys/id_ed25519 ' ) ,
}
} ,
' servers ' : [ ] ,
}
DATA_DIR . mkdir ( parents = True , exist_ok = True )
SERVERS_JSON . write_text ( json . dumps ( inv , ensure_ascii = False , indent = 2 ) + ' \n ' )
return inv
return json . loads ( SERVERS_JSON . read_text ( ) )
def save_inventory ( inv : dict ) :
DATA_DIR . mkdir ( parents = True , exist_ok = True )
if SERVERS_JSON . exists ( ) :
backup = SERVERS_JSON . with_name ( f ' { SERVERS_JSON . name } .bak- { datetime . now ( ) . strftime ( " % Y % m %d - % H % M % S " ) } ' )
shutil . copy2 ( SERVERS_JSON , backup )
SERVERS_JSON . write_text ( json . dumps ( inv , ensure_ascii = False , indent = 2 ) + ' \n ' )
def next_manual_id ( servers ) :
used = set ( )
for s in servers :
try :
used . add ( int ( server_id ( s ) ) )
except Exception :
pass
n = - 1
while n in used :
n - = 1
return n
2026-06-23 00:58:45 +00:00
def backup_bundle_path ( ) - > Path :
ts = datetime . now ( ) . strftime ( ' % Y % m %d - % H % M % S ' )
out = TMP_DIR / f ' guko-backup- { ts } .tar.gz '
out . parent . mkdir ( parents = True , exist_ok = True )
files = [ ]
for path in ( SERVERS_JSON , HISTORY_JSON ) :
if path . exists ( ) :
files . append ( path )
for d in ( KEYS_DIR , MEDIA_DIR , RESULTS_DIR ) :
if d . exists ( ) :
files . append ( d )
import tarfile
with tarfile . open ( out , ' w:gz ' ) as tar :
for item in files :
arc = item . name if item . is_file ( ) else item . name
tar . add ( item , arcname = arc )
try :
os . chmod ( out , 0o600 )
except Exception :
pass
return out
def inventory_summary_text ( ) - > str :
inv = load_inventory ( )
servers = inv . get ( ' servers ' ) or [ ]
total = len ( servers )
password = key = skipped = 0
for item in servers :
try :
cfg = resolve_ssh ( item , inv )
if cfg . get ( ' auth ' ) == ' password ' :
password + = 1
elif cfg . get ( ' key ' ) :
key + = 1
else :
skipped + = 1
except Exception :
skipped + = 1
return (
' 📦 <b>GUKO 配置概览</b> \n \n '
f ' 服务器:<b> { total } </b> 台 \n '
f ' 密钥登录: { key } 台 \n '
f ' 密码登录: { password } 台 \n '
f ' 未完整配置: { skipped } 台 \n '
f ' 清单:<code> { safe ( str ( SERVERS_JSON ) ) } </code> \n '
f ' 密钥目录:<code> { safe ( str ( KEYS_DIR ) ) } </code> \n '
f ' 结果目录:<code> { safe ( str ( RESULTS_DIR ) ) } </code> '
)
2026-06-23 00:44:35 +00:00
def redact_inventory ( inv : dict ) :
def clean_server ( s ) :
out = json . loads ( json . dumps ( s , ensure_ascii = False ) )
ssh = out . get ( ' ssh ' ) or { }
if ' password ' in ssh :
ssh [ ' password ' ] = ' *** '
if out . get ( ' password ' ) :
out [ ' password ' ] = ' *** '
if ' key ' in ssh and ssh . get ( ' key ' ) :
ssh [ ' key ' ] = str ( ssh [ ' key ' ] ) . replace ( str ( DATA_DIR ) , ' /data ' )
if out . get ( ' key ' ) :
out [ ' key ' ] = str ( out [ ' key ' ] ) . replace ( str ( DATA_DIR ) , ' /data ' )
out [ ' ssh ' ] = ssh
return out
data = { k : v for k , v in inv . items ( ) if k != ' servers ' }
defaults = json . loads ( json . dumps ( data . get ( ' defaults ' ) or { } , ensure_ascii = False ) )
dssh = defaults . get ( ' ssh ' ) or { }
if ' password ' in dssh :
dssh [ ' password ' ] = ' *** '
defaults [ ' ssh ' ] = dssh
data [ ' defaults ' ] = defaults
data [ ' servers ' ] = [ clean_server ( s ) for s in inv . get ( ' servers ' , [ ] ) ]
return data
def server_id ( s ) :
for key in ( ' id ' , ' legacy_id ' , ' nezha_id ' ) :
if s . get ( key ) is not None :
return s . get ( key )
host = str ( s . get ( ' host ' ) or ' ' ) . strip ( )
if host :
port = ( s . get ( ' ssh ' ) or { } ) . get ( ' port ' ) or s . get ( ' port ' )
if port :
safe_host = re . sub ( r ' [^A-Za-z0-9_.-]+ ' , ' _ ' , host ) . strip ( ' _ ' )
return f ' { safe_host } - { port } '
return host
return s . get ( ' name ' )
def update_server_by_id ( sid : str , patch : dict ) :
inv = load_inventory ( )
servers = inv . get ( ' servers ' ) or [ ]
for i , s in enumerate ( servers ) :
if str ( server_id ( s ) ) == str ( sid ) :
merged = dict ( s )
ssh = dict ( merged . get ( ' ssh ' ) or { } )
for k in ( ' name ' , ' host ' , ' aliases ' , ' role ' , ' specs ' ) :
if k in patch :
merged [ k ] = patch [ k ]
if ' ssh ' in patch :
ssh . update ( patch [ ' ssh ' ] )
merged [ ' ssh ' ] = ssh
servers [ i ] = merged
inv [ ' updated_at ' ] = datetime . now ( ) . astimezone ( ) . isoformat ( timespec = ' seconds ' )
save_inventory ( inv )
return merged
return None
def delete_server_by_id ( sid : str ) :
inv = load_inventory ( )
servers = inv . get ( ' servers ' ) or [ ]
kept = [ ]
removed = None
for s in servers :
if str ( server_id ( s ) ) == str ( sid ) :
removed = s
else :
kept . append ( s )
if removed is None :
return None
inv [ ' servers ' ] = kept
inv [ ' updated_at ' ] = datetime . now ( ) . astimezone ( ) . isoformat ( timespec = ' seconds ' )
save_inventory ( inv )
return removed
def upsert_server ( item : dict ) :
inv = load_inventory ( )
servers = inv . setdefault ( ' servers ' , [ ] )
q = { str ( x ) . lower ( ) for x in [ item . get ( ' name ' ) , str ( item . get ( ' id ' ) or item . get ( ' legacy_id ' ) or ' ' ) ] if x }
q . update ( str ( x ) . lower ( ) for x in ( item . get ( ' aliases ' ) or [ ] ) )
item_host = str ( item . get ( ' host ' ) or ' ' ) . lower ( )
item_port = ( item . get ( ' ssh ' ) or { } ) . get ( ' port ' ) or item . get ( ' port ' )
replaced = False
for i , old in enumerate ( servers ) :
fields = { str ( x ) . lower ( ) for x in [ old . get ( ' name ' ) , str ( old . get ( ' id ' ) or old . get ( ' legacy_id ' ) or ' ' ) ] if x }
fields . update ( str ( x ) . lower ( ) for x in ( old . get ( ' aliases ' ) or [ ] ) )
old_host = str ( old . get ( ' host ' ) or ' ' ) . lower ( )
old_port = ( old . get ( ' ssh ' ) or { } ) . get ( ' port ' ) or old . get ( ' port ' )
same_endpoint = item_host and old_host == item_host and ( not item_port or not old_port or str ( item_port ) == str ( old_port ) )
if ( q & fields ) or same_endpoint :
item . setdefault ( ' id ' , old . get ( ' id ' ) or old . get ( ' legacy_id ' ) or next_manual_id ( servers ) )
item . setdefault ( ' state ' , old . get ( ' state ' ) or { } )
merged = dict ( old )
merged . update ( enrich_server_geo ( item ) )
servers [ i ] = merged
replaced = True
break
if not replaced :
item . setdefault ( ' id ' , next_manual_id ( servers ) )
item . setdefault ( ' role ' , ' manual ' )
item . setdefault ( ' source ' , ' local-manual ' )
item . setdefault ( ' state ' , { } )
servers . append ( enrich_server_geo ( item ) )
inv [ ' updated_at ' ] = datetime . now ( ) . astimezone ( ) . isoformat ( timespec = ' seconds ' )
save_inventory ( inv )
return item , ' updated ' if replaced else ' added '
def ssh_config ( s ) :
return resolve_ssh ( s , load_inventory ( ) )
def fmt_bytes ( n ) :
if n is None :
return ' - '
n = float ( n )
for unit in [ ' B ' , ' KB ' , ' MB ' , ' GB ' , ' TB ' ] :
if abs ( n ) < 1024 :
return f ' { n : .1f } { unit } '
n / = 1024
return f ' { n : .1f } PB '
def pct_num ( used , total ) :
if used is None or total in ( None , 0 ) :
return None
return float ( used ) / float ( total ) * 100
def pct ( used , total ) :
p = pct_num ( used , total )
return ' - ' if p is None else f ' { p : .1f } % '
def meter ( value , total = 100 , width = 22 ) :
p = pct_num ( value , total )
if p is None :
return ' ▕ ' + ' ▱ ' * width + ' ▏ ' , ' - '
p = max ( 0 , min ( 100 , p ) )
filled = max ( 0 , min ( width , round ( p / 100 * width ) ) )
if p > = 85 :
icon = ' 🔴 '
elif p > = 65 :
icon = ' 🟠 '
else :
icon = ' 🟢 '
# Braille/box chars render cleaner in Telegram than block+shade when wrapped in <code>.
bar_text = ' ▕ ' + ' ▰ ' * filled + ' ▱ ' * ( width - filled ) + ' ▏ '
return bar_text , f ' { icon } { p : .1f } % '
def usage_block ( label , emoji , used , total ) :
b , p = meter ( used , total )
return f ' { emoji } <b> { label } </b> { p } \n <code> { b } </code> \n { fmt_bytes ( used ) } / { fmt_bytes ( total ) } '
def cpu_block ( cpu , width = 22 ) :
b , p = meter ( float ( cpu or 0 ) , 100 , width )
return f ' 🧠 <b>CPU</b> { p } \n <code> { b } </code> '
def fmt_duration ( seconds ) :
if seconds is None :
return ' - '
seconds = int ( seconds )
days , rem = divmod ( seconds , 86400 )
hours , rem = divmod ( rem , 3600 )
minutes , _ = divmod ( rem , 60 )
if days :
return f ' { days } 天 { hours } 小时 '
if hours :
return f ' { hours } 小时 { minutes } 分 '
return f ' { minutes } 分 '
def short_cpu_name ( name ) :
if not name :
return ' - '
text = str ( name ) . replace ( ' (R) ' , ' ' ) . replace ( ' (TM) ' , ' ' )
text = ' ' . join ( text . split ( ) )
return text [ : 58 ] + ( ' … ' if len ( text ) > 58 else ' ' )
IPV4_RE = re . compile ( r ' (?:^| \ D)((?: \ d { 1,3} \ .) {3} \ d { 1,3})(?: \ D|$) ' )
DOMAIN_RE = re . compile ( r ' ^(?:https?://)?(?:[A-Za-z0-9-]+ \ .)+[A-Za-z] { 2,}(?:: \ d+)?(?:[/?#].*)?$ ' )
def is_ipv4 ( value ) :
m = IPV4_RE . search ( str ( value or ' ' ) )
if not m :
return False
try :
parts = [ int ( x ) for x in m . group ( 1 ) . split ( ' . ' ) ]
return len ( parts ) == 4 and all ( 0 < = x < = 255 for x in parts )
except Exception :
return False
def extract_ipv4 ( value ) :
m = IPV4_RE . search ( str ( value or ' ' ) )
return m . group ( 1 ) if m and is_ipv4 ( m . group ( 1 ) ) else ' '
def normalize_domain ( value ) :
text = str ( value or ' ' ) . strip ( )
if not text or text . startswith ( ' / ' ) :
return ' '
first = text . split ( ) [ 0 ] . strip ( )
if extract_ipv4 ( first ) :
return ' '
if not DOMAIN_RE . match ( first ) :
return ' '
parsed = urlparse ( first if ' :// ' in first else ' // ' + first )
host = ( parsed . hostname or ' ' ) . strip ( ) . rstrip ( ' . ' )
return host if re . match ( r ' ^[A-Za-z0-9.-]+$ ' , host ) else ' '
def safe_target ( value ) :
text = str ( value or ' ' ) . strip ( )
text = re . sub ( r ' ^[a-zA-Z][a-zA-Z0-9+.-]*:// ' , ' ' , text ) . split ( ' / ' ) [ 0 ] . split ( ' ? ' ) [ 0 ] . replace ( ' : ' , ' _ ' )
text = re . sub ( r ' [^A-Za-z0-9_.-]+ ' , ' _ ' , text ) . strip ( ' _ ' )
return text or ' target '
def country_flag ( code ) :
code = ( code or ' ' ) . strip ( ) . upper ( )
if code == ' TW ' :
code = ' CN '
if len ( code ) != 2 or not code . isalpha ( ) :
return ' 🌐 '
return chr ( 0x1F1E6 + ord ( code [ 0 ] ) - ord ( ' A ' ) ) + chr ( 0x1F1E6 + ord ( code [ 1 ] ) - ord ( ' A ' ) )
def kulin_api_request ( path , * , timeout = 4 ) :
if not ( KULIN_USERNAME and KULIN_PASSWORD ) :
return None
if not KULIN_BASE_URL :
return None
base = KULIN_BASE_URL . rstrip ( ' / ' )
cookie = urllib . request . HTTPCookieProcessor ( )
opener = urllib . request . build_opener ( cookie )
try :
login_body = json . dumps ( { ' username ' : KULIN_USERNAME , ' password ' : KULIN_PASSWORD } ) . encode ( )
login_req = urllib . request . Request (
base + ' /api/v1/login ' ,
data = login_body ,
headers = { ' Content-Type ' : ' application/json ' , ' User-Agent ' : ' GUKO/1.0 ' } ,
)
with opener . open ( login_req , timeout = timeout ) as resp :
json . loads ( resp . read ( ) . decode ( errors = ' replace ' ) or ' {} ' )
req = urllib . request . Request ( base + path , headers = { ' User-Agent ' : ' GUKO/1.0 ' } )
with opener . open ( req , timeout = timeout ) as resp :
return json . loads ( resp . read ( ) . decode ( errors = ' replace ' ) or ' {} ' )
except Exception :
return None
def flatten_kulin_servers ( payload ) :
data = ( payload or { } ) . get ( ' data ' )
if isinstance ( data , dict ) :
for key in ( ' servers ' , ' list ' , ' items ' , ' records ' ) :
if isinstance ( data . get ( key ) , list ) :
return data . get ( key )
if isinstance ( data , list ) :
return data
return [ ]
def ip_from_kulin_host ( item ) :
if not isinstance ( item , dict ) :
return ' '
geoip = item . get ( ' geoip ' )
if isinstance ( geoip , dict ) :
geo_ip = geoip . get ( ' ip ' )
if isinstance ( geo_ip , dict ) :
found = extract_ipv4 ( geo_ip . get ( ' ipv4_addr ' ) or geo_ip . get ( ' IP ' ) or geo_ip . get ( ' ip ' ) or ' ' )
if found :
return found
found = extract_ipv4 ( geoip . get ( ' ip ' ) or geoip . get ( ' ipv4 ' ) or ' ' )
if found :
return found
host = item . get ( ' host ' )
if isinstance ( host , dict ) :
return extract_ipv4 ( host . get ( ' IP ' ) or host . get ( ' ip ' ) or host . get ( ' ipv4 ' ) or ' ' )
return extract_ipv4 ( item . get ( ' ip ' ) or item . get ( ' ipv4 ' ) or item . get ( ' host ' ) or ' ' )
def country_from_kulin_item ( item ) :
if not isinstance ( item , dict ) :
return ' '
geoip = item . get ( ' geoip ' )
candidates = [ ]
if isinstance ( geoip , dict ) :
candidates . extend ( [ geoip . get ( ' country_code ' ) , geoip . get ( ' country ' ) , geoip . get ( ' region ' ) ] )
candidates . extend ( [ item . get ( ' region ' ) , item . get ( ' country ' ) , item . get ( ' country_code ' ) ] )
host = item . get ( ' host ' )
if isinstance ( host , dict ) :
candidates . extend ( [ host . get ( ' CountryCode ' ) , host . get ( ' country_code ' ) , host . get ( ' country ' ) ] )
for val in candidates :
text = str ( val or ' ' ) . strip ( ) . lower ( )
if len ( text ) == 2 and text . isalpha ( ) :
return text
return ' '
def kulin_geo_map ( ) :
now = time . time ( )
if now - float ( KULIN_GEO_CACHE . get ( ' ts ' ) or 0 ) < KULIN_API_CACHE_TTL :
return KULIN_GEO_CACHE . get ( ' by_ip ' ) or { }
by_ip = { }
payload = kulin_api_request ( ' /api/v1/server ' )
for item in flatten_kulin_servers ( payload ) :
ip = ip_from_kulin_host ( item )
code = country_from_kulin_item ( item )
if ip and code :
by_ip [ ip ] = code
KULIN_GEO_CACHE [ ' ts ' ] = now
KULIN_GEO_CACHE [ ' by_ip ' ] = by_ip
return by_ip
def geolocate_host ( host , timeout = 4 ) :
ip = extract_ipv4 ( host or ' ' )
if not ip :
return None
# Keep GUKO's country flags aligned with Kulin/Komari. Its panel writes
# server.region from the currently configured GeoIP provider, so prefer
# that over ad-hoc public web APIs.
code = kulin_geo_map ( ) . get ( ip )
if code :
return code
providers = [
( f ' http://ip-api.com/json/ { ip } ?fields=status,countryCode,query,message ' , lambda d : d . get ( ' countryCode ' ) if d . get ( ' status ' ) == ' success ' else None ) ,
( f ' https://ipinfo.io/ { ip } /json ' , lambda d : d . get ( ' country ' ) ) ,
( f ' https://get.geojs.io/v1/ip/country/ { ip } .json ' , lambda d : d . get ( ' country ' ) ) ,
]
for url , pick in providers :
try :
req = urllib . request . Request ( url , headers = { ' User-Agent ' : ' GUKO/1.0 ' } )
with urllib . request . urlopen ( req , timeout = timeout ) as resp :
data = json . loads ( resp . read ( ) . decode ( errors = ' replace ' ) )
code = pick ( data )
if code :
return str ( code ) . lower ( )
except Exception :
continue
return None
def enrich_server_geo ( item ) :
if item . get ( ' country ' ) :
item [ ' country ' ] = str ( item . get ( ' country ' ) ) . lower ( )
return item
code = geolocate_host ( item . get ( ' host ' ) )
if code :
item [ ' country ' ] = code
return item
ANSI_RE = re . compile ( r ' \ x1b \ [[0-?]*[ -/]*[@-~] ' )
def strip_ansi ( text ) :
return ANSI_RE . sub ( ' ' , text or ' ' )
def safe ( s ) :
return html . escape ( str ( s ) ) if s is not None else ' - '
def running_text ( s , task ) :
return f ' 正在运行中: { safe ( s . get ( " name " ) ) } { task } '
async def send_running_notice ( bot , chat_id , s , task ) :
await bot . send_message ( chat_id , running_text ( s , task ) , parse_mode = ParseMode . HTML )
async def bot_task_started_notice ( bot , chat_id , s , task , started = True ) :
if started :
await bot . send_message ( chat_id , running_text ( s , task ) , parse_mode = ParseMode . HTML )
else :
await bot . send_message ( chat_id , f ' 这个任务已经在运行中: { safe ( s . get ( " name " ) ) } { task } ' , parse_mode = ParseMode . HTML )
def is_valid_hostname ( value ) :
text = str ( value or ' ' ) . strip ( )
if not text or len ( text ) > 253 or ' ' in text :
return False
if is_ipv4 ( text ) :
return True
return bool ( re . match ( r ' ^[A-Za-z0-9.-]+$ ' , text ) and ' . ' in text )
def parse_host_port ( text ) :
raw = str ( text or ' ' ) . strip ( )
if not raw :
return ' ' , None
if raw . count ( ' : ' ) == 1 and not raw . startswith ( ' [ ' ) :
host , port = raw . rsplit ( ' : ' , 1 )
if port . isdigit ( ) :
return host . strip ( ) , int ( port )
return raw , None
def source_repo ( kind ) :
return SCRIPT_SOURCES . get ( kind , ( ' ' , ' ' ) ) [ 1 ]
def script_command_text ( kind , * * kwargs ) :
if kind == ' nexttrace ' :
target = kwargs . get ( ' target ' ) or ' <目标IP或域名> '
return (
' 脚本命令: \n '
' curl -sL https://nxtrace.org/nt | bash \n '
f ' nexttrace { target } '
)
if kind == ' stream ' :
region_id = kwargs . get ( ' region_id ' ) or ' <地区编号> '
proto_arg = kwargs . get ( ' proto_arg ' ) or ' -M 4 '
return (
' 脚本命令: \n '
' bash <(curl -L -s check.unlock.media) '
f ' { proto_arg } -R { region_id } '
)
if kind == ' ipq ' :
return ' 脚本命令: \n bash <(curl -Ls https://IP.Check.Place) -y '
if kind in PROXY_TOOLS :
tool = PROXY_TOOLS [ kind ]
action = kwargs . get ( ' action ' ) or ' <install|view> '
arg = tool [ ' install_arg ' ] if action in ( ' install ' , ' ensure ' ) else tool [ ' view_arg ' ]
if kind == ' vless ' and action in ( ' install ' , ' ensure ' ) :
mode = kwargs . get ( ' mode ' ) or ' <plain|reality> '
return f " 脚本命令: \n bash <(curl -Ls { tool [ ' script_url ' ] } ) # 选择 { mode } "
return f " 脚本命令: \n bash <(curl -Ls { tool [ ' script_url ' ] } ) { arg } "
if kind == ' nq ' :
selected = kwargs . get ( ' selected ' )
ip_mode = kwargs . get ( ' ip_mode ' )
extra = ' '
if selected or ip_mode :
extra = f ' \n 选择: { selected or " - " } ; { ip_mode or " - " } '
return ' 脚本命令: \n bash <(curl -sL https://run.NodeQuality.com) ' + extra
return ' '
def script_command_html ( kind , * * kwargs ) :
return safe ( script_command_text ( kind , * * kwargs ) )
def find_server ( name : str , servers : Iterable [ dict ] ) :
q = name . lower ( )
for s in servers :
fields = [ s . get ( ' name ' ) , s . get ( ' host ' ) , str ( server_id ( s ) ) ]
fields + = s . get ( ' aliases ' ) or [ ]
if any ( ( f or ' ' ) . lower ( ) == q for f in fields ) :
return s
for s in servers :
fields = [ s . get ( ' name ' ) , s . get ( ' host ' ) ] + ( s . get ( ' aliases ' ) or [ ] )
if any ( q in ( f or ' ' ) . lower ( ) for f in fields ) :
return s
return None
def find_server_by_id ( sid : str ) :
return find_server ( sid , load_inventory ( ) . get ( ' servers ' , [ ] ) )
def server_button_label ( s ) :
return f " { country_flag ( s . get ( ' country ' ) ) } { s . get ( ' name ' ) } "
def main_menu_markup ( ) :
servers = load_inventory ( ) . get ( ' servers ' , [ ] )
rows = [ ]
for i in range ( 0 , len ( servers ) , 2 ) :
rows . append ( [
InlineKeyboardButton ( server_button_label ( s ) , callback_data = f " srv: { server_id ( s ) } " )
for s in servers [ i : i + 2 ]
] )
rows . append ( [
InlineKeyboardButton ( ' ➕ 添加服务器' , callback_data = ' add:start ' ) ,
InlineKeyboardButton ( ' 📥 批量导入 ' , callback_data = ' add:bulk ' ) ,
] )
return InlineKeyboardMarkup ( rows )
def server_has_ipv6 ( s ) :
return bool ( s . get ( ' ipv6 ' ) and str ( s . get ( ' ipv6 ' ) ) . strip ( ) not in ( ' - ' , ' None ' ) )
def add_start_markup ( ) :
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' ➕ 添加单台' , callback_data = ' add:one ' ) ] ,
[ InlineKeyboardButton ( ' 📥 批量导入 ' , callback_data = ' add:bulk ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ,
] )
def add_auth_markup ( ) :
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' ♻️ 沿用默认密钥/配置 ' , callback_data = ' addauth:default ' ) ] ,
[ InlineKeyboardButton ( ' 📁 使用已有密钥路径 ' , callback_data = ' addauth:keypath ' ) ] ,
[ InlineKeyboardButton ( ' 🔑 上传/粘贴新私钥 ' , callback_data = ' addauth:key ' ) ] ,
[ InlineKeyboardButton ( ' 🔐 使用密码 ' , callback_data = ' addauth:password ' ) ] ,
[ InlineKeyboardButton ( ' 📦 先只保存,不测试登录 ' , callback_data = ' addauth:skip ' ) ] ,
[ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ,
] )
def bulk_mode_markup ( ) :
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' ✅ 全部同一个端口 ' , callback_data = ' bulkport:same ' ) ] ,
[ InlineKeyboardButton ( ' 🧩 每台自己写端口 ' , callback_data = ' bulkport:per ' ) ] ,
[ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ,
] )
def bulk_auth_markup ( ) :
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' 🔑 全部同一把密钥 ' , callback_data = ' bulkauth:key ' ) ] ,
[ InlineKeyboardButton ( ' 🔐 全部同一个密码 ' , callback_data = ' bulkauth:password ' ) ] ,
[ InlineKeyboardButton ( ' 🧩 每台自己写认证 ' , callback_data = ' bulkauth:per ' ) ] ,
[ InlineKeyboardButton ( ' 📦 先只导入,不测试登录 ' , callback_data = ' bulkauth:skip ' ) ] ,
[ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ,
] )
def add_help_text ( ) :
return (
' ➕ <b>添加服务器</b>\n \n '
' 可以单台添加,也可以批量导入。 \n '
' 支持:不同端口、不同用户名、沿用默认密钥、已有密钥路径、上传私钥、密码登录。 '
)
def bulk_help_text ( ) :
return (
' 📥 <b>批量导入服务器</b> \n \n '
' 先选端口策略,再选认证策略。 \n \n '
' 每行格式: \n '
' <code>名称 IP 用户</code> \n '
' <code>名称 IP:端口 用户</code> \n \n '
' 如果选择“每台自己写认证”,每行可以写: \n '
' <code>名称 IP 端口 用户 key:/data/keys/a</code> \n '
' <code>名称 IP 端口 用户 password:你的密码</code> '
)
def tool_enabled ( name ) :
val = os . environ . get ( f ' ENABLE_ { name . upper ( ) } ' , ' true ' ) . strip ( ) . lower ( )
return val in ( ' 1 ' , ' true ' , ' yes ' , ' on ' )
def button_rows ( buttons , per_row = 2 ) :
return [ buttons [ i : i + per_row ] for i in range ( 0 , len ( buttons ) , per_row ) ]
def server_markup ( s ) :
sid = server_id ( s )
host = s . get ( ' host ' ) or ' '
rows = [ ]
if host :
rows . append ( [ InlineKeyboardButton ( ' 📋 复制 IPv4 ' , copy_text = CopyTextButton ( host ) ) ] )
if s . get ( ' ipv6 ' ) :
rows . append ( [ InlineKeyboardButton ( ' 📋 复制 IPv6 ' , copy_text = CopyTextButton ( s . get ( ' ipv6 ' ) ) ) ] )
test_buttons = [ ]
if tool_enabled ( ' ipq ' ) :
test_buttons . append ( InlineKeyboardButton ( ' 🧪 IP质量 ' , callback_data = f ' ipq: { sid } ' ) )
if tool_enabled ( ' nq ' ) :
test_buttons . append ( InlineKeyboardButton ( ' 📊 NodeQuality ' , callback_data = f ' nqask: { sid } ' ) )
if tool_enabled ( ' gb5 ' ) :
test_buttons . append ( InlineKeyboardButton ( ' 🏁 GB5 ' , callback_data = f ' gb5: { sid } ' ) )
if tool_enabled ( ' stream ' ) :
test_buttons . append ( InlineKeyboardButton ( ' 🎬 流媒体 ' , callback_data = f ' stream: { sid } ' ) )
if tool_enabled ( ' bgp ' ) :
test_buttons . append ( InlineKeyboardButton ( ' 🧭 BGP图 ' , callback_data = f ' bgp: { sid } ' ) )
if tool_enabled ( ' ippure ' ) :
test_buttons . append ( InlineKeyboardButton ( ' 🧼 IPPure ' , callback_data = f ' ippure: { sid } ' ) )
if tool_enabled ( ' ss ' ) :
test_buttons . append ( InlineKeyboardButton ( PROXY_TOOLS [ ' ss ' ] [ ' button ' ] , callback_data = f ' proxy:ss: { sid } ' ) )
if tool_enabled ( ' anytls ' ) :
test_buttons . append ( InlineKeyboardButton ( PROXY_TOOLS [ ' anytls ' ] [ ' button ' ] , callback_data = f ' proxy:anytls: { sid } ' ) )
if tool_enabled ( ' vless ' ) :
test_buttons . append ( InlineKeyboardButton ( PROXY_TOOLS [ ' vless ' ] [ ' button ' ] , callback_data = f ' proxy:vless: { sid } ' ) )
if tool_enabled ( ' snell ' ) :
test_buttons . append ( InlineKeyboardButton ( PROXY_TOOLS [ ' snell ' ] [ ' button ' ] , callback_data = f ' proxy:snell: { sid } ' ) )
rows . extend ( button_rows ( test_buttons , 2 ) )
ops_buttons = [
InlineKeyboardButton ( ' 📋 当前任务 ' , callback_data = f ' jobsrv: { sid } ' ) ,
InlineKeyboardButton ( ' 📜 历史记录 ' , callback_data = f ' hist: { sid } ' ) ,
InlineKeyboardButton ( ' 🧪 测试SSH ' , callback_data = f ' testssh: { sid } ' ) ,
]
if tool_enabled ( ' nexttrace ' ) :
ops_buttons . append ( InlineKeyboardButton ( ' 🛣 NextTrace ' , callback_data = f ' ntask: { sid } ' ) )
rows . extend ( button_rows ( ops_buttons , 2 ) )
rows . extend ( [
[ InlineKeyboardButton ( ' ✏️ 编辑 ' , callback_data = f ' edit: { sid } ' ) , InlineKeyboardButton ( ' 🗑 删除 ' , callback_data = f ' delask: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ,
] )
return InlineKeyboardMarkup ( rows )
NQ_ITEMS = [
( ' hardware ' , ' 硬件 ' , ' HardwareQuality ' , 1 ) ,
( ' ip ' , ' IP质量 ' , ' IPQuality ' , 2 ) ,
( ' net ' , ' 网络 ' , ' NetQuality ' , 4 ) ,
( ' backroute ' , ' 回程 ' , ' Backroute Trace ' , 8 ) ,
]
NQ_ALL_MASK = sum ( x [ 3 ] for x in NQ_ITEMS )
NQ_DEFAULT_MASK = 0
NQ_IP_MODES = { ' 4 ' : ' 仅 IPv4 ' , ' 46 ' : ' IPv4 + IPv6 ' }
def confirm_nq_markup ( s , mask = NQ_DEFAULT_MASK , ip_mode = ' 4 ' ) :
sid = server_id ( s )
rows = [ ]
for _key , label , _full , bit in NQ_ITEMS :
mark = ' ✅ ' if mask & bit else ' ☐ '
new_mask = mask & ~ bit if mask & bit else mask | bit
rows . append ( [ InlineKeyboardButton ( f ' { mark } { label } ' , callback_data = f ' nqtoggle: { sid } : { new_mask } : { ip_mode } ' ) ] )
rows . append ( [
InlineKeyboardButton ( ' 全选 ' , callback_data = f ' nqsel: { sid } : { NQ_ALL_MASK } : { ip_mode } ' ) ,
InlineKeyboardButton ( ' 清空 ' , callback_data = f ' nqsel: { sid } :0: { ip_mode } ' ) ,
] )
if server_has_ipv6 ( s ) :
rows . append ( [
InlineKeyboardButton ( ( ' ✅ ' if ip_mode == ' 4 ' else ' ☐ ' ) + ' 仅 IPv4 ' , callback_data = f ' nqproto: { sid } : { mask } :4 ' ) ,
InlineKeyboardButton ( ( ' ✅ ' if ip_mode == ' 46 ' else ' ☐ ' ) + ' IPv4 + IPv6 ' , callback_data = f ' nqproto: { sid } : { mask } :46 ' ) ,
] )
run_text = ' ✅ 开始测试 ' if mask != NQ_ALL_MASK else ' ✅ 开始全测 '
rows . append ( [ InlineKeyboardButton ( run_text , callback_data = f ' nqrun: { sid } : { mask } : { ip_mode } ' ) ] )
rows . append ( [ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] )
return InlineKeyboardMarkup ( rows )
def nq_selected ( mask ) :
return [ item for item in NQ_ITEMS if mask & item [ 3 ] ]
def nq_selected_text ( mask ) :
items = nq_selected ( mask )
if not items :
return ' 未选择 '
return ' / ' . join ( item [ 2 ] for item in items )
def nq_ip_mode_text ( ip_mode ) :
return NQ_IP_MODES . get ( str ( ip_mode ) , NQ_IP_MODES [ ' 4 ' ] )
def nq_answer_script ( mask ) :
return ' ' . join ( ( ' y \n ' if mask & bit else ' n \n ' ) for _key , _label , _full , bit in NQ_ITEMS )
def nq_remote_ipv_arg ( s , ip_mode ) :
# NodeQuality default runs dual-stack when IPv6 exists. Force -4 for v4-only.
return ' ' if ( ip_mode == ' 46 ' and server_has_ipv6 ( s ) ) else ' -4 '
STREAM_REGION_BY_COUNTRY = {
' tw ' : ( ' 1 ' , ' 跨国 + 台湾 ' ) ,
' hk ' : ( ' 2 ' , ' 跨国 + 香港 ' ) ,
' mo ' : ( ' 2 ' , ' 跨国 + 香港 ' ) ,
' jp ' : ( ' 3 ' , ' 跨国 + 日本 ' ) ,
' us ' : ( ' 4 ' , ' 跨国 + 北美 ' ) ,
' ca ' : ( ' 4 ' , ' 跨国 + 北美 ' ) ,
' br ' : ( ' 5 ' , ' 跨国 + 南美 ' ) ,
' ar ' : ( ' 5 ' , ' 跨国 + 南美 ' ) ,
' cl ' : ( ' 5 ' , ' 跨国 + 南美 ' ) ,
' gb ' : ( ' 6 ' , ' 跨国 + 欧洲 ' ) ,
' uk ' : ( ' 6 ' , ' 跨国 + 欧洲 ' ) ,
' de ' : ( ' 6 ' , ' 跨国 + 欧洲 ' ) ,
' fr ' : ( ' 6 ' , ' 跨国 + 欧洲 ' ) ,
' nl ' : ( ' 6 ' , ' 跨国 + 欧洲 ' ) ,
' au ' : ( ' 7 ' , ' 跨国 + 大洋洲 ' ) ,
' nz ' : ( ' 7 ' , ' 跨国 + 大洋洲 ' ) ,
' kr ' : ( ' 8 ' , ' 跨国 + 韩国 ' ) ,
' sg ' : ( ' 9 ' , ' 跨国 + 东南亚 ' ) ,
' my ' : ( ' 9 ' , ' 跨国 + 东南亚 ' ) ,
' th ' : ( ' 9 ' , ' 跨国 + 东南亚 ' ) ,
' vn ' : ( ' 9 ' , ' 跨国 + 东南亚 ' ) ,
' ph ' : ( ' 9 ' , ' 跨国 + 东南亚 ' ) ,
' id ' : ( ' 9 ' , ' 跨国 + 东南亚 ' ) ,
' in ' : ( ' 10 ' , ' 跨国 + 印度 ' ) ,
' za ' : ( ' 11 ' , ' 跨国 + 非洲 ' ) ,
}
def stream_region_for_server ( s ) :
code = str ( s . get ( ' country ' ) or ' ' ) . lower ( )
if not code :
code = geolocate_host ( s . get ( ' host ' ) ) or ' '
return STREAM_REGION_BY_COUNTRY . get ( code , ( ' 0 ' , ' 只测跨国平台 ' ) )
def stream_menu_text ( s ) :
rid , label = stream_region_for_server ( s )
proto = ' 优先 IPv4; 若无 IPv4 自动改测 IPv6 '
return (
f ' 🎬 准备在 <b> { safe ( s . get ( " name " ) ) } </b> 跑流媒体检测: \n \n '
f ' 地区选项:<b> { safe ( label ) } </b> \n '
f ' 协议策略:<b> { safe ( proto ) } </b> \n \n '
' 会在目标机器本机执行 RegionRestrictionCheck, 并把结果整理成更好读的摘要。 '
)
def stream_markup ( s ) :
sid = server_id ( s )
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' ✅ 开始流媒体检测 ' , callback_data = f ' streamrun: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ,
] )
def nq_menu_text ( s , mask , ip_mode ) :
return (
f ' 📊 选择要在 <b> { safe ( s . get ( " name " ) ) } </b> 跑的 NodeQuality 项目: \n \n '
f ' 当前项目:<b> { safe ( nq_selected_text ( mask ) ) } </b> \n '
f ' IP 协议:<b> { safe ( nq_ip_mode_text ( ip_mode ) ) } </b> \n \n '
' 点击项目进行选择/取消;全选就是完整 NodeQuality。 '
)
def menu_text ( ) :
d = load_inventory ( )
servers = d . get ( ' servers ' , [ ] )
return (
f ' <b>GUKO</b> <code>v { safe ( GUKO_VERSION ) } </code> \n '
f ' 服务器 <b> { len ( servers ) } </b> 台 \n \n '
' 👇 点服务器打开操作面板。 '
)
def server_detail_text ( s ) :
name = safe ( s . get ( ' name ' ) )
title = f " { country_flag ( s . get ( ' country ' ) ) } <b> { name } </b> "
cfg = ssh_config ( s )
lines = [
title ,
f " <code> { safe ( cfg . get ( ' host ' ) ) } </code> · SSH <code> { safe ( cfg . get ( ' port ' ) ) } </code> · <code> { safe ( cfg . get ( ' user ' ) ) } </code> " ,
]
ipv6 = s . get ( ' ipv6 ' )
if ipv6 and str ( ipv6 ) . strip ( ) not in ( ' - ' , ' None ' ) :
lines . append ( f " IPv6 <code> { safe ( ipv6 ) } </code> " )
return ' \n ' . join ( lines )
def ssh_env_for ( s ) :
cfg = ssh_config ( s )
if cfg . get ( ' auth ' ) == ' password ' and cfg . get ( ' password ' ) :
env = os . environ . copy ( )
env [ ' SSHPASS ' ] = str ( cfg [ ' password ' ] )
return env
return None
async def run_cmd ( args , timeout = 60 , env = None ) :
proc = await asyncio . create_subprocess_exec (
* args ,
stdout = asyncio . subprocess . PIPE ,
stderr = asyncio . subprocess . STDOUT ,
env = env ,
)
try :
out , _ = await asyncio . wait_for ( proc . communicate ( ) , timeout = timeout )
except asyncio . TimeoutError :
proc . kill ( )
return 124 , ' 命令超时 '
return proc . returncode , out . decode ( errors = ' replace ' )
def ssh_args ( s , remote , * , tty = False ) :
return build_ssh_args ( s , remote , tty = tty , inv = load_inventory ( ) )
async def test_server_login ( s , timeout = 15 ) :
try :
code , out = await run_cmd ( ssh_args ( s , ' printf " ok: " ; hostname ' , tty = False ) , timeout = timeout , env = ssh_env_for ( s ) )
return code == 0 , strip_ansi ( out ) . strip ( )
except FileNotFoundError as e :
return False , f ' 缺少依赖: { e } '
except Exception as e :
return False , str ( e )
def server_specs_remote_script ( ) :
return r '''
set - eu
cores = $ ( nproc 2 > / dev / null | | true )
mem_bytes = $ ( awk ' /MemTotal:/ { print $2 * 1024; exit} ' / proc / meminfo 2 > / dev / null | | true )
disk_bytes = ' '
if command - v lsblk > / dev / null 2 > & 1 ; then
disk_bytes = $ ( lsblk - bdn - o SIZE , TYPE 2 > / dev / null | awk ' $2 == " disk " { sum += $1} END { if (sum > 0) printf " %.0f " , sum} ' )
fi
[ - n " $disk_bytes " ] | | disk_bytes = $ ( df - B1 / 2 > / dev / null | awk ' NR==2 { printf " %.0f " , $2} ' )
printf ' CPU= %s \n MEM_BYTES= %s \n DISK_BYTES= %s \n ' " $cores " " $mem_bytes " " $disk_bytes "
'''
def fmt_capacity_2 ( value ) :
try :
n = float ( value )
except Exception :
return ' '
if n < = 0 :
return ' '
gb = n / 1000 / 1000 / 1000
if gb < 1 :
return f ' { n / 1000 / 1000 : .2f } MB '
return f ' { gb : .2f } GB '
def format_server_specs ( specs ) :
if not isinstance ( specs , dict ) :
return ' '
parts = [ ]
cores = specs . get ( ' cpu_cores ' )
if cores not in ( None , ' ' ) :
try :
cores_text = str ( int ( float ( cores ) ) )
except Exception :
cores_text = str ( cores )
parts . append ( f ' 🧠 { safe ( cores_text ) } Cores ' )
mem = fmt_capacity_2 ( specs . get ( ' mem_bytes ' ) )
if mem :
parts . append ( f ' 💾 { safe ( mem ) } 内存 ' )
disk = fmt_capacity_2 ( specs . get ( ' disk_bytes ' ) )
if disk :
parts . append ( f ' 🗄️ { safe ( disk ) } 硬盘 ' )
return ' · ' . join ( parts )
def cache_server_specs ( s , specs ) :
if not isinstance ( specs , dict ) or not specs :
return None
payload = dict ( specs )
payload [ ' cached_at ' ] = iso_now ( )
return update_server_by_id ( str ( server_id ( s ) ) , { ' specs ' : payload } )
async def read_server_specs ( s , timeout = 8 ) :
try :
code , out = await run_cmd ( ssh_args ( s , server_specs_remote_script ( ) , tty = False ) , timeout = timeout , env = ssh_env_for ( s ) )
if code != 0 :
return None
vals = { }
for raw in strip_ansi ( out ) . splitlines ( ) :
if ' = ' in raw :
k , v = raw . split ( ' = ' , 1 )
vals [ k . strip ( ) ] = v . strip ( )
specs = {
' cpu_cores ' : vals . get ( ' CPU ' ) or ' ' ,
' mem_bytes ' : vals . get ( ' MEM_BYTES ' ) or ' ' ,
' disk_bytes ' : vals . get ( ' DISK_BYTES ' ) or ' ' ,
}
if not any ( specs . values ( ) ) :
return None
cache_server_specs ( s , specs )
return specs
except Exception :
return None
async def server_detail_text_with_specs ( s ) :
text = server_detail_text ( s )
specs_text = format_server_specs ( s . get ( ' specs ' ) )
if not specs_text :
specs = await read_server_specs ( s )
specs_text = format_server_specs ( specs )
if specs_text :
text + = ' \n ' + specs_text
return text
def build_server_item ( name , host , user , port , auth_kind = None , password = None , key = None ) :
item = {
' name ' : name ,
' host ' : host ,
' role ' : ' manual ' ,
' source ' : ' local-manual ' ,
' ssh ' : {
' user ' : user or ' root ' ,
' port ' : int ( port or 22 ) ,
} ,
}
if auth_kind == ' password ' :
item [ ' ssh ' ] [ ' auth ' ] = ' password '
item [ ' ssh ' ] [ ' password ' ] = password or ' '
elif auth_kind == ' key ' :
item [ ' ssh ' ] [ ' auth ' ] = ' key '
item [ ' ssh ' ] [ ' key ' ] = key or ' '
elif auth_kind == ' default ' :
item [ ' ssh ' ] [ ' auth ' ] = ' key '
return enrich_server_geo ( item )
def parse_bulk_lines ( text , * , same_port = None , auth_mode = ' skip ' , shared_auth = None ) :
items = [ ]
errors = [ ]
for lineno , raw in enumerate ( ( text or ' ' ) . splitlines ( ) , 1 ) :
line = raw . strip ( )
if not line or line . startswith ( ' # ' ) :
continue
try :
parts = shlex . split ( line )
except Exception as e :
errors . append ( f ' 第 { lineno } 行解析失败: { e } ' )
continue
if len ( parts ) < 2 :
errors . append ( f ' 第 { lineno } 行字段太少 ' )
continue
name = parts [ 0 ]
host , embedded_port = parse_host_port ( parts [ 1 ] )
idx = 2
port = same_port or embedded_port
if port is None and idx < len ( parts ) and parts [ idx ] . isdigit ( ) :
port = int ( parts [ idx ] ) ; idx + = 1
user = ' root '
if idx < len ( parts ) and not parts [ idx ] . startswith ( ( ' key: ' , ' password: ' , ' auth: ' ) ) :
user = parts [ idx ] ; idx + = 1
if not port :
port = 22
if not is_valid_hostname ( host ) :
errors . append ( f ' 第 { lineno } 行 IP/域名不正确: { host } ' )
continue
auth_kind = None
password = None
key = None
if auth_mode == ' key ' :
auth_kind , key = ' key ' , shared_auth
elif auth_mode == ' password ' :
auth_kind , password = ' password ' , shared_auth
elif auth_mode == ' per ' :
for token in parts [ idx : ] :
if token . startswith ( ' key: ' ) :
auth_kind , key = ' key ' , token [ 4 : ]
elif token . startswith ( ' password: ' ) :
auth_kind , password = ' password ' , token [ 9 : ]
if not auth_kind :
errors . append ( f ' 第 { lineno } 行缺少 key: 或 password: ' )
continue
item = build_server_item ( name , host , user , port , auth_kind , password , key )
items . append ( item )
return items , errors
def save_private_key ( chat_id , content , filename = ' id_key ' ) :
text = content . decode ( errors = ' replace ' ) if isinstance ( content , bytes ) else str ( content or ' ' )
if ' PRIVATE KEY ' not in text :
raise ValueError ( ' 没有识别到 PRIVATE KEY 内容 ' )
KEYS_DIR . mkdir ( parents = True , exist_ok = True )
safe_name = safe_target ( filename ) . replace ( ' . ' , ' _ ' ) [ : 40 ] or ' id_key '
path = KEYS_DIR / f ' { chat_id } - { int ( time . time ( ) ) } - { safe_name } .pem '
path . write_text ( text . strip ( ) + ' \n ' )
os . chmod ( path , 0o600 )
return str ( path )
def edit_markup ( s ) :
sid = server_id ( s )
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' 改名称 ' , callback_data = f ' editfield: { sid } :name ' ) , InlineKeyboardButton ( ' 改主机/IP ' , callback_data = f ' editfield: { sid } :host ' ) ] ,
[ InlineKeyboardButton ( ' 改端口 ' , callback_data = f ' editfield: { sid } :port ' ) , InlineKeyboardButton ( ' 改用户 ' , callback_data = f ' editfield: { sid } :user ' ) ] ,
[ InlineKeyboardButton ( ' 改密钥路径 ' , callback_data = f ' editfield: { sid } :key ' ) , InlineKeyboardButton ( ' 改密码 ' , callback_data = f ' editfield: { sid } :password ' ) ] ,
[ InlineKeyboardButton ( ' 改为默认密钥 ' , callback_data = f ' editdefault: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ,
] )
def add_session ( chat_id , * * data ) :
cur = ADD_SESSIONS . setdefault ( chat_id , { } )
cur . update ( data )
return cur
def clear_add_session ( chat_id ) :
ADD_SESSIONS . pop ( chat_id , None )
async def finish_single_add ( update : Update , context : ContextTypes . DEFAULT_TYPE , sess : dict ) :
item = build_server_item (
sess . get ( ' name ' ) , sess . get ( ' host ' ) , sess . get ( ' user ' ) or ' root ' , sess . get ( ' port ' ) or 22 ,
sess . get ( ' auth_kind ' ) , sess . get ( ' password ' ) , sess . get ( ' key ' ) ,
)
saved , action = upsert_server ( item )
ok_text = ' 未测试登录 '
if sess . get ( ' auth_kind ' ) in ( ' key ' , ' password ' , ' default ' ) :
ok , out = await test_server_login ( saved )
ok_text = ( ' ✅ 登录成功: ' if ok else ' ⚠️ 已保存,但登录测试失败: ' ) + safe ( out [ - 800 : ] )
clear_add_session ( update . effective_chat . id )
verb = ' 更新 ' if action == ' updated ' else ' 添加 '
cfg = ssh_config ( saved )
await update . effective_message . reply_text (
f ' ✅ 服务器已 { verb } : <b>{ safe ( saved . get ( " name " ) ) } </b> \n '
f ' <code> { safe ( cfg . get ( " user " ) ) } @ { safe ( cfg . get ( " host " ) ) } : { safe ( cfg . get ( " port " ) ) } </code> \n \n '
f ' { ok_text } ' ,
parse_mode = ParseMode . HTML ,
reply_markup = main_menu_markup ( ) ,
)
async def finish_bulk_add ( update : Update , context : ContextTypes . DEFAULT_TYPE , sess : dict , text : str ) :
items , errors = parse_bulk_lines (
text ,
same_port = sess . get ( ' same_port ' ) ,
auth_mode = sess . get ( ' auth_mode ' ) or ' skip ' ,
shared_auth = sess . get ( ' shared_auth ' ) ,
)
if not items :
await update . effective_message . reply_text ( ' 没有可导入的服务器。 \n ' + ' \n ' . join ( errors [ : 8 ] ) )
return
results = [ ]
for item in items :
saved , action = upsert_server ( item )
results . append ( ( saved , action ) )
clear_add_session ( update . effective_chat . id )
lines = [ f ' ✅ 已导入 { len ( results ) } 台服务器。 ' ]
if errors :
lines . append ( f ' ⚠️ 跳过 { len ( errors ) } 行: ' )
lines . extend ( errors [ : 6 ] )
lines . append ( ' \n 前几台: ' )
for saved , action in results [ : 8 ] :
cfg = ssh_config ( saved )
lines . append ( f ' - { saved . get ( " name " ) } { cfg . get ( " user " ) } @ { cfg . get ( " host " ) } : { cfg . get ( " port " ) } { action } ' )
await update . effective_message . reply_text ( ' \n ' . join ( safe ( x ) for x in lines ) , parse_mode = ParseMode . HTML , reply_markup = main_menu_markup ( ) )
def job_id ( kind , s ) :
return f " { kind } - { server_id ( s ) } - { int ( time . time ( ) * 1000 ) } "
KIND_NAME = {
' ipq ' : ' IP质量 ' , ' nq ' : ' NodeQuality ' , ' gb5 ' : ' GB5 ' , ' stream ' : ' 流媒体检测 ' ,
' nexttrace ' : ' NextTrace ' , ' bgp ' : ' BGP图 ' , ' ippure ' : ' IPPure图 ' ,
' ss ' : ' SS ' , ' anytls ' : ' AnyTLS ' ,
}
STATUS_ICON = { ' running ' : ' 🟢 ' , ' done ' : ' ✅ ' , ' failed ' : ' 🔴 ' }
def iso_now ( ) :
return datetime . now ( ) . astimezone ( ) . isoformat ( timespec = ' seconds ' )
def kind_result_dir ( s , kind ) :
return RESULTS_DIR / str ( server_id ( s ) ) / str ( kind )
def latest_result_files ( s , kind ) :
root = kind_result_dir ( s , kind )
if not root . exists ( ) :
return [ ]
files = [ p for p in root . iterdir ( ) if p . is_file ( ) and p . stat ( ) . st_size > 0 ]
return sorted ( files , key = lambda p : p . stat ( ) . st_mtime , reverse = True )
def legacy_media_files ( s , kind ) :
host = str ( s . get ( ' host ' ) or ' ' ) . strip ( )
files = [ ]
if kind == ' bgp ' and host :
for base in ( ' guko-bgp ' , ' vpspilot-bgp ' ) :
files . extend ( [ p for p in ( MEDIA_DIR / base ) . glob ( f ' */latest- { host } .png ' ) if p . is_file ( ) and p . stat ( ) . st_size > 0 ] )
return sorted ( files , key = lambda p : p . stat ( ) . st_mtime , reverse = True )
def all_result_files ( s , kind ) :
files = latest_result_files ( s , kind )
return files if files else legacy_media_files ( s , kind )
def clear_result_files ( s , kind ) :
root = kind_result_dir ( s , kind )
if not root . exists ( ) :
return
for old in root . iterdir ( ) :
if old . is_file ( ) :
try :
old . unlink ( )
except Exception :
pass
def persist_result_file ( s , kind , src , suffix = None , clear = True ) :
if not src :
return None
src = Path ( src )
if not src . exists ( ) or not src . is_file ( ) or src . stat ( ) . st_size < = 0 :
return None
root = kind_result_dir ( s , kind )
root . mkdir ( parents = True , exist_ok = True )
if clear :
clear_result_files ( s , kind )
ext = suffix or src . suffix or ' .bin '
dst = root / f ' latest { ext } '
if src . resolve ( ) != dst . resolve ( ) :
shutil . copy2 ( src , dst )
return str ( dst )
def latest_media_path ( item ) :
paths = item . get ( ' media_paths ' ) or [ ]
for x in paths :
p = Path ( x )
if p . exists ( ) and p . is_file ( ) and p . stat ( ) . st_size > 0 :
return p
return None
def load_history ( ) :
if not HISTORY_JSON . exists ( ) :
return [ ]
try :
data = json . loads ( HISTORY_JSON . read_text ( ) or ' [] ' )
return data if isinstance ( data , list ) else [ ]
except Exception :
return [ ]
def save_history ( items ) :
HISTORY_JSON . parent . mkdir ( parents = True , exist_ok = True )
HISTORY_JSON . write_text ( json . dumps ( items [ - HISTORY_LIMIT : ] , ensure_ascii = False , indent = 2 ) + ' \n ' )
def history_append ( jid , job ) :
item = {
' job_id ' : jid ,
' server ' : job . get ( ' server ' ) ,
' server_id ' : job . get ( ' server_id ' ) ,
' kind ' : job . get ( ' kind ' ) ,
' status ' : job . get ( ' status ' ) ,
' target ' : job . get ( ' target ' ) ,
' selected ' : job . get ( ' selected ' ) ,
' ip_mode ' : job . get ( ' ip_mode ' ) ,
' region ' : job . get ( ' region ' ) ,
' started_at ' : job . get ( ' started_at ' ) ,
' completed_at ' : job . get ( ' completed_at ' ) ,
' duration_sec ' : job . get ( ' duration_sec ' ) ,
' urls ' : history_urls ( job . get ( ' log ' ) or ' ' ) ,
' media_paths ' : job . get ( ' media_paths ' ) or ( [ ] if not job . get ( ' media_path ' ) else [ job . get ( ' media_path ' ) ] ) ,
' log_tail ' : trim_log ( strip_ansi ( job . get ( ' log ' ) or ' ' ) , 3500 ) ,
}
hist = [
x for x in load_history ( )
if not (
x . get ( ' job_id ' ) == jid
or ( str ( x . get ( ' server_id ' ) or ' ' ) == str ( item . get ( ' server_id ' ) or ' ' ) and x . get ( ' kind ' ) == item . get ( ' kind ' ) )
)
]
hist . append ( item )
save_history ( hist )
def create_job ( s , kind , status = ' running ' , * * extra ) :
jid = job_id ( kind , s )
now = iso_now ( )
JOBS [ jid ] = {
' status ' : status ,
' server ' : s . get ( ' name ' ) ,
' server_id ' : str ( server_id ( s ) ) ,
' kind ' : kind ,
' created_at ' : now ,
* * extra ,
}
if status == ' running ' :
JOBS [ jid ] [ ' started_at ' ] = now
return jid
def start_job ( s , kind , * * extra ) :
key = ( server_id ( s ) , kind )
if key in RUNNING :
return None , key
RUNNING . add ( key )
jid = create_job ( s , kind , status = ' running ' , * * extra )
return jid , key
def finish_job ( jid , key = None ) :
job = JOBS . get ( jid ) or { }
now = iso_now ( )
job . setdefault ( ' status ' , ' done ' )
job [ ' completed_at ' ] = now
try :
st = datetime . fromisoformat ( str ( job . get ( ' started_at ' ) or job . get ( ' created_at ' ) ) )
en = datetime . fromisoformat ( now )
job [ ' duration_sec ' ] = max ( 0 , int ( ( en - st ) . total_seconds ( ) ) )
except Exception :
pass
JOBS [ jid ] = job
history_append ( jid , job )
if key :
RUNNING . discard ( key )
def launch_job ( s , kind , runner , bot , chat_id , server , * runner_tail , * * extra ) :
jid , _key = start_job ( s , kind , * * extra )
if not jid :
return None
asyncio . create_task ( runner ( bot , chat_id , server , jid , * runner_tail ) )
return jid
def server_history ( s , limit = 20 ) :
sid = str ( server_id ( s ) )
name = str ( s . get ( ' name ' ) or ' ' )
host = str ( s . get ( ' host ' ) or ' ' )
out = [ ]
seen = set ( )
for item in load_history ( ) :
if str ( item . get ( ' server_id ' ) or ' ' ) == sid or str ( item . get ( ' server ' ) or ' ' ) in ( name , host , sid ) :
kind = item . get ( ' kind ' )
if kind :
seen . add ( kind )
out . append ( item )
scan_kinds = [ ]
root = RESULTS_DIR / sid
if root . exists ( ) :
scan_kinds . extend ( [ p . name for p in root . iterdir ( ) if p . is_dir ( ) ] )
scan_kinds . extend ( [ ' bgp ' ] )
for kind in sorted ( set ( scan_kinds ) ) :
if kind in seen :
continue
files = all_result_files ( s , kind )
if not files :
continue
newest = files [ 0 ]
out . append ( {
' job_id ' : f ' file- { sid } - { kind } ' ,
' server ' : s . get ( ' name ' ) ,
' server_id ' : sid ,
' kind ' : kind ,
' status ' : ' done ' ,
' completed_at ' : datetime . fromtimestamp ( newest . stat ( ) . st_mtime ) . astimezone ( ) . isoformat ( timespec = ' seconds ' ) ,
' media_paths ' : [ str ( p ) for p in files ] ,
' log_tail ' : str ( newest ) ,
} )
return out [ - limit : ]
def history_item_for ( s , kind ) :
for item in reversed ( server_history ( s , 50 ) ) :
if item . get ( ' kind ' ) == kind :
return item
return None
def history_markup ( s ) :
sid = server_id ( s )
items = list ( reversed ( server_history ( s , 50 ) ) )
buttons = [ ]
seen = set ( )
for item in items :
kind = item . get ( ' kind ' )
if not kind or kind in seen :
continue
seen . add ( kind )
icon = STATUS_ICON . get ( item . get ( ' status ' ) , ' • ' )
label = f ' { icon } { KIND_NAME . get ( kind , kind ) } '
buttons . append ( InlineKeyboardButton ( label , callback_data = f ' histd: { sid } : { kind } ' ) )
rows = button_rows ( buttons , 2 )
rows . append ( [ InlineKeyboardButton ( ' 🔄 刷新历史 ' , callback_data = f ' hist: { sid } ' ) ] )
rows . append ( [ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] )
return InlineKeyboardMarkup ( rows )
def history_detail_text ( s , kind ) :
item = history_item_for ( s , kind )
if not item :
return f ' 📜 <b> { safe ( s . get ( " name " ) ) } </b> 暂无 { safe ( KIND_NAME . get ( kind , kind ) ) } 历史。 '
icon = STATUS_ICON . get ( item . get ( ' status ' ) , ' • ' )
lines = [
f ' { icon } <b> { safe ( s . get ( " name " ) ) } · { safe ( KIND_NAME . get ( kind , kind ) ) } </b> ' ,
f ' 状态:<b> { safe ( item . get ( " status " ) or " - " ) } </b> ' ,
]
when = item . get ( ' completed_at ' ) or item . get ( ' started_at ' )
if when :
lines . append ( f ' 时间:<code> { safe ( when ) } </code> ' )
if item . get ( ' duration_sec ' ) is not None :
lines . append ( f ' 耗时:<code> { safe ( item . get ( " duration_sec " ) ) } s</code> ' )
params = [ ]
for label , key in [ ( ' 目标 ' , ' target ' ) , ( ' 选择 ' , ' selected ' ) , ( ' IP模式 ' , ' ip_mode ' ) , ( ' 地区 ' , ' region ' ) ] :
if item . get ( key ) :
params . append ( f ' { label } : { item . get ( key ) } ' )
if params :
lines . append ( ' 参数: ' + safe ( ' ; ' . join ( params ) ) )
urls = item . get ( ' urls ' ) or [ ]
if urls :
lines . append ( ' \n 链接: \n ' + ' \n ' . join ( safe ( u ) for u in urls [ : 8 ] ) )
media = latest_media_path ( item )
if media :
lines . append ( ' \n 图片:点击后会重新发送最近一次结果图。 ' )
log_tail = ( item . get ( ' log_tail ' ) or ' ' ) . strip ( )
if log_tail and not media :
lines . append ( ' \n 详情: \n <pre> ' + safe ( log_tail [ - 3200 : ] ) + ' </pre> ' )
else :
lines . append ( ' \n 详情:暂无可展示内容。 ' )
return ' \n ' . join ( lines )
def history_text ( s ) :
items = server_history ( s , 20 )
if not items :
return f ' 📜 <b> { safe ( s . get ( " name " ) ) } </b> 暂无测试历史。 '
lines = [ f ' 📜 <b> { safe ( s . get ( " name " ) ) } </b> 最近一次测试结果 ' , ' 点下面的功能按钮可以查看具体内容。 ' ]
for item in reversed ( items ) :
icon = STATUS_ICON . get ( item . get ( ' status ' ) , ' • ' )
kind = KIND_NAME . get ( item . get ( ' kind ' ) , item . get ( ' kind ' ) or ' - ' )
extra = [ ]
for k in ( ' target ' , ' selected ' , ' ip_mode ' , ' region ' ) :
if item . get ( k ) :
extra . append ( str ( item . get ( k ) ) )
dur = f " · { item . get ( ' duration_sec ' ) } s " if item . get ( ' duration_sec ' ) is not None else ' '
when = item . get ( ' completed_at ' ) or item . get ( ' started_at ' ) or ' - '
suffix = f " — { ' ; ' . join ( extra ) } " if extra else ' '
lines . append ( f ' { icon } { safe ( kind ) } · { safe ( item . get ( " status " ) ) } { safe ( dur ) } \n <code> { safe ( when ) } </code> { safe ( suffix ) } ' )
urls = item . get ( ' urls ' ) or [ ]
if urls :
lines . append ( ' ' + safe ( urls [ 0 ] ) )
return ' \n ' . join ( lines )
def server_jobs ( s ) :
sid = str ( server_id ( s ) )
name = str ( s . get ( ' name ' ) or ' ' )
host = str ( s . get ( ' host ' ) or ' ' )
found = [ ]
for jid , j in JOBS . items ( ) :
if ( j . get ( ' status ' ) or ' ' ) != ' running ' :
continue
j_server = str ( j . get ( ' server ' ) or ' ' )
if f ' - { sid } - ' in str ( jid ) or j_server in ( name , host , sid ) :
found . append ( ( jid , j ) )
found . sort ( key = lambda kv : kv [ 1 ] . get ( ' created_at ' ) or kv [ 1 ] . get ( ' started_at ' ) or kv [ 0 ] )
return found
def compact_job_line ( s , jid , j ) :
status = j . get ( ' status ' ) or ' - '
icon = STATUS_ICON . get ( status , ' • ' )
kind = KIND_NAME . get ( j . get ( ' kind ' ) , j . get ( ' kind ' ) or ' - ' )
parts = [ kind ]
if j . get ( ' selected ' ) :
parts . append ( str ( j . get ( ' selected ' ) ) )
elif j . get ( ' target ' ) :
parts . append ( str ( j . get ( ' target ' ) ) )
elif j . get ( ' region ' ) :
parts . append ( str ( j . get ( ' region ' ) ) )
label = ' · ' . join ( parts )
tail = status
extras = [ ]
if j . get ( ' ip_mode ' ) :
extras . append ( str ( j . get ( ' ip_mode ' ) ) )
if extras :
tail + = f " ( { ' ; ' . join ( extras ) } ) "
return f ' { icon } { safe ( label ) } - { safe ( tail ) } '
def job_status_text ( s ) :
jobs = server_jobs ( s )
if not jobs :
return f ' 📋 <b> { safe ( s . get ( " name " ) ) } </b> 当前没有任务。 '
lines = [ f ' 📋 <b> { safe ( s . get ( " name " ) ) } </b> 当前任务 ' ]
for jid , j in jobs [ - 10 : ] :
lines . append ( compact_job_line ( s , jid , j ) )
return ' \n ' . join ( lines )
def extract_urls ( text ) :
text = strip_ansi ( text or ' ' )
urls = re . findall ( r ' https?://[^ \ s<> " \' \ x00- \ x1f \ x7f]+ ' , text )
return [ u . rstrip ( ' .,;,。)】] ' ) for u in urls ]
def history_urls ( text , limit = 24 ) :
urls = [ ]
seen = set ( )
nq = nodequality_url ( text )
if nq :
urls . append ( nq )
seen . add ( nq )
for u in extract_urls ( text ) :
clean = strip_ansi ( u ) . rstrip ( ' .,;,。)】] ' )
m = re . search ( r ' (https?://nodequality \ .com/r/[A-Za-z0-9] {32} ) ' , clean )
if m :
clean = m . group ( 1 )
if clean not in seen and (
' Report.Check.Place/ ' in clean
or ' browser.geekbench.com/ ' in clean
or ' nodequality.com/r/ ' in clean
) :
urls . append ( clean )
seen . add ( clean )
for u in extract_urls ( text ) :
clean = strip_ansi ( u ) . rstrip ( ' .,;,。)】] ' )
if clean not in seen :
urls . append ( clean )
seen . add ( clean )
if len ( urls ) > = limit :
break
return urls [ : limit ]
def first_report_url ( text , category = None ) :
urls = extract_urls ( text )
reports = [ u for u in urls if ' Report.Check.Place ' in u ]
if category :
needle = f ' /Report.Check.Place/ { category } / '
needle2 = f ' Report.Check.Place/ { category } / '
for u in reports :
clean = strip_ansi ( u ) . strip ( )
if needle in clean or needle2 in clean :
return clean
return None
return strip_ansi ( reports [ 0 ] ) . strip ( ) if reports else None
def geekbench_urls ( text ) :
urls = [ ]
seen = set ( )
for u in extract_urls ( text ) :
clean = strip_ansi ( u ) . rstrip ( ' .,;,。)】] ' )
if re . search ( r ' browser \ .geekbench \ .com/v \ d+/cpu/ \ d+ ' , clean ) and clean not in seen :
urls . append ( clean )
seen . add ( clean )
return urls
def nodequality_url ( text ) :
clean = strip_ansi ( text or ' ' )
# NodeQuality output can be immediately followed by curl progress digits
# (for example the trailing "00" from "100"), so capture only the token.
m = re . search ( r ' https?://nodequality \ .com/r/([A-Za-z0-9] {32} ) ' , clean )
if m :
return f ' https://nodequality.com/r/ { m . group ( 1 ) } '
for u in extract_urls ( clean ) :
u = strip_ansi ( u ) . rstrip ( ' .,;,。 ' )
m = re . search ( r ' (https?://nodequality \ .com/r/[A-Za-z0-9] {32} ) ' , u )
if m :
return m . group ( 1 )
return None
def all_report_urls ( text , category ) :
urls = [ ]
seen = set ( )
needle = f ' Report.Check.Place/ { category } / '
for u in extract_urls ( text ) :
clean = strip_ansi ( u ) . strip ( )
if needle in clean and clean not in seen :
urls . append ( clean )
seen . add ( clean )
return urls
def trim_log ( text , limit = 3200 ) :
text = ( text or ' ' ) . strip ( )
return text [ - limit : ] if len ( text ) > limit else text
def run_pty_command_sync ( args , timeout = 900 , send_enter_after = 2 ) :
master , slave = os . openpty ( )
try :
try :
os . set_blocking ( master , False )
except Exception :
pass
proc = subprocess . Popen ( args , stdin = slave , stdout = slave , stderr = slave , close_fds = True )
os . close ( slave )
slave = None
out = bytearray ( )
start = time . monotonic ( )
sent_enter = False
while True :
now = time . monotonic ( )
if not sent_enter and now - start > = send_enter_after :
try :
os . write ( master , b ' \r ' )
except OSError :
pass
sent_enter = True
if now - start > timeout :
try :
proc . terminate ( )
time . sleep ( 1 )
if proc . poll ( ) is None :
proc . kill ( )
except Exception :
pass
return 124 , out . decode ( errors = ' replace ' ) + ' \n 命令超时 '
r , _ , _ = select . select ( [ master ] , [ ] , [ ] , 0.2 )
if r :
try :
chunk = os . read ( master , 8192 )
if chunk :
out . extend ( chunk )
except OSError :
pass
if proc . poll ( ) is not None :
# drain remaining output
for _ in range ( 10 ) :
r , _ , _ = select . select ( [ master ] , [ ] , [ ] , 0.05 )
if not r :
break
try :
chunk = os . read ( master , 8192 )
if chunk :
out . extend ( chunk )
except OSError :
break
return proc . returncode , out . decode ( errors = ' replace ' )
finally :
if slave is not None :
try :
os . close ( slave )
except OSError :
pass
try :
os . close ( master )
except OSError :
pass
async def run_pty_command ( args , timeout = 900 , send_enter_after = 2 ) :
return await asyncio . to_thread ( run_pty_command_sync , args , timeout , send_enter_after )
async def send_long_text ( bot , chat_id , text , * , parse_mode = None ) :
text = text or ' 无输出 '
max_len = 3600 if parse_mode == ParseMode . HTML else 3900
chunks = [ text [ i : i + max_len ] for i in range ( 0 , len ( text ) , max_len ) ] or [ ' 无输出 ' ]
for chunk in chunks [ : 3 ] :
await bot . send_message ( chat_id , chunk , parse_mode = parse_mode )
async def run_until_report ( args , timeout = 900 , env = None ) :
proc = await asyncio . create_subprocess_exec (
* args , stdout = asyncio . subprocess . PIPE , stderr = asyncio . subprocess . STDOUT , env = env
)
out = bytearray ( )
start = time . monotonic ( )
report = None
try :
while True :
if time . monotonic ( ) - start > timeout :
proc . kill ( )
await proc . wait ( )
return 124 , out . decode ( errors = ' replace ' ) + ' \n 命令超时 ' , report
try :
chunk = await asyncio . wait_for ( proc . stdout . read ( 1024 ) , timeout = 1 )
except asyncio . TimeoutError :
if proc . returncode is not None :
break
continue
if chunk :
out . extend ( chunk )
text = out . decode ( errors = ' replace ' )
report = first_report_url ( text , ' ip ' ) or report
if report :
proc . terminate ( )
try :
await asyncio . wait_for ( proc . wait ( ) , timeout = 3 )
except asyncio . TimeoutError :
proc . kill ( )
await proc . wait ( )
return 0 , text , report
elif proc . returncode is not None :
break
text = out . decode ( errors = ' replace ' )
return proc . returncode , text , first_report_url ( text , ' ip ' )
finally :
if proc . returncode is None :
try :
proc . kill ( )
except ProcessLookupError :
pass
async def run_subprocess ( args , timeout , * , send_enter_after = None , env = None ) :
proc = await asyncio . create_subprocess_exec (
* args , stdin = asyncio . subprocess . PIPE , stdout = asyncio . subprocess . PIPE , stderr = asyncio . subprocess . STDOUT , env = env
)
async def nudge_enter ( ) :
if send_enter_after is None :
return
await asyncio . sleep ( send_enter_after )
if proc . returncode is None and proc . stdin :
try :
proc . stdin . write ( b ' \n ' )
await proc . stdin . drain ( )
except Exception :
pass
nudger = asyncio . create_task ( nudge_enter ( ) )
try :
out , _ = await asyncio . wait_for ( proc . communicate ( ) , timeout = timeout )
except asyncio . TimeoutError :
proc . kill ( )
try :
out , _ = await proc . communicate ( )
except Exception :
out = b ' '
return 124 , ( out or b ' ' ) . decode ( errors = ' replace ' ) + ' \n 命令超时 '
finally :
nudger . cancel ( )
return proc . returncode , out . decode ( errors = ' replace ' )
async def resolve_target_to_ipv4 ( target ) :
ip = extract_ipv4 ( target )
if ip :
return ip , None
host = normalize_domain ( target )
if not host :
raise RuntimeError ( ' 没有识别到 IPv4 或域名 ' )
def lookup ( ) :
infos = socket . getaddrinfo ( host , None , socket . AF_INET , socket . SOCK_STREAM )
seen = [ ]
for info in infos :
addr = info [ 4 ] [ 0 ]
if addr not in seen :
seen . append ( addr )
if not seen :
raise RuntimeError ( ' 域名没有解析到 IPv4 ' )
return seen [ 0 ]
return await asyncio . to_thread ( lookup ) , host
def parse_bgp_png ( stdout , target , outdir ) :
m = re . search ( r ' ^LATEST=(.+)$ ' , stdout or ' ' , re . M )
if m :
return Path ( m . group ( 1 ) . strip ( ) )
return Path ( outdir ) / f ' latest- { safe_target ( target ) } .png '
def is_bgp_temporary_no_path ( output ) :
text = str ( output or ' ' )
return (
' PLACEHOLDER ' in text
or ' NONE ' in text
or ' temporarily returned no path image ' in text
or ' prefix not visible in DFZ ' in text
or ' no usable BGP path image found ' in text
or ' no path data ' in text
)
def bgp_retry_message ( ) :
return ' BGP 图暂时没取到,应该是 bgp.tools 偶发抽风,请再试一次。 '
async def ensure_bgp_tool ( ) :
if BGP_FETCH . exists ( ) :
return
bundled = Path ( ' /app/tools/bgp_fetch.py ' )
if bundled . exists ( ) :
BGP_FETCH . parent . mkdir ( parents = True , exist_ok = True )
shutil . copy2 ( bundled , BGP_FETCH )
os . chmod ( BGP_FETCH , 0o755 )
return
raise RuntimeError ( ' BGP 工具不存在。请使用项目 Dockerfile 构建镜像,或设置 BGP_FETCH 指向 bgp_fetch.py。 ' )
async def ensure_ippure_tool ( ) :
if not shutil . which ( ' node ' ) :
raise RuntimeError ( ' 容器里没有 node, 无法运行 IPPure。请使用项目 Dockerfile 构建镜像。 ' )
if not IPPURE_DOWNLOAD . exists ( ) :
bundled = Path ( ' /app/tools/download_ippure.js ' )
if bundled . exists ( ) :
IPPURE_DOWNLOAD . parent . mkdir ( parents = True , exist_ok = True )
shutil . copy2 ( bundled , IPPURE_DOWNLOAD )
os . chmod ( IPPURE_DOWNLOAD , 0o755 )
else :
raise RuntimeError ( ' IPPure 工具不存在。请使用项目 Dockerfile 构建镜像,或设置 IPPURE_DOWNLOAD 指向 download_ippure.js。 ' )
try :
code , _ = await run_subprocess ( [
' node ' , ' -e ' ,
' const fs=require( " fs " ); const paths=[process.env.CHROMIUM_PATH, " /usr/bin/chromium " , " /usr/bin/chromium-browser " , " /usr/bin/google-chrome " , " /usr/bin/google-chrome-stable " ].filter(Boolean); '
' if(paths.some(p=>fs.existsSync(p))) { process.exit(0)}; '
' const {chromium} =require( " playwright " ); const p=chromium.executablePath(); console.log(p); process.exit(fs.existsSync(p)?0:2) '
] , timeout = 20 )
if code != 0 :
raise RuntimeError ( ' missing playwright browser ' )
except Exception :
code , out = await run_subprocess ( [ ' bash ' , ' -lc ' , ' npm install -g playwright@1.59.1 && PLAYWRIGHT_BROWSERS_PATH=$ { PLAYWRIGHT_BROWSERS_PATH:-/ms-playwright} npx playwright install chromium chromium-headless-shell ' ] , timeout = 600 )
if code != 0 :
raise RuntimeError ( ' Playwright 自动安装失败: \n ' + trim_log ( out , 1000 ) )
async def generate_bgp_png ( ip ) :
await ensure_bgp_tool ( )
outdir = BGP_OUT_ROOT / f ' bgp- { int ( time . time ( ) ) } - { os . getpid ( ) } '
outdir . mkdir ( parents = True , exist_ok = True )
code , out = await run_subprocess ( [ ' python3 ' , str ( BGP_FETCH ) , ' --outdir ' , str ( outdir ) , ip ] , timeout = 120 )
if code != 0 :
if is_bgp_temporary_no_path ( out ) :
raise RuntimeError ( bgp_retry_message ( ) )
raise RuntimeError ( trim_log ( out , 1000 ) or f ' BGP 生成失败: { code } ' )
png = parse_bgp_png ( out , ip , outdir )
if not png . exists ( ) or png . stat ( ) . st_size < = 0 :
raise RuntimeError ( ' BGP 图片生成后未找到文件 ' )
return png
async def generate_ippure_png ( ip ) :
await ensure_ippure_tool ( )
outdir = IPPURE_TMP_ROOT / f ' ippure- { int ( time . time ( ) ) } - { os . getpid ( ) } '
outdir . mkdir ( parents = True , exist_ok = True )
code , out = await run_subprocess ( [ ' node ' , str ( IPPURE_DOWNLOAD ) , ' --ip ' , ip , ' --outdir ' , str ( outdir ) ] , timeout = 120 )
if code != 0 :
raise RuntimeError ( trim_log ( out , 1000 ) or f ' IPPure 生成失败: { code } ' )
candidates = [ Path ( line . strip ( ) ) for line in ( out or ' ' ) . splitlines ( ) if line . strip ( ) . endswith ( ' .png ' ) ]
png = candidates [ - 1 ] if candidates else None
if not png or not png . exists ( ) or png . stat ( ) . st_size < = 0 :
raise RuntimeError ( ' IPPure 图片生成后未找到文件 ' )
return png
async def send_png_and_cleanup ( bot , chat_id , png , cleanup_dir = None ) :
with Path ( png ) . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
if cleanup_dir :
await asyncio . to_thread ( shutil . rmtree , str ( cleanup_dir ) , True )
async def run_bgp_task ( bot , chat_id , s , jid ) :
key = ( server_id ( s ) , ' bgp ' )
try :
ip = s . get ( ' host ' )
png = await generate_bgp_png ( ip )
saved = persist_result_file ( s , ' bgp ' , png , ' .png ' )
JOBS [ jid ] . update ( { ' status ' : ' done ' , ' log ' : str ( png ) , ' media_path ' : saved } )
await send_png_and_cleanup ( bot , chat_id , png )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } BGP 图失败:<code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
async def run_ippure_task ( bot , chat_id , s , jid ) :
key = ( server_id ( s ) , ' ippure ' )
try :
ip = s . get ( ' host ' )
png = await generate_ippure_png ( ip )
saved = persist_result_file ( s , ' ippure ' , png , ' .png ' )
JOBS [ jid ] . update ( { ' status ' : ' done ' , ' log ' : str ( png ) , ' media_path ' : saved } )
await send_png_and_cleanup ( bot , chat_id , png , Path ( png ) . parent )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } IPPure 图失败:<code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
def ip_tools_markup ( ip ) :
row = [ ]
if tool_enabled ( ' ippure ' ) :
row . append ( InlineKeyboardButton ( ' 🧼 IPPure 图 ' , callback_data = f ' ippureip: { ip } ' ) )
if tool_enabled ( ' bgp ' ) :
row . append ( InlineKeyboardButton ( ' 🧭 BGP 图 ' , callback_data = f ' bgpip: { ip } ' ) )
if not row :
row . append ( InlineKeyboardButton ( ' 未启用 IP 图像工具 ' , callback_data = ' noop ' ) )
return InlineKeyboardMarkup ( [ row ] )
async def ensure_checkplace_renderer ( ) :
if RENDER_CHECKPLACE . exists ( ) :
return
fallback = Path ( __file__ ) . resolve ( ) . parent / ' render_checkplace.py '
if fallback . exists ( ) :
return
raise RuntimeError ( ' Check.Place PNG 渲染器不存在。请确认镜像包含 /app/render_checkplace.py, 或设置 RENDER_CHECKPLACE。 ' )
async def render_checkplace_png ( svg_url , out_png ) :
await ensure_checkplace_renderer ( )
renderer = RENDER_CHECKPLACE if RENDER_CHECKPLACE . exists ( ) else Path ( __file__ ) . resolve ( ) . parent / ' render_checkplace.py '
with tempfile . TemporaryDirectory ( ) as td :
svg_path = Path ( td ) / ' report.svg '
def download ( ) :
req = urllib . request . Request (
svg_url ,
headers = {
' User-Agent ' : ' Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36 ' ,
' Accept ' : ' image/svg+xml,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 ' ,
' Referer ' : ' https://Report.Check.Place/ ' ,
} ,
)
with urllib . request . urlopen ( req , timeout = 60 ) as r :
svg_path . write_bytes ( r . read ( ) )
await asyncio . to_thread ( download )
code , out = await run_subprocess ( [ ' python ' , str ( renderer ) , str ( svg_path ) , str ( out_png ) ] , timeout = 90 )
if code != 0 :
raise RuntimeError ( out [ - 1000 : ] )
async def send_report_images ( bot , chat_id , report_links , prefix ) :
if not report_links :
return [ ]
out_dir = Path ( ' /tmp/guko-results ' )
out_dir . mkdir ( parents = True , exist_ok = True )
sent = [ ]
for label , url in report_links :
png = out_dir / f " { prefix } - { label } - { int ( time . time ( ) ) } .png "
await render_checkplace_png ( url , png )
with png . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
sent . append ( ( label , url , png ) )
return sent
def proxy_tool_config ( kind ) :
tool = PROXY_TOOLS . get ( kind )
if not tool :
raise RuntimeError ( ' 未知工具 ' )
return tool
def proxy_menu_text ( s , kind ) :
tool = proxy_tool_config ( kind )
return (
f " { safe ( tool [ ' button ' ] ) } <b> { safe ( s . get ( ' name ' ) ) } · { safe ( tool [ ' name ' ] ) } </b> \n \n "
' 安装/更新会检测目标服务器的协议核心/服务端,不是更新 GUKO 脚本本身。 \n '
' 查看只读取目标服务器当前配置并返回连接信息。 '
)
def proxy_markup ( s , kind ) :
sid = server_id ( s )
tool = proxy_tool_config ( kind )
if kind == ' vless ' :
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' 安装/更新 纯 VLESS ' , callback_data = f ' vlessmode:plain: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' 安装/更新 Vision + Reality ' , callback_data = f ' vlessmode:reality: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' 查看配置 ' , callback_data = f ' proxyrun: { kind } :view: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ,
] )
return InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( f " 安装/更新 { tool [ ' name ' ] } 服务端 " , callback_data = f ' proxyrun: { kind } :ensure: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' 查看配置 ' , callback_data = f ' proxyrun: { kind } :view: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ,
] )
def proxy_answers ( kind , port = None , mode = None ) :
# Manager scripts are interactive for install: port/SNI/password prompts.
# Empty password keeps the script default random password.
port = str ( port or ' ' ) . strip ( )
if kind == ' ss ' :
# ss-rust also asks encryption method before password; empty method keeps the recommended default.
return f " { port } \n \n \n "
if kind == ' anytls ' :
return f " { port } \n \n "
if kind == ' snell ' :
return f " { port } \n \n "
if kind == ' vless ' :
# Xray-VLESS-Manager modes used by GUKO:
# 2 = pure VLESS TCP, 3 = VLESS + XTLS Vision + REALITY.
if mode == ' reality ' :
return f " { port } \n \n "
return f " { port } \n "
return " \n "
def proxy_extract_config ( text , kind ) :
clean = strip_ansi ( text or ' ' ) . replace ( ' \r ' , ' ' )
if kind == ' vless ' :
lines = [ ]
keep = False
for raw in clean . splitlines ( ) :
line = raw . rstrip ( )
if line . startswith ( ' VLESS ' ) or line . startswith ( ' vless:// ' ) or line . startswith ( ' UUID: ' ) or line . startswith ( ' 端口: ' ) or line . startswith ( ' 客户端 JSON: ' ) or line . startswith ( ' 服务端配置: ' ) or line . startswith ( ' { ' ) :
keep = True
if keep :
if ' Xray VLESS Manager ' in line or line . startswith ( ' === 基础功能 === ' ) or line . startswith ( ' === 服务管理 === ' ) or line . startswith ( ' === 系统功能 === ' ) or re . match ( r ' ^ \ s* \ d+ \ ) ' , line ) or line . strip ( ) == ' 0) 退出 ' :
break
lines . append ( line )
body = ' \n ' . join ( lines ) . strip ( )
return trim_log ( body or clean . strip ( ) , 3600 )
markers = [ ' 当前配置 ' , ' Surge: ' , ' Mihomo: ' , ' URI: ' , ' 地址: ' , ' 端口: ' , ' 密码: ' , ' 加密: ' , ' 版本: ' , ' UUID: ' , ' PublicKey: ' , ' ShortId: ' , ' SNI: ' , ' 服务端配置: ' , ' 客户端 JSON: ' ]
lines = [ ]
keep = False
for raw in clean . splitlines ( ) :
line = raw . rstrip ( )
if any ( m in line for m in markers ) :
keep = True
if keep :
lines . append ( line )
body = ' \n ' . join ( lines ) . strip ( ) or clean . strip ( )
return trim_log ( body , 3600 )
def is_proxy_link_line ( line ) :
s = ( line or ' ' ) . strip ( )
return s . startswith ( ( ' vless:// ' , ' ss:// ' , ' anytls:// ' , ' snell:// ' , ' VPS = ' , ' - { ' , ' - { " ' , ' { ' ) )
def format_proxy_config_html ( body ) :
if not body :
return ' <code>无节点配置输出</code> '
out = [ ]
block = [ ]
def flush_block ( ) :
if block :
out . append ( ' <pre> ' + safe ( ' \n ' . join ( block ) ) + ' </pre> ' )
block . clear ( )
for raw in str ( body ) . splitlines ( ) :
line = raw . rstrip ( )
if is_proxy_link_line ( line ) :
block . append ( line . strip ( ) )
else :
flush_block ( )
out . append ( safe ( line ) )
flush_block ( )
return ' \n ' . join ( out ) . strip ( )
async def run_proxy_tool_task ( bot , chat_id , s , jid , kind , action , mode = None ) :
key = ( server_id ( s ) , kind )
tool = proxy_tool_config ( kind )
try :
script = tool [ ' script_url ' ]
if action in ( ' install ' , ' ensure ' ) :
port = os . environ . get ( f " GUKO_ { kind . upper ( ) } _DEFAULT_PORT " , ' ' ) . strip ( )
dynamic_vless_port = kind == ' vless ' and not port
if kind == ' vless ' and not port :
port = ' 8443 '
answers = proxy_answers ( kind , port , mode )
service = tool [ ' service ' ]
bin_path_map = {
' ss ' : ' /usr/local/bin/ss-rust ' ,
' anytls ' : ' /usr/local/bin/anytls-server ' ,
' vless ' : ' /usr/local/bin/xray ' ,
' snell ' : ' /usr/local/bin/snell-server ' ,
}
repo_map = {
' ss ' : ' shadowsocks/shadowsocks-rust ' ,
' anytls ' : ' anytls/anytls-go ' ,
' vless ' : ' ' ,
' snell ' : ' ' ,
}
bin_path = bin_path_map [ kind ]
repo = repo_map [ kind ]
if kind == ' ss ' :
version_cmd = ' $BIN --version 2>/dev/null | head -n1 || true '
elif kind == ' anytls ' :
version_cmd = ' strings $BIN 2>/dev/null | grep -Eo " v?[0-9]+ \\ .[0-9]+ \\ .[0-9]+ " | sort -Vr | head -n1 || true '
elif kind == ' vless ' :
version_cmd = ' $BIN version 2>/dev/null | head -n1 || $BIN run -version 2>/dev/null | head -n1 || true '
else :
version_cmd = ' $BIN version 2>/dev/null | head -n1 || $BIN --version 2>/dev/null | head -n1 || true '
latest_probe = ' '
if kind in ( ' ss ' , ' anytls ' ) :
latest_probe = ' latest=$(curl -fsSL " https://api.github.com/repos/$REPO/releases/latest " | grep -m1 " tag_name " | sed -E " s/.* \\ \" tag_name \\ \" [[:space:]]*:[[:space:]]* \\ \" ([^ \\ \" ]+) \\ \" .*/ \\ 1/ " || true); '
elif kind == ' vless ' :
latest_probe = ' latest= " official XTLS installer " ; '
else :
latest_probe = ' latest= " latest from manager script " ; '
install_cmd = f ' printf %b { shlex . quote ( answers ) } | bash " $tmp " install '
if kind == ' vless ' :
menu_choice = ' 3 ' if mode == ' reality ' else ' 2 '
safe_sed = (
" perl -0pi -e ' s/ \\ $XRAY_BIN test -config \" \\ $CONFIG \" /if \\ $XRAY_BIN help 2> \\ /dev \\ /null | grep -qE \" ^[[:space:]]*test[[:space:]] \" ; then \\ $XRAY_BIN test -config \" \\ $CONFIG \" ; else \\ $XRAY_BIN run -test -config \" \\ $CONFIG \" ; fi/ ' \" $tmp \" ; "
" perl -0pi -e ' s/ \\ \" geoip:private \\ \" / \\ \" 127.0.0.0 \\ \\ \\ /8 \\ \" , \\ \" 10.0.0.0 \\ \\ \\ /8 \\ \" , \\ \" 172.16.0.0 \\ \\ \\ /12 \\ \" , \\ \" 192.168.0.0 \\ \\ \\ /16 \\ \" , \\ \" fc00:: \\ \\ \\ /7 \\ \" /g ' \" $tmp \" ; "
)
if dynamic_vless_port :
install_cmd = f " { safe_sed } guko_port=8443; if ss -lnt | awk ' NR>1 {{ print $4 }} ' | grep -Eq ' (^|:)8443$ ' ; then while :; do guko_port=$(shuf -i 20000-65000 -n 1); ss -lnt | awk ' NR>1 {{ print $4 }} ' | grep -Eq \" (^|:)$ {{ guko_port }} $ \" || break; done; echo \" GUKO_STATUS:默认端口 8443 已占用,改用 $guko_port \" ; fi; printf \" %b \" \" { menu_choice } \\ n$ {{ guko_port }} \\ n \\ n0 \\ n \" | bash \" $tmp \" "
else :
vless_answers = menu_choice + ' \n ' + answers + ' \n 0 \n '
install_cmd = f ' { safe_sed } printf %b { shlex . quote ( vless_answers ) } | bash " $tmp " '
remote = (
' export TERM=xterm-256color; cd /root; '
f ' BIN= { shlex . quote ( bin_path ) } ; SERVICE= { shlex . quote ( service ) } ; REPO= { shlex . quote ( repo ) } ; '
f ' tmp=$(mktemp /root/guko- { kind } .XXXXXX.sh); '
f ' curl -LfsS { shlex . quote ( script ) } -o " $tmp " ; chmod +x " $tmp " ; '
f ' { latest_probe } '
f ' current= " " ; [[ -x " $BIN " ]] && current=$( { version_cmd } ); '
f ' latest_num=$ {{ latest#v }} ; current_num=$(printf %s " $current " | grep -Eo " [0-9]+ \\ .[0-9]+ \\ .[0-9]+ " | head -n1 || true); '
f ' if [[ ! -x " $BIN " || ! -f " /etc/systemd/system/$SERVICE.service " ]]; then '
f ' echo " GUKO_STATUS:未安装,开始安装 " ; '
f ' { install_cmd } ; '
f " elif [[ \" { kind } \" == \" vless \" && -s /usr/local/etc/xray/client.txt && -s /usr/local/etc/xray/config.json ]] && jq -e ' .inbounds and (.inbounds|length>0) and any(.inbounds[]; .protocol == \" vless \" ) ' /usr/local/etc/xray/config.json >/dev/null 2>&1; then "
f ' echo " GUKO_STATUS:已安装且已有配置,无需重新安装 " ; '
f ' if command -v systemctl >/dev/null 2>&1 && ! systemctl is-active --quiet " $SERVICE " ; then echo " GUKO_STATUS:服务未运行,尝试启动 " ; systemctl start " $SERVICE " || true; fi; '
f ' cat /usr/local/etc/xray/client.txt; '
f " elif [[ \" { kind } \" == \" vless \" && -s /usr/local/etc/xray/config.json ]] && jq -e ' .inbounds and (.inbounds|length>0) and all(.inbounds[]; .protocol != \" vless \" ) ' /usr/local/etc/xray/config.json >/dev/null 2>&1; then "
f ' echo " GUKO_STATUS:检测到现有 Xray 配置不是 VLESS, 为避免覆盖请先迁移/备份现有配置或手动安装 " ; exit 23; '
f ' elif [[ " { kind } " != " ss " && " { kind } " != " anytls " ]]; then '
f ' echo " GUKO_STATUS:开始安装/更新协议服务端 " ; '
f ' { install_cmd } ; '
f ' elif [[ -n " $latest_num " && -n " $current_num " && " $current_num " == " $latest_num " ]]; then '
f ' echo " GUKO_STATUS:已安装最新版,无需更新 ($latest) " ; '
f ' if command -v systemctl >/dev/null 2>&1 && ! systemctl is-active --quiet " $SERVICE " ; then echo " GUKO_STATUS:服务未运行,尝试启动 " ; systemctl start " $SERVICE " || true; fi; '
f ' bash " $tmp " view; '
f ' else '
f ' echo " GUKO_STATUS:发现程序更新:当前=$ {{ current:-unknown }} 最新=$ {{ latest:-unknown }} ,开始更新 " ; '
f ' { install_cmd } ; '
f ' fi 2>&1 '
)
timeout = 1800
elif action == ' view ' :
if kind == ' vless ' :
remote = (
' export TERM=xterm-256color; '
' if [[ -s /usr/local/etc/xray/client.txt ]]; then '
' cat /usr/local/etc/xray/client.txt; '
' elif [[ -s /usr/local/etc/xray/config.json ]]; then '
' echo " 服务端配置: /usr/local/etc/xray/config.json " ; '
' cat /usr/local/etc/xray/config.json; '
' else '
' echo " 暂无配置 " ; '
' fi 2>&1 '
)
else :
remote = (
' export TERM=xterm-256color; cd /root; '
f ' tmp=$(mktemp /root/guko- { kind } .XXXXXX.sh); '
f ' curl -LfsS { shlex . quote ( script ) } -o " $tmp " ; chmod +x " $tmp " ; '
' bash " $tmp " view 2>&1 '
)
timeout = 300
else :
raise RuntimeError ( ' 未知操作 ' )
code , out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = timeout , env = ssh_env_for ( s ) )
sections = proxy_extract_config ( out , kind )
ok = code == 0 and bool ( sections )
JOBS [ jid ] . update ( { ' status ' : ' done ' if ok else ' failed ' , ' log ' : out , ' target ' : action } )
if action in ( ' install ' , ' ensure ' ) :
if ' GUKO_STATUS:未安装 ' in out :
title = ' 安装完成 '
elif ' GUKO_STATUS:已安装最新版 ' in out :
title = ' 已安装最新版,无需更新 '
elif ' GUKO_STATUS:发现程序更新 ' in out :
title = ' 程序已更新 '
else :
title = ' 安装/更新检查完成 '
else :
title = ' 当前配置 '
icon = ' ✅ ' if ok else ' ❌ '
msg = f " { icon } { safe ( s . get ( ' name ' ) ) } { safe ( tool [ ' name ' ] ) } { title } "
if not ok :
msg + = f " (退出码 { safe ( code ) } ) "
status_lines = [ line . split ( ' : ' , 1 ) [ 1 ] for line in strip_ansi ( out or ' ' ) . splitlines ( ) if line . startswith ( ' GUKO_STATUS: ' ) ]
status_text = ' \n ' . join ( status_lines ) . strip ( )
if status_text :
msg + = f " \n { safe ( status_text ) } "
msg + = f " \n \n { format_proxy_config_html ( sections ) } "
await send_long_text ( bot , chat_id , msg , parse_mode = ParseMode . HTML )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) , ' target ' : action } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } { safe ( tool [ ' name ' ] ) } 任务失败:<code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
async def run_ip_quality_task ( bot , chat_id , s , jid ) :
key = ( server_id ( s ) , ' ipq ' )
try :
remote = " export TERM=xterm-256color; cd /tmp && bash <(curl -Ls https://IP.Check.Place) -y "
code , out , url = await run_until_report ( ssh_args ( s , remote , tty = False ) , timeout = 900 , env = ssh_env_for ( s ) )
JOBS [ jid ] . update ( { ' status ' : ' done ' if code == 0 else ' failed ' , ' log ' : out } )
if not url :
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } IP质量没拿到报告链接。 \n <pre> { safe ( trim_log ( out ) ) } </pre> " , parse_mode = ParseMode . HTML )
return
out_dir = Path ( ' /tmp/guko-results ' )
out_dir . mkdir ( parents = True , exist_ok = True )
png = out_dir / f " ipq- { server_id ( s ) } - { int ( time . time ( ) ) } .png "
try :
await render_checkplace_png ( url , png )
saved = persist_result_file ( s , ' ipq ' , png , ' .png ' )
JOBS [ jid ] . update ( { ' media_path ' : saved } )
with png . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
await bot . send_message ( chat_id , f " ✅ { safe ( s . get ( ' name ' ) ) } IP质量完成 \n { safe ( url ) } \n \n { script_command_html ( ' ipq ' ) } " , parse_mode = ParseMode . HTML )
except Exception as e :
await bot . send_message ( chat_id , f " ✅ { safe ( s . get ( ' name ' ) ) } IP质量报告: \n { safe ( url ) } \n \n { script_command_html ( ' ipq ' ) } \n \n 转 PNG 失败:<code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } IP质量任务失败: <code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
async def fetch_checkplace_svg_from_json ( category , json_path ) :
# Check.Place rejects replayed/masked JSON from nodequality.com exports, so this
# is only a best-effort fallback for future unmasked JSON cases. Normal path
# should parse the SVG printed by the live sub-script stdout.
if not json_path . exists ( ) or json_path . stat ( ) . st_size < = 0 :
return None
script = (
" json_file=$1; category=$2; "
" curl -s -X POST https://upload.check.place "
" -d type=$category --data-urlencode json@$json_file --data-urlencode content= "
)
code , text = await run_subprocess ( [ ' bash ' , ' -lc ' , script , ' bash ' , str ( json_path ) , category ] , timeout = 60 )
if code != 0 :
return None
m = re . search ( r ' https://Report \ .Check \ .Place/[^ \ s<> " ]+ \ .svg ' , text )
return m . group ( 0 ) if m else None
def report_log_from_nodequality_url ( text ) :
token = None
nq = nodequality_url ( text )
if nq :
token = nq . rstrip ( ' / ' ) . split ( ' / ' ) [ - 1 ]
return f " https://api.nodequality.com/api/v1/record/ { token } " if token else None
def nodequality_token ( text ) :
nq = nodequality_url ( text )
return nq . rstrip ( ' / ' ) . split ( ' / ' ) [ - 1 ] if nq else None
async def upload_nodequality_result_from_remote ( s ) :
""" Re-upload exactly like official NodeQuality.sh: base64(result.zip) as raw POST body. """
remote = r ''' set -e
z = " "
for d in $ ( ls - td / root / . nodequality * / tmp / . nodequality * 2 > / dev / null ) ; do
if [ - s " $d/result.zip " ] ; then z = " $d/result.zip " ; break ; fi
done
[ - n " $z " ] | | exit 2
# Official NodeQuality.sh does: base64 result.zip | curl --data-binary @-
base64 " $z " | curl - fsS - X POST - - data - binary @ - https : / / api . nodequality . com / api / v1 / record
'''
code , out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = 120 , env = ssh_env_for ( s ) )
if code != 0 :
return None
return nodequality_url ( out )
async def recover_report_links_from_remote ( s , selected ) :
cats = [ ]
for label , cat , bit in [ ( ' 硬件 ' , ' hardware ' , 1 ) , ( ' IP质量 ' , ' ip ' , 2 ) , ( ' 网络 ' , ' net ' , 4 ) , ( ' 回程 ' , ' backroute ' , 8 ) ] :
if selected & bit :
cats . append ( ( label , cat ) )
if not cats :
return [ ]
remote = """ td=$(mktemp -d /tmp/nqrecover.XXXXXX)
for d in $ ( ls - td / root / . nodequality * / tmp / . nodequality * 2 > / dev / null ) ; do
r = \" $d/BenchOs/result \"
[ - d \" $r \" ] || { [ -s \" $d/result.zip \" ] && unzip -oq \" $d/result.zip \" -d \" $td \" && r= \" $td \" || continue; }
for pair in hardware : hardware_quality . json ip : ip_quality . json net : net_quality . json backroute : backroute_trace . json ; do
cat = $ { pair % % : * } ; fn = $ { pair #*:}; p=\"$r/$fn\"
[ - s \" $p \" ] && printf ' %s \t %s \n ' \" $cat \" \" $p \"
done
done | | true """
code , out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = 30 , env = ssh_env_for ( s ) )
if code != 0 :
return [ ]
mapping = { }
for line in out . splitlines ( ) :
if ' \t ' not in line :
continue
cat , path = line . split ( ' \t ' , 1 )
mapping . setdefault ( cat , path )
recovered = [ ]
with tempfile . TemporaryDirectory ( ) as td :
for label , cat in cats :
rp = mapping . get ( cat )
if not rp :
continue
local = Path ( td ) / f ' { cat } .json '
scp_args = scp_from_args ( s , rp , str ( local ) , inv = load_inventory ( ) )
c , scp_out = await run_subprocess ( scp_args , timeout = 60 , env = ssh_env_for ( s ) )
if c != 0 :
c , data = await run_subprocess ( ssh_args ( s , f " cat { shlex . quote ( rp ) } " , tty = False ) , timeout = 60 , env = ssh_env_for ( s ) )
if c != 0 :
continue
local . write_text ( data )
try :
u = await fetch_checkplace_svg_from_json ( cat , local )
except Exception :
u = None
if u :
recovered . append ( ( label , u ) )
return recovered
def parse_geekbench5_scores ( text ) :
clean = strip_ansi ( text or ' ' )
scores = { }
patterns = {
' single ' : r ' Single-Core Score \ s+( \ d+) ' ,
' multi ' : r ' Multi-Core Score \ s+( \ d+) ' ,
' url ' : r ' https://browser \ .geekbench \ .com/v5/cpu/ \ d+ ' ,
}
for key , pat in patterns . items ( ) :
m = re . search ( pat , clean , re . I )
if m :
scores [ key ] = m . group ( 1 ) if key != ' url ' else m . group ( 0 )
return scores
def gb5_result_image ( s , scores , out_png ) :
out_png = Path ( out_png )
out_png . parent . mkdir ( parents = True , exist_ok = True )
W , H = 1080 , 1350
bg = ( 245 , 247 , 250 )
blue = ( 47 , 111 , 191 )
dark = ( 30 , 41 , 59 )
text = ( 31 , 41 , 55 )
muted = ( 100 , 116 , 139 )
line = ( 226 , 232 , 240 )
green = ( 22 , 163 , 74 )
try :
font_title = ImageFont . truetype ( ' /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf ' , 52 )
font_h1 = ImageFont . truetype ( ' /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf ' , 42 )
font_h2 = ImageFont . truetype ( ' /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf ' , 32 )
font_score = ImageFont . truetype ( ' /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf ' , 86 )
font_txt = ImageFont . truetype ( ' /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ' , 28 )
font_small = ImageFont . truetype ( ' /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ' , 22 )
except Exception :
font_title = font_h1 = font_h2 = font_score = font_txt = font_small = ImageFont . load_default ( )
def fit ( draw , value , font , width ) :
value = str ( value or ' - ' )
if draw . textlength ( value , font = font ) < = width :
return value
ell = ' … '
while value and draw . textlength ( value + ell , font = font ) > width :
value = value [ : - 1 ]
return value + ell
im = Image . new ( ' RGB ' , ( W , H ) , bg )
d = ImageDraw . Draw ( im )
d . rectangle ( [ 0 , 0 , W , 110 ] , fill = ( 255 , 255 , 255 ) )
d . text ( ( 70 , 32 ) , ' Geekbench Browser ' , fill = blue , font = font_title )
d . rounded_rectangle ( [ 70 , 150 , W - 70 , 425 ] , radius = 18 , fill = ( 255 , 255 , 255 ) , outline = line , width = 2 )
d . text ( ( 110 , 190 ) , ' Geekbench 5 Score ' , fill = muted , font = font_txt )
single = str ( scores . get ( ' single ' ) or ' - ' )
multi = str ( scores . get ( ' multi ' ) or ' - ' )
d . text ( ( 145 , 255 ) , single , fill = dark , font = font_score )
d . text ( ( 145 , 350 ) , ' Single-Core Score ' , fill = muted , font = font_txt )
d . line ( [ W / / 2 , 220 , W / / 2 , 390 ] , fill = line , width = 2 )
d . text ( ( 620 , 255 ) , multi , fill = dark , font = font_score )
d . text ( ( 620 , 350 ) , ' Multi-Core Score ' , fill = muted , font = font_txt )
d . rounded_rectangle ( [ 70 , 465 , W - 70 , 820 ] , radius = 18 , fill = ( 255 , 255 , 255 ) , outline = line , width = 2 )
d . text ( ( 110 , 505 ) , str ( s . get ( ' name ' ) or s . get ( ' host ' ) or ' Server ' ) , fill = text , font = font_h1 )
rows = [
( ' Operating System ' , f " { s . get ( ' platform ' ) or ' - ' } { s . get ( ' platform_version ' ) or ' ' } " . strip ( ) ) ,
( ' Model ' , str ( s . get ( ' name ' ) or ' - ' ) ) ,
( ' Processor ' , str ( s . get ( ' cpu ' ) or ' - ' ) ) ,
( ' Memory ' , fmt_bytes ( s . get ( ' mem_total ' ) ) ) ,
( ' IPv4 ' , str ( s . get ( ' host ' ) or ' - ' ) ) ,
]
y = 585
for k , v in rows :
d . text ( ( 110 , y ) , k , fill = muted , font = font_small )
d . text ( ( 390 , y ) , fit ( d , v , font_small , 560 ) , fill = text , font = font_small )
y + = 42
d . rounded_rectangle ( [ 70 , 860 , W - 70 , 1180 ] , radius = 18 , fill = ( 255 , 255 , 255 ) , outline = line , width = 2 )
d . text ( ( 110 , 900 ) , ' Benchmark Summary ' , fill = text , font = font_h2 )
d . text ( ( 110 , 965 ) , ' Single-Core ' , fill = muted , font = font_txt )
d . rounded_rectangle ( [ 330 , 970 , 900 , 1000 ] , radius = 15 , fill = ( 219 , 234 , 254 ) )
try :
sw = max ( 8 , min ( 570 , int ( single ) / max ( int ( multi or 1 ) , int ( single ) , 1 ) * 570 ) )
except Exception :
sw = 20
d . rounded_rectangle ( [ 330 , 970 , 330 + sw , 1000 ] , radius = 15 , fill = blue )
d . text ( ( 920 , 960 ) , single , fill = text , font = font_txt , anchor = ' ra ' )
d . text ( ( 110 , 1045 ) , ' Multi-Core ' , fill = muted , font = font_txt )
d . rounded_rectangle ( [ 330 , 1050 , 900 , 1080 ] , radius = 15 , fill = ( 220 , 252 , 231 ) )
d . rounded_rectangle ( [ 330 , 1050 , 900 , 1080 ] , radius = 15 , fill = green )
d . text ( ( 920 , 1040 ) , multi , fill = text , font = font_txt , anchor = ' ra ' )
if scores . get ( ' url ' ) :
d . text ( ( 110 , 1125 ) , fit ( d , scores [ ' url ' ] , font_small , 850 ) , fill = blue , font = font_small )
d . text ( ( 70 , 1255 ) , ' Generated by GUKO · Geekbench 5 ' , fill = muted , font = font_small )
im . save ( out_png , quality = 95 )
return out_png
async def run_gb5_task ( bot , chat_id , s , jid ) :
key = ( server_id ( s ) , ' gb5 ' )
try :
remote = (
" set -e; export TERM=xterm-256color; cd /root; "
" swapfile=; cleanup() { if [ -n \" $swapfile \" ]; then swapoff $swapfile 2>/dev/null || true; rm -f $swapfile; fi; }; trap cleanup EXIT; "
" mem_kb=$(awk ' /MemTotal:/ { print $2} ' /proc/meminfo); "
" if [ $ {mem_kb:-0} -lt 900000 ]; then "
" swapfile=/root/geekbench5.swap; rm -f $swapfile; "
" (fallocate -l 2G $swapfile 2>/dev/null || dd if=/dev/zero of=$swapfile bs=1M count=2048 status=none); "
" chmod 600 $swapfile; mkswap $swapfile >/dev/null; swapon $swapfile; "
" fi; "
" d=/root/Geekbench- " + GB5_VERSION + " -Linux; "
" if [ ! -x $d/geekbench5 ]; then "
" curl -fsSL " + shlex . quote ( GB5_URL ) + " -o /tmp/geekbench5.tar.gz; "
" tar -xzf /tmp/geekbench5.tar.gz -C /root; "
" fi; "
" $d/geekbench5 --upload 2>&1 "
)
code , out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = 3600 , env = ssh_env_for ( s ) )
scores = parse_geekbench5_scores ( out )
gb_urls = [ u for u in extract_urls ( out ) if ' browser.geekbench.com/v5/cpu/ ' in u ]
if gb_urls :
scores [ ' url ' ] = gb_urls [ - 1 ]
JOBS [ jid ] . update ( { ' status ' : ' done ' if code == 0 and scores . get ( ' url ' ) else ' failed ' , ' log ' : out } )
if code != 0 or not scores . get ( ' url ' ) :
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } GB5 没拿到结果链接。 \n <pre> { safe ( trim_log ( out ) ) } </pre> " , parse_mode = ParseMode . HTML )
return
img = gb5_result_image ( s , scores , RESULTS_DIR / str ( server_id ( s ) ) / ' gb5 ' / ' latest.jpg ' )
JOBS [ jid ] . update ( { ' media_path ' : str ( img ) } )
with img . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
await bot . send_message ( chat_id , f " ✅ { safe ( s . get ( ' name ' ) ) } GB5 完成 \n { safe ( scores [ ' url ' ] ) } " , parse_mode = ParseMode . HTML , disable_web_page_preview = True )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } GB5任务失败: <code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
def stream_clean_output ( text ) :
clean = strip_ansi ( text or ' ' )
clean = clean . replace ( ' \r ' , ' \n ' )
lines = [ ]
for raw in clean . splitlines ( ) :
line = raw . strip ( )
if not line :
continue
if set ( line ) < = set ( ' -_=* ' ) :
continue
skip_needles = [
' 请选择检测项目 ' , ' 请输入正确数字 ' , ' 检测脚本当天运行次数 ' , ' 本次测试已结束 ' ,
' 感谢使用此脚本 ' , ' 广告招租 ' , ' 请联系 ' , ' Github ' , ' YouTube ' , ' 支持系统 ' ,
' RegionRestrictionCheck ' , ' Streaming Media Unlock Test ' , ' 正在下载 ' , ' Downloading ' ,
' Number of Script Runs ' , ' Testing Done ' , ' Press ENTER ' , ' Input Number ' , ' 请输入 ' ,
]
if any ( x . lower ( ) in line . lower ( ) for x in skip_needles ) :
continue
lines . append ( line )
return lines
def parse_stream_results ( text ) :
lines = stream_clean_output ( text )
net_type = ' '
section = ' '
results = [ ]
network = [ ]
for line in lines :
l = line . strip ( )
if ' 正在测试 IPv4 ' in l or ' Checking Results Under IPv4 ' in l :
net_type = ' IPv4 '
continue
if ' 正在测试 IPv6 ' in l or ' Checking Results Under IPv6 ' in l :
net_type = ' IPv6 '
continue
if ' 正在测试默认网络 ' in l or ' Checking Results Under Default ' in l :
net_type = ' 默认网络 '
continue
if ' 您的网络为: ' in l or ' Your Network Provider: ' in l :
network . append ( l . replace ( ' ** ' , ' ' ) . strip ( ) )
continue
# Original script section lines:
# ============[ Multination ]============
# ---Game---
msec = re . search ( r ' \ [ \ s*([^ \ ]]+?) \ s* \ ] ' , l )
if msec and set ( l . replace ( msec . group ( 0 ) , ' ' ) ) < = set ( ' =-_ ' ) :
section = msec . group ( 1 ) . strip ( )
continue
msub = re . match ( r ' ^-+ \ s*([^ \ -]+?) \ s*-+$ ' , l )
if msub :
section = msub . group ( 1 ) . strip ( )
continue
if set ( l ) < = set ( ' = ' ) :
continue
m = re . match ( r ' (.+?): \ s*(Yes|No|Failed|Originals Only|IPv6 Is Not Currently Supported|Available For .* Soon|即将推出|Unsupported|N/A)(.*)$ ' , l , re . I )
if not m :
continue
name = m . group ( 1 ) . strip ( )
status = m . group ( 2 ) . strip ( )
extra = m . group ( 3 ) . strip ( )
sec = section or net_type or ' - '
results . append ( { ' section ' : sec , ' net ' : net_type , ' name ' : name , ' status ' : status , ' extra ' : extra } )
return network , results
def stream_status_icon ( status , extra = ' ' ) :
status_l = str ( status or ' ' ) . lower ( )
t = f ' { status } { extra } ' . lower ( )
if ' only available ' in t or ' only avaliable ' in t or ' mobile app ' in t :
return ' 🟡 '
if status_l == ' yes ' or ' region: ' in t or ' available for ' in t :
return ' ✅ '
if status_l == ' no ' or ' not available ' in t or ' blocked ' in t :
return ' ❌ '
if ' originals only ' in t :
return ' 🟡 '
if ' ipv6 is not currently supported ' in t or ' unsupported ' in t :
return ' ➖ '
return ' ⚠️ '
def format_stream_summary ( s , out , proto , region_label , region_id ) :
network , results = parse_stream_results ( out )
groups = OrderedDict ( )
for r in results :
groups . setdefault ( r [ ' section ' ] , [ ] ) . append ( r )
total = len ( results )
yes = sum ( 1 for r in results if stream_status_icon ( r [ ' status ' ] , r . get ( ' extra ' ) ) == ' ✅ ' )
no = sum ( 1 for r in results if stream_status_icon ( r [ ' status ' ] , r . get ( ' extra ' ) ) == ' ❌ ' )
warn = max ( 0 , total - yes - no )
head = [
f ' 🎬 <b> { safe ( s . get ( " name " ) ) } 流媒体检测完成</b> ' ,
f ' 协议:<b> { safe ( proto ) } </b> · 地区:<b> { safe ( region_label ) } </b> ' ,
f ' 检测时间:<code> { safe ( datetime . now ( ) . strftime ( " % Y- % m- %d % H: % M: % S " ) ) } </code> ' ,
]
if network :
head . append ( safe ( network [ - 1 ] ) )
if total :
head . append ( f ' 结果: Yes { yes } / No { no } / Error { warn } ' )
parts = [ ' \n ' . join ( head ) ]
if not results :
parts . append ( ' 没解析到结构化结果,末尾日志: \n <pre> ' + safe ( trim_log ( strip_ansi ( out ) , 2600 ) ) + ' </pre> ' )
return ' \n \n ' . join ( parts )
for sec , items in groups . items ( ) :
lines = [ f ' <b> { safe ( sec ) } </b> ' ]
for r in items [ : 80 ] :
extra = ( ' ' + r . get ( ' extra ' , ' ' ) ) if r . get ( ' extra ' ) else ' '
lines . append ( f ' { safe ( r [ " name " ] ) } : <code>{ safe ( r [ " status " ] + extra ) } </code> ' )
parts . append ( ' \n ' . join ( lines ) )
text = ' \n \n ' . join ( parts )
return text [ : 3900 ] + ( ' \n \n …结果较长,已截断。 ' if len ( text ) > 3900 else ' ' )
def stream_status_color ( status , extra = ' ' ) :
icon = stream_status_icon ( status , extra )
if icon == ' ✅ ' :
return ( 22 , 163 , 74 )
if icon == ' ❌ ' :
return ( 220 , 38 , 38 )
return ( 202 , 138 , 4 )
def stream_status_label ( status , extra = ' ' ) :
text = ( str ( status or ' ' ) + ( ' ' + str ( extra ) . strip ( ) if extra else ' ' ) ) . strip ( )
if not text :
return ' Error '
if text . lower ( ) . startswith ( ' failed ' ) :
return re . sub ( r ' ^Failed ' , ' Error ' , text , flags = re . I )
return text
def load_font ( candidates , size ) :
for path in candidates :
try :
return ImageFont . truetype ( path , size )
except Exception :
pass
return ImageFont . load_default ( )
def stream_result_image ( s , out , proto , region_label , region_id , out_png ) :
network , results = parse_stream_results ( out )
if not results :
return None
groups = OrderedDict ( )
for r in results :
groups . setdefault ( r [ ' section ' ] , [ ] ) . append ( r )
total = len ( results )
yes = sum ( 1 for r in results if stream_status_icon ( r [ ' status ' ] , r . get ( ' extra ' ) ) == ' ✅ ' )
no = sum ( 1 for r in results if stream_status_icon ( r [ ' status ' ] , r . get ( ' extra ' ) ) == ' ❌ ' )
warn = max ( 0 , total - yes - no )
font_cjk = [
' /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc ' ,
' /usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc ' ,
' /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ' ,
]
font_cjk_bold = [
' /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc ' ,
' /usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc ' ,
' /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf ' ,
]
mono_fonts = [
' /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf ' ,
' /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ' ,
]
W = 1120
pad = 50
row_h = 32
title_h = 190
section_h = 42
footer_h = 78
max_rows = sum ( len ( v ) for v in groups . values ( ) )
H = title_h + footer_h + len ( groups ) * section_h + max_rows * row_h + 24
H = max ( 500 , H + 82 )
bg = ( 247 , 249 , 252 )
card = ( 255 , 255 , 255 )
line = ( 226 , 232 , 240 )
line_soft = ( 241 , 245 , 249 )
dark = ( 30 , 41 , 59 )
muted = ( 100 , 116 , 139 )
blue = ( 37 , 99 , 235 )
im = Image . new ( ' RGB ' , ( W , H ) , bg )
d = ImageDraw . Draw ( im )
title_font = load_font ( font_cjk_bold , 36 )
meta_font = load_font ( font_cjk , 21 )
mono_font = load_font ( mono_fonts , 23 )
mono_small = load_font ( mono_fonts , 20 )
section_font = load_font ( mono_fonts , 24 )
status_font = load_font ( font_cjk_bold , 23 )
small_font = load_font ( font_cjk , 20 )
d . rounded_rectangle ( [ 26 , 22 , W - 26 , H - 24 ] , radius = 24 , fill = card , outline = line , width = 2 )
server_name = str ( s . get ( ' name ' ) or s . get ( ' host ' ) or ' Server ' )
d . text ( ( pad , 44 ) , f ' { server_name } 流媒体解锁测试 ' , fill = dark , font = title_font )
d . text ( ( W - pad , 52 ) , source_repo ( ' stream ' ) , fill = muted , font = meta_font , anchor = ' ra ' )
d . text ( ( pad , 92 ) , f ' { proto } · { region_label } · { datetime . now ( ) . strftime ( " % Y- % m- %d % H: % M: % S " ) } ' , fill = muted , font = meta_font )
d . text ( ( W - pad , 92 ) , f ' Yes { yes } No { no } Error { warn } ' , fill = blue , font = meta_font , anchor = ' ra ' )
if network :
nt = network [ - 1 ] . replace ( ' ** ' , ' ' ) . strip ( )
if len ( nt ) > 90 :
nt = nt [ : 87 ] + ' … '
d . text ( ( pad , 126 ) , nt , fill = muted , font = small_font )
d . line ( [ pad , title_h - 18 , W - pad , title_h - 18 ] , fill = line , width = 2 )
y = title_h + 4
table_left = pad
table_right = W - pad
status_x = table_right
# mirror the script's tabbed layout: service name column then a fixed result column
name_col_width = 500
def fit_text ( value , font , max_width ) :
value = str ( value or ' - ' )
if d . textlength ( value , font = font ) < = max_width :
return value
ell = ' … '
while value and d . textlength ( value + ell , font = font ) > max_width :
value = value [ : - 1 ]
return value + ell
def centered_rule ( label ) :
text = f ' [ { label } ] '
tw = d . textlength ( text , font = section_font )
dash_w = d . textlength ( ' = ' , font = section_font )
left_count = max ( 2 , int ( ( table_right - table_left - tw ) / 2 / dash_w ) )
right_count = left_count
return ' = ' * left_count + text + ' = ' * right_count
for sec , items in groups . items ( ) :
rule = fit_text ( centered_rule ( str ( sec or ' - ' ) ) , section_font , table_right - table_left )
d . text ( ( ( W - d . textlength ( rule , font = section_font ) ) / 2 , y ) , rule , fill = blue , font = section_font )
y + = section_h
for r in items :
name = fit_text ( ( str ( r . get ( ' name ' ) or ' - ' ) . rstrip ( ' : ' ) + ' : ' ) , mono_font , name_col_width )
status = fit_text ( stream_status_label ( r . get ( ' status ' ) , r . get ( ' extra ' ) ) , status_font , 470 )
color = stream_status_color ( r . get ( ' status ' ) , r . get ( ' extra ' ) )
d . text ( ( table_left , y ) , name , fill = dark , font = mono_font )
d . text ( ( status_x , y ) , status , fill = color , font = status_font , anchor = ' ra ' )
y + = row_h
end_rule = ' = ' * max ( 8 , int ( ( table_right - table_left ) / max ( d . textlength ( ' = ' , font = mono_small ) , 1 ) ) )
d . text ( ( table_left , y ) , fit_text ( end_rule , mono_small , table_right - table_left ) , fill = line , font = mono_small )
y + = 16
out_png = Path ( out_png )
out_png . parent . mkdir ( parents = True , exist_ok = True )
im . save ( out_png , quality = 95 )
return out_png
async def remote_has_ipv4 ( s ) :
remote = " curl -4fsS --max-time 8 https://api.ipify.org >/dev/null "
code , _out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = 15 , env = ssh_env_for ( s ) )
return code == 0
def ansi_to_spans ( text ) :
palette = {
30 : ( 51 , 65 , 85 ) , 90 : ( 100 , 116 , 139 ) ,
31 : ( 220 , 38 , 38 ) , 91 : ( 220 , 38 , 38 ) ,
32 : ( 22 , 163 , 74 ) , 92 : ( 22 , 163 , 74 ) ,
33 : ( 202 , 138 , 4 ) , 93 : ( 202 , 138 , 4 ) ,
34 : ( 37 , 99 , 235 ) , 94 : ( 37 , 99 , 235 ) ,
35 : ( 192 , 38 , 211 ) , 95 : ( 192 , 38 , 211 ) ,
36 : ( 8 , 145 , 178 ) , 96 : ( 8 , 145 , 178 ) ,
37 : ( 30 , 41 , 59 ) , 97 : ( 15 , 23 , 42 ) ,
}
default = ( 30 , 41 , 59 )
spans = [ ]
color = default
bold = False
i = 0
buf = ' '
while i < len ( text ) :
if text [ i ] == ' \x1b ' and i + 1 < len ( text ) and text [ i + 1 ] == ' [ ' :
m = re . match ( r ' \ x1b \ [([0-9;]*)m ' , text [ i : ] )
if m :
if buf :
spans . append ( ( buf , color , bold ) )
buf = ' '
codes = [ int ( x ) if x else 0 for x in m . group ( 1 ) . split ( ' ; ' ) ]
if not codes :
codes = [ 0 ]
for code in codes :
if code == 0 :
color = default
bold = False
elif code == 1 :
bold = True
elif code == 22 :
bold = False
elif code in palette :
color = palette [ code ]
i + = len ( m . group ( 0 ) )
continue
buf + = text [ i ]
i + = 1
if buf :
spans . append ( ( buf , color , bold ) )
return spans
def terminal_char_width ( ch ) :
o = ord ( ch )
if o == 0 :
return 0
if o < 32 or 0x7f < = o < 0xa0 :
return 0
# CJK / fullwidth ranges; enough for NextTrace Chinese geo/ISP labels.
if (
0x1100 < = o < = 0x115f or 0x2e80 < = o < = 0xa4cf or
0xac00 < = o < = 0xd7a3 or 0xf900 < = o < = 0xfaff or
0xfe10 < = o < = 0xfe19 or 0xfe30 < = o < = 0xfe6f or
0xff00 < = o < = 0xff60 or 0xffe0 < = o < = 0xffe6
) :
return 2
return 1
def terminal_text_width ( text ) :
return sum ( terminal_char_width ( ch ) for ch in strip_ansi ( text or ' ' ) )
def draw_terminal_spans ( draw , x0 , y , spans , * , cell_w , ascii_font , ascii_bold , cjk_font , cjk_bold ) :
col = 0
for text , color , bold in spans :
for ch in text :
w = terminal_char_width ( ch )
if w < = 0 :
continue
is_cjk = w == 2
font = ( cjk_bold if bold else cjk_font ) if is_cjk else ( ascii_bold if bold else ascii_font )
# Draw every glyph onto a fixed terminal grid. This keeps NextTrace's original column layout
# while still letting CJK render with a CJK font.
draw . text ( ( x0 + col * cell_w , y ) , ch , fill = color , font = font )
col + = w
def render_nexttrace_image ( s , target , out , code , out_png ) :
raw = ( out or ' ' ) . replace ( ' \r \n ' , ' \n ' ) . replace ( ' \r ' , ' \n ' )
raw = re . sub ( r ' \ x1b \ [[0-?]*[ -/]*[@-~] ' , lambda m : m . group ( 0 ) if m . group ( 0 ) . endswith ( ' m ' ) else ' ' , raw )
lines = [ ]
for line in raw . split ( ' \n ' ) :
if ' Generated by ' in strip_ansi ( line ) or ' MapTrace URL: ' in strip_ansi ( line ) :
continue
lines . append ( line . rstrip ( ) )
while lines and not strip_ansi ( lines [ 0 ] ) . strip ( ) :
lines . pop ( 0 )
while lines and not strip_ansi ( lines [ - 1 ] ) . strip ( ) :
lines . pop ( )
if not lines :
lines = [ ' 无输出 ' ]
title_font = load_font ( [
' /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc ' ,
' /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf ' ,
] , 42 )
mono_font = load_font ( [
' /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf ' ,
] , 24 )
mono_bold = load_font ( [
' /usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf ' ,
] , 24 )
cjk_font = load_font ( [
' /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc ' ,
] , 24 )
cjk_bold = load_font ( [
' /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc ' ,
] , 24 )
dummy = Image . new ( ' RGB ' , ( 1 , 1 ) )
d0 = ImageDraw . Draw ( dummy )
char_w = max ( 13 , int ( d0 . textlength ( ' M ' , font = mono_font ) ) )
line_h = 34
max_cols = min ( 150 , max ( 78 , max ( ( terminal_text_width ( x ) for x in lines ) , default = 78 ) ) )
W = max ( 1180 , min ( 2100 , 112 + max_cols * char_w ) )
H = 150 + max ( 1 , len ( lines ) ) * line_h + 42
bg = ( 248 , 250 , 252 )
panel = ( 255 , 255 , 255 )
border = ( 203 , 213 , 225 )
fg = ( 15 , 23 , 42 )
muted = ( 100 , 116 , 139 )
accent = ( 2 , 132 , 199 )
warn = ( 202 , 138 , 4 )
err = ( 220 , 38 , 38 )
im = Image . new ( ' RGB ' , ( W , H ) , bg )
d = ImageDraw . Draw ( im )
d . rounded_rectangle ( ( 28 , 24 , W - 28 , H - 28 ) , radius = 24 , fill = panel , outline = border , width = 2 )
d . rounded_rectangle ( ( 48 , 44 , W - 48 , 118 ) , radius = 18 , fill = ( 241 , 245 , 249 ) , outline = ( 203 , 213 , 225 ) , width = 1 )
title = ' NextTrace '
title_box = d . textbbox ( ( 0 , 0 ) , title , font = title_font )
title_h = title_box [ 3 ] - title_box [ 1 ]
title_y = 44 + ( 118 - 44 - title_h ) / 2 - title_box [ 1 ]
d . text ( ( 72 , title_y ) , title , fill = accent , font = title_font )
source_name = source_repo ( ' nexttrace ' )
source_w = d . textlength ( source_name , font = mono_font )
d . text ( ( W - 72 - source_w , 72 ) , source_name , fill = muted , font = mono_font )
if code != 0 :
d . text ( ( W - 260 , 96 ) , f ' 退出码 { code } ' , fill = warn , font = mono_font )
d . line ( ( 54 , 126 , W - 54 , 126 ) , fill = border , width = 1 )
y = 148
x0 = 68
for line in lines :
x = x0
plain = strip_ansi ( line )
if plain . startswith ( ' traceroute to ' ) or ' hops max ' in plain :
d . rounded_rectangle ( ( 54 , y - 4 , W - 54 , y + line_h - 2 ) , radius = 8 , fill = ( 248 , 250 , 252 ) , outline = ( 226 , 232 , 240 ) , width = 1 )
draw_terminal_spans (
d , x0 , y , ansi_to_spans ( line ) ,
cell_w = char_w ,
ascii_font = mono_font ,
ascii_bold = mono_bold ,
cjk_font = cjk_font ,
cjk_bold = cjk_bold ,
)
y + = line_h
out_png = Path ( out_png )
out_png . parent . mkdir ( parents = True , exist_ok = True )
im . save ( out_png , quality = 95 )
return out_png
def nexttrace_prompt_text ( s ) :
return (
f ' 🛣 <b> { safe ( s . get ( " name " ) ) } NextTrace</b> \n \n '
' 请直接发送要追踪的 <b>IP 或域名</b>。 \n '
' 例如:<code>1.1.1.1</code> 或 <code>cloudflare.com</code> \n \n '
' 也可以用命令:<code>/nexttrace 服务器 目标IP或域名</code> '
)
def format_nexttrace_output ( s , target , out , code ) :
clean = strip_ansi ( out or ' ' ) . replace ( ' \r ' , ' ' )
lines = [ ]
for raw in clean . splitlines ( ) :
line = raw . rstrip ( )
if not line :
continue
skip = [
' NextTrace ' , ' nali ' , ' MapTrace ' , ' IP Geo Data Provider ' ,
' traceroute to ' , ' Generated by ' , ' 请勿用于商业用途 ' ,
]
if any ( x . lower ( ) in line . lower ( ) for x in skip ) :
continue
lines . append ( line )
body = ' \n ' . join ( lines ) . strip ( ) or clean . strip ( ) or ' 无输出 '
body = trim_log ( body , 3200 )
title = f ' 🛣 <b> { safe ( s . get ( " name " ) ) } NextTrace</b> → <code> { safe ( target ) } </code> '
if code != 0 :
title = ' ⚠️ ' + title + f ' \n 退出码:<code> { safe ( code ) } </code> '
return title + ' \n \n <pre> ' + safe ( body ) + ' </pre> '
async def run_nexttrace_task ( bot , chat_id , s , jid , target = ' 1.1.1.1 ' ) :
key = ( server_id ( s ) , ' nexttrace ' )
try :
qt = shlex . quote ( str ( target ) )
trace_cmd = ' nexttrace ' + qt
remote = (
" set -e; export TERM=xterm-256color COLORTERM=truecolor; export CLICOLOR_FORCE=1 FORCE_COLOR=1; "
" if ! command -v nexttrace >/dev/null 2>&1; then "
" curl -sL https://nxtrace.org/nt | bash >/tmp/nexttrace-install.log 2>&1 || "
" curl -Ls https://raw.githubusercontent.com/nxtrace/NTrace-core/main/nt_install.sh | bash >/tmp/nexttrace-install.log 2>&1; "
" fi; "
" script -qfec " + shlex . quote ( trace_cmd ) + " /dev/null 2>&1 "
)
code , out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = 300 , env = ssh_env_for ( s ) )
JOBS [ jid ] . update ( { ' status ' : ' done ' if code == 0 else ' failed ' , ' log ' : out , ' target ' : target } )
out_dir = Path ( ' /tmp/guko-results ' )
out_dir . mkdir ( parents = True , exist_ok = True )
png = out_dir / f " nexttrace- { server_id ( s ) } - { safe_target ( target ) } - { int ( time . time ( ) ) } .jpg "
try :
img = render_nexttrace_image ( s , target , out , code , png )
except Exception :
img = None
if img :
saved = persist_result_file ( s , ' nexttrace ' , img , ' .jpg ' )
JOBS [ jid ] . update ( { ' media_path ' : saved } )
with img . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
await bot . send_message ( chat_id , script_command_text ( ' nexttrace ' , target = target ) )
if code != 0 :
await bot . send_message ( chat_id , ' 提示: NextTrace 退出码不为 0, 图片是已抓到的部分结果。 ' )
else :
await bot . send_message ( chat_id , format_nexttrace_output ( s , target , out , code ) , parse_mode = ParseMode . HTML , disable_web_page_preview = True )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) , ' target ' : target } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } NextTrace 失败:<code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
async def run_stream_task ( bot , chat_id , s , jid ) :
key = ( server_id ( s ) , ' stream ' )
try :
region_id , region_label = stream_region_for_server ( s )
use_v4 = await remote_has_ipv4 ( s )
proto_arg = ' -M 4 ' if use_v4 else ' -M 6 '
proto_text = ' IPv4 ' if use_v4 else ' IPv6( 无 IPv4, 自动切换) '
remote = (
" export TERM=xterm-256color; cd /tmp; "
" script=$(mktemp /tmp/stream-unlock.XXXXXX.sh); "
" curl -4LfsS --max-time 30 check.unlock.media -o $script || "
" curl -4LfsS --max-time 30 http://check.unlock.media -o $script || "
" curl -4LfsS --max-time 30 https://raw.githubusercontent.com/lmc999/RegionRestrictionCheck/main/check.sh -o $script || "
" curl -6LfsS --max-time 30 https://raw.githubusercontent.com/lmc999/RegionRestrictionCheck/main/check.sh -o $script; "
" bash $script " + proto_arg + " -R " + shlex . quote ( region_id ) + " 2>&1 "
)
code , out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = 1800 , env = ssh_env_for ( s ) )
JOBS [ jid ] . update ( { ' status ' : ' done ' if code == 0 else ' failed ' , ' log ' : out , ' proto ' : proto_text , ' region ' : region_label } )
out_dir = Path ( ' /tmp/guko-results ' )
out_dir . mkdir ( parents = True , exist_ok = True )
png = out_dir / f " stream- { server_id ( s ) } - { int ( time . time ( ) ) } .jpg "
try :
img = stream_result_image ( s , out , proto_text , region_label , region_id , png )
except Exception :
img = None
if img :
saved = persist_result_file ( s , ' stream ' , img , ' .jpg ' )
JOBS [ jid ] . update ( { ' media_path ' : saved } )
with img . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
await bot . send_message ( chat_id , script_command_text ( ' stream ' , proto_arg = proto_arg , region_id = region_id ) )
if code != 0 :
await bot . send_message ( chat_id , ' 提示:脚本退出码不为 0, 图片是已抓到的部分结果。 ' )
else :
msg = format_stream_summary ( s , out , proto_text , region_label , region_id )
if code != 0 :
msg = ' ⚠️ 脚本退出码不为 0, 但下面是已抓到的输出: \n \n ' + msg
await bot . send_message ( chat_id , msg , parse_mode = ParseMode . HTML , disable_web_page_preview = True )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } 流媒体检测失败:<code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
async def run_nq_task ( bot , chat_id , s , jid , mask = NQ_ALL_MASK , ip_mode = ' 4 ' ) :
key = ( server_id ( s ) , ' nq ' )
try :
selected_text = nq_selected_text ( mask )
ip_text = nq_ip_mode_text ( ip_mode )
answers = nq_answer_script ( mask )
ipv_arg = nq_remote_ipv_arg ( s , ip_mode )
# Patch Net.Check.Place's TCP large-packet mtr probes with a per-probe
# timeout. On some VPSes mtr can hang forever at the 09% delay stage,
# leaving empty net_quality.json/result.zip.
remote = (
" export TERM=xterm-256color; cd /root && "
" script=$(mktemp /root/nodequality.XXXXXX.sh); "
" curl -sL https://run.NodeQuality.com > $script; "
" sed -i ' s#rm -rf \\ \" $ {work_dir} \\ \" /#: # rm -rf \\ \" $ {work_dir} \\ \" /# ' $script; "
" sed -i ' s#response=$( \\ $pingcom 2> \\ &1)#response=$(timeout -s SIGKILL 15 $pingcom 2> \\ &1)# ' $script; "
" printf % b " + shlex . quote ( answers ) + " | bash $script " + ipv_arg
)
code , out = await run_subprocess ( ssh_args ( s , remote , tty = False ) , timeout = 7200 , env = ssh_env_for ( s ) )
JOBS [ jid ] . update ( { ' log ' : out , ' selected ' : selected_text , ' ip_mode ' : ip_text } )
nq = nodequality_url ( out )
gb_urls = geekbench_urls ( out )
# Keep the URL generated by the official NodeQuality script. Re-uploading
# result.zip can create a record that opens with `bad token` / JSON parse
# errors on nodequality.com, so do not replace the original link here.
report_links = [ ]
for label , cat , bit in [ ( ' 硬件 ' , ' hardware ' , 1 ) , ( ' IP质量 ' , ' ip ' , 2 ) , ( ' 网络 ' , ' net ' , 4 ) , ( ' 回程 ' , ' backroute ' , 8 ) ] :
if not ( mask & bit ) :
continue
urls = all_report_urls ( out , cat )
if urls :
report_links . append ( ( label , urls [ - 1 ] ) )
if not report_links :
report_links = await recover_report_links_from_remote ( s , mask )
image_ok = False
image_error = ' '
# Full NodeQuality already has a combined result page; avoid sending
# multiple分项 screenshots. Partial/single selections still send images.
if report_links and mask != NQ_ALL_MASK :
try :
sent = await send_report_images ( bot , chat_id , report_links , f " nq- { server_id ( s ) } " )
media_paths = [ ]
clear_result_files ( s , ' nq ' )
for label , url , png in sent :
saved = persist_result_file ( s , ' nq ' , png , f ' - { label } .png ' , clear = False )
if saved :
media_paths . append ( saved )
if media_paths :
JOBS [ jid ] . update ( { ' media_paths ' : media_paths } )
image_ok = True
except Exception as e :
image_error = str ( e )
final_ok = bool ( nq or report_links or image_ok )
JOBS [ jid ] . update ( { ' status ' : ' done ' if final_ok else ' failed ' } )
msg = f " ✅ { safe ( s . get ( ' name ' ) ) } NodeQuality 完成: { safe ( selected_text ) } ; { safe ( ip_text ) } " if final_ok else f " ❌ { safe ( s . get ( ' name ' ) ) } NodeQuality 失败: { safe ( selected_text ) } ; { safe ( ip_text ) } "
if nq :
msg + = f " \n \n NodeQuality: \n { safe ( nq ) } "
if gb_urls :
msg + = " \n \n Geekbench: \n " + " \n " . join ( safe ( u ) for u in gb_urls )
if mask != NQ_ALL_MASK and not image_ok and report_links :
msg + = " \n \n 分项报告: \n " + " \n " . join ( f " - { safe ( label ) } : { safe ( url ) } " for label , url in report_links )
if image_error :
msg + = f " \n \n 转 PNG 失败:<code> { safe ( image_error ) } </code> "
if not nq and not report_links :
msg + = f " \n \n 没解析到结果链接,末尾日志: \n <pre> { safe ( trim_log ( out ) ) } </pre> "
msg + = f " \n \n { script_command_html ( ' nq ' , selected = selected_text , ip_mode = ip_text ) } "
await bot . send_message ( chat_id , msg , parse_mode = ParseMode . HTML , disable_web_page_preview = True )
except Exception as e :
JOBS [ jid ] . update ( { ' status ' : ' failed ' , ' log ' : repr ( e ) } )
await bot . send_message ( chat_id , f " ❌ { safe ( s . get ( ' name ' ) ) } NQ任务失败: <code> { safe ( e ) } </code> " , parse_mode = ParseMode . HTML )
finally :
finish_job ( jid , key )
async def send_history_result ( bot , chat_id , s , kind ) :
item = history_item_for ( s , kind )
if not item :
await bot . send_message ( chat_id , f ' 暂无 { safe ( KIND_NAME . get ( kind , kind ) ) } 历史。 ' , parse_mode = ParseMode . HTML )
return False
media_paths = [ ]
for x in item . get ( ' media_paths ' ) or [ ] :
p = Path ( x )
if p . exists ( ) and p . is_file ( ) and p . stat ( ) . st_size > 0 :
media_paths . append ( p )
if kind == ' nq ' :
urls = item . get ( ' urls ' ) or [ ]
nq = next ( ( u for u in urls if ' nodequality.com/r/ ' in u ) , None )
gb_urls = [ u for u in urls if ' browser.geekbench.com/ ' in u ]
report_urls = [ u for u in urls if ' Report.Check.Place/ ' in u ]
selected = item . get ( ' selected ' ) or ' - '
ip_mode = item . get ( ' ip_mode ' ) or ' - '
is_full_nq = selected == nq_selected_text ( NQ_ALL_MASK )
if not is_full_nq :
for media in media_paths :
with media . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
msg = f " ✅ { safe ( s . get ( ' name ' ) ) } NodeQuality 完成: { safe ( selected ) } ; { safe ( ip_mode ) } "
if nq :
msg + = f " \n \n NodeQuality: \n { safe ( nq ) } "
if gb_urls :
msg + = " \n \n Geekbench: \n " + " \n " . join ( safe ( u ) for u in gb_urls )
if not media_paths and report_urls :
msg + = " \n \n 分项报告: \n " + " \n " . join ( f " - { safe ( u ) } " for u in report_urls )
msg + = f " \n \n { script_command_html ( ' nq ' , selected = selected , ip_mode = ip_mode ) } "
await bot . send_message ( chat_id , msg , parse_mode = ParseMode . HTML , disable_web_page_preview = True )
return True
if media_paths :
for media in media_paths :
with media . open ( ' rb ' ) as f :
await bot . send_photo ( chat_id , photo = f )
return True
await bot . send_message ( chat_id , history_detail_text ( s , kind ) , parse_mode = ParseMode . HTML , disable_web_page_preview = True )
return True
async def send_or_edit ( update : Update , text , markup = None ) :
if update . callback_query :
await update . callback_query . edit_message_text ( text , parse_mode = ParseMode . HTML , reply_markup = markup )
else :
await update . message . reply_text ( text , parse_mode = ParseMode . HTML , reply_markup = markup )
async def start ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
await send_or_edit ( update , menu_text ( ) , main_menu_markup ( ) )
async def version_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
await update . message . reply_text ( f ' GUKO v { GUKO_VERSION } ' )
async def list_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
await send_or_edit ( update , menu_text ( ) , main_menu_markup ( ) )
async def status_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
await send_or_edit ( update , menu_text ( ) , main_menu_markup ( ) )
async def addserver_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await admin_guard ( update ) : return
clear_add_session ( update . effective_chat . id )
await update . message . reply_text ( add_help_text ( ) , parse_mode = ParseMode . HTML , reply_markup = add_start_markup ( ) )
async def info_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
if not context . args :
await update . message . reply_text ( ' 用法:/info <名字/IP/ID/别名> ' )
return
s = find_server ( ' ' . join ( context . args ) , load_inventory ( ) . get ( ' servers ' , [ ] ) )
if not s :
await update . message . reply_text ( ' 没找到这台。 ' )
return
await update . message . reply_text ( await server_detail_text_with_specs ( s ) , parse_mode = ParseMode . HTML , reply_markup = server_markup ( s ) )
async def export_config_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await admin_guard ( update ) : return
data = redact_inventory ( load_inventory ( ) )
text = json . dumps ( data , ensure_ascii = False , indent = 2 ) + ' \n '
path = TMP_DIR / f ' guko-export-redacted- { int ( time . time ( ) ) } .json '
path . parent . mkdir ( parents = True , exist_ok = True )
path . write_text ( text )
with path . open ( ' rb ' ) as f :
await update . message . reply_document ( document = f , filename = ' guko-servers-redacted.json ' , caption = ' 已导出脱敏配置(密码已隐藏)。 ' )
2026-06-23 00:58:45 +00:00
async def backup_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await admin_guard ( update ) : return
path = backup_bundle_path ( )
with path . open ( ' rb ' ) as f :
await update . message . reply_document ( document = f , filename = path . name , caption = ' GUKO 完整备份: servers/history/keys/media/results。请妥善保存。 ' )
async def summary_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
await update . message . reply_text ( inventory_summary_text ( ) , parse_mode = ParseMode . HTML )
2026-06-23 00:44:35 +00:00
async def testall_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await admin_guard ( update ) : return
servers = [ s for s in load_inventory ( ) . get ( ' servers ' , [ ] ) if s . get ( ' host ' ) ]
if not servers :
await update . message . reply_text ( ' 当前没有服务器。 ' )
return
msg = await update . message . reply_text ( f ' 🧪 开始批量测试 SSH: { len ( servers ) } 台,并发 3。 ' )
sem = asyncio . Semaphore ( 3 )
results = [ ]
async def one ( s ) :
async with sem :
ok , out = await test_server_login ( s , timeout = 12 )
results . append ( ( s , ok , out ) )
await asyncio . gather ( * ( one ( s ) for s in servers ) )
lines = [ ]
for s , ok , out in results :
mark = ' ✅ ' if ok else ' ❌ '
cfg = ssh_config ( s )
lines . append ( f ' { mark } { s . get ( " name " ) } { cfg . get ( " user " ) } @ { cfg . get ( " host " ) } : { cfg . get ( " port " ) } ' )
if not ok :
lines . append ( ' ' + strip_ansi ( out ) . splitlines ( ) [ - 1 ] [ : 120 ] if out else ' 无输出 ' )
await msg . edit_text ( ' <pre> ' + safe ( ' \n ' . join ( lines ) [ - 3500 : ] ) + ' </pre> ' , parse_mode = ParseMode . HTML )
async def testssh_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await admin_guard ( update ) : return
if not context . args :
await update . message . reply_text ( ' 用法:/testssh <名字/IP/ID/别名> ' )
return
s = find_server ( ' ' . join ( context . args ) , load_inventory ( ) . get ( ' servers ' , [ ] ) )
if not s :
await update . message . reply_text ( ' 没找到这台服务器。 ' )
return
msg = await update . message . reply_text ( f ' 🧪 正在测试 { safe ( s . get ( " name " ) ) } SSH… ' , parse_mode = ParseMode . HTML )
ok , out = await test_server_login ( s )
await msg . edit_text ( ( ' ✅ SSH 登录成功: ' if ok else ' ⚠️ SSH 登录失败: ' ) + ' <pre> ' + safe ( out [ - 1200 : ] ) + ' </pre> ' , parse_mode = ParseMode . HTML )
async def history_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
if not context . args :
await update . message . reply_text ( ' 用法:/history <名字/IP/ID/别名> ' )
return
s = find_server ( ' ' . join ( context . args ) , load_inventory ( ) . get ( ' servers ' , [ ] ) )
if not s :
await update . message . reply_text ( ' 没找到这台服务器。 ' )
return
await update . message . reply_text ( history_text ( s ) , parse_mode = ParseMode . HTML , disable_web_page_preview = True , reply_markup = history_markup ( s ) )
async def jobs_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
if not JOBS :
await update . message . reply_text ( ' 暂无后台任务。 ' )
return
lines = [ ]
for jid , j in list ( JOBS . items ( ) ) [ - 10 : ] :
lines . append ( f " { jid } : { j . get ( ' server ' ) } { j . get ( ' kind ' ) } { j . get ( ' status ' ) } " )
await update . message . reply_text ( ' <pre> ' + safe ( ' \n ' . join ( lines ) ) + ' </pre> ' , parse_mode = ParseMode . HTML )
async def document_handler ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
chat_id = update . effective_chat . id if update . effective_chat else None
sess = ADD_SESSIONS . get ( chat_id ) if chat_id is not None else None
if not sess or sess . get ( ' step ' ) not in ( ' single_key_text ' , ' bulk_shared_key_text ' ) :
await update . message . reply_text ( ' 收到文件了,但当前没有等待密钥上传。请先点“添加服务器”。 ' )
return
doc = update . message . document
if not doc :
return
if doc . file_size and doc . file_size > 128 * 1024 :
await update . message . reply_text ( ' 密钥文件太大了,不像 SSH 私钥。 ' )
return
await update . message . chat . send_action ( ChatAction . TYPING )
f = await doc . get_file ( )
data = await f . download_as_bytearray ( )
try :
key_path = save_private_key ( chat_id , bytes ( data ) , doc . file_name or ' telegram-key ' )
except Exception as e :
await update . message . reply_text ( f ' 密钥识别失败: { safe ( e ) } ' , parse_mode = ParseMode . HTML )
return
if sess . get ( ' step ' ) == ' single_key_text ' :
add_session ( chat_id , key = key_path , auth_kind = ' key ' )
await finish_single_add ( update , context , ADD_SESSIONS [ chat_id ] )
else :
add_session ( chat_id , auth_mode = ' key ' , shared_auth = key_path , step = ' bulk_lines ' )
await update . message . reply_text ( ' 密钥已保存。现在发送服务器列表,每行一台。 \n \n 格式:<code>名称 IP 用户</code> ' , parse_mode = ParseMode . HTML )
async def fallback_panel ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
text = update . message . text or ' '
chat_id = update . effective_chat . id if update . effective_chat else None
sess = ADD_SESSIONS . get ( chat_id ) if chat_id is not None else None
if sess :
step = sess . get ( ' step ' )
if step == ' single_basic ' :
parts = shlex . split ( text )
if len ( parts ) < 2 :
await update . message . reply_text ( ' 格式:<code>名称 IP [端口] [用户]</code> \n 例:<code>hk-01 1.2.3.4 22 root</code> ' , parse_mode = ParseMode . HTML )
return
host , embedded_port = parse_host_port ( parts [ 1 ] )
if not is_valid_hostname ( host ) :
await update . message . reply_text ( ' IP/域名看起来不对,重新发一次。 ' )
return
port = embedded_port
user = ' root '
if len ( parts ) > = 3 and parts [ 2 ] . isdigit ( ) :
port = int ( parts [ 2 ] )
if len ( parts ) > = 4 :
user = parts [ 3 ]
elif len ( parts ) > = 3 :
user = parts [ 2 ]
add_session ( chat_id , step = ' single_auth ' , name = parts [ 0 ] , host = host , port = port or 22 , user = user )
await update . message . reply_text ( ' 选择这台服务器的登录方式: ' , reply_markup = add_auth_markup ( ) )
return
if step == ' single_password ' :
add_session ( chat_id , password = text . strip ( ) , auth_kind = ' password ' )
await finish_single_add ( update , context , ADD_SESSIONS [ chat_id ] )
return
if step == ' single_key_path ' :
key_path = os . path . expanduser ( text . strip ( ) )
if not key_path :
await update . message . reply_text ( ' 密钥路径不能为空。 ' )
return
add_session ( chat_id , key = key_path , auth_kind = ' key ' )
await finish_single_add ( update , context , ADD_SESSIONS [ chat_id ] )
return
if step == ' single_key_text ' :
try :
key_path = save_private_key ( chat_id , text , ' telegram-key ' )
except Exception as e :
await update . message . reply_text ( f ' 密钥识别失败: { safe ( e ) } ' , parse_mode = ParseMode . HTML )
return
add_session ( chat_id , key = key_path , auth_kind = ' key ' )
await finish_single_add ( update , context , ADD_SESSIONS [ chat_id ] )
return
if step == ' bulk_port ' :
if not text . strip ( ) . isdigit ( ) :
await update . message . reply_text ( ' 端口需要是数字,比如 22 或 53580。 ' )
return
add_session ( chat_id , same_port = int ( text . strip ( ) ) , step = ' bulk_auth ' )
await update . message . reply_text ( ' 端口已设置。现在选择认证方式: ' , reply_markup = bulk_auth_markup ( ) )
return
if step == ' bulk_shared_password ' :
add_session ( chat_id , auth_mode = ' password ' , shared_auth = text . strip ( ) , step = ' bulk_lines ' )
await update . message . reply_text ( ' 密码已记录。现在发送服务器列表,每行一台。 \n \n 格式:<code>名称 IP 用户</code> ' , parse_mode = ParseMode . HTML )
return
if step == ' bulk_shared_key_text ' :
try :
key_path = save_private_key ( chat_id , text , ' bulk-key ' )
except Exception as e :
await update . message . reply_text ( f ' 密钥识别失败: { safe ( e ) } ' , parse_mode = ParseMode . HTML )
return
add_session ( chat_id , auth_mode = ' key ' , shared_auth = key_path , step = ' bulk_lines ' )
await update . message . reply_text ( ' 密钥已保存。现在发送服务器列表,每行一台。 \n \n 格式:<code>名称 IP 用户</code> ' , parse_mode = ParseMode . HTML )
return
if step == ' bulk_lines ' :
await finish_bulk_add ( update , context , sess , text )
return
if step == ' edit_value ' :
sid = sess . get ( ' sid ' )
field = sess . get ( ' field ' )
s = find_server_by_id ( sid )
if not s :
clear_add_session ( chat_id )
await update . message . reply_text ( ' 这台服务器不在当前清单里。 ' )
return
val = text . strip ( )
patch = { }
if field == ' name ' :
patch [ ' name ' ] = val
elif field == ' host ' :
host , embedded_port = parse_host_port ( val )
if not is_valid_hostname ( host ) :
await update . message . reply_text ( ' IP/域名看起来不对,重新发一次。 ' )
return
patch [ ' host ' ] = host
if embedded_port :
patch [ ' ssh ' ] = { ' port ' : embedded_port }
elif field == ' port ' :
if not val . isdigit ( ) :
await update . message . reply_text ( ' 端口需要是数字。 ' )
return
patch [ ' ssh ' ] = { ' port ' : int ( val ) }
elif field == ' user ' :
patch [ ' ssh ' ] = { ' user ' : val }
elif field == ' key ' :
patch [ ' ssh ' ] = { ' auth ' : ' key ' , ' key ' : os . path . expanduser ( val ) }
elif field == ' password ' :
patch [ ' ssh ' ] = { ' auth ' : ' password ' , ' password ' : val }
updated = update_server_by_id ( sid , patch )
clear_add_session ( chat_id )
await update . message . reply_text ( f ' 已更新:<b> { safe ( updated . get ( " name " ) ) } </b> ' , parse_mode = ParseMode . HTML , reply_markup = server_markup ( updated ) )
return
pending_sid = PENDING_NEXTTRACE . pop ( chat_id , None ) if chat_id is not None else None
if pending_sid :
s = find_server_by_id ( pending_sid )
if not s :
await update . message . reply_text ( ' 刚才选择的服务器不在当前清单里。 ' )
return
target = extract_ipv4 ( text ) or normalize_domain ( text )
if not target :
await update . message . reply_text ( ' 没识别到 IP 或域名,已取消这次 NextTrace。 ' )
return
jid = launch_job ( s , ' nexttrace ' , run_nexttrace_task , context . bot , chat_id , s , target , target = target )
await bot_task_started_notice ( context . bot , chat_id , s , f ' NextTrace { safe ( target ) } ' , jid is not None )
return
# 普通文本不再触发任何功能;只有点击 NextTrace 后的下一条 IP/域名才会被消费。
return
async def post_init ( app : Application ) :
commands = [
BotCommand ( ' start ' , ' 打开 GUKO 面板 ' ) ,
BotCommand ( ' list ' , ' 服务器列表 ' ) ,
BotCommand ( ' status ' , ' 总览状态 ' ) ,
BotCommand ( ' addserver ' , ' 添加/批量导入服务器 ' ) ,
BotCommand ( ' testssh ' , ' 测试服务器 SSH ' ) ,
BotCommand ( ' testall ' , ' 批量测试 SSH ' ) ,
BotCommand ( ' exportconfig ' , ' 导出脱敏配置 ' ) ,
2026-06-23 00:58:45 +00:00
BotCommand ( ' backup ' , ' 导出完整备份 ' ) ,
BotCommand ( ' summary ' , ' 配置概览 ' ) ,
2026-06-23 00:44:35 +00:00
BotCommand ( ' info ' , ' 查看单台操作面板:/info 名字/IP/ID ' ) ,
BotCommand ( ' jobs ' , ' 查看后台任务 ' ) ,
BotCommand ( ' history ' , ' 查看测试历史:/history 服务器 ' ) ,
BotCommand ( ' ip ' , ' IP/域名工具:/ip 1.1.1.1 ' ) ,
BotCommand ( ' nexttrace ' , ' 路由追踪:/nexttrace 服务器 目标 ' ) ,
BotCommand ( ' version ' , ' 查看 GUKO 版本 ' ) ,
]
await app . bot . set_my_commands ( commands )
async def ip_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
if not context . args :
await update . message . reply_text ( ' 用法:/ip <IPv4 或域名> ' )
return
target = ' ' . join ( context . args )
try :
ip , host = await resolve_target_to_ipv4 ( target )
suffix = f ' { safe ( host ) } → <code> { safe ( ip ) } </code> ' if host else f ' <code> { safe ( ip ) } </code> '
await update . message . reply_text ( f ' 识别到 { suffix } ,选一个生成: ' , parse_mode = ParseMode . HTML , reply_markup = ip_tools_markup ( ip ) )
except Exception as e :
await update . message . reply_text ( f ' 解析失败: { safe ( e ) } ' , parse_mode = ParseMode . HTML )
async def nexttrace_cmd ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
if not context . args :
await update . message . reply_text ( ' 用法:/nexttrace <服务器名/IP/ID/别名> [目标IP或域名] \n 例:/nexttrace 海创 1.1.1.1 ' )
return
s = find_server ( context . args [ 0 ] , load_inventory ( ) . get ( ' servers ' , [ ] ) )
if not s :
await update . message . reply_text ( ' 没找到这台服务器。 ' )
return
target = context . args [ 1 ] if len ( context . args ) > 1 else ' 1.1.1.1 '
if not ( extract_ipv4 ( target ) or normalize_domain ( target ) ) :
await update . message . reply_text ( ' 目标需要是 IPv4 或域名。 ' )
return
jid = launch_job ( s , ' nexttrace ' , run_nexttrace_task , context . bot , update . effective_chat . id , s , target , target = target )
if jid :
await update . message . reply_text ( f ' 🛣 已启动 { safe ( s . get ( " name " ) ) } NextTrace: { safe ( target ) } ' , parse_mode = ParseMode . HTML )
else :
await update . message . reply_text ( f ' 这个任务已经在运行中: { safe ( s . get ( " name " ) ) } NextTrace: { safe ( target ) } ' , parse_mode = ParseMode . HTML )
async def on_button ( update : Update , context : ContextTypes . DEFAULT_TYPE ) :
if not await guard ( update ) : return
q = update . callback_query
await q . answer ( )
data = q . data or ' '
chat_id = q . message . chat_id if q . message else update . effective_chat . id
if data == ' add:start ' :
if not await admin_guard ( update ) : return
clear_add_session ( chat_id )
await q . edit_message_text ( add_help_text ( ) , parse_mode = ParseMode . HTML , reply_markup = add_start_markup ( ) )
elif data == ' add:one ' :
if not await admin_guard ( update ) : return
add_session ( chat_id , step = ' single_basic ' )
await q . edit_message_text (
' ➕ 发送服务器信息:\n \n '
' <code>名称 IP [端口] [用户]</code> \n \n '
' 例:<code>hk-01 1.2.3.4 22 root</code> \n '
' 也支持:<code>hk-01 1.2.3.4:53580 root</code> ' ,
parse_mode = ParseMode . HTML ,
reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] )
)
elif data == ' add:bulk ' :
if not await admin_guard ( update ) : return
add_session ( chat_id , step = ' bulk_choose_port ' )
await q . edit_message_text ( bulk_help_text ( ) + ' \n \n 先选择端口策略: ' , parse_mode = ParseMode . HTML , reply_markup = bulk_mode_markup ( ) )
elif data == ' add:cancel ' :
clear_add_session ( chat_id )
await q . edit_message_text ( ' 已取消添加服务器。 ' , reply_markup = main_menu_markup ( ) )
elif data == ' addauth:default ' :
defaults = inventory_defaults ( load_inventory ( ) )
add_session ( chat_id , auth_kind = ' default ' )
await q . edit_message_text (
f ' 将沿用默认 SSH 配置测试登录: \n <code> { safe ( defaults . get ( " user " ) ) } @服务器: { safe ( defaults . get ( " port " ) ) } </code> \n 密钥:<code> { safe ( defaults . get ( " key " ) ) } </code> ' ,
parse_mode = ParseMode . HTML ,
)
await finish_single_add ( update , context , ADD_SESSIONS [ chat_id ] )
elif data == ' addauth:keypath ' :
add_session ( chat_id , step = ' single_key_path ' )
await q . edit_message_text ( ' 请发送已有 SSH 私钥路径,例如: \n <code>/data/keys/id_ed25519</code> \n \n 适合新服务器继续使用以前同一把密钥。 ' , parse_mode = ParseMode . HTML , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data == ' addauth:key ' :
add_session ( chat_id , step = ' single_key_text ' )
await q . edit_message_text ( ' 请直接发送 SSH 私钥文本,或以文件形式上传私钥。 \n \n 需要包含 SSH 私钥的 BEGIN/END 头尾标记。 ' , parse_mode = ParseMode . HTML , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data == ' addauth:password ' :
add_session ( chat_id , step = ' single_password ' )
await q . edit_message_text ( ' 请发送这台服务器的 SSH 密码。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data == ' addauth:skip ' :
add_session ( chat_id , auth_kind = None )
await finish_single_add ( update , context , ADD_SESSIONS [ chat_id ] )
elif data == ' bulkport:same ' :
add_session ( chat_id , step = ' bulk_port ' )
await q . edit_message_text ( ' 请输入所有服务器共用的 SSH 端口,例如 <code>22</code> 或 <code>53580</code>。 ' , parse_mode = ParseMode . HTML , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data == ' bulkport:per ' :
add_session ( chat_id , same_port = None , step = ' bulk_auth ' )
await q . edit_message_text ( ' 好,每台服务器自己写端口。现在选择认证方式: ' , reply_markup = bulk_auth_markup ( ) )
elif data == ' bulkauth:key ' :
add_session ( chat_id , step = ' bulk_shared_key_text ' )
await q . edit_message_text ( ' 请发送所有服务器共用的 SSH 私钥文本,或上传私钥文件。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data == ' bulkauth:password ' :
add_session ( chat_id , step = ' bulk_shared_password ' )
await q . edit_message_text ( ' 请发送所有服务器共用的 SSH 密码。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data == ' bulkauth:per ' :
add_session ( chat_id , auth_mode = ' per ' , step = ' bulk_lines ' )
await q . edit_message_text (
' 请发送服务器列表,每行一台: \n \n '
' <code>名称 IP 端口 用户 key:/data/keys/a</code> \n '
' <code>名称 IP 端口 用户 password:你的密码</code> ' ,
parse_mode = ParseMode . HTML ,
reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] )
)
elif data == ' bulkauth:skip ' :
add_session ( chat_id , auth_mode = ' skip ' , step = ' bulk_lines ' )
await q . edit_message_text ( ' 请发送服务器列表,每行一台: \n \n <code>名称 IP [端口] [用户]</code> ' , parse_mode = ParseMode . HTML , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data == ' noop ' :
await q . answer ( ' 该功能未启用 ' , show_alert = True )
elif data == ' act:list ' :
await send_or_edit ( update , menu_text ( ) , main_menu_markup ( ) )
elif data . startswith ( ' edit: ' ) :
if not await admin_guard ( update ) : return
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
cfg = ssh_config ( s )
await q . edit_message_text (
f ' ✏️ 编辑 <b> { safe ( s . get ( " name " ) ) } </b> \n <code> { safe ( cfg . get ( " user " ) ) } @ { safe ( cfg . get ( " host " ) ) } : { safe ( cfg . get ( " port " ) ) } </code> ' ,
parse_mode = ParseMode . HTML ,
reply_markup = edit_markup ( s ) ,
)
elif data . startswith ( ' editfield: ' ) :
if not await admin_guard ( update ) : return
_p , sid , field = data . split ( ' : ' , 2 )
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = main_menu_markup ( ) )
return
add_session ( chat_id , step = ' edit_value ' , sid = sid , field = field )
labels = { ' name ' : ' 新名称 ' , ' host ' : ' 新 IP/域名(可带 :端口) ' , ' port ' : ' 新端口 ' , ' user ' : ' 新 SSH 用户名 ' , ' key ' : ' 新密钥路径 ' , ' password ' : ' 新密码 ' }
await q . edit_message_text ( f ' 请发送 { labels . get ( field , " 新值 " ) } : ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ❌ 取消 ' , callback_data = ' add:cancel ' ) ] ] ) )
elif data . startswith ( ' editdefault: ' ) :
if not await admin_guard ( update ) : return
sid = data . split ( ' : ' , 1 ) [ 1 ]
updated = update_server_by_id ( sid , { ' ssh ' : { ' auth ' : ' key ' , ' key ' : None } } )
if not updated :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = main_menu_markup ( ) )
return
await q . edit_message_text ( f ' 已改为沿用默认密钥:<b> { safe ( updated . get ( " name " ) ) } </b> ' , parse_mode = ParseMode . HTML , reply_markup = server_markup ( updated ) )
elif data . startswith ( ' jobsrv: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
await q . edit_message_text (
job_status_text ( s ) ,
parse_mode = ParseMode . HTML ,
reply_markup = InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' 🔄 刷新任务 ' , callback_data = f ' jobsrv: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ,
] ) ,
)
elif data . startswith ( ' hist: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
await q . edit_message_text (
history_text ( s ) ,
parse_mode = ParseMode . HTML ,
disable_web_page_preview = True ,
reply_markup = history_markup ( s ) ,
)
elif data . startswith ( ' histd: ' ) :
parts = data . split ( ' : ' , 2 )
sid = parts [ 1 ] if len ( parts ) > 1 else ' '
kind = parts [ 2 ] if len ( parts ) > 2 else ' '
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
item = history_item_for ( s , kind )
if item and ( latest_media_path ( item ) or kind == ' nq ' ) :
await send_history_result ( context . bot , q . message . chat_id , s , kind )
await q . edit_message_text (
f ' 已重新发送 <b> { safe ( KIND_NAME . get ( kind , kind ) ) } </b> 最近一次完整结果。 ' ,
parse_mode = ParseMode . HTML ,
reply_markup = InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' ↩️ 返回历史记录 ' , callback_data = f ' hist: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ,
] ) ,
)
else :
await q . edit_message_text (
history_detail_text ( s , kind ) ,
parse_mode = ParseMode . HTML ,
disable_web_page_preview = True ,
reply_markup = InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' ↩️ 返回历史记录 ' , callback_data = f ' hist: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ,
] ) ,
)
elif data . startswith ( ' testssh: ' ) :
if not await admin_guard ( update ) : return
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
await q . edit_message_text ( f ' 🧪 正在测试 { safe ( s . get ( " name " ) ) } SSH… ' , parse_mode = ParseMode . HTML )
ok , out = await test_server_login ( s )
await q . edit_message_text (
( f ' ✅ <b> { safe ( s . get ( " name " ) ) } </b> SSH 登录成功: ' if ok else f ' ⚠️ <b> { safe ( s . get ( " name " ) ) } </b> SSH 登录失败: ' ) + ' <pre> ' + safe ( out [ - 1200 : ] ) + ' </pre> ' ,
parse_mode = ParseMode . HTML ,
reply_markup = server_markup ( s ) ,
)
elif data . startswith ( ' delask: ' ) :
if not await admin_guard ( update ) : return
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
cfg = ssh_config ( s )
await q . edit_message_text (
' ⚠️ 确认删除这台服务器? \n \n '
f ' <b> { safe ( s . get ( " name " ) ) } </b> \n '
f ' <code> { safe ( cfg . get ( " user " ) ) } @ { safe ( cfg . get ( " host " ) ) } : { safe ( cfg . get ( " port " ) ) } </code> \n \n '
' 只会从 GUKO 配置删除,不会动远端机器。 ' ,
parse_mode = ParseMode . HTML ,
reply_markup = InlineKeyboardMarkup ( [
[ InlineKeyboardButton ( ' ✅ 确认删除 ' , callback_data = f ' delconfirm: { sid } ' ) ] ,
[ InlineKeyboardButton ( ' ❌ 取消删除 ' , callback_data = f ' srv: { sid } ' ) ] ,
] ) ,
)
elif data . startswith ( ' delconfirm: ' ) :
if not await admin_guard ( update ) : return
sid = data . split ( ' : ' , 1 ) [ 1 ]
removed = delete_server_by_id ( sid )
if not removed :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = main_menu_markup ( ) )
return
await q . edit_message_text ( f ' 已从配置删除:<b> { safe ( removed . get ( " name " ) ) } </b> ' , parse_mode = ParseMode . HTML , reply_markup = main_menu_markup ( ) )
elif data . startswith ( ' proxy: ' ) :
parts = data . split ( ' : ' , 2 )
kind = parts [ 1 ]
sid = parts [ 2 ] if len ( parts ) > 2 else ' '
s = find_server_by_id ( sid )
if kind not in PROXY_TOOLS or not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
await q . edit_message_text ( proxy_menu_text ( s , kind ) , parse_mode = ParseMode . HTML , reply_markup = proxy_markup ( s , kind ) )
elif data . startswith ( ' proxyrun: ' ) :
parts = data . split ( ' : ' , 3 )
kind = parts [ 1 ] if len ( parts ) > 1 else ' '
action = parts [ 2 ] if len ( parts ) > 2 else ' '
sid = parts [ 3 ] if len ( parts ) > 3 else ' '
s = find_server_by_id ( sid )
if kind not in PROXY_TOOLS or action not in ( ' install ' , ' ensure ' , ' view ' ) or not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
tool = proxy_tool_config ( kind )
task = f " { safe ( tool [ ' name ' ] ) } { ' 安装/更新检查 ' if action in ( ' install ' , ' ensure ' ) else ' 查看配置 ' } "
jid = launch_job ( s , kind , run_proxy_tool_task , context . bot , q . message . chat_id , s , kind , action , target = action )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , task , jid is not None )
elif data . startswith ( ' vlessmode: ' ) :
parts = data . split ( ' : ' , 2 )
mode = parts [ 1 ] if len ( parts ) > 1 else ' plain '
sid = parts [ 2 ] if len ( parts ) > 2 else ' '
s = find_server_by_id ( sid )
if mode not in ( ' plain ' , ' reality ' ) or not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
tool = proxy_tool_config ( ' vless ' )
label = ' Vision + Reality ' if mode == ' reality ' else ' 纯 VLESS '
task = f " { safe ( tool [ ' name ' ] ) } { label } 安装/更新 "
jid = launch_job ( s , f ' vless- { mode } ' , run_proxy_tool_task , context . bot , q . message . chat_id , s , ' vless ' , ' ensure ' , mode , target = mode )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , task , jid is not None )
elif data . startswith ( ' ipq: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
jid = launch_job ( s , ' ipq ' , run_ip_quality_task , context . bot , q . message . chat_id , s )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , ' IP质量任务 ' , jid is not None )
elif data . startswith ( ' gb5: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
jid = launch_job ( s , ' gb5 ' , run_gb5_task , context . bot , q . message . chat_id , s )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , ' GB5 ' , jid is not None )
elif data . startswith ( ' stream: ' ) or data . startswith ( ' streamrun: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
region_id , region_label = stream_region_for_server ( s )
jid = launch_job ( s , ' stream ' , run_stream_task , context . bot , q . message . chat_id , s , region = region_label )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , f ' 流媒体检测( { safe ( region_label ) } ) ' , jid is not None )
elif data . startswith ( ' ntask: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
PENDING_NEXTTRACE [ q . message . chat_id ] = sid
await q . edit_message_text (
nexttrace_prompt_text ( s ) ,
parse_mode = ParseMode . HTML ,
reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回操作面板 ' , callback_data = f ' srv: { sid } ' ) ] ] )
)
elif data . startswith ( ' ntrun: ' ) :
parts = data . split ( ' : ' , 2 )
sid = parts [ 1 ]
target = parts [ 2 ] if len ( parts ) > 2 else ' 1.1.1.1 '
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
jid = launch_job ( s , ' nexttrace ' , run_nexttrace_task , context . bot , q . message . chat_id , s , target , target = target )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , f ' NextTrace { safe ( target ) } ' , jid is not None )
elif data . startswith ( ' bgp: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
jid = launch_job ( s , ' bgp ' , run_bgp_task , context . bot , q . message . chat_id , s )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , ' BGP 图任务 ' , jid is not None )
elif data . startswith ( ' ippure: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
jid = launch_job ( s , ' ippure ' , run_ippure_task , context . bot , q . message . chat_id , s )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , ' IPPure 图任务 ' , jid is not None )
elif data . startswith ( ' bgpip: ' ) :
ip = data . split ( ' : ' , 1 ) [ 1 ]
if not is_ipv4 ( ip ) :
await q . answer ( ' 无效 IPv4 ' , show_alert = True )
return
pseudo = { ' id ' : ip , ' name ' : ip , ' host ' : ip }
jid , key = start_job ( pseudo , ' bgp ' )
if not jid :
await q . answer ( ' 这个 IP 的 BGP 图已经在生成了 ' , show_alert = True )
return
await send_running_notice ( context . bot , q . message . chat_id , pseudo , ' BGP 图任务 ' )
asyncio . create_task ( run_bgp_task ( context . bot , q . message . chat_id , pseudo , jid ) )
elif data . startswith ( ' ippureip: ' ) :
ip = data . split ( ' : ' , 1 ) [ 1 ]
if not is_ipv4 ( ip ) :
await q . answer ( ' 无效 IPv4 ' , show_alert = True )
return
pseudo = { ' id ' : ip , ' name ' : ip , ' host ' : ip }
jid , key = start_job ( pseudo , ' ippure ' )
if not jid :
await q . answer ( ' 这个 IP 的 IPPure 图已经在生成了 ' , show_alert = True )
return
await send_running_notice ( context . bot , q . message . chat_id , pseudo , ' IPPure 图任务 ' )
asyncio . create_task ( run_ippure_task ( context . bot , q . message . chat_id , pseudo , jid ) )
elif data . startswith ( ' nqask: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
mask = NQ_DEFAULT_MASK
await q . edit_message_text (
nq_menu_text ( s , mask , ' 4 ' ) ,
parse_mode = ParseMode . HTML , reply_markup = confirm_nq_markup ( s , mask , ' 4 ' )
)
elif data . startswith ( ' nqtoggle: ' ) or data . startswith ( ' nqsel: ' ) or data . startswith ( ' nqproto: ' ) :
parts = data . split ( ' : ' )
_kind , sid = parts [ 0 ] , parts [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
try :
mask = int ( parts [ 2 ] ) & NQ_ALL_MASK
except Exception :
mask = NQ_DEFAULT_MASK
ip_mode = parts [ 3 ] if len ( parts ) > 3 else ' 4 '
if ip_mode == ' 46 ' and not server_has_ipv6 ( s ) :
ip_mode = ' 4 '
await q . edit_message_text (
nq_menu_text ( s , mask , ip_mode ) ,
parse_mode = ParseMode . HTML , reply_markup = confirm_nq_markup ( s , mask , ip_mode )
)
elif data . startswith ( ' nqrun: ' ) :
parts = data . split ( ' : ' )
sid = parts [ 1 ]
try :
mask = int ( parts [ 2 ] ) & NQ_ALL_MASK if len ( parts ) > 2 else NQ_DEFAULT_MASK
except Exception :
mask = NQ_DEFAULT_MASK
ip_mode = parts [ 3 ] if len ( parts ) > 3 else ' 4 '
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
if mask == 0 :
await q . answer ( ' 至少选一项 ' , show_alert = True )
await q . edit_message_text (
nq_menu_text ( s , mask , ip_mode ) + ' \n \n 至少选一项才能开始。 ' ,
parse_mode = ParseMode . HTML , reply_markup = confirm_nq_markup ( s , mask , ip_mode )
)
return
if ip_mode == ' 46 ' and not server_has_ipv6 ( s ) :
ip_mode = ' 4 '
selected_text = nq_selected_text ( mask )
ip_text = nq_ip_mode_text ( ip_mode )
jid = launch_job ( s , ' nq ' , run_nq_task , context . bot , q . message . chat_id , s , mask , ip_mode , selected = selected_text , ip_mode = ip_text )
await bot_task_started_notice ( context . bot , q . message . chat_id , s , f ' NodeQuality( { safe ( selected_text ) } ; { safe ( ip_text ) } ) ' , jid is not None )
elif data . startswith ( ' srv: ' ) :
sid = data . split ( ' : ' , 1 ) [ 1 ]
s = find_server_by_id ( sid )
if not s :
await q . edit_message_text ( ' 这台服务器不在当前清单里。 ' , reply_markup = InlineKeyboardMarkup ( [ [ InlineKeyboardButton ( ' ↩️ 返回列表 ' , callback_data = ' act:list ' ) ] ] ) )
return
if not format_server_specs ( s . get ( ' specs ' ) ) :
await q . edit_message_text ( server_detail_text ( s ) + ' \n 正在读取配置… ' , parse_mode = ParseMode . HTML )
await q . edit_message_text ( await server_detail_text_with_specs ( s ) , parse_mode = ParseMode . HTML , reply_markup = server_markup ( s ) )
async def error_handler ( update : object , context : ContextTypes . DEFAULT_TYPE ) :
print ( f ' bot error: { context . error !r} ' , flush = True )
def main ( ) :
startup_check ( )
app = Application . builder ( ) . token ( BOT_TOKEN ) . post_init ( post_init ) . build ( )
app . add_handler ( CommandHandler ( [ ' start ' , ' help ' ] , start ) )
app . add_handler ( CommandHandler ( ' version ' , version_cmd ) )
app . add_handler ( CommandHandler ( ' list ' , list_cmd ) )
app . add_handler ( CommandHandler ( ' status ' , status_cmd ) )
app . add_handler ( CommandHandler ( ' addserver ' , addserver_cmd ) )
app . add_handler ( CommandHandler ( ' testssh ' , testssh_cmd ) )
app . add_handler ( CommandHandler ( ' testall ' , testall_cmd ) )
app . add_handler ( CommandHandler ( ' exportconfig ' , export_config_cmd ) )
2026-06-23 00:58:45 +00:00
app . add_handler ( CommandHandler ( ' backup ' , backup_cmd ) )
app . add_handler ( CommandHandler ( ' summary ' , summary_cmd ) )
2026-06-23 00:44:35 +00:00
app . add_handler ( CommandHandler ( ' info ' , info_cmd ) )
app . add_handler ( CommandHandler ( ' jobs ' , jobs_cmd ) )
app . add_handler ( CommandHandler ( ' history ' , history_cmd ) )
app . add_handler ( CommandHandler ( ' ip ' , ip_cmd ) )
app . add_handler ( CommandHandler ( ' nexttrace ' , nexttrace_cmd ) )
app . add_handler ( CallbackQueryHandler ( on_button ) )
app . add_handler ( MessageHandler ( filters . Document . ALL , document_handler ) )
app . add_handler ( MessageHandler ( filters . TEXT & ~ filters . COMMAND , fallback_panel ) )
app . add_error_handler ( error_handler )
print ( f ' guko telegram bot started v { GUKO_VERSION } ' , flush = True )
app . run_polling ( allowed_updates = Update . ALL_TYPES )
if __name__ == ' __main__ ' :
main ( )