diff --git a/bot.py b/bot.py index bb85c1a..159345b 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,4 @@ -import os, json, time, base64, asyncio, re, urllib.request, logging, threading +import os, json, time, base64, asyncio, re, urllib.request, logging, threading, secrets from http.server import HTTPServer, BaseHTTPRequestHandler from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import Application, CommandHandler, CallbackQueryHandler, MessageHandler, filters, ContextTypes @@ -12,12 +12,16 @@ 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', 'changeme') +SUB_HOST = os.environ.get('SUB_HOST', 'substore.mjjtop.com') WAITING_ADD = set() # user_ids waiting to add sub def load_data(): if os.path.exists(DATA_FILE): - with open(DATA_FILE) as f: return json.load(f) - return {'subs': [], 'groups': []} + with open(DATA_FILE) as f: + d = json.load(f) + d.setdefault('sub_groups', []) + return d + return {'subs': [], 'groups': [], 'sub_groups': []} def save_data(data): os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True) @@ -85,6 +89,22 @@ def add_sub(link, user): save_data(data) return f'✅ 已添加 [{proto}] {name}' +def add_sub_to_group(link, user, group_id): + """添加节点到指定分组""" + proto = detect_type(link) + if proto == 'unknown': return None + data = load_data() + sg = next((g for g in data.get('sub_groups', []) if g['id'] == group_id), None) + if not sg: return '❌ 分组不存在' + for s in sg['nodes']: + if s['link'] == link: return 'dup' + name = extract_name(link) + sg['nodes'].append({'link': link, 'type': proto, 'name': name, + 'added_by': user.id, 'added_name': user.first_name, + 'added_at': int(time.time()), 'alive': True}) + save_data(data) + return f'✅ 已添加到 [{sg["name"]}] [{proto}] {name}' + # --- /help: main menu with buttons --- async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): if not await is_member(update, context): @@ -95,6 +115,7 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): [InlineKeyboardButton("➕ 添加订阅", callback_data='menu_add'), InlineKeyboardButton("🗑 删除订阅", callback_data='menu_del')], [InlineKeyboardButton("🔍 检测存活", callback_data='menu_check')], + [InlineKeyboardButton("📁 分组管理", callback_data='sg_list')], ] if update.effective_user.id == ADMIN_ID: buttons.append([InlineKeyboardButton("⚙️ 绑定当前群", callback_data='menu_setgroup')]) @@ -378,6 +399,57 @@ async def auto_detect(update: Update, context: ContextTypes.DEFAULT_TYPE): if not update.message or not update.message.text: return text = update.message.text.strip() uid = update.effective_user.id + + # --- 分组相关文本输入处理 --- + # 创建分组:等待输入名称 + if context.user_data.get('waiting_sg_name'): + del context.user_data['waiting_sg_name'] + data = load_data() + gid = secrets.token_hex(4) + sec = secrets.token_hex(16) + data['sub_groups'].append({ + 'id': gid, 'name': text, 'secret': sec, + 'nodes': [], 'created_at': int(time.time()) + }) + save_data(data) + host = SUB_HOST + url = f"https://{host}/{sec}/download?target=ClashMeta" + return await send_del(update.message, + f"✅ 分组 *{text}* 已创建\n\n🔗 订阅链接:\n`{url}`", parse_mode='Markdown') + + # 重命名分组:等待输入新名称 + if context.user_data.get('waiting_sg_rename'): + gid = context.user_data.pop('waiting_sg_rename') + data = load_data() + sg = next((g for g in data.get('sub_groups', []) if g['id'] == gid), None) + if sg: + old = sg['name'] + sg['name'] = text + save_data(data) + return await send_del(update.message, f"✅ 分组已重命名: {old} → {text}") + return await send_del(update.message, '❌ 分组不存在') + + # 添加节点到分组:等待输入链接 + if context.user_data.get('waiting_sg_add'): + gid = context.user_data.pop('waiting_sg_add') + found = [w for w in text.split() if any(w.startswith(p) for p in PROTOS)] + surge_links = [] + if not found: + for line in text.split('\n'): + ss_link, name = parse_surge_ss(line) + if ss_link: surge_links.append(ss_link) + if not found and not surge_links: + return await send_del(update.message, '❌ 未识别到订阅链接') + results = [] + for link in found + surge_links: + r = add_sub_to_group(link, update.effective_user, gid) + if r and r != 'dup': results.append(r) + elif r == 'dup': results.append(f'⚠️ 已存在: {extract_name(link)}') + if results: + return await send_del(update.message, '\n'.join(results)) + return + + # --- 原有逻辑 --- # check standard protocol links found = [w for w in text.split() if any(w.startswith(p) for p in PROTOS)] # check Surge format (line-based) @@ -473,15 +545,34 @@ def gen_clash_meta(subs): class SubHandler(BaseHTTPRequestHandler): def do_GET(self): path = self.path.split('?')[0] - if path != f'/{SUB_SECRET}/download': - self.send_response(404) - self.end_headers() - return - data = load_data() - alive = [s for s in data['subs'] if s.get('alive', True)] + # 解析查询参数 qs = {} if '?' in self.path: qs = dict(x.split('=',1) for x in self.path.split('?',1)[1].split('&') if '=' in x) + + # 匹配默认分组或自定义分组 + alive = [] + if path == f'/{SUB_SECRET}/download': + data = load_data() + alive = [s for s in data['subs'] if s.get('alive', True)] + else: + # 尝试匹配分组 secret + parts = path.strip('/').split('/') + if len(parts) == 2 and parts[1] == 'download': + group_secret = parts[0] + data = load_data() + sg = next((g for g in data.get('sub_groups', []) if g['secret'] == group_secret), None) + if sg: + alive = [s for s in sg.get('nodes', []) if s.get('alive', True)] + else: + self.send_response(404) + self.end_headers() + return + else: + self.send_response(404) + self.end_headers() + return + ftype = qs.get('type', '') if ftype: alive = [s for s in alive if s['type'] == ftype] fname = qs.get('name', '') @@ -512,6 +603,165 @@ async def cmd_setgroup(update: Update, context: ContextTypes.DEFAULT_TYPE): save_data(data) await send_del(update.message, f'✅ 已绑定群 `{cid}`', parse_mode='Markdown') +# --- 分组管理回调 --- +async def cb_subgroups(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + action = q.data + data = load_data() + sgs = data.get('sub_groups', []) + + if action == 'sg_list': + buttons = [] + for sg in sgs: + cnt = len(sg.get('nodes', [])) + buttons.append([InlineKeyboardButton(f"📁 {sg['name']} ({cnt}个节点)", callback_data=f"sg_detail_{sg['id']}")]) + buttons.append([InlineKeyboardButton("➕ 创建分组", callback_data='sg_create')]) + buttons.append([InlineKeyboardButton("◀️ 返回主菜单", callback_data='menu_back')]) + text = f"📁 *分组管理*\n\n共 {len(sgs)} 个分组" if sgs else "📁 *分组管理*\n\n暂无分组,点击创建" + try: + await q.edit_message_text(text, parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + except: + await send_del(q.message, text, parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + + elif action == 'sg_create': + context.user_data['waiting_sg_name'] = True + try: + await q.edit_message_text('📝 请发送分组名称:') + except: + await send_del(q.message, '📝 请发送分组名称:') + + elif action.startswith('sg_detail_'): + gid = action[len('sg_detail_'):] + sg = next((g for g in sgs if g['id'] == gid), None) + if not sg: + return await send_del(q.message, '❌ 分组不存在') + cnt = len(sg.get('nodes', [])) + host = SUB_HOST + url = f"https://{host}/{sg['secret']}/download?target=ClashMeta" + text = (f"📁 *{sg['name']}*\n\n" + f"📊 节点数: {cnt}\n" + f"🔗 订阅链接:\n`{url}`") + buttons = [ + [InlineKeyboardButton("📋 节点列表", callback_data=f"sg_nodes_{gid}"), + InlineKeyboardButton("➕ 添加节点", callback_data=f"sg_add_{gid}")], + [InlineKeyboardButton("📥 获取订阅", callback_data=f"sg_getsub_{gid}"), + InlineKeyboardButton("🔄 重置链接", callback_data=f"sg_reset_{gid}")], + [InlineKeyboardButton("✏️ 重命名", callback_data=f"sg_rename_{gid}"), + InlineKeyboardButton("🗑 删除分组", callback_data=f"sg_delconfirm_{gid}")], + [InlineKeyboardButton("◀️ 返回分组列表", callback_data='sg_list')], + ] + try: + await q.edit_message_text(text, parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + except: + await send_del(q.message, text, parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + + elif action.startswith('sg_nodes_'): + gid = action[len('sg_nodes_'):] + sg = next((g for g in sgs if g['id'] == gid), None) + if not sg: return + nodes = sg.get('nodes', []) + if not nodes: + text = f"📁 *{sg['name']}* — 暂无节点" + else: + lines = [f"📁 *{sg['name']}* 节点列表\n"] + for i, s in enumerate(nodes): + st = '🟢' if s.get('alive', True) else '🔴' + lines.append(f"`{i+1}` {st} [{s['type']}] {s['name']}") + text = '\n'.join(lines) + buttons = [[InlineKeyboardButton("◀️ 返回", callback_data=f"sg_detail_{gid}")]] + try: + await q.edit_message_text(text, parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + except: + await send_del(q.message, text, parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + + elif action.startswith('sg_add_'): + gid = action[len('sg_add_'):] + context.user_data['waiting_sg_add'] = gid + sg = next((g for g in sgs if g['id'] == gid), None) + name = sg['name'] if sg else '?' + try: + await q.edit_message_text(f'📎 请发送要添加到 [{name}] 的订阅链接:') + except: + await send_del(q.message, f'📎 请发送要添加到 [{name}] 的订阅链接:') + + elif action.startswith('sg_getsub_'): + gid = action[len('sg_getsub_'):] + sg = next((g for g in sgs if g['id'] == gid), None) + if not sg: return + host = SUB_HOST + url = f"https://{host}/{sg['secret']}/download?target=ClashMeta" + buttons = [ + [InlineKeyboardButton("📋 复制链接", callback_data=f"sg_detail_{gid}")], + [InlineKeyboardButton("◀️ 返回", callback_data=f"sg_detail_{gid}")], + ] + await send_del(q.message, f"📎 *{sg['name']}* 订阅链接:\n\n`{url}`", + parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + + elif action.startswith('sg_reset_'): + gid = action[len('sg_reset_'):] + sg = next((g for g in sgs if g['id'] == gid), None) + if not sg: return + sg['secret'] = secrets.token_hex(16) + save_data(data) + host = SUB_HOST + url = f"https://{host}/{sg['secret']}/download?target=ClashMeta" + buttons = [[InlineKeyboardButton("◀️ 返回", callback_data=f"sg_detail_{gid}")]] + try: + await q.edit_message_text(f"✅ 链接已重置\n\n新链接:\n`{url}`", + parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + except: + await send_del(q.message, f"✅ 链接已重置\n\n新链接:\n`{url}`", + parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + + elif action.startswith('sg_rename_'): + gid = action[len('sg_rename_'):] + context.user_data['waiting_sg_rename'] = gid + try: + await q.edit_message_text('✏️ 请发送新的分组名称:') + except: + await send_del(q.message, '✏️ 请发送新的分组名称:') + + elif action.startswith('sg_delconfirm_'): + gid = action[len('sg_delconfirm_'):] + sg = next((g for g in sgs if g['id'] == gid), None) + if not sg: return + buttons = [ + [InlineKeyboardButton("⚠️ 确认删除", callback_data=f"sg_dodel_{gid}"), + InlineKeyboardButton("取消", callback_data=f"sg_detail_{gid}")], + ] + try: + await q.edit_message_text(f"⚠️ 确认删除分组 *{sg['name']}*?\n节点数据将全部丢失!", + parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + except: + await send_del(q.message, f"⚠️ 确认删除分组 *{sg['name']}*?", + parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + + elif action.startswith('sg_dodel_'): + gid = action[len('sg_dodel_'):] + data['sub_groups'] = [g for g in sgs if g['id'] != gid] + save_data(data) + buttons = [[InlineKeyboardButton("◀️ 返回分组列表", callback_data='sg_list')]] + try: + await q.edit_message_text("✅ 分组已删除", reply_markup=InlineKeyboardMarkup(buttons)) + except: + await send_del(q.message, "✅ 分组已删除", reply_markup=InlineKeyboardMarkup(buttons)) + + elif action == 'menu_back': + # 返回主菜单 + buttons = [ + [InlineKeyboardButton("📋 订阅列表", callback_data='menu_list'), + InlineKeyboardButton("📥 获取订阅", callback_data='menu_get')], + [InlineKeyboardButton("➕ 添加订阅", callback_data='menu_add'), + InlineKeyboardButton("🗑 删除订阅", callback_data='menu_del')], + [InlineKeyboardButton("🔍 检测存活", callback_data='menu_check')], + [InlineKeyboardButton("📁 分组管理", callback_data='sg_list')], + ] + try: + await q.edit_message_text('🚀 *订阅管理 Bot*\n\n直接发订阅链接自动入库\n或点击下方按钮操作:', + parse_mode='Markdown', reply_markup=InlineKeyboardMarkup(buttons)) + except: pass + async def auto_cleanup(bot_token): """Every 6h: check nodes, auto-delete dead ones, notify group""" import httpx @@ -551,6 +801,7 @@ def main(): 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(CallbackQueryHandler(cb_subgroups, pattern='^(sg_|menu_back)')) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, auto_detect)) log.info('Sub Bot starting polling...') threading.Thread(target=start_http, daemon=True).start()