feat: SS2022 support, multi-select nodes, single node sub URL, name filter

This commit is contained in:
mango
2026-02-23 10:33:31 +08:00
parent debb0040d0
commit 015b3b8417

141
bot.py
View File

@@ -8,11 +8,10 @@ log = logging.getLogger(__name__)
TOKEN = os.environ.get('BOT_TOKEN', '')
ADMIN_ID = int(os.environ.get('ADMIN_ID', '0'))
DATA_FILE = os.environ.get('DATA_FILE', '/opt/sub-bot/data.json')
DATA_FILE = '/opt/sub-bot/data.json'
PROTOS = ['ss://', 'vmess://', 'vless://', 'trojan://', 'hysteria2://', 'hy2://', 'tuic://']
AUTO_DEL = 60
SUB_SECRET = os.environ.get('SUB_SECRET', 'change_me_to_random_string')
SUB_HOST = os.environ.get('SUB_HOST', '0.0.0.0:18888')
SUB_SECRET = os.environ.get('SUB_SECRET', 'changeme')
WAITING_ADD = set() # user_ids waiting to add sub
def load_data():
@@ -131,6 +130,7 @@ async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
InlineKeyboardButton(f"🔗 {t} ({cnt})", callback_data=f'get_{t}_raw'),
InlineKeyboardButton(f"📄 {t} Base64", callback_data=f'get_{t}_b64')])
buttons.append([InlineKeyboardButton("⚡ Clash Meta 订阅", callback_data='get_all_clash')])
buttons.append([InlineKeyboardButton("🎯 自选节点", callback_data='menu_pick_multi')])
await send_del(q.message, '选择格式:', reply_markup=InlineKeyboardMarkup(buttons))
elif action == 'menu_del':
@@ -163,6 +163,15 @@ async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
except: pass
await send_del(q.message, '📊 *检测结果*\n\n' + '\n'.join(results), parse_mode='Markdown')
elif action == 'menu_pick_multi':
alive = [s for s in data['subs'] if s.get('alive', True)]
if not alive:
return await send_del(q.message, '📭 暂无可用订阅')
context.user_data['multi_sel'] = set()
buttons = [[InlineKeyboardButton(f"{s['name']}", callback_data=f'msel_{i}')] for i, s in enumerate(alive)]
buttons.append([InlineKeyboardButton("✅ 确认", callback_data='msel_done')])
await send_del(q.message, '🎯 点击选择节点:', reply_markup=InlineKeyboardMarkup(buttons), delay=120)
elif action == 'menu_setgroup':
if q.from_user.id != ADMIN_ID:
return await send_del(q.message, '⛔ 仅管理员可用')
@@ -185,7 +194,8 @@ async def cb_getsub(update: Update, context: ContextTypes.DEFAULT_TYPE):
if idx < len(alive):
buttons = [
[InlineKeyboardButton("🔗 原始链接", callback_data=f'fmt_{idx}_raw')],
[InlineKeyboardButton("⚡ Clash Meta 订阅", callback_data=f'fmt_{idx}_clash')],
[InlineKeyboardButton("⚡ Clash Meta", callback_data=f'fmt_{idx}_clash')],
[InlineKeyboardButton("📎 Clash Meta 订阅URL", callback_data=f'fmt_{idx}_url')],
]
await send_del(q.message, f'📍 {alive[idx]["name"]}\n选择输出格式:',
reply_markup=InlineKeyboardMarkup(buttons))
@@ -198,10 +208,12 @@ async def cb_getsub(update: Update, context: ContextTypes.DEFAULT_TYPE):
s = alive[idx]
if fmt == 'raw':
await send_del(q.message, s["link"])
else:
# single node clash meta - generate inline
elif fmt == 'clash':
cm = gen_clash_meta([s])
await send_del(q.message, f'```yaml\n{cm}\n```', parse_mode='Markdown')
elif fmt == 'url':
url = f'http://YOUR_SERVER:18888/{SUB_SECRET}/download?target=ClashMeta'
await send_del(q.message, f'📎 订阅URL:\n{url}')
return
if action.startswith('send_'):
@@ -223,7 +235,7 @@ async def cb_getsub(update: Update, context: ContextTypes.DEFAULT_TYPE):
proto, fmt = parts[1], parts[2]
subs = alive if proto == 'all' else [s for s in alive if s['type'] == proto]
if fmt == 'clash':
url = f'http://{SUB_HOST}/{SUB_SECRET}/download?target=ClashMeta'
url = f'http://YOUR_SERVER:18888/{SUB_SECRET}/download?target=ClashMeta'
if proto != 'all': url += f'&type={proto}'
return await send_del(q.message, f'📎 Clash Meta 订阅链接:\n{url}')
links = '\n'.join(s['link'] for s in subs)
@@ -237,7 +249,7 @@ async def cb_getsub(update: Update, context: ContextTypes.DEFAULT_TYPE):
return
if action == 'get_all_clash':
url = f'http://{SUB_HOST}/{SUB_SECRET}/download?target=ClashMeta'
url = 'http://YOUR_SERVER:18888/{SUB_SECRET}/download?target=ClashMeta'
await send_del(q.message, f'📎 Clash Meta 订阅链接:\n{url}')
return
@@ -270,6 +282,71 @@ async def cb_getsub(update: Update, context: ContextTypes.DEFAULT_TYPE):
else:
await send_del(q.message, links)
async def cb_multisel(update: Update, context: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
data = load_data()
alive = [s for s in data['subs'] if s.get('alive', True)]
sel = context.user_data.get('multi_sel', set())
action = q.data
if action == 'msel_done':
if not sel:
return await send_del(q.message, '⚠️ 未选择任何节点')
chosen = [alive[i] for i in sorted(sel) if i < len(alive)]
if not chosen:
return await send_del(q.message, '⚠️ 选择已失效')
context.user_data['multi_sel'] = set()
names = ', '.join(s['name'] for s in chosen)
buttons = [
[InlineKeyboardButton("🔗 原始链接", callback_data='mout_raw')],
[InlineKeyboardButton("⚡ Clash Meta", callback_data='mout_clash')],
[InlineKeyboardButton("📎 订阅URL", callback_data='mout_url')],
[InlineKeyboardButton("📄 Base64", callback_data='mout_b64')],
]
context.user_data['multi_chosen'] = chosen
await send_del(q.message, f'已选 {len(chosen)} 个: {names}\n选择输出格式:',
reply_markup=InlineKeyboardMarkup(buttons))
return
idx = int(action.split('_')[1])
if idx in sel:
sel.discard(idx)
else:
sel.add(idx)
context.user_data['multi_sel'] = sel
buttons = []
for i, s in enumerate(alive):
mark = '' if i in sel else ''
buttons.append([InlineKeyboardButton(f"{mark} {s['name']}", callback_data=f'msel_{i}')])
buttons.append([InlineKeyboardButton(f"✅ 确认 ({len(sel)})", callback_data='msel_done')])
try:
await q.message.edit_text('🎯 点击选择节点:', reply_markup=InlineKeyboardMarkup(buttons))
except: pass
async def cb_multiout(update: Update, context: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
chosen = context.user_data.get('multi_chosen', [])
if not chosen:
return await send_del(q.message, '⚠️ 选择已过期,请重新操作')
fmt = q.data.split('_')[1]
if fmt == 'raw':
links = '\n'.join(s['link'] for s in chosen)
await send_del(q.message, links)
elif fmt == 'clash':
cm = gen_clash_meta(chosen)
await send_del(q.message, f'```yaml\n{cm}\n```', parse_mode='Markdown')
elif fmt == 'url':
names = ','.join(urllib.request.quote(s['name']) for s in chosen)
url = f'http://YOUR_SERVER:18888/{SUB_SECRET}/download?target=ClashMeta&name={names}'
await send_del(q.message, f'📎 订阅URL:\n{url}')
elif fmt == 'b64':
links = '\n'.join(s['link'] for s in chosen)
await send_del(q.message, f'```\n{base64.b64encode(links.encode()).decode()}\n```', parse_mode='Markdown')
context.user_data.pop('multi_chosen', None)
async def cb_delsub(update: Update, context: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
@@ -336,13 +413,25 @@ async def check_node(link, proto):
def parse_sp(link, proto):
try:
if proto == 'ss':
if proto == "ss":
raw = link[5:]
if '#' in raw: raw = raw.split('#')[0]
decoded = base64.b64decode(raw + '==').decode()
_, rest = decoded.split(':', 1)
_, sp = rest.rsplit('@', 1)
return sp.rsplit(':', 1)
if "#" in raw: raw = raw.split("#")[0]
if "@" in raw:
sp = raw.split("@")[-1]
return sp.rsplit(":", 1)
try:
decoded = base64.b64decode(raw + "==").decode()
_, rest = decoded.split(":", 1)
_, sp = rest.rsplit("@", 1)
return sp.rsplit(":", 1)
except: pass
m = re.search(r'@([^:/?#]+):(\d+)', link)
if m: return m.group(1), m.group(2)
except: pass
@@ -354,10 +443,20 @@ def parse_to_clash_proxy(s):
raw = s['link'][5:]
name = ''
if '#' in raw: raw, name = raw.rsplit('#', 1); name = urllib.request.unquote(name)
decoded = base64.b64decode(raw + '==').decode()
method, rest = decoded.split(':', 1)
pwd, sp = rest.rsplit('@', 1)
server, port = sp.rsplit(':', 1)
if "@" in raw:
userinfo, sp = raw.rsplit("@", 1)
server, port = sp.rsplit(":", 1)
decoded = base64.b64decode(userinfo + "==").decode()
method, pwd = decoded.split(":", 1)
else:
decoded = base64.b64decode(raw + "==").decode()
method, rest = decoded.split(":", 1)
pwd, sp = rest.rsplit("@", 1)
server, port = sp.rsplit(":", 1)
return {'name': name or server, 'type': 'ss', 'server': server, 'port': int(port),
'cipher': method, 'password': pwd}
except: pass
@@ -385,6 +484,10 @@ class SubHandler(BaseHTTPRequestHandler):
qs = dict(x.split('=',1) for x in self.path.split('?',1)[1].split('&') if '=' in x)
ftype = qs.get('type', '')
if ftype: alive = [s for s in alive if s['type'] == ftype]
fname = qs.get('name', '')
if fname:
names = [urllib.request.unquote(n) for n in fname.split(',')]
alive = [s for s in alive if s['name'] in names]
target = qs.get('target', 'ClashMeta')
body = gen_clash_meta(alive) if target == 'ClashMeta' else '\n'.join(s['link'] for s in alive)
self.send_response(200)
@@ -445,6 +548,8 @@ def main():
app.add_handler(CommandHandler('setgroup', cmd_setgroup))
app.add_handler(CallbackQueryHandler(cb_menu, pattern='^menu_'))
app.add_handler(CallbackQueryHandler(cb_delsub, pattern='^del_'))
app.add_handler(CallbackQueryHandler(cb_multisel, pattern='^msel_'))
app.add_handler(CallbackQueryHandler(cb_multiout, pattern='^mout_'))
app.add_handler(CallbackQueryHandler(cb_getsub, pattern='^(get_|pick_|send_|fmt_|out_)'))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, auto_detect))
log.info('Sub Bot starting polling...')