feat: 添加订阅分组管理功能

This commit is contained in:
mango
2026-02-25 17:18:33 +08:00
parent 4bbfb2d9dd
commit 321bb434a5

269
bot.py
View File

@@ -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()