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"