feat: 添加订阅分组管理功能
This commit is contained in:
269
bot.py
269
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 http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, MessageHandler, filters, ContextTypes
|
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://']
|
PROTOS = ['ss://', 'vmess://', 'vless://', 'trojan://', 'hysteria2://', 'hy2://', 'tuic://']
|
||||||
AUTO_DEL = 60
|
AUTO_DEL = 60
|
||||||
SUB_SECRET = os.environ.get('SUB_SECRET', 'changeme')
|
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
|
WAITING_ADD = set() # user_ids waiting to add sub
|
||||||
|
|
||||||
def load_data():
|
def load_data():
|
||||||
if os.path.exists(DATA_FILE):
|
if os.path.exists(DATA_FILE):
|
||||||
with open(DATA_FILE) as f: return json.load(f)
|
with open(DATA_FILE) as f:
|
||||||
return {'subs': [], 'groups': []}
|
d = json.load(f)
|
||||||
|
d.setdefault('sub_groups', [])
|
||||||
|
return d
|
||||||
|
return {'subs': [], 'groups': [], 'sub_groups': []}
|
||||||
|
|
||||||
def save_data(data):
|
def save_data(data):
|
||||||
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
|
||||||
@@ -85,6 +89,22 @@ def add_sub(link, user):
|
|||||||
save_data(data)
|
save_data(data)
|
||||||
return f'✅ 已添加 [{proto}] {name}'
|
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 ---
|
# --- /help: main menu with buttons ---
|
||||||
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
if not await is_member(update, context):
|
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_add'),
|
||||||
InlineKeyboardButton("🗑 删除订阅", callback_data='menu_del')],
|
InlineKeyboardButton("🗑 删除订阅", callback_data='menu_del')],
|
||||||
[InlineKeyboardButton("🔍 检测存活", callback_data='menu_check')],
|
[InlineKeyboardButton("🔍 检测存活", callback_data='menu_check')],
|
||||||
|
[InlineKeyboardButton("📁 分组管理", callback_data='sg_list')],
|
||||||
]
|
]
|
||||||
if update.effective_user.id == ADMIN_ID:
|
if update.effective_user.id == ADMIN_ID:
|
||||||
buttons.append([InlineKeyboardButton("⚙️ 绑定当前群", callback_data='menu_setgroup')])
|
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
|
if not update.message or not update.message.text: return
|
||||||
text = update.message.text.strip()
|
text = update.message.text.strip()
|
||||||
uid = update.effective_user.id
|
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
|
# check standard protocol links
|
||||||
found = [w for w in text.split() if any(w.startswith(p) for p in PROTOS)]
|
found = [w for w in text.split() if any(w.startswith(p) for p in PROTOS)]
|
||||||
# check Surge format (line-based)
|
# check Surge format (line-based)
|
||||||
@@ -473,15 +545,34 @@ def gen_clash_meta(subs):
|
|||||||
class SubHandler(BaseHTTPRequestHandler):
|
class SubHandler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = self.path.split('?')[0]
|
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 = {}
|
qs = {}
|
||||||
if '?' in self.path:
|
if '?' in self.path:
|
||||||
qs = dict(x.split('=',1) for x in self.path.split('?',1)[1].split('&') if '=' in x)
|
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', '')
|
ftype = qs.get('type', '')
|
||||||
if ftype: alive = [s for s in alive if s['type'] == ftype]
|
if ftype: alive = [s for s in alive if s['type'] == ftype]
|
||||||
fname = qs.get('name', '')
|
fname = qs.get('name', '')
|
||||||
@@ -512,6 +603,165 @@ async def cmd_setgroup(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
save_data(data)
|
save_data(data)
|
||||||
await send_del(update.message, f'✅ 已绑定群 `{cid}`', parse_mode='Markdown')
|
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):
|
async def auto_cleanup(bot_token):
|
||||||
"""Every 6h: check nodes, auto-delete dead ones, notify group"""
|
"""Every 6h: check nodes, auto-delete dead ones, notify group"""
|
||||||
import httpx
|
import httpx
|
||||||
@@ -551,6 +801,7 @@ def main():
|
|||||||
app.add_handler(CallbackQueryHandler(cb_multisel, pattern='^msel_'))
|
app.add_handler(CallbackQueryHandler(cb_multisel, pattern='^msel_'))
|
||||||
app.add_handler(CallbackQueryHandler(cb_multiout, pattern='^mout_'))
|
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_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))
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, auto_detect))
|
||||||
log.info('Sub Bot starting polling...')
|
log.info('Sub Bot starting polling...')
|
||||||
threading.Thread(target=start_http, daemon=True).start()
|
threading.Thread(target=start_http, daemon=True).start()
|
||||||
|
|||||||
Reference in New Issue
Block a user