feat: SS2022 support, multi-select nodes, single node sub URL, name filter
This commit is contained in:
141
bot.py
141
bot.py
@@ -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...')
|
||||
|
||||
Reference in New Issue
Block a user