diff --git a/src/config/constants.py b/src/config/constants.py index 7e9a7a38..2c296d13 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -40,6 +40,7 @@ class EmailServiceType(str, Enum): FREEMAIL = "freemail" IMAP_MAIL = "imap_mail" CLOUDMAIL = "cloudmail" + LUCKMAIL = "luckmail" # ============================================================================ @@ -149,6 +150,20 @@ class EmailServiceType(str, Enum): "password": "", "timeout": 30, "max_retries": 3, + }, + "luckmail": { + "base_url": "https://mails.luckyous.com/", + "api_key": "", + "project_code": "openai", + "email_type": "ms_graph", + "preferred_domain": "", + "inbox_mode": "purchase", + "reuse_existing_purchases": True, + "purchase_scan_pages": 5, + "purchase_scan_page_size": 100, + "timeout": 30, + "max_retries": 3, + "poll_interval": 3.0, } } diff --git a/src/core/anyauto/__init__.py b/src/core/anyauto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/core/anyauto/chatgpt_client.py b/src/core/anyauto/chatgpt_client.py new file mode 100644 index 00000000..13def6c0 --- /dev/null +++ b/src/core/anyauto/chatgpt_client.py @@ -0,0 +1,911 @@ +""" +ChatGPT 注册客户端模块 +使用 curl_cffi 模拟浏览器行为 +""" + +import random +import uuid +import time +from urllib.parse import urlparse + +try: + from curl_cffi import requests as curl_requests +except ImportError: + print("❌ 需要安装 curl_cffi: pip install curl_cffi") + import sys + sys.exit(1) + +from .sentinel_token import build_sentinel_token +from .utils import ( + FlowState, + build_browser_headers, + decode_jwt_payload, + describe_flow_state, + extract_flow_state, + generate_datadog_trace, + normalize_flow_url, + random_delay, + seed_oai_device_cookie, +) + + +# Chrome 指纹配置 +_CHROME_PROFILES = [ + { + "major": 131, "impersonate": "chrome131", + "build": 6778, "patch_range": (69, 205), + "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + }, + { + "major": 133, "impersonate": "chrome133a", + "build": 6943, "patch_range": (33, 153), + "sec_ch_ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"', + }, + { + "major": 136, "impersonate": "chrome136", + "build": 7103, "patch_range": (48, 175), + "sec_ch_ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', + }, +] + + +def _random_chrome_version(): + """随机选择一个 Chrome 版本""" + profile = random.choice(_CHROME_PROFILES) + major = profile["major"] + build = profile["build"] + patch = random.randint(*profile["patch_range"]) + full_ver = f"{major}.0.{build}.{patch}" + ua = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{full_ver} Safari/537.36" + return profile["impersonate"], major, full_ver, ua, profile["sec_ch_ua"] + + +class ChatGPTClient: + """ChatGPT 注册客户端""" + + BASE = "https://chatgpt.com" + AUTH = "https://auth.openai.com" + + def __init__(self, proxy=None, verbose=True, browser_mode="protocol"): + """ + 初始化 ChatGPT 客户端 + + Args: + proxy: 代理地址 + verbose: 是否输出详细日志 + browser_mode: protocol | headless | headed + """ + self.proxy = proxy + self.verbose = verbose + self.browser_mode = browser_mode or "protocol" + self.device_id = str(uuid.uuid4()) + self.accept_language = random.choice([ + "en-US,en;q=0.9", + "en-US,en;q=0.9,zh-CN;q=0.8", + "en,en-US;q=0.9", + "en-US,en;q=0.8", + ]) + + # 随机 Chrome 版本 + self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version() + + # 创建 session + self.session = curl_requests.Session(impersonate=self.impersonate) + + if self.proxy: + self.session.proxies = {"http": self.proxy, "https": self.proxy} + + # 设置基础 headers + self.session.headers.update({ + "User-Agent": self.ua, + "Accept-Language": self.accept_language, + "sec-ch-ua": self.sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": f'"{self.chrome_full}"', + "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', + }) + + # 设置 oai-did cookie + seed_oai_device_cookie(self.session, self.device_id) + self.last_registration_state = FlowState() + + def _log(self, msg): + """输出日志""" + if self.verbose: + print(f" {msg}") + + def _browser_pause(self, low=0.15, high=0.45): + """在 headed 模式下加入轻微停顿,模拟有头浏览器节奏。""" + if self.browser_mode == "headed": + random_delay(low, high) + + def _headers( + self, + url, + *, + accept, + referer=None, + origin=None, + content_type=None, + navigation=False, + fetch_mode=None, + fetch_dest=None, + fetch_site=None, + extra_headers=None, + ): + return build_browser_headers( + url=url, + user_agent=self.ua, + sec_ch_ua=self.sec_ch_ua, + chrome_full_version=self.chrome_full, + accept=accept, + accept_language=self.accept_language, + referer=referer, + origin=origin, + content_type=content_type, + navigation=navigation, + fetch_mode=fetch_mode, + fetch_dest=fetch_dest, + fetch_site=fetch_site, + headed=self.browser_mode == "headed", + extra_headers=extra_headers, + ) + + def _reset_session(self): + """重置浏览器指纹与会话,用于绕过偶发的 Cloudflare/SPA 中间页。""" + self.device_id = str(uuid.uuid4()) + self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version() + self.accept_language = random.choice([ + "en-US,en;q=0.9", + "en-US,en;q=0.9,zh-CN;q=0.8", + "en,en-US;q=0.9", + "en-US,en;q=0.8", + ]) + + self.session = curl_requests.Session(impersonate=self.impersonate) + if self.proxy: + self.session.proxies = {"http": self.proxy, "https": self.proxy} + + self.session.headers.update({ + "User-Agent": self.ua, + "Accept-Language": self.accept_language, + "sec-ch-ua": self.sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": f'"{self.chrome_full}"', + "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', + }) + seed_oai_device_cookie(self.session, self.device_id) + + def _state_from_url(self, url, method="GET"): + state = extract_flow_state( + current_url=normalize_flow_url(url, auth_base=self.AUTH), + auth_base=self.AUTH, + default_method=method, + ) + if method: + state.method = str(method).upper() + return state + + def _state_from_payload(self, data, current_url=""): + return extract_flow_state( + data=data, + current_url=current_url, + auth_base=self.AUTH, + ) + + def _state_signature(self, state: FlowState): + return ( + state.page_type or "", + state.method or "", + state.continue_url or "", + state.current_url or "", + ) + + def _is_registration_complete_state(self, state: FlowState): + current_url = (state.current_url or "").lower() + continue_url = (state.continue_url or "").lower() + page_type = state.page_type or "" + return ( + page_type in {"callback", "chatgpt_home", "oauth_callback"} + or ("chatgpt.com" in current_url and "redirect_uri" not in current_url) + or ("chatgpt.com" in continue_url and "redirect_uri" not in continue_url and page_type != "external_url") + ) + + def _state_is_password_registration(self, state: FlowState): + return state.page_type in {"create_account_password", "password"} + + def _state_is_email_otp(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target + + def _state_is_about_you(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "about_you" or "about-you" in target + + def _state_is_add_phone(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "add_phone" or "add-phone" in target + + def _state_requires_navigation(self, state: FlowState): + if (state.method or "GET").upper() != "GET": + return False + if state.page_type == "external_url" and state.continue_url: + return True + if state.continue_url and state.continue_url != state.current_url: + return True + return False + + def _follow_flow_state(self, state: FlowState, referer=None): + """跟随服务端返回的 continue_url,推进注册状态机。""" + target_url = state.continue_url or state.current_url + if not target_url: + return False, "缺少可跟随的 continue_url" + + try: + self._browser_pause() + r = self.session.get( + target_url, + headers=self._headers( + target_url, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=referer, + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + final_url = str(r.url) + self._log(f"follow -> {r.status_code} {final_url}") + + content_type = (r.headers.get("content-type", "") or "").lower() + if "application/json" in content_type: + try: + next_state = self._state_from_payload(r.json(), current_url=final_url) + except Exception: + next_state = self._state_from_url(final_url) + else: + next_state = self._state_from_url(final_url) + + self._log(f"follow state -> {describe_flow_state(next_state)}") + return True, next_state + except Exception as e: + self._log(f"跟随 continue_url 失败: {e}") + return False, str(e) + + def _get_cookie_value(self, name, domain_hint=None): + """读取当前会话中的 Cookie。""" + for cookie in self.session.cookies.jar: + if cookie.name != name: + continue + if domain_hint and domain_hint not in (cookie.domain or ""): + continue + return cookie.value + return "" + + def get_next_auth_session_token(self): + """获取 ChatGPT next-auth 会话 Cookie。""" + return self._get_cookie_value("__Secure-next-auth.session-token", "chatgpt.com") + + def fetch_chatgpt_session(self): + """请求 ChatGPT Session 接口并返回原始会话数据。""" + url = f"{self.BASE}/api/auth/session" + self._browser_pause() + response = self.session.get( + url, + headers=self._headers( + url, + accept="application/json", + referer=f"{self.BASE}/", + fetch_site="same-origin", + ), + timeout=30, + ) + if response.status_code != 200: + return False, f"/api/auth/session -> HTTP {response.status_code}" + + try: + data = response.json() + except Exception as exc: + return False, f"/api/auth/session 返回非 JSON: {exc}" + + access_token = str(data.get("accessToken") or "").strip() + if not access_token: + return False, "/api/auth/session 未返回 accessToken" + return True, data + + def reuse_session_and_get_tokens(self): + """ + 复用注册阶段已建立的 ChatGPT 会话,直接读取 Session / AccessToken。 + + Returns: + tuple[bool, dict|str]: 成功时返回标准化 token/session 数据;失败时返回错误信息。 + """ + state = self.last_registration_state or FlowState() + self._log("步骤 1/4: 跟随注册回调 external_url ...") + if state.page_type == "external_url" or self._state_requires_navigation(state): + ok, followed = self._follow_flow_state( + state, + referer=state.current_url or f"{self.AUTH}/about-you", + ) + if not ok: + return False, f"注册回调落地失败: {followed}" + self.last_registration_state = followed + else: + self._log("注册回调已落地,跳过额外跟随") + + self._log("步骤 2/4: 检查 __Secure-next-auth.session-token ...") + session_cookie = self.get_next_auth_session_token() + if not session_cookie: + return False, "缺少 __Secure-next-auth.session-token,注册回调可能未落地" + + self._log("步骤 3/4: 请求 ChatGPT /api/auth/session ...") + ok, session_or_error = self.fetch_chatgpt_session() + if not ok: + return False, session_or_error + + session_data = session_or_error + access_token = str(session_data.get("accessToken") or "").strip() + session_token = str(session_data.get("sessionToken") or session_cookie or "").strip() + user = session_data.get("user") or {} + account = session_data.get("account") or {} + jwt_payload = decode_jwt_payload(access_token) + auth_payload = jwt_payload.get("https://api.openai.com/auth") or {} + + account_id = ( + str(account.get("id") or "").strip() + or str(auth_payload.get("chatgpt_account_id") or "").strip() + ) + user_id = ( + str(user.get("id") or "").strip() + or str(auth_payload.get("chatgpt_user_id") or "").strip() + or str(auth_payload.get("user_id") or "").strip() + ) + + normalized = { + "access_token": access_token, + "session_token": session_token, + "account_id": account_id, + "user_id": user_id, + "workspace_id": account_id, + "expires": session_data.get("expires"), + "user": user, + "account": account, + "auth_provider": session_data.get("authProvider"), + "raw_session": session_data, + } + + self._log("步骤 4/4: 已从复用会话中提取 accessToken") + if account_id: + self._log(f"Session Account ID: {account_id}") + if user_id: + self._log(f"Session User ID: {user_id}") + return True, normalized + + def visit_homepage(self): + """访问首页,建立 session""" + self._log("访问 ChatGPT 首页...") + url = f"{self.BASE}/" + try: + self._browser_pause() + r = self.session.get( + url, + headers=self._headers( + url, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + return r.status_code == 200 + except Exception as e: + self._log(f"访问首页失败: {e}") + return False + + def get_csrf_token(self): + """获取 CSRF token""" + self._log("获取 CSRF token...") + url = f"{self.BASE}/api/auth/csrf" + try: + r = self.session.get( + url, + headers=self._headers( + url, + accept="application/json", + referer=f"{self.BASE}/", + fetch_site="same-origin", + ), + timeout=30, + ) + + if r.status_code == 200: + data = r.json() + token = data.get("csrfToken", "") + if token: + self._log(f"CSRF token: {token[:20]}...") + return token + except Exception as e: + self._log(f"获取 CSRF token 失败: {e}") + + return None + + def signin(self, email, csrf_token): + """ + 提交邮箱,获取 authorize URL + + Returns: + str: authorize URL + """ + self._log(f"提交邮箱: {email}") + url = f"{self.BASE}/api/auth/signin/openai" + + params = { + "prompt": "login", + "ext-oai-did": self.device_id, + "auth_session_logging_id": str(uuid.uuid4()), + "screen_hint": "login_or_signup", + "login_hint": email, + } + + form_data = { + "callbackUrl": f"{self.BASE}/", + "csrfToken": csrf_token, + "json": "true", + } + + try: + self._browser_pause() + r = self.session.post( + url, + params=params, + data=form_data, + headers=self._headers( + url, + accept="application/json", + referer=f"{self.BASE}/", + origin=self.BASE, + content_type="application/x-www-form-urlencoded", + fetch_site="same-origin", + ), + timeout=30 + ) + + if r.status_code == 200: + data = r.json() + authorize_url = data.get("url", "") + if authorize_url: + self._log(f"获取到 authorize URL") + return authorize_url + except Exception as e: + self._log(f"提交邮箱失败: {e}") + + return None + + def authorize(self, url, max_retries=3): + """ + 访问 authorize URL,跟随重定向(带重试机制) + 这是关键步骤,建立 auth.openai.com 的 session + + Returns: + str: 最终重定向的 URL + """ + for attempt in range(max_retries): + try: + if attempt > 0: + self._log(f"访问 authorize URL... (尝试 {attempt + 1}/{max_retries})") + time.sleep(1) # 重试前等待 + else: + self._log("访问 authorize URL...") + + self._browser_pause() + r = self.session.get( + url, + headers=self._headers( + url, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=f"{self.BASE}/", + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + + final_url = str(r.url) + self._log(f"重定向到: {final_url}") + return final_url + + except Exception as e: + error_msg = str(e) + is_tls_error = "TLS" in error_msg or "SSL" in error_msg or "curl: (35)" in error_msg + + if is_tls_error and attempt < max_retries - 1: + self._log(f"Authorize TLS 错误 (尝试 {attempt + 1}/{max_retries}): {error_msg[:100]}") + continue + else: + self._log(f"Authorize 失败: {e}") + return "" + + return "" + + def callback(self, callback_url=None, referer=None): + """完成注册回调""" + self._log("执行回调...") + url = callback_url or f"{self.AUTH}/api/accounts/authorize/callback" + ok, _ = self._follow_flow_state( + self._state_from_url(url), + referer=referer or f"{self.AUTH}/about-you", + ) + return ok + + def register_user(self, email, password): + """ + 注册用户(邮箱 + 密码) + + Returns: + tuple: (success, message) + """ + self._log(f"注册用户: {email}") + url = f"{self.AUTH}/api/accounts/user/register" + + headers = self._headers( + url, + accept="application/json", + referer=f"{self.AUTH}/create-account/password", + origin=self.AUTH, + content_type="application/json", + fetch_site="same-origin", + ) + headers.update(generate_datadog_trace()) + + payload = { + "username": email, + "password": password, + } + + try: + self._browser_pause() + r = self.session.post(url, json=payload, headers=headers, timeout=30) + + if r.status_code == 200: + data = r.json() + self._log("注册成功") + return True, "注册成功" + else: + try: + error_data = r.json() + error_msg = error_data.get("error", {}).get("message", r.text[:200]) + except: + error_msg = r.text[:200] + self._log(f"注册失败: {r.status_code} - {error_msg}") + return False, f"HTTP {r.status_code}: {error_msg}" + + except Exception as e: + self._log(f"注册异常: {e}") + return False, str(e) + + def send_email_otp(self): + """触发发送邮箱验证码""" + self._log("触发发送验证码...") + url = f"{self.AUTH}/api/accounts/email-otp/send" + + try: + self._browser_pause() + r = self.session.get( + url, + headers=self._headers( + url, + accept="application/json, text/plain, */*", + referer=f"{self.AUTH}/create-account/password", + fetch_site="same-origin", + ), + allow_redirects=True, + timeout=30, + ) + return r.status_code == 200 + except Exception as e: + self._log(f"发送验证码失败: {e}") + return False + + def verify_email_otp(self, otp_code, return_state=False): + """ + 验证邮箱 OTP 码 + + Args: + otp_code: 6位验证码 + + Returns: + tuple: (success, message) + """ + self._log(f"验证 OTP 码: {otp_code}") + url = f"{self.AUTH}/api/accounts/email-otp/validate" + + headers = self._headers( + url, + accept="application/json", + referer=f"{self.AUTH}/email-verification", + origin=self.AUTH, + content_type="application/json", + fetch_site="same-origin", + ) + headers.update(generate_datadog_trace()) + + payload = {"code": otp_code} + + try: + self._browser_pause() + r = self.session.post(url, json=payload, headers=headers, timeout=30) + + if r.status_code == 200: + try: + data = r.json() + except Exception: + data = {} + next_state = self._state_from_payload(data, current_url=str(r.url) or f"{self.AUTH}/about-you") + self._log(f"验证成功 {describe_flow_state(next_state)}") + return (True, next_state) if return_state else (True, "验证成功") + else: + try: + error_msg = r.text[:200] + except Exception: + error_msg = "" + self._log(f"验证失败: {r.status_code} - {error_msg}") + return False, f"HTTP {r.status_code}: {error_msg}".strip() + + except Exception as e: + self._log(f"验证异常: {e}") + return False, str(e) + + def create_account(self, first_name, last_name, birthdate, return_state=False): + """ + 完成账号创建(提交姓名和生日) + + Args: + first_name: 名 + last_name: 姓 + birthdate: 生日 (YYYY-MM-DD) + + Returns: + tuple: (success, message) + """ + name = f"{first_name} {last_name}" + self._log(f"完成账号创建: {name}") + url = f"{self.AUTH}/api/accounts/create_account" + + sentinel_token = build_sentinel_token( + self.session, + self.device_id, + flow="authorize_continue", + user_agent=self.ua, + sec_ch_ua=self.sec_ch_ua, + impersonate=self.impersonate, + ) + if sentinel_token: + self._log("create_account: 已生成 sentinel token") + else: + self._log("create_account: 未生成 sentinel token,降级继续请求") + + headers = self._headers( + url, + accept="application/json", + referer=f"{self.AUTH}/about-you", + origin=self.AUTH, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": self.device_id, + }, + ) + if sentinel_token: + headers["openai-sentinel-token"] = sentinel_token + headers.update(generate_datadog_trace()) + + payload = { + "name": name, + "birthdate": birthdate, + } + + try: + self._browser_pause() + r = self.session.post(url, json=payload, headers=headers, timeout=30) + + if r.status_code == 200: + try: + data = r.json() + except Exception: + data = {} + next_state = self._state_from_payload(data, current_url=str(r.url) or self.BASE) + self._log(f"账号创建成功 {describe_flow_state(next_state)}") + return (True, next_state) if return_state else (True, "账号创建成功") + else: + error_msg = r.text[:200] + self._log(f"创建失败: {r.status_code} - {error_msg}") + return False, f"HTTP {r.status_code}" + + except Exception as e: + self._log(f"创建异常: {e}") + return False, str(e) + + def register_complete_flow(self, email, password, first_name, last_name, birthdate, skymail_client): + """ + 完整的注册流程(基于原版 run_register 方法) + + Args: + email: 邮箱 + password: 密码 + first_name: 名 + last_name: 姓 + birthdate: 生日 + skymail_client: Skymail 客户端(用于获取验证码) + + Returns: + tuple: (success, message) + """ + from urllib.parse import urlparse + + max_auth_attempts = 3 + final_url = "" + final_path = "" + + for auth_attempt in range(max_auth_attempts): + if auth_attempt > 0: + self._log(f"预授权阶段重试 {auth_attempt + 1}/{max_auth_attempts}...") + self._reset_session() + + # 1. 访问首页 + if not self.visit_homepage(): + if auth_attempt < max_auth_attempts - 1: + continue + return False, "访问首页失败" + + # 2. 获取 CSRF token + csrf_token = self.get_csrf_token() + if not csrf_token: + if auth_attempt < max_auth_attempts - 1: + continue + return False, "获取 CSRF token 失败" + + # 3. 提交邮箱,获取 authorize URL + auth_url = self.signin(email, csrf_token) + if not auth_url: + if auth_attempt < max_auth_attempts - 1: + continue + return False, "提交邮箱失败" + + # 4. 访问 authorize URL(关键步骤!) + final_url = self.authorize(auth_url) + if not final_url: + if auth_attempt < max_auth_attempts - 1: + continue + return False, "Authorize 失败" + + final_path = urlparse(final_url).path + self._log(f"Authorize → {final_path}") + + # /api/accounts/authorize 实际上常对应 Cloudflare 403 中间页,不要继续走 authorize_continue。 + if "api/accounts/authorize" in final_path or final_path == "/error": + self._log(f"检测到 Cloudflare/SPA 中间页,准备重试预授权: {final_url[:160]}...") + if auth_attempt < max_auth_attempts - 1: + continue + return False, f"预授权被拦截: {final_path}" + + break + + state = self._state_from_url(final_url) + self._log(f"注册状态起点: {describe_flow_state(state)}") + + register_submitted = False + otp_verified = False + account_created = False + seen_states = {} + + for _ in range(12): + signature = self._state_signature(state) + seen_states[signature] = seen_states.get(signature, 0) + 1 + if seen_states[signature] > 2: + return False, f"注册状态卡住: {describe_flow_state(state)}" + + if self._is_registration_complete_state(state): + self.last_registration_state = state + self._log("✅ 注册流程完成") + return True, "注册成功" + + if self._state_is_password_registration(state): + self._log("全新注册流程") + if register_submitted: + return False, "注册密码阶段重复进入" + success, msg = self.register_user(email, password) + if not success: + return False, f"注册失败: {msg}" + register_submitted = True + if not self.send_email_otp(): + self._log("发送验证码接口返回失败,继续等待邮箱中的验证码...") + state = self._state_from_url(f"{self.AUTH}/email-verification") + continue + + if self._state_is_email_otp(state): + self._log("等待邮箱验证码...") + otp_code = skymail_client.wait_for_verification_code(email, timeout=90) + if not otp_code: + return False, "未收到验证码" + + tried_codes = {otp_code} + for _ in range(3): + success, next_state = self.verify_email_otp(otp_code, return_state=True) + if success: + otp_verified = True + state = next_state + self.last_registration_state = state + break + + err_text = str(next_state or "") + is_wrong_code = any( + marker in err_text.lower() + for marker in ( + "wrong_email_otp_code", + "wrong code", + "http 401", + ) + ) + if not is_wrong_code: + return False, f"验证码失败: {next_state}" + + self._log("验证码疑似过期/错误,尝试获取新验证码...") + otp_code = skymail_client.wait_for_verification_code( + email, + timeout=45, + exclude_codes=tried_codes, + ) + if not otp_code: + return False, "未收到新的验证码" + tried_codes.add(otp_code) + + if not otp_verified: + return False, "验证码失败: 多次尝试仍无效" + continue + + if self._state_is_about_you(state): + if account_created: + return False, "填写信息阶段重复进入" + success, next_state = self.create_account( + first_name, + last_name, + birthdate, + return_state=True, + ) + if not success: + return False, f"创建账号失败: {next_state}" + account_created = True + state = next_state + self.last_registration_state = state + continue + + if self._state_is_add_phone(state): + self._log("检测到 add_phone 阶段,交由后续登录补全流程处理") + self.last_registration_state = state + return True, "add_phone_required" + + if self._state_requires_navigation(state): + success, next_state = self._follow_flow_state( + state, + referer=state.current_url or f"{self.AUTH}/about-you", + ) + if not success: + return False, f"跳转失败: {next_state}" + state = next_state + self.last_registration_state = state + continue + + if (not register_submitted) and (not otp_verified) and (not account_created): + self._log(f"未知起始状态,回退为全新注册流程: {describe_flow_state(state)}") + state = self._state_from_url(f"{self.AUTH}/create-account/password") + continue + + return False, f"未支持的注册状态: {describe_flow_state(state)}" + + return False, "注册状态机超出最大步数" diff --git a/src/core/anyauto/oauth_client.py b/src/core/anyauto/oauth_client.py new file mode 100644 index 00000000..a891da9a --- /dev/null +++ b/src/core/anyauto/oauth_client.py @@ -0,0 +1,1614 @@ +""" +OAuth 客户端模块 - 处理 Codex OAuth 登录流程 +""" + +import time +import secrets +from urllib.parse import urlparse, parse_qs + +try: + from curl_cffi import requests as curl_requests +except ImportError: + import requests as curl_requests + +from .utils import ( + FlowState, + build_browser_headers, + describe_flow_state, + extract_flow_state, + generate_datadog_trace, + generate_pkce, + normalize_flow_url, + random_delay, + seed_oai_device_cookie, +) +from .sentinel_token import build_sentinel_token + + +class OAuthClient: + """OAuth 客户端 - 用于获取 Access Token 和 Refresh Token""" + + def __init__(self, config, proxy=None, verbose=True, browser_mode="protocol"): + """ + 初始化 OAuth 客户端 + + Args: + config: 配置字典 + proxy: 代理地址 + verbose: 是否输出详细日志 + browser_mode: protocol | headless | headed + """ + self.config = dict(config or {}) + self.oauth_issuer = self.config.get("oauth_issuer", "https://auth.openai.com") + self.oauth_client_id = self.config.get("oauth_client_id", "app_EMoamEEZ73f0CkXaXp7hrann") + self.oauth_redirect_uri = self.config.get("oauth_redirect_uri", "http://localhost:1455/auth/callback") + self.proxy = proxy + self.verbose = verbose + self.browser_mode = browser_mode or "protocol" + self.last_error = "" + + # 创建 session + self.session = curl_requests.Session() + if self.proxy: + self.session.proxies = {"http": self.proxy, "https": self.proxy} + + def _log(self, msg): + """输出日志""" + if self.verbose: + print(f" [OAuth] {msg}") + + def _set_error(self, message): + self.last_error = str(message or "").strip() + if self.last_error: + self._log(self.last_error) + + def _browser_pause(self, low=0.15, high=0.4): + """在 headed 模式下注入轻微延迟,模拟真实浏览器操作节奏。""" + if self.browser_mode == "headed": + random_delay(low, high) + + @staticmethod + def _iter_text_fragments(value): + if isinstance(value, str): + text = value.strip() + if text: + yield text + return + if isinstance(value, dict): + for item in value.values(): + yield from OAuthClient._iter_text_fragments(item) + return + if isinstance(value, (list, tuple, set)): + for item in value: + yield from OAuthClient._iter_text_fragments(item) + + @classmethod + def _should_blacklist_phone_failure(cls, detail="", state: FlowState | None = None): + fragments = [str(detail or "").strip()] + if state is not None: + fragments.extend( + cls._iter_text_fragments( + { + "page_type": state.page_type, + "continue_url": state.continue_url, + "current_url": state.current_url, + "payload": state.payload, + "raw": state.raw, + } + ) + ) + + combined = " | ".join(fragment for fragment in fragments if fragment).lower() + if not combined: + return False + + non_blacklist_markers = ( + "whatsapp", + "未收到短信验证码", + "手机号验证码错误", + "phone-otp/resend", + "phone-otp/validate 异常", + "phone-otp/validate 响应不是 json", + "phone-otp/validate 失败", + "timeout", + "timed out", + "network", + "connection", + "proxy", + "ssl", + "tls", + "captcha", + "too many phone", + "too many phone numbers", + "too many verification requests", + "验证请求过多", + "接受短信次数过多", + "session limit", + "rate limit", + ) + if any(marker in combined for marker in non_blacklist_markers): + return False + + blacklist_markers = ( + "phone number is invalid", + "invalid phone number", + "invalid phone", + "phone number invalid", + "sms verification failed", + "send sms verification failed", + "unable to send sms", + "not a valid mobile number", + "unsupported phone number", + "phone number not supported", + "carrier not supported", + "电话号码无效", + "手机号无效", + "发送短信验证失败", + "号码无效", + "号码不支持", + "手机号不支持", + ) + return any(marker in combined for marker in blacklist_markers) + + def _blacklist_phone_if_needed(self, phone_service, entry, detail="", state: FlowState | None = None): + if not entry or not self._should_blacklist_phone_failure(detail, state): + return False + try: + phone_service.mark_blacklisted(entry.phone) + self._log(f"已将手机号加入黑名单: {entry.phone}") + return True + except Exception as e: + self._log(f"写入手机号黑名单失败: {e}") + return False + + def _headers( + self, + url, + *, + user_agent=None, + sec_ch_ua=None, + accept, + referer=None, + origin=None, + content_type=None, + navigation=False, + fetch_mode=None, + fetch_dest=None, + fetch_site=None, + extra_headers=None, + ): + accept_language = None + try: + accept_language = self.session.headers.get("Accept-Language") + except Exception: + accept_language = None + + return build_browser_headers( + url=url, + user_agent=user_agent or "Mozilla/5.0", + sec_ch_ua=sec_ch_ua, + accept=accept, + accept_language=accept_language or "en-US,en;q=0.9", + referer=referer, + origin=origin, + content_type=content_type, + navigation=navigation, + fetch_mode=fetch_mode, + fetch_dest=fetch_dest, + fetch_site=fetch_site, + headed=self.browser_mode == "headed", + extra_headers=extra_headers, + ) + + def _state_from_url(self, url, method="GET"): + state = extract_flow_state( + current_url=normalize_flow_url(url, auth_base=self.oauth_issuer), + auth_base=self.oauth_issuer, + default_method=method, + ) + if method: + state.method = str(method).upper() + return state + + def _state_from_payload(self, data, current_url=""): + return extract_flow_state( + data=data, + current_url=current_url, + auth_base=self.oauth_issuer, + ) + + def _state_signature(self, state: FlowState): + return ( + state.page_type or "", + state.method or "", + state.continue_url or "", + state.current_url or "", + ) + + def _extract_code_from_state(self, state: FlowState): + for candidate in ( + state.continue_url, + state.current_url, + (state.payload or {}).get("url", ""), + ): + code = self._extract_code_from_url(candidate) + if code: + return code + return None + + def _state_is_login_password(self, state: FlowState): + return state.page_type == "login_password" + + def _state_is_email_otp(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target + + def _state_is_add_phone(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + return state.page_type == "add_phone" or "add-phone" in target + + def _state_requires_navigation(self, state: FlowState): + method = (state.method or "GET").upper() + if method != "GET": + return False + if ( + state.source == "api" + and state.current_url + and state.page_type not in {"login_password", "email_otp_verification"} + ): + return True + if state.page_type == "external_url" and state.continue_url: + return True + if state.continue_url and state.continue_url != state.current_url: + return True + return False + + def _state_supports_workspace_resolution(self, state: FlowState): + target = f"{state.continue_url} {state.current_url}".lower() + if state.page_type in {"consent", "workspace_selection", "organization_selection"}: + return True + if any(marker in target for marker in ("sign-in-with-chatgpt", "consent", "workspace", "organization")): + return True + session_data = self._decode_oauth_session_cookie() or {} + return bool(session_data.get("workspaces")) + + def _follow_flow_state(self, state: FlowState, referer=None, user_agent=None, impersonate=None, max_hops=16): + """跟随服务端返回的 continue_url / current_url,返回新的状态或 authorization code。""" + import re + + current_url = state.continue_url or state.current_url + last_url = current_url or "" + referer_url = referer + + if not current_url: + return None, state + + initial_code = self._extract_code_from_url(current_url) + if initial_code: + return initial_code, self._state_from_url(current_url) + + for hop in range(max_hops): + try: + headers = self._headers( + current_url, + user_agent=user_agent, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=referer_url, + navigation=True, + ) + kwargs = {"headers": headers, "allow_redirects": False, "timeout": 30} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause(0.12, 0.3) + r = self.session.get(current_url, **kwargs) + last_url = str(r.url) + self._log(f"follow[{hop + 1}] {r.status_code} {last_url[:120]}") + except Exception as e: + maybe_localhost = re.search(r'(https?://localhost[^\s\'\"]+)', str(e)) + if maybe_localhost: + location = maybe_localhost.group(1) + code = self._extract_code_from_url(location) + if code: + self._log("从 localhost 异常提取到 authorization code") + return code, self._state_from_url(location) + self._log(f"follow[{hop + 1}] 异常: {str(e)[:160]}") + return None, self._state_from_url(last_url or current_url) + + code = self._extract_code_from_url(last_url) + if code: + return code, self._state_from_url(last_url) + + if r.status_code in (301, 302, 303, 307, 308): + location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer) + if not location: + return None, self._state_from_url(last_url or current_url) + code = self._extract_code_from_url(location) + if code: + return code, self._state_from_url(location) + referer_url = last_url or referer_url + current_url = location + continue + + content_type = (r.headers.get("content-type", "") or "").lower() + if "application/json" in content_type: + try: + next_state = self._state_from_payload(r.json(), current_url=last_url or current_url) + except Exception: + next_state = self._state_from_url(last_url or current_url) + else: + next_state = self._state_from_url(last_url or current_url) + + return None, next_state + + return None, self._state_from_url(last_url or current_url) + + def _bootstrap_oauth_session(self, authorize_url, authorize_params, device_id=None, user_agent=None, sec_ch_ua=None, impersonate=None): + """启动 OAuth 会话,确保 auth 域上的 login_session 已建立。""" + if device_id: + seed_oai_device_cookie(self.session, device_id) + + has_login_session = False + authorize_final_url = "" + + try: + headers = self._headers( + authorize_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer="https://chatgpt.com/", + navigation=True, + ) + kwargs = {"params": authorize_params, "headers": headers, "allow_redirects": True, "timeout": 30} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.get(authorize_url, **kwargs) + authorize_final_url = str(r.url) + redirects = len(getattr(r, "history", []) or []) + self._log(f"/oauth/authorize -> {r.status_code}, redirects={redirects}") + + has_login_session = any( + (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session" + for cookie in self.session.cookies + ) + self._log(f"login_session: {'已获取' if has_login_session else '未获取'}") + except Exception as e: + self._log(f"/oauth/authorize 异常: {e}") + + if has_login_session: + return authorize_final_url + + self._log("未获取到 login_session,尝试 /api/oauth/oauth2/auth...") + try: + oauth2_url = f"{self.oauth_issuer}/api/oauth/oauth2/auth" + kwargs = { + "params": authorize_params, + "headers": self._headers( + oauth2_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer="https://chatgpt.com/", + navigation=True, + ), + "allow_redirects": True, + "timeout": 30, + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r2 = self.session.get(oauth2_url, **kwargs) + authorize_final_url = str(r2.url) + redirects2 = len(getattr(r2, "history", []) or []) + self._log(f"/api/oauth/oauth2/auth -> {r2.status_code}, redirects={redirects2}") + + has_login_session = any( + (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session" + for cookie in self.session.cookies + ) + self._log(f"login_session(重试): {'已获取' if has_login_session else '未获取'}") + except Exception as e: + self._log(f"/api/oauth/oauth2/auth 异常: {e}") + + return authorize_final_url + + def _submit_authorize_continue( + self, + email, + device_id, + continue_referer, + *, + user_agent=None, + sec_ch_ua=None, + impersonate=None, + authorize_url=None, + authorize_params=None, + screen_hint: str | None = None, + ): + """提交邮箱,获取 OAuth 流程的第一页状态。""" + self._log("步骤2: POST /api/accounts/authorize/continue") + + sentinel_token = build_sentinel_token( + self.session, + device_id, + flow="authorize_continue", + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not sentinel_token: + self._set_error("无法获取 sentinel token (authorize_continue)") + return None + + request_url = f"{self.oauth_issuer}/api/accounts/authorize/continue" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=continue_referer, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + "openai-sentinel-token": sentinel_token, + }, + ) + headers.update(generate_datadog_trace()) + payload = {"username": {"kind": "email", "value": email}} + if screen_hint: + payload["screen_hint"] = screen_hint + + try: + kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post(request_url, **kwargs) + self._log(f"/authorize/continue -> {r.status_code}") + + if r.status_code == 400 and "invalid_auth_step" in (r.text or "") and authorize_url and authorize_params: + self._log("invalid_auth_step,重新 bootstrap...") + authorize_final_url = self._bootstrap_oauth_session( + authorize_url, + authorize_params, + device_id=device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + continue_referer = ( + authorize_final_url + if authorize_final_url.startswith(self.oauth_issuer) + else f"{self.oauth_issuer}/log-in" + ) + headers["Referer"] = continue_referer + headers["Sec-Fetch-Site"] = "same-origin" + headers.update(generate_datadog_trace()) + kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause() + r = self.session.post(request_url, **kwargs) + self._log(f"/authorize/continue(重试) -> {r.status_code}") + + if r.status_code != 200: + self._set_error(f"提交邮箱失败: {r.status_code} - {r.text[:180]}") + return None + + data = r.json() + flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url) + self._log(describe_flow_state(flow_state)) + return flow_state + except Exception as e: + self._set_error(f"提交邮箱异常: {e}") + return None + + def _submit_password_verify(self, password, device_id, *, user_agent=None, sec_ch_ua=None, impersonate=None, referer=None): + """提交密码,获取下一步状态。""" + self._log("步骤3: POST /api/accounts/password/verify") + + sentinel_pwd = build_sentinel_token( + self.session, + device_id, + flow="password_verify", + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not sentinel_pwd: + self._set_error("无法获取 sentinel token (password_verify)") + return None + + request_url = f"{self.oauth_issuer}/api/accounts/password/verify" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=referer or f"{self.oauth_issuer}/log-in/password", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + "openai-sentinel-token": sentinel_pwd, + }, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = {"json": {"password": password}, "headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post(request_url, **kwargs) + self._log(f"/password/verify -> {r.status_code}") + + if r.status_code != 200: + self._set_error(f"密码验证失败: {r.status_code} - {r.text[:180]}") + return None + + data = r.json() + flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url) + self._log(f"verify {describe_flow_state(flow_state)}") + return flow_state + except Exception as e: + self._set_error(f"密码验证异常: {e}") + return None + + def login_and_get_tokens(self, email, password, device_id, user_agent=None, sec_ch_ua=None, impersonate=None, skymail_client=None): + """ + 完整的 OAuth 登录流程,获取 tokens + + Args: + email: 邮箱 + password: 密码 + device_id: 设备 ID + user_agent: User-Agent + sec_ch_ua: sec-ch-ua header + impersonate: curl_cffi impersonate 参数 + skymail_client: Skymail 客户端(用于获取 OTP,如果需要) + + Returns: + dict: tokens 字典,包含 access_token, refresh_token, id_token + """ + self.last_error = "" + self._log("开始 OAuth 登录流程...") + + code_verifier, code_challenge = generate_pkce() + oauth_state = secrets.token_urlsafe(32) + authorize_params = { + "response_type": "code", + "client_id": self.oauth_client_id, + "redirect_uri": self.oauth_redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": oauth_state, + } + authorize_url = f"{self.oauth_issuer}/oauth/authorize" + + seed_oai_device_cookie(self.session, device_id) + + self._log("步骤1: Bootstrap OAuth session...") + authorize_final_url = self._bootstrap_oauth_session( + authorize_url, + authorize_params, + device_id=device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not authorize_final_url: + self._set_error("Bootstrap 失败") + return None + + continue_referer = ( + authorize_final_url + if authorize_final_url.startswith(self.oauth_issuer) + else f"{self.oauth_issuer}/log-in" + ) + + state = self._submit_authorize_continue( + email, + device_id, + continue_referer, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + authorize_url=authorize_url, + authorize_params=authorize_params, + ) + if not state: + if not self.last_error: + self._set_error("提交邮箱后未进入有效的 OAuth 状态") + return None + + self._log(f"OAuth 状态起点: {describe_flow_state(state)}") + seen_states = {} + referer = continue_referer + + for step in range(20): + signature = self._state_signature(state) + seen_states[signature] = seen_states.get(signature, 0) + 1 + if seen_states[signature] > 2: + self._set_error(f"OAuth 状态卡住: {describe_flow_state(state)}") + return None + + code = self._extract_code_from_state(state) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + + if self._state_is_login_password(state): + next_state = self._submit_password_verify( + password, + device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + referer=state.current_url or state.continue_url or referer, + ) + if not next_state: + if not self.last_error: + self._set_error("密码验证后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_is_email_otp(state): + if not skymail_client: + self._set_error("当前流程需要邮箱 OTP,但缺少接码客户端") + return None + next_state = self._handle_otp_verification( + email, + device_id, + user_agent, + sec_ch_ua, + impersonate, + skymail_client, + state, + ) + if not next_state: + if not self.last_error: + self._set_error("邮箱 OTP 验证后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_is_add_phone(state): + next_state = self._handle_add_phone_verification( + device_id, + user_agent, + sec_ch_ua, + impersonate, + state, + ) + if not next_state: + if not self.last_error: + self._set_error("手机号验证后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_requires_navigation(state): + code, next_state = self._follow_flow_state( + state, + referer=referer, + user_agent=user_agent, + impersonate=impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + referer = state.current_url or referer + state = next_state + self._log(f"follow state -> {describe_flow_state(state)}") + continue + + if self._state_supports_workspace_resolution(state): + self._log("步骤6: 执行 workspace/org 选择") + code, next_state = self._oauth_submit_workspace_and_org( + state.continue_url or state.current_url or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + device_id, + user_agent, + impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + if next_state: + referer = state.current_url or referer + state = next_state + self._log(f"workspace state -> {describe_flow_state(state)}") + continue + + if not self.last_error: + self._set_error(f"workspace/org 选择失败: {describe_flow_state(state)}") + return None + + self._set_error(f"未支持的 OAuth 状态: {describe_flow_state(state)}") + return None + + self._set_error("OAuth 状态机超出最大步数") + return None + + def login_passwordless_and_get_tokens(self, email, device_id, user_agent=None, sec_ch_ua=None, impersonate=None, skymail_client=None): + """ + 使用 passwordless 邮箱 OTP 完成 OAuth 登录流程,获取 tokens。 + """ + self.last_error = "" + self._log("开始 OAuth Passwordless 登录流程...") + + if not skymail_client: + self._set_error("缺少接码客户端,无法执行 passwordless OTP") + return None + + code_verifier, code_challenge = generate_pkce() + oauth_state = secrets.token_urlsafe(32) + authorize_params = { + "response_type": "code", + "client_id": self.oauth_client_id, + "redirect_uri": self.oauth_redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": oauth_state, + "prompt": "login", + "screen_hint": "login", + "login_hint": email, + } + authorize_url = f"{self.oauth_issuer}/oauth/authorize" + + seed_oai_device_cookie(self.session, device_id) + + self._log("步骤1: Bootstrap OAuth session (passwordless)...") + authorize_final_url = self._bootstrap_oauth_session( + authorize_url, + authorize_params, + device_id=device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not authorize_final_url: + self._set_error("Bootstrap 失败") + return None + + continue_referer = ( + authorize_final_url + if authorize_final_url.startswith(self.oauth_issuer) + else f"{self.oauth_issuer}/log-in" + ) + + state = self._submit_authorize_continue( + email, + device_id, + continue_referer, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + authorize_url=authorize_url, + authorize_params=authorize_params, + screen_hint="login", + ) + if not state: + if not self.last_error: + self._set_error("提交邮箱后未进入有效的 OAuth 状态") + return None + + self._log(f"Passwordless OAuth 状态起点: {describe_flow_state(state)}") + + send_ok, send_detail = self._send_email_otp( + device_id, + user_agent, + sec_ch_ua, + impersonate, + referer=state.current_url or continue_referer, + ) + if not send_ok: + self._set_error(send_detail or "email-otp/send 失败") + return None + + otp_state = self._state_from_url(f"{self.oauth_issuer}/email-verification") + next_state = self._handle_otp_verification( + email, + device_id, + user_agent, + sec_ch_ua, + impersonate, + skymail_client, + otp_state, + ) + if not next_state: + if not self.last_error: + self._set_error("邮箱 OTP 验证后未进入下一步 OAuth 状态") + return None + + state = next_state + referer = state.current_url or continue_referer + + for step in range(20): + code = self._extract_code_from_state(state) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ Passwordless OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + + if self._state_is_add_phone(state): + self._set_error("add_phone_required") + return None + + if self._state_requires_navigation(state): + code, next_state = self._follow_flow_state( + state, + referer=referer, + user_agent=user_agent, + impersonate=impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ Passwordless OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + referer = state.current_url or referer + state = next_state + self._log(f"follow state -> {describe_flow_state(state)}") + continue + + if self._state_supports_workspace_resolution(state): + self._log("执行 workspace/org 选择") + code, next_state = self._oauth_submit_workspace_and_org( + state.continue_url or state.current_url or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + device_id, + user_agent, + impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤: POST /oauth/token") + tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate) + if tokens: + self._log("✅ Passwordless OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + if next_state: + referer = state.current_url or referer + state = next_state + self._log(f"workspace state -> {describe_flow_state(state)}") + continue + + self._set_error(f"未支持的 Passwordless OAuth 状态: {describe_flow_state(state)}") + return None + + self._set_error("Passwordless OAuth 状态机超出最大步数") + return None + + def _extract_code_from_url(self, url): + """从 URL 中提取 code""" + if not url or "code=" not in url: + return None + try: + return parse_qs(urlparse(url).query).get("code", [None])[0] + except Exception: + return None + + def _oauth_follow_for_code(self, start_url, referer, user_agent, impersonate, max_hops=16): + """跟随 URL 获取 authorization code(手动跟随重定向)""" + code, next_state = self._follow_flow_state( + self._state_from_url(start_url), + referer=referer, + user_agent=user_agent, + impersonate=impersonate, + max_hops=max_hops, + ) + return code, (next_state.current_url or next_state.continue_url or start_url) + + def _oauth_submit_workspace_and_org(self, consent_url, device_id, user_agent, impersonate, max_retries=3): + """提交 workspace 和 organization 选择(带重试)""" + session_data = None + + for attempt in range(max_retries): + session_data = self._load_workspace_session_data( + consent_url=consent_url, + user_agent=user_agent, + impersonate=impersonate, + ) + if session_data: + break + + if attempt < max_retries - 1: + self._log(f"无法获取 consent session 数据 (尝试 {attempt + 1}/{max_retries})") + time.sleep(0.3) + else: + self._set_error("无法获取 consent session 数据") + return None, None + + workspaces = session_data.get("workspaces", []) + if not workspaces: + self._set_error("session 中没有 workspace 信息") + return None, None + + workspace_id = (workspaces[0] or {}).get("id") + if not workspace_id: + self._set_error("workspace_id 为空") + return None, None + + self._log(f"选择 workspace: {workspace_id}") + + headers = self._headers( + f"{self.oauth_issuer}/api/accounts/workspace/select", + user_agent=user_agent, + accept="application/json", + referer=consent_url, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + }, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = { + "json": {"workspace_id": workspace_id}, + "headers": headers, + "allow_redirects": False, + "timeout": 30 + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post( + f"{self.oauth_issuer}/api/accounts/workspace/select", + **kwargs + ) + + self._log(f"workspace/select -> {r.status_code}") + + # 检查重定向 + if r.status_code in (301, 302, 303, 307, 308): + location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer) + if "code=" in location: + code = self._extract_code_from_url(location) + if code: + self._log("从 workspace/select 重定向获取到 code") + return code, self._state_from_url(location) + if location: + return None, self._state_from_url(location) + + # 如果返回 200,检查响应中的 orgs + if r.status_code == 200: + try: + data = r.json() + orgs = data.get("data", {}).get("orgs", []) + workspace_state = self._state_from_payload(data, current_url=str(r.url)) + continue_url = workspace_state.continue_url + + if orgs: + org_id = (orgs[0] or {}).get("id") + projects = (orgs[0] or {}).get("projects", []) + project_id = (projects[0] or {}).get("id") if projects else None + + if org_id: + self._log(f"选择 organization: {org_id}") + + org_body = {"org_id": org_id} + if project_id: + org_body["project_id"] = project_id + + org_referer = continue_url if continue_url and continue_url.startswith("http") else consent_url + headers = self._headers( + f"{self.oauth_issuer}/api/accounts/organization/select", + user_agent=user_agent, + accept="application/json", + referer=org_referer, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + }, + ) + headers.update(generate_datadog_trace()) + + kwargs = { + "json": org_body, + "headers": headers, + "allow_redirects": False, + "timeout": 30 + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r_org = self.session.post( + f"{self.oauth_issuer}/api/accounts/organization/select", + **kwargs + ) + + self._log(f"organization/select -> {r_org.status_code}") + + # 检查重定向 + if r_org.status_code in (301, 302, 303, 307, 308): + location = normalize_flow_url(r_org.headers.get("Location", ""), auth_base=self.oauth_issuer) + if "code=" in location: + code = self._extract_code_from_url(location) + if code: + self._log("从 organization/select 重定向获取到 code") + return code, self._state_from_url(location) + if location: + return None, self._state_from_url(location) + + # 检查 continue_url + if r_org.status_code == 200: + try: + org_state = self._state_from_payload(r_org.json(), current_url=str(r_org.url)) + self._log(f"organization/select -> {describe_flow_state(org_state)}") + if self._extract_code_from_state(org_state): + return self._extract_code_from_state(org_state), org_state + return None, org_state + except Exception as e: + self._set_error(f"解析 organization/select 响应异常: {e}") + + # 如果有 continue_url,跟随它 + if continue_url: + code, _ = self._oauth_follow_for_code(continue_url, consent_url, user_agent, impersonate) + if code: + return code, self._state_from_url(continue_url) + return None, workspace_state + + except Exception as e: + self._set_error(f"处理 workspace/select 响应异常: {e}") + return None, None + + except Exception as e: + self._set_error(f"workspace/select 异常: {e}") + return None, None + + return None, None + + def _load_workspace_session_data(self, consent_url, user_agent, impersonate): + """优先从 cookie 解码 session,失败时回退到 consent HTML 中提取 workspace 数据。""" + session_data = self._decode_oauth_session_cookie() + if session_data and session_data.get("workspaces"): + return session_data + + html = self._fetch_consent_page_html(consent_url, user_agent, impersonate) + if not html: + return session_data + + parsed = self._extract_session_data_from_consent_html(html) + if parsed and parsed.get("workspaces"): + self._log(f"从 consent HTML 提取到 {len(parsed.get('workspaces', []))} 个 workspace") + return parsed + + return session_data + + def _fetch_consent_page_html(self, consent_url, user_agent, impersonate): + """获取 consent 页 HTML,用于解析 React Router stream 中的 session 数据。""" + try: + headers = self._headers( + consent_url, + user_agent=user_agent, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=f"{self.oauth_issuer}/email-verification", + navigation=True, + ) + kwargs = {"headers": headers, "allow_redirects": False, "timeout": 30} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.3) + r = self.session.get(consent_url, **kwargs) + if r.status_code == 200 and "text/html" in (r.headers.get("content-type", "").lower()): + return r.text + except Exception: + pass + return "" + + def _extract_session_data_from_consent_html(self, html): + """从 consent HTML 的 React Router stream 中提取 workspace session 数据。""" + import json + import re + + if not html or "workspaces" not in html: + return None + + def _first_match(patterns, text): + for pattern in patterns: + m = re.search(pattern, text, re.S) + if m: + return m.group(1) + return "" + + def _build_from_text(text): + if not text or "workspaces" not in text: + return None + + normalized = text.replace('\\"', '"') + + session_id = _first_match( + [ + r'"session_id","([^"]+)"', + r'"session_id":"([^"]+)"', + ], + normalized, + ) + client_id = _first_match( + [ + r'"openai_client_id","([^"]+)"', + r'"openai_client_id":"([^"]+)"', + ], + normalized, + ) + + start = normalized.find('"workspaces"') + if start < 0: + start = normalized.find('workspaces') + if start < 0: + return None + + end = normalized.find('"openai_client_id"', start) + if end < 0: + end = normalized.find('openai_client_id', start) + if end < 0: + end = min(len(normalized), start + 4000) + else: + end = min(len(normalized), end + 600) + + workspace_chunk = normalized[start:end] + ids = re.findall(r'"id"(?:,|:)"([0-9a-fA-F-]{36})"', workspace_chunk) + if not ids: + return None + + kinds = re.findall(r'"kind"(?:,|:)"([^"]+)"', workspace_chunk) + workspaces = [] + seen = set() + for idx, wid in enumerate(ids): + if wid in seen: + continue + seen.add(wid) + item = {"id": wid} + if idx < len(kinds): + item["kind"] = kinds[idx] + workspaces.append(item) + + if not workspaces: + return None + + return { + "session_id": session_id, + "openai_client_id": client_id, + "workspaces": workspaces, + } + + candidates = [html] + + for quoted in re.findall( + r'streamController\.enqueue\(("(?:\\.|[^"\\])*")\)', + html, + re.S, + ): + try: + decoded = json.loads(quoted) + except Exception: + continue + if decoded: + candidates.append(decoded) + + if '\\"' in html: + candidates.append(html.replace('\\"', '"')) + + for candidate in candidates: + parsed = _build_from_text(candidate) + if parsed and parsed.get("workspaces"): + return parsed + + return None + + def _decode_oauth_session_cookie(self): + """解码 oai-client-auth-session cookie""" + try: + for cookie in self.session.cookies: + try: + name = cookie.name if hasattr(cookie, 'name') else str(cookie) + if name == "oai-client-auth-session": + value = cookie.value if hasattr(cookie, 'value') else self.session.cookies.get(name) + if value: + data = self._decode_cookie_json_value(value) + if data: + return data + except Exception: + continue + except Exception: + pass + + return None + + @staticmethod + def _decode_cookie_json_value(value): + import base64 + import json + + raw_value = str(value or "").strip() + if not raw_value: + return None + + candidates = [raw_value] + if "." in raw_value: + candidates.insert(0, raw_value.split(".", 1)[0]) + + for candidate in candidates: + candidate = candidate.strip() + if not candidate: + continue + padded = candidate + "=" * (-len(candidate) % 4) + for decoder in (base64.urlsafe_b64decode, base64.b64decode): + try: + decoded = decoder(padded).decode("utf-8") + parsed = json.loads(decoded) + except Exception: + continue + if isinstance(parsed, dict): + return parsed + + return None + + def _exchange_code_for_tokens(self, code, code_verifier, user_agent, impersonate): + """用 authorization code 换取 tokens""" + url = f"{self.oauth_issuer}/oauth/token" + + payload = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.oauth_redirect_uri, + "client_id": self.oauth_client_id, + "code_verifier": code_verifier, + } + + headers = self._headers( + url, + user_agent=user_agent, + accept="application/json", + referer=f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + origin=self.oauth_issuer, + content_type="application/x-www-form-urlencoded", + fetch_site="same-origin", + ) + + try: + kwargs = {"data": payload, "headers": headers, "timeout": 60} + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.post(url, **kwargs) + + if r.status_code == 200: + return r.json() + else: + self._set_error(f"换取 tokens 失败: {r.status_code} - {r.text[:200]}") + + except Exception as e: + self._set_error(f"换取 tokens 异常: {e}") + + return None + + def _send_email_otp(self, device_id, user_agent, sec_ch_ua, impersonate, referer=None): + request_url = f"{self.oauth_issuer}/api/accounts/email-otp/send" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=referer or f"{self.oauth_issuer}/log-in", + origin=self.oauth_issuer, + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id} if device_id else None, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = {"headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.25) + resp = self.session.get(request_url, **kwargs) + except Exception as e: + return False, f"email-otp/send 异常: {e}" + + self._log(f"/email-otp/send -> {resp.status_code}") + if resp.status_code != 200: + return False, f"email-otp/send 失败: {resp.status_code} - {resp.text[:180]}" + return True, "" + + def _send_phone_number(self, phone, device_id, user_agent, sec_ch_ua, impersonate): + request_url = f"{self.oauth_issuer}/api/accounts/add-phone/send" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=f"{self.oauth_issuer}/add-phone", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id}, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = { + "json": {"phone_number": phone}, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause(0.12, 0.25) + resp = self.session.post(request_url, **kwargs) + except Exception as e: + return False, None, f"add-phone/send 异常: {e}" + + self._log(f"/add-phone/send -> {resp.status_code}") + if resp.status_code != 200: + return False, None, f"add-phone/send 失败: {resp.status_code} - {resp.text[:180]}" + + try: + data = resp.json() + except Exception: + return False, None, "add-phone/send 响应不是 JSON" + + next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url) + self._log(f"add-phone/send {describe_flow_state(next_state)}") + return True, next_state, "" + + def _resend_phone_otp(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/resend" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/phone-verification", + origin=self.oauth_issuer, + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id}, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = {"headers": headers, "timeout": 30, "allow_redirects": False} + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.25) + resp = self.session.post(request_url, **kwargs) + except Exception as e: + return False, f"phone-otp/resend 异常: {e}" + + self._log(f"/phone-otp/resend -> {resp.status_code}") + if resp.status_code == 200: + return True, "" + return False, f"phone-otp/resend 失败: {resp.status_code} - {resp.text[:180]}" + + def _validate_phone_otp(self, code, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/validate" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/phone-verification", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={"oai-device-id": device_id}, + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = { + "json": {"code": code}, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause(0.12, 0.25) + resp = self.session.post(request_url, **kwargs) + except Exception as e: + return False, None, f"phone-otp/validate 异常: {e}" + + self._log(f"/phone-otp/validate -> {resp.status_code}") + if resp.status_code != 200: + if resp.status_code == 401: + return False, None, "手机号验证码错误" + return False, None, f"phone-otp/validate 失败: {resp.status_code} - {resp.text[:180]}" + + try: + data = resp.json() + except Exception: + return False, None, "phone-otp/validate 响应不是 JSON" + + next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url) + self._log(f"手机号 OTP 验证通过 {describe_flow_state(next_state)}") + return True, next_state, "" + + def _handle_add_phone_verification(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + """ + add_phone 阶段处理(已禁用自动手机号验证)。 + 按要求:不将手机号验证失败视为硬失败,记录状态后交由上层处理。 + """ + self._set_error("add_phone_required") + return None + + def _handle_otp_verification(self, email, device_id, user_agent, sec_ch_ua, impersonate, skymail_client, state): + """处理 OAuth 阶段的邮箱 OTP 验证,返回服务端声明的下一步状态。""" + self._log("步骤4: 检测到邮箱 OTP 验证") + + request_url = f"{self.oauth_issuer}/api/accounts/email-otp/validate" + headers_otp = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/email-verification", + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers={ + "oai-device-id": device_id, + }, + ) + headers_otp.update(generate_datadog_trace()) + + if not hasattr(skymail_client, "_used_codes"): + skymail_client._used_codes = set() + + tried_codes = set(getattr(skymail_client, "_used_codes", set())) + otp_deadline = time.time() + 60 + otp_sent_at = time.time() + + def validate_otp(code): + tried_codes.add(code) + self._log(f"尝试 OTP: {code}") + + try: + kwargs = { + "json": {"code": code}, + "headers": headers_otp, + "timeout": 30, + "allow_redirects": False, + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause(0.12, 0.25) + resp_otp = self.session.post(request_url, **kwargs) + except Exception as e: + self._log(f"email-otp/validate 异常: {e}") + return None + + self._log(f"/email-otp/validate -> {resp_otp.status_code}") + if resp_otp.status_code != 200: + self._log(f"OTP 无效: {resp_otp.text[:160]}") + return None + + try: + otp_data = resp_otp.json() + except Exception: + self._log("email-otp/validate 响应不是 JSON") + return None + + next_state = self._state_from_payload( + otp_data, + current_url=str(resp_otp.url) or (state.current_url or state.continue_url or request_url), + ) + self._log(f"OTP 验证通过 {describe_flow_state(next_state)}") + skymail_client._used_codes.add(code) + return next_state + + if hasattr(skymail_client, "wait_for_verification_code"): + self._log("使用 wait_for_verification_code 进行阻塞式获取新验证码...") + while time.time() < otp_deadline: + remaining = max(1, int(otp_deadline - time.time())) + wait_time = min(10, remaining) + try: + code = skymail_client.wait_for_verification_code( + email, + timeout=wait_time, + otp_sent_at=otp_sent_at, + exclude_codes=tried_codes, + ) + except Exception as e: + self._log(f"等待 OTP 异常: {e}") + code = None + + if not code: + self._log("暂未收到新的 OTP,继续等待...") + if self.last_error: + break + continue + + if code in tried_codes: + self._log(f"跳过已尝试验证码: {code}") + continue + + next_state = validate_otp(code) + if next_state: + return next_state + if self.last_error: + break + else: + while time.time() < otp_deadline: + messages = skymail_client.fetch_emails(email) or [] + candidate_codes = [] + + for msg in messages[:12]: + content = msg.get("content") or msg.get("text") or "" + code = skymail_client.extract_verification_code(content) + if code and code not in tried_codes: + candidate_codes.append(code) + + if not candidate_codes: + elapsed = int(60 - max(0, otp_deadline - time.time())) + self._log(f"等待新的 OTP... ({elapsed}s/60s)") + time.sleep(2) + continue + + for otp_code in candidate_codes: + next_state = validate_otp(otp_code) + if next_state: + return next_state + + time.sleep(2) + if self.last_error: + break + + if not self.last_error: + self._set_error(f"OAuth 阶段 OTP 验证失败,已尝试 {len(tried_codes)} 个验证码") + return None diff --git a/src/core/anyauto/register_flow.py b/src/core/anyauto/register_flow.py new file mode 100644 index 00000000..bf2171ae --- /dev/null +++ b/src/core/anyauto/register_flow.py @@ -0,0 +1,396 @@ +""" +Any-auto-register 风格注册流程(V2)。 +以状态机 + Session 复用为主,必要时回退 OAuth。 +""" + +from __future__ import annotations + +import secrets +import time +from datetime import datetime +from typing import Optional, Callable, Dict, Any + +from .chatgpt_client import ChatGPTClient +from .oauth_client import OAuthClient +from .utils import generate_random_name, generate_random_birthday, decode_jwt_payload +from ...config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH +from ...config.settings import get_settings + + +class EmailServiceAdapter: + """将 codex-console 邮箱服务适配成 any-auto-register 预期接口。""" + + def __init__(self, email_service, email: str, email_id: Optional[str], log_fn: Callable[[str], None]): + self.es = email_service + self.email = email + self.email_id = email_id + self.log_fn = log_fn or (lambda _msg: None) + self._used_codes: set[str] = set() + + def wait_for_verification_code(self, email, timeout=60, otp_sent_at=None, exclude_codes=None): + exclude = set(exclude_codes or []) + exclude.update(self._used_codes) + deadline = time.time() + max(1, int(timeout)) + sent_at = otp_sent_at or time.time() + + while time.time() < deadline: + remaining = max(1, int(deadline - time.time())) + code = self.es.get_verification_code( + email=email, + email_id=self.email_id, + timeout=remaining, + otp_sent_at=sent_at, + ) + if not code: + return None + if code in exclude: + exclude.add(code) + continue + self._used_codes.add(code) + self.log_fn(f"成功获取验证码: {code}") + return code + return None + + +class AnyAutoRegistrationEngine: + def __init__( + self, + email_service, + proxy_url: Optional[str] = None, + callback_logger: Optional[Callable[[str], None]] = None, + max_retries: int = 3, + browser_mode: str = "protocol", + extra_config: Optional[Dict[str, Any]] = None, + ): + self.email_service = email_service + self.proxy_url = proxy_url + self.callback_logger = callback_logger or (lambda _msg: None) + self.max_retries = max(1, int(max_retries or 1)) + self.browser_mode = browser_mode or "protocol" + self.extra_config = dict(extra_config or {}) + + self.email: Optional[str] = None + self.inbox_email: Optional[str] = None + self.email_info: Optional[Dict[str, Any]] = None + self.password: Optional[str] = None + self.session = None + self.device_id: Optional[str] = None + + def _log(self, message: str): + if self.callback_logger: + self.callback_logger(message) + + @staticmethod + def _build_password(length: int) -> str: + length = max(8, int(length or DEFAULT_PASSWORD_LENGTH)) + return "".join(secrets.choice(PASSWORD_CHARSET) for _ in range(length)) + + @staticmethod + def _should_retry(message: str) -> bool: + text = str(message or "").lower() + retriable_markers = [ + "tls", + "ssl", + "curl: (35)", + "预授权被拦截", + "authorize", + "registration_disallowed", + "http 400", + "创建账号失败", + "未获取到 authorization code", + "consent", + "workspace", + "organization", + "otp", + "验证码", + "session", + "accesstoken", + "next-auth", + ] + return any(marker.lower() in text for marker in retriable_markers) + + @staticmethod + def _extract_account_id_from_token(token: str) -> str: + payload = decode_jwt_payload(token) + if not isinstance(payload, dict): + return "" + auth_claims = payload.get("https://api.openai.com/auth") or {} + for key in ("chatgpt_account_id", "account_id", "workspace_id"): + value = str(auth_claims.get(key) or payload.get(key) or "").strip() + if value: + return value + return "" + + @staticmethod + def _is_phone_required_error(message: str) -> bool: + text = str(message or "").lower() + return any( + marker in text + for marker in ( + "add_phone", + "add-phone", + "phone", + "phone required", + "phone verification", + "手机号", + ) + ) + + def _passwordless_oauth_reauth( + self, + chatgpt_client: ChatGPTClient, + email: str, + skymail_adapter: EmailServiceAdapter, + oauth_config: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + self._log("检测到 add_phone,尝试 passwordless OTP 登录补全 workspace...") + oauth_client = OAuthClient( + config=oauth_config, + proxy=self.proxy_url, + verbose=False, + browser_mode=self.browser_mode, + ) + oauth_client._log = self._log + + tokens = oauth_client.login_passwordless_and_get_tokens( + email, + chatgpt_client.device_id, + chatgpt_client.ua, + chatgpt_client.sec_ch_ua, + chatgpt_client.impersonate, + skymail_adapter, + ) + if tokens and tokens.get("access_token"): + return { + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "id_token": tokens.get("id_token", ""), + "session": oauth_client.session, + } + + if oauth_client.last_error: + self._log(f"Passwordless OAuth 失败: {oauth_client.last_error}") + return None + + def run(self): + """ + 执行 any-auto-register 风格注册流程。 + 返回 dict:包含 result(RegistrationResult 填充所需字段) + 额外上下文。 + """ + last_error = "" + settings = get_settings() + password_len = int(getattr(settings, "registration_default_password_length", DEFAULT_PASSWORD_LENGTH) or DEFAULT_PASSWORD_LENGTH) + + oauth_config = dict(self.extra_config or {}) + if not oauth_config: + oauth_config = { + "oauth_issuer": str(getattr(settings, "openai_auth_url", "") or "https://auth.openai.com"), + "oauth_client_id": str(getattr(settings, "openai_client_id", "") or "app_EMoamEEZ73f0CkXaXp7hrann"), + "oauth_redirect_uri": str(getattr(settings, "openai_redirect_uri", "") or "http://localhost:1455/auth/callback"), + } + + for attempt in range(self.max_retries): + try: + if attempt == 0: + self._log("=" * 60) + self._log("开始注册流程 V2 (Session 复用直取 AccessToken)") + self._log(f"请求模式: {self.browser_mode}") + self._log("=" * 60) + else: + self._log(f"整流程重试 {attempt + 1}/{self.max_retries} ...") + time.sleep(1) + + # 1. 创建邮箱 + self.email_info = self.email_service.create_email() + raw_email = str((self.email_info or {}).get("email") or "").strip() + if not raw_email: + last_error = "创建邮箱失败" + return {"success": False, "error_message": last_error} + + normalized_email = raw_email.lower() + self.inbox_email = raw_email + self.email = normalized_email + try: + self.email_info["email"] = normalized_email + except Exception: + pass + + if raw_email != normalized_email: + self._log(f"邮箱规范化: {raw_email} -> {normalized_email}") + + # 2. 生成密码 & 用户信息 + self.password = self.password or self._build_password(password_len) + first_name, last_name = generate_random_name() + birthdate = generate_random_birthday() + self._log(f"邮箱: {normalized_email}, 密码: {self.password}") + self._log(f"注册信息: {first_name} {last_name}, 生日: {birthdate}") + + # 3. 邮箱适配器 + email_id = (self.email_info or {}).get("service_id") + skymail_adapter = EmailServiceAdapter(self.email_service, normalized_email, email_id, self._log) + + # 4. 注册状态机 + chatgpt_client = ChatGPTClient( + proxy=self.proxy_url, + verbose=False, + browser_mode=self.browser_mode, + ) + chatgpt_client._log = self._log + + self._log("步骤 1/2: 执行注册状态机...") + success, msg = chatgpt_client.register_complete_flow( + normalized_email, self.password, first_name, last_name, birthdate, skymail_adapter + ) + if not success: + last_error = f"注册流失败: {msg}" + if attempt < self.max_retries - 1 and self._should_retry(msg): + self._log(f"注册流失败,准备整流程重试: {msg}") + continue + return {"success": False, "error_message": last_error} + + add_phone_required = "add_phone" in str(msg or "").lower() + try: + state = getattr(chatgpt_client, "last_registration_state", None) + if state: + target = f"{getattr(state, 'continue_url', '')} {getattr(state, 'current_url', '')}".lower() + if "add-phone" in target or "add_phone" in str(getattr(state, "page_type", "")).lower(): + add_phone_required = True + except Exception: + pass + + # 保存会话与设备 + self.session = chatgpt_client.session + self.device_id = chatgpt_client.device_id + + if add_phone_required: + pwdless = self._passwordless_oauth_reauth( + chatgpt_client, + normalized_email, + skymail_adapter, + oauth_config, + ) + if pwdless and pwdless.get("access_token"): + self.session = pwdless.get("session") or self.session + return { + "success": True, + "access_token": pwdless.get("access_token", ""), + "refresh_token": pwdless.get("refresh_token", ""), + "id_token": pwdless.get("id_token", ""), + } + + # 5. 复用 session 取 token + self._log("步骤 2/2: 优先复用注册会话提取 ChatGPT Session / AccessToken...") + session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens() + if session_ok: + self._log("Token 提取完成!") + account_id = str(session_result.get("account_id", "") or "").strip() + if not account_id: + account_id = str(session_result.get("workspace_id", "") or "").strip() + if not account_id: + account_id = self._extract_account_id_from_token(session_result.get("access_token", "")) + workspace_id = str(session_result.get("workspace_id", "") or "").strip() or account_id + return { + "success": True, + "access_token": session_result.get("access_token", ""), + "session_token": session_result.get("session_token", ""), + "account_id": account_id, + "workspace_id": workspace_id, + "metadata": { + "auth_provider": session_result.get("auth_provider", ""), + "expires": session_result.get("expires", ""), + "user_id": session_result.get("user_id", ""), + "user": session_result.get("user") or {}, + "account": session_result.get("account") or {}, + }, + } + + # 6. OAuth 回退 + self._log(f"复用会话失败,回退到 OAuth 登录补全流程: {session_result}") + tokens = None + oauth_client = None + for oauth_attempt in range(2): + if oauth_attempt > 0: + self._log(f"同账号 OAuth 重试 {oauth_attempt + 1}/2 ...") + time.sleep(1) + + oauth_client = OAuthClient( + config=oauth_config, + proxy=self.proxy_url, + verbose=False, + browser_mode=self.browser_mode, + ) + oauth_client._log = self._log + oauth_client.session = chatgpt_client.session + + tokens = oauth_client.login_and_get_tokens( + normalized_email, + self.password, + chatgpt_client.device_id, + chatgpt_client.ua, + chatgpt_client.sec_ch_ua, + chatgpt_client.impersonate, + skymail_adapter, + ) + if tokens and tokens.get("access_token"): + break + + if oauth_client.last_error and "add_phone" in oauth_client.last_error: + break + + if tokens and tokens.get("access_token"): + self._log("OAuth 回退补全成功!") + workspace_id = "" + session_cookie = "" + try: + session_data = oauth_client._decode_oauth_session_cookie() + if session_data: + workspaces = session_data.get("workspaces", []) + if workspaces: + workspace_id = str((workspaces[0] or {}).get("id") or "") + if workspace_id: + self._log(f"成功萃取 Workspace ID: {workspace_id}") + except Exception: + pass + + try: + for cookie in oauth_client.session.cookies.jar: + if cookie.name == "__Secure-next-auth.session-token": + session_cookie = cookie.value + break + except Exception: + pass + + account_id = self._extract_account_id_from_token(tokens.get("access_token", "")) or workspace_id + return { + "success": True, + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "id_token": tokens.get("id_token", ""), + "account_id": account_id or ("v2_acct_" + chatgpt_client.device_id[:8]), + "workspace_id": workspace_id or account_id, + "session_token": session_cookie, + } + + # 7. 手机号验证需求:按成功返回,但标记为待补全 + if oauth_client and self._is_phone_required_error(oauth_client.last_error): + self._log("检测到手机号验证需求,按成功返回并标记待补全") + return { + "success": True, + "metadata": { + "phone_verification_required": True, + "token_pending": True, + "oauth_error": oauth_client.last_error, + }, + } + + last_error = str(getattr(oauth_client, "last_error", "") or "").strip() or "获取最终 OAuth Tokens 失败" + return {"success": False, "error_message": f"账号已创建成功,但 {last_error}"} + + except Exception as attempt_error: + last_error = str(attempt_error) + if attempt < self.max_retries - 1 and self._should_retry(last_error): + self._log(f"本轮出现异常,准备整流程重试: {last_error}") + continue + return {"success": False, "error_message": last_error} + + return {"success": False, "error_message": last_error or "注册失败"} diff --git a/src/core/anyauto/sentinel_token.py b/src/core/anyauto/sentinel_token.py new file mode 100644 index 00000000..bf4bd1c9 --- /dev/null +++ b/src/core/anyauto/sentinel_token.py @@ -0,0 +1,206 @@ +""" +Sentinel Token 生成器模块 +基于对 sentinel.openai.com SDK 的逆向分析 +""" + +import json +import time +import uuid +import random +import base64 +import hashlib + + +class SentinelTokenGenerator: + """ + Sentinel Token 纯 Python 生成器 + + 通过逆向 sentinel SDK 的 PoW 算法,纯 Python 构造合法的 openai-sentinel-token。 + """ + + MAX_ATTEMPTS = 500000 # 最大 PoW 尝试次数 + ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" # SDK 中的错误前缀常量 + + def __init__(self, device_id=None, user_agent=None): + self.device_id = device_id or str(uuid.uuid4()) + self.user_agent = user_agent or ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" + ) + self.requirements_seed = str(random.random()) + self.sid = str(uuid.uuid4()) + + @staticmethod + def _fnv1a_32(text): + """ + FNV-1a 32位哈希算法(从 SDK JS 逆向还原) + """ + h = 2166136261 # FNV offset basis + for ch in text: + code = ord(ch) + h ^= code + h = (h * 16777619) & 0xFFFFFFFF + + # xorshift 混合(murmurhash3 finalizer) + h ^= h >> 16 + h = (h * 2246822507) & 0xFFFFFFFF + h ^= h >> 13 + h = (h * 3266489909) & 0xFFFFFFFF + h ^= h >> 16 + h = h & 0xFFFFFFFF + + return format(h, "08x") + + def _get_config(self): + """构造浏览器环境数据数组""" + from datetime import datetime, timezone + + screen_info = "1920x1080" + now = datetime.now(timezone.utc) + date_str = now.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)") + js_heap_limit = 4294705152 + nav_random1 = random.random() + ua = self.user_agent + script_src = "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js" + script_version = None + data_build = None + language = "en-US" + languages = "en-US,en" + nav_random2 = random.random() + + nav_props = [ + "vendorSub", "productSub", "vendor", "maxTouchPoints", + "scheduling", "userActivation", "doNotTrack", "geolocation", + "connection", "plugins", "mimeTypes", "pdfViewerEnabled", + "webkitTemporaryStorage", "webkitPersistentStorage", + "hardwareConcurrency", "cookieEnabled", "credentials", + "mediaDevices", "permissions", "locks", "ink", + ] + nav_prop = random.choice(nav_props) + nav_val = f"{nav_prop}−undefined" + + doc_key = random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]) + win_key = random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]) + perf_now = random.uniform(1000, 50000) + hardware_concurrency = random.choice([4, 8, 12, 16]) + time_origin = time.time() * 1000 - perf_now + + config = [ + screen_info, date_str, js_heap_limit, nav_random1, ua, + script_src, script_version, data_build, language, languages, + nav_random2, nav_val, doc_key, win_key, perf_now, + self.sid, "", hardware_concurrency, time_origin, + ] + return config + + @staticmethod + def _base64_encode(data): + """模拟 SDK 的 E() 函数:JSON.stringify → TextEncoder.encode → btoa""" + json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + encoded = json_str.encode("utf-8") + return base64.b64encode(encoded).decode("ascii") + + def _run_check(self, start_time, seed, difficulty, config, nonce): + """单次 PoW 检查""" + config[3] = nonce + config[9] = round((time.time() - start_time) * 1000) + data = self._base64_encode(config) + hash_input = seed + data + hash_hex = self._fnv1a_32(hash_input) + diff_len = len(difficulty) + if hash_hex[:diff_len] <= difficulty: + return data + "~S" + return None + + def generate_token(self, seed=None, difficulty=None): + """生成 sentinel token(完整 PoW 流程)""" + if seed is None: + seed = self.requirements_seed + difficulty = difficulty or "0" + + start_time = time.time() + config = self._get_config() + + for i in range(self.MAX_ATTEMPTS): + result = self._run_check(start_time, seed, difficulty, config, i) + if result: + return "gAAAAAB" + result + + return "gAAAAAB" + self.ERROR_PREFIX + self._base64_encode(str(None)) + + def generate_requirements_token(self): + """生成 requirements token(不需要服务端参数)""" + config = self._get_config() + config[3] = 1 + config[9] = round(random.uniform(5, 50)) + data = self._base64_encode(config) + return "gAAAAAC" + data + + +def fetch_sentinel_challenge(session, device_id, flow="authorize_continue", user_agent=None, sec_ch_ua=None, impersonate=None): + """调用 sentinel 后端 API 获取 challenge 数据""" + generator = SentinelTokenGenerator(device_id=device_id, user_agent=user_agent) + req_body = { + "p": generator.generate_requirements_token(), + "id": device_id, + "flow": flow, + } + + headers = { + "Content-Type": "text/plain;charset=UTF-8", + "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html", + "Origin": "https://sentinel.openai.com", + "User-Agent": user_agent or "Mozilla/5.0", + "sec-ch-ua": sec_ch_ua or '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + } + + kwargs = { + "data": json.dumps(req_body), + "headers": headers, + "timeout": 20, + } + if impersonate: + kwargs["impersonate"] = impersonate + + try: + resp = session.post("https://sentinel.openai.com/backend-api/sentinel/req", **kwargs) + if resp.status_code == 200: + return resp.json() + except Exception: + pass + + return None + + +def build_sentinel_token(session, device_id, flow="authorize_continue", user_agent=None, sec_ch_ua=None, impersonate=None): + """构建完整的 openai-sentinel-token JSON 字符串""" + challenge = fetch_sentinel_challenge(session, device_id, flow=flow, user_agent=user_agent, sec_ch_ua=sec_ch_ua, impersonate=impersonate) + + if not challenge: + return None + + c_value = challenge.get("token", "") + if not c_value: + return None + + pow_data = challenge.get("proofofwork") or {} + generator = SentinelTokenGenerator(device_id=device_id, user_agent=user_agent) + + if pow_data.get("required") and pow_data.get("seed"): + p_value = generator.generate_token( + seed=pow_data.get("seed"), + difficulty=pow_data.get("difficulty", "0"), + ) + else: + p_value = generator.generate_requirements_token() + + return json.dumps({ + "p": p_value, + "t": "", + "c": c_value, + "id": device_id, + "flow": flow, + }, separators=(",", ":")) diff --git a/src/core/anyauto/utils.py b/src/core/anyauto/utils.py new file mode 100644 index 00000000..7d2a4a39 --- /dev/null +++ b/src/core/anyauto/utils.py @@ -0,0 +1,362 @@ +""" +通用工具函数模块 +""" + +from dataclasses import dataclass, field +import random +import string +import secrets +import hashlib +import base64 +import uuid +import re +from urllib.parse import urlparse +from typing import Any, Dict + + +@dataclass +class FlowState: + """OpenAI Auth/Registration 流程中的页面状态。""" + + page_type: str = "" + continue_url: str = "" + method: str = "GET" + current_url: str = "" + source: str = "" + payload: Dict[str, Any] = field(default_factory=dict) + raw: Dict[str, Any] = field(default_factory=dict) + + +def generate_device_id(): + """生成设备唯一标识(oai-did),UUID v4 格式""" + return str(uuid.uuid4()) + + +def generate_random_password(length=16): + """生成符合 OpenAI 要求的随机密码""" + chars = string.ascii_letters + string.digits + "!@#$%" + pwd = list( + random.choice(string.ascii_uppercase) + + random.choice(string.ascii_lowercase) + + random.choice(string.digits) + + random.choice("!@#$%") + + "".join(random.choice(chars) for _ in range(length - 4)) + ) + random.shuffle(pwd) + return "".join(pwd) + + +def generate_random_name(): + """随机生成自然的英文姓名,返回 (first_name, last_name)""" + first = [ + "James", "Robert", "John", "Michael", "David", "William", "Richard", + "Mary", "Jennifer", "Linda", "Elizabeth", "Susan", "Jessica", "Sarah", + "Emily", "Emma", "Olivia", "Sophia", "Liam", "Noah", "Oliver", "Ethan", + ] + last = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", + "Davis", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Martin", + ] + return random.choice(first), random.choice(last) + + +def generate_random_birthday(): + """生成随机生日字符串,格式 YYYY-MM-DD(20~30岁)""" + year = random.randint(1996, 2006) + month = random.randint(1, 12) + day = random.randint(1, 28) + return f"{year:04d}-{month:02d}-{day:02d}" + + +def generate_datadog_trace(): + """生成 Datadog APM 追踪头""" + trace_id = str(random.getrandbits(64)) + parent_id = str(random.getrandbits(64)) + trace_hex = format(int(trace_id), "016x") + parent_hex = format(int(parent_id), "016x") + return { + "traceparent": f"00-0000000000000000{trace_hex}-{parent_hex}-01", + "tracestate": "dd=s:1;o:rum", + "x-datadog-origin": "rum", + "x-datadog-parent-id": parent_id, + "x-datadog-sampling-priority": "1", + "x-datadog-trace-id": trace_id, + } + + +def generate_pkce(): + """生成 PKCE code_verifier 和 code_challenge""" + code_verifier = ( + base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii") + ) + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return code_verifier, code_challenge + + +def decode_jwt_payload(token): + """解析 JWT token 的 payload 部分""" + try: + parts = token.split(".") + if len(parts) != 3: + return {} + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + decoded = base64.urlsafe_b64decode(payload) + import json + return json.loads(decoded) + except Exception: + return {} + + +def extract_code_from_url(url): + """从 URL 中提取 authorization code""" + if not url or "code=" not in url: + return None + try: + from urllib.parse import urlparse, parse_qs + return parse_qs(urlparse(url).query).get("code", [None])[0] + except Exception: + return None + + +def normalize_page_type(value): + """将 page.type 归一化为便于分支判断的 snake_case。""" + return str(value or "").strip().lower().replace("-", "_").replace("/", "_").replace(" ", "_") + + +def normalize_flow_url(url, auth_base="https://auth.openai.com"): + """将 continue_url / payload.url 归一化成绝对 URL。""" + value = str(url or "").strip() + if not value: + return "" + if value.startswith("//"): + return f"https:{value}" + if value.startswith("/"): + return f"{auth_base.rstrip('/')}{value}" + return value + + +def infer_page_type_from_url(url): + """从 URL 推断流程状态,用于服务端未返回 page.type 时兜底。""" + if not url: + return "" + + try: + parsed = urlparse(url) + except Exception: + return "" + + host = (parsed.netloc or "").lower() + path = (parsed.path or "").lower() + + if "code=" in (parsed.query or ""): + return "oauth_callback" + if "chatgpt.com" in host and "/api/auth/callback/" in path: + return "callback" + if "create-account/password" in path: + return "create_account_password" + if "email-verification" in path or "email-otp" in path: + return "email_otp_verification" + if "about-you" in path: + return "about_you" + if "log-in/password" in path: + return "login_password" + if "sign-in-with-chatgpt" in path and "consent" in path: + return "consent" + if "workspace" in path and "select" in path: + return "workspace_selection" + if "organization" in path and "select" in path: + return "organization_selection" + if "add-phone" in path: + return "add_phone" + if "callback" in path: + return "callback" + if "chatgpt.com" in host and path in {"", "/"}: + return "chatgpt_home" + if path: + return normalize_page_type(path.strip("/").replace("/", "_")) + return "" + + +def extract_flow_state(data=None, current_url="", auth_base="https://auth.openai.com", default_method="GET"): + """从 API 响应或 URL 中提取统一的流程状态。""" + raw = data if isinstance(data, dict) else {} + page = raw.get("page") or {} + payload = page.get("payload") or {} + + continue_url = normalize_flow_url( + raw.get("continue_url") or payload.get("url") or "", + auth_base=auth_base, + ) + effective_current_url = continue_url if raw and continue_url else current_url + current = normalize_flow_url(effective_current_url or continue_url, auth_base=auth_base) + page_type = normalize_page_type(page.get("type")) or infer_page_type_from_url(continue_url or current) + method = str(raw.get("method") or payload.get("method") or default_method or "GET").upper() + + return FlowState( + page_type=page_type, + continue_url=continue_url, + method=method, + current_url=current, + source="api" if raw else "url", + payload=payload if isinstance(payload, dict) else {}, + raw=raw, + ) + + +def describe_flow_state(state: FlowState): + """生成简短的流程状态描述,便于记录日志。""" + target = state.continue_url or state.current_url or "-" + return f"page={state.page_type or '-'} method={state.method or '-'} next={target[:80]}..." + + +def random_delay(low=0.3, high=1.0): + """随机延迟""" + import time + time.sleep(random.uniform(low, high)) + + +def extract_chrome_full_version(user_agent): + """从 UA 中提取完整的 Chrome 版本号。""" + if not user_agent: + return "" + match = re.search(r"Chrome/([0-9.]+)", user_agent) + return match.group(1) if match else "" + + +def _registrable_domain(hostname): + """粗略提取可注册域名,用于推断 Sec-Fetch-Site。""" + if not hostname: + return "" + host = hostname.split(":")[0].strip(".").lower() + parts = [part for part in host.split(".") if part] + if len(parts) <= 2: + return ".".join(parts) + return ".".join(parts[-2:]) + + +def infer_sec_fetch_site(url, referer=None, navigation=False): + """根据目标 URL 和 Referer 推断 Sec-Fetch-Site。""" + if not referer: + return "none" if navigation else "same-origin" + + try: + target = urlparse(url or "") + source = urlparse(referer or "") + + if not target.scheme or not target.netloc or not source.netloc: + return "none" if navigation else "same-origin" + + if (target.scheme, target.netloc) == (source.scheme, source.netloc): + return "same-origin" + + if _registrable_domain(target.hostname) == _registrable_domain(source.hostname): + return "same-site" + except Exception: + pass + + return "cross-site" + + +def build_sec_ch_ua_full_version_list(sec_ch_ua, chrome_full_version): + """根据 sec-ch-ua 生成 sec-ch-ua-full-version-list。""" + if not sec_ch_ua or not chrome_full_version: + return "" + + entries = [] + for brand, version in re.findall(r'"([^"]+)";v="([^"]+)"', sec_ch_ua): + full_version = chrome_full_version if brand in {"Chromium", "Google Chrome"} else f"{version}.0.0.0" + entries.append(f'"{brand}";v="{full_version}"') + + return ", ".join(entries) + + +def build_browser_headers( + *, + url, + user_agent, + sec_ch_ua=None, + chrome_full_version=None, + accept=None, + accept_language="en-US,en;q=0.9", + referer=None, + origin=None, + content_type=None, + navigation=False, + fetch_mode=None, + fetch_dest=None, + fetch_site=None, + headed=False, + extra_headers=None, +): + """构造更接近真实 Chrome 有头浏览器的请求头。""" + chrome_full = chrome_full_version or extract_chrome_full_version(user_agent) + full_version_list = build_sec_ch_ua_full_version_list(sec_ch_ua, chrome_full) + + headers = { + "User-Agent": user_agent or "Mozilla/5.0", + "Accept-Language": accept_language, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + } + + if accept: + headers["Accept"] = accept + if referer: + headers["Referer"] = referer + if origin: + headers["Origin"] = origin + if content_type: + headers["Content-Type"] = content_type + if sec_ch_ua: + headers["sec-ch-ua"] = sec_ch_ua + if chrome_full: + headers["sec-ch-ua-full-version"] = f'"{chrome_full}"' + headers["sec-ch-ua-platform-version"] = '"15.0.0"' + if full_version_list: + headers["sec-ch-ua-full-version-list"] = full_version_list + + if navigation: + headers["Sec-Fetch-Dest"] = "document" + headers["Sec-Fetch-Mode"] = "navigate" + headers["Sec-Fetch-User"] = "?1" + headers["Upgrade-Insecure-Requests"] = "1" + headers["Cache-Control"] = "max-age=0" + else: + headers["Sec-Fetch-Dest"] = fetch_dest or "empty" + headers["Sec-Fetch-Mode"] = fetch_mode or "cors" + + headers["Sec-Fetch-Site"] = fetch_site or infer_sec_fetch_site(url, referer, navigation=navigation) + + if headed: + headers.setdefault("Priority", "u=0, i" if navigation else "u=1, i") + headers.setdefault("DNT", "1") + headers.setdefault("Sec-GPC", "1") + + if extra_headers: + for key, value in extra_headers.items(): + if value is not None: + headers[key] = value + + return headers + + +def seed_oai_device_cookie(session, device_id): + """在 ChatGPT / OpenAI 相关域上同步设置 oai-did。""" + for domain in ( + "chatgpt.com", + ".chatgpt.com", + "openai.com", + ".openai.com", + "auth.openai.com", + ".auth.openai.com", + ): + try: + session.cookies.set("oai-did", device_id, domain=domain) + except Exception: + continue diff --git a/src/core/openai/overview.py b/src/core/openai/overview.py index 640c2b20..fd16d68a 100644 --- a/src/core/openai/overview.py +++ b/src/core/openai/overview.py @@ -17,6 +17,14 @@ logger = logging.getLogger(__name__) + +class AccountDeactivatedError(RuntimeError): + """账号被停用/冻结(401 with deactivated message)。""" + + def __init__(self, message: str, status_code: int = 401): + super().__init__(message) + self.status_code = status_code + _USAGE_ENDPOINTS: Tuple[Tuple[str, str, bool], ...] = ( # required=True: 核心稳定端点,失败计入 errors ("me", "https://chatgpt.com/backend-api/me", True), @@ -160,6 +168,10 @@ def _request_json(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Di timeout=20, impersonate="chrome110", ) + if resp.status_code == 401: + text = str(resp.text or "") + if "deactivated" in text.lower(): + raise AccountDeactivatedError(f"account_deactivated: {text[:200]}") resp.raise_for_status() data = resp.json() if isinstance(data, dict): @@ -173,6 +185,9 @@ def _extract_http_status(exc: Exception) -> Optional[int]: status = getattr(response, "status_code", None) if isinstance(status, int): return status + status = getattr(exc, "status_code", None) + if isinstance(status, int): + return status match = re.search(r"HTTP Error\s+(\d{3})", str(exc)) if match: try: @@ -188,6 +203,8 @@ def _request_json_with_proxy_fallback(url: str, headers: Dict[str, str], proxy: """ try: return _request_json(url, headers, proxy) + except AccountDeactivatedError: + raise except Exception as proxy_exc: if not proxy: raise diff --git a/src/core/openai/payment.py b/src/core/openai/payment.py index 5abfeda4..7db6eede 100644 --- a/src/core/openai/payment.py +++ b/src/core/openai/payment.py @@ -15,7 +15,7 @@ from curl_cffi import requests as cffi_requests from ...database.models import Account -from .overview import fetch_codex_overview +from .overview import fetch_codex_overview, AccountDeactivatedError logger = logging.getLogger(__name__) @@ -50,6 +50,29 @@ def _build_proxies(proxy: Optional[str]) -> Optional[dict]: return None +def _raise_if_deactivated(resp, source: str) -> None: + if resp is None: + return + if getattr(resp, "status_code", None) != 401: + return + text = str(getattr(resp, "text", "") or "") + if "deactivated" in text.lower(): + raise AccountDeactivatedError(f"account_deactivated({source}): {text[:200]}") + + +def _request_json_with_deactivated(url: str, headers: Dict[str, str], proxy: Optional[str], source: str) -> Dict[str, Any]: + resp = cffi_requests.get( + url, + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + _raise_if_deactivated(resp, source) + resp.raise_for_status() + return resp.json() if resp.content else {} + + def _is_connectivity_error(err: Any) -> bool: text = str(err or "").strip().lower() if not text: @@ -1048,19 +1071,18 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, # 1) me 接口 try: - resp = cffi_requests.get( + data = _request_json_with_deactivated( "https://chatgpt.com/backend-api/me", headers=headers, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="me", ) - resp.raise_for_status() successful_sources.append("me") - data = resp.json() if resp.content else {} detected = _analyze_me_payload(data, "me") if detected: return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"me: {exc}") @@ -1069,16 +1091,13 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, try: headers_no_scope = dict(headers) headers_no_scope.pop("ChatGPT-Account-Id", None) - resp_no_scope = cffi_requests.get( + data_no_scope = _request_json_with_deactivated( "https://chatgpt.com/backend-api/me", headers=headers_no_scope, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="me_no_scope", ) - resp_no_scope.raise_for_status() successful_sources.append("me_no_scope") - data_no_scope = resp_no_scope.json() if resp_no_scope.content else {} detected = _analyze_me_payload(data_no_scope, "me.no_scope") if detected: logger.info( @@ -1088,39 +1107,37 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, detected.get("confidence"), ) return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"me_no_scope: {exc}") # 2) wham/usage(Cockpit-tools 同款核心) try: - usage_resp = cffi_requests.get( + usage_data = _request_json_with_deactivated( "https://chatgpt.com/backend-api/wham/usage", headers=headers, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_usage", ) - usage_resp.raise_for_status() successful_sources.append("wham_usage") - usage_data = usage_resp.json() if usage_resp.content else {} detected = _analyze_usage_payload(usage_data, "wham_usage") if detected: return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_usage: {exc}") # 3) wham/accounts/check(Cockpit-tools 用于账号资料同步的官方口径) try: - account_check_resp = cffi_requests.get( + account_check_data = _request_json_with_deactivated( ACCOUNT_CHECK_URL, headers=headers, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_accounts_check", ) - account_check_resp.raise_for_status() successful_sources.append("wham_accounts_check") - account_check_data = account_check_resp.json() if account_check_resp.content else {} for raw in _collect_plan_candidates(account_check_data): mapped = _map_plan_to_subscription(raw) if mapped in ("plus", "team"): @@ -1128,6 +1145,8 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, if mapped == "free": weak_free_source = weak_free_source or "wham_accounts_check.plan" explicit_free_value = explicit_free_value or str(raw) + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_accounts_check: {exc}") @@ -1136,16 +1155,13 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, headers_no_scope = dict(headers) headers_no_scope.pop("ChatGPT-Account-Id", None) try: - usage_no_scope_resp = cffi_requests.get( + usage_no_scope_data = _request_json_with_deactivated( "https://chatgpt.com/backend-api/wham/usage", headers=headers_no_scope, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_usage_no_scope", ) - usage_no_scope_resp.raise_for_status() successful_sources.append("wham_usage_no_scope") - usage_no_scope_data = usage_no_scope_resp.json() if usage_no_scope_resp.content else {} detected = _analyze_usage_payload(usage_no_scope_data, "wham_usage.no_scope") if detected: logger.info( @@ -1155,20 +1171,19 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, detected.get("confidence"), ) return detected + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_usage_no_scope: {exc}") try: - account_check_no_scope_resp = cffi_requests.get( + account_check_no_scope_data = _request_json_with_deactivated( ACCOUNT_CHECK_URL, headers=headers_no_scope, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + proxy=proxy, + source="wham_accounts_check_no_scope", ) - account_check_no_scope_resp.raise_for_status() successful_sources.append("wham_accounts_check_no_scope") - account_check_no_scope_data = account_check_no_scope_resp.json() if account_check_no_scope_resp.content else {} for raw in _collect_plan_candidates(account_check_no_scope_data): mapped = _map_plan_to_subscription(raw) if mapped in ("plus", "team"): @@ -1176,6 +1191,8 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, if mapped == "free": weak_free_source = weak_free_source or "wham_accounts_check.no_scope.plan" explicit_free_value = explicit_free_value or str(raw) + except AccountDeactivatedError as exc: + return _result("deactivated", "account_deactivated", "high", note=str(exc)) except Exception as exc: errors.append(f"wham_accounts_check_no_scope: {exc}") diff --git a/src/core/register.py b/src/core/register.py index f4a098c9..2df48bd0 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -10,6 +10,7 @@ import secrets import string import uuid +import os from typing import Optional, Dict, Any, Tuple, Callable, List from dataclasses import dataclass from datetime import datetime @@ -18,6 +19,7 @@ from .openai.oauth import OAuthManager, OAuthStart from .http_client import OpenAIHTTPClient, HTTPClientError +from .anyauto.register_flow import AnyAutoRegistrationEngine from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType from ..database import crud from ..database.session import get_db @@ -36,6 +38,10 @@ logger = logging.getLogger(__name__) +_DEFAULT_OAI_CLIENT_VERSION = os.getenv("OAI_CLIENT_VERSION", "prod-34ffa95763ddf2cc215bcd7545731a9818ca9a8b") +_DEFAULT_OAI_CLIENT_BUILD = os.getenv("OAI_CLIENT_BUILD", "5583259") +_DEFAULT_OAI_LANGUAGE = os.getenv("OAI_LANGUAGE", "zh-CN") + @dataclass class RegistrationResult: @@ -146,12 +152,18 @@ def __init__( self._create_account_workspace_id: Optional[str] = None self._create_account_account_id: Optional[str] = None self._create_account_refresh_token: Optional[str] = None + self._create_account_callback_url: Optional[str] = None # create_account 返回的 OAuth callback(含 code) + self._create_account_page_type: Optional[str] = None self._last_validate_otp_continue_url: Optional[str] = None self._last_validate_otp_workspace_id: Optional[str] = None self._last_register_password_error: Optional[str] = None self._last_otp_validation_code: Optional[str] = None self._last_otp_validation_status_code: Optional[int] = None self._last_otp_validation_outcome: str = "" # success/http_non_200/network_timeout/network_error + self._oai_session_id: str = str(uuid.uuid4()) + self._oai_client_version: str = _DEFAULT_OAI_CLIENT_VERSION + self._oai_client_build: str = _DEFAULT_OAI_CLIENT_BUILD + self._oai_language: str = _DEFAULT_OAI_LANGUAGE def _log(self, message: str, level: str = "info"): """记录日志""" @@ -376,6 +388,10 @@ def _create_email(self) -> bool: if raw_email and raw_email != normalized_email: self._log(f"邮箱规范化: {raw_email} -> {normalized_email}") + source = str(self.email_info.get("source") or "").strip().lower() + if source == "reuse_purchase": + self._log("命中已购未注册邮箱池,优先复用历史未注册邮箱") + self._log(f"邮箱已就位,地址新鲜出炉: {self.email}") return True @@ -575,6 +591,20 @@ def _submit_auth_start( page_type = response_data.get("page", {}).get("type", "") self._log(f"响应页面类型: {page_type}") + continue_url = str(response_data.get("continue_url") or "").strip() + if continue_url: + try: + self.session.get( + continue_url, + headers={ + "referer": referer, + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + timeout=15, + ) + except Exception as e: + self._log(f"{log_label}补跳 continue_url 失败: {e}", "warning") + is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"] if is_existing: @@ -708,6 +738,17 @@ def _submit_login_password(self) -> SignupFormResult: is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"] if is_existing: self._otp_sent_at = time.time() + try: + self.session.get( + "https://auth.openai.com/email-verification", + headers={ + "referer": "https://auth.openai.com/log-in/password", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + timeout=15, + ) + except Exception as e: + self._log(f"登录验证码页预热失败: {e}", "warning") self._log("登录密码校验通过,等待系统自动发送的验证码") return SignupFormResult( @@ -738,6 +779,77 @@ def _reset_auth_flow(self) -> None: self.session_token = None self._otp_sent_at = None + def _get_device_id_for_headers(self) -> str: + did = str(self.device_id or "").strip() + if not did: + try: + did = str(self.session.cookies.get("oai-did") or "").strip() + except Exception: + did = "" + if not did: + did = str(uuid.uuid4()) + try: + self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + self.device_id = did + return did + + def _build_chatgpt_headers( + self, + referer: str = "https://chatgpt.com/", + access_token: Optional[str] = None, + ) -> Dict[str, str]: + did = self._get_device_id_for_headers() + headers = { + "accept": "application/json, text/plain, */*", + "accept-language": f"{self._oai_language},zh;q=0.9,en;q=0.8", + "origin": "https://chatgpt.com", + "referer": referer, + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + ), + "oai-client-build-number": self._oai_client_build, + "oai-client-version": self._oai_client_version, + "oai-device-id": did, + "oai-language": self._oai_language, + "oai-session-id": self._oai_session_id, + "priority": "u=1, i", + "sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Windows\"", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + } + if access_token: + headers["authorization"] = f"Bearer {access_token}" + return headers + + def _touch_otp_continue_url(self, stage_label: str) -> None: + """登录 OTP 成功后,访问 continue_url 以落地授权 cookie(对齐副本脚本)。""" + try: + continue_url = str(self._last_validate_otp_continue_url or "").strip() + if not continue_url: + return + self._log(f"{stage_label}命中 continue_url,尝试补跳授权页...") + self.session.get( + continue_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://auth.openai.com/email-verification", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + }, + allow_redirects=True, + timeout=20, + ) + except Exception as e: + self._log(f"{stage_label}补跳 continue_url 失败: {e}", "warning") + def _prepare_authorize_flow(self, label: str) -> Tuple[Optional[str], Optional[str]]: """初始化当前阶段的授权流程,返回 device id 和 sentinel token。""" self._log(f"{label}: 先把会话热热身...") @@ -799,14 +911,7 @@ def _warmup_chatgpt_session(self) -> None: try: self.session.get( "https://chatgpt.com/", - headers={ - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://auth.openai.com/", - "user-agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - ), - }, + headers=self._build_chatgpt_headers(referer="https://auth.openai.com/"), timeout=20, ) except Exception as e: @@ -832,8 +937,14 @@ def _capture_auth_session_tokens(self, result: RegistrationResult, access_hint: "cache-control": "no-cache", "pragma": "no-cache", } - if access_token: - headers["authorization"] = f"Bearer {access_token}" + headers = self._build_chatgpt_headers( + referer="https://chatgpt.com/", + access_token=access_token or None, + ) + headers.update({ + "cache-control": "no-cache", + "pragma": "no-cache", + }) response = self.session.get( "https://chatgpt.com/api/auth/session", headers=headers, @@ -875,14 +986,10 @@ def _capture_auth_session_tokens(self, result: RegistrationResult, access_hint: retry_response = self.session.get( "https://chatgpt.com/api/auth/session", headers={ - "accept": "application/json", - "referer": "https://chatgpt.com/", - "origin": "https://chatgpt.com", - "user-agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + **self._build_chatgpt_headers( + referer="https://chatgpt.com/", + access_token=access_token, ), - "authorization": f"Bearer {access_token}", "cache-control": "no-cache", "pragma": "no-cache", }, @@ -951,15 +1058,7 @@ def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> b try: csrf_resp = self.session.get( "https://chatgpt.com/api/auth/csrf", - headers={ - "accept": "application/json", - "referer": "https://chatgpt.com/auth/login", - "origin": "https://chatgpt.com", - "user-agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - ), - }, + headers=self._build_chatgpt_headers(referer="https://chatgpt.com/auth/login"), timeout=20, ) if csrf_resp.status_code == 200: @@ -977,14 +1076,8 @@ def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> b signin_resp = self.session.post( "https://chatgpt.com/api/auth/signin/openai", headers={ - "accept": "application/json", + **self._build_chatgpt_headers(referer="https://chatgpt.com/auth/login"), "content-type": "application/x-www-form-urlencoded", - "origin": "https://chatgpt.com", - "referer": "https://chatgpt.com/auth/login", - "user-agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - ), }, data={ "csrfToken": csrf_token, @@ -1018,14 +1111,7 @@ def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> b try: self.session.get( callback_url, - headers={ - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://chatgpt.com/auth/login", - "user-agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - ), - }, + headers=self._build_chatgpt_headers(referer="https://chatgpt.com/auth/login"), allow_redirects=True, timeout=25, ) @@ -1180,11 +1266,7 @@ def _follow_chatgpt_auth_redirects(self, start_url: str) -> Tuple[str, str]: resp = self.session.get( current_url, - headers={ - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://chatgpt.com/", - "user-agent": ua, - }, + headers=self._build_chatgpt_headers(referer="https://chatgpt.com/"), timeout=25, allow_redirects=False, ) @@ -1217,11 +1299,7 @@ def _follow_chatgpt_auth_redirects(self, start_url: str) -> Tuple[str, str]: try: self.session.get( "https://chatgpt.com/", - headers={ - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": current_url, - "user-agent": ua, - }, + headers=self._build_chatgpt_headers(referer=current_url), timeout=20, ) except Exception: @@ -1579,6 +1657,11 @@ def _complete_token_exchange_outlook(self, result: RegistrationResult) -> bool: result.error_message = "验证码校验失败" return False + if self._create_account_callback_url and not self._is_existing_account: + self._log("检测到 create_account callback,尝试直接换取 token...") + if self._consume_create_account_callback(result): + return True + self._log("摸一下 Workspace ID,看看该坐哪桌...") workspace_id = str(self._last_validate_otp_workspace_id or "").strip() if workspace_id: @@ -2032,8 +2115,7 @@ def _register_password(self, did: Optional[str] = None, sen_token: Optional[str] OPENAI_PAGE_TYPES["LOGIN_PASSWORD"], OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"], ): - self._log("登录入口探测命中:该邮箱大概率已是 OpenAI 账号", "warning") - self._mark_email_as_registered() + self._log("登录入口探测命中:该邮箱大概率已是 OpenAI 账号(按失败记录并走申诉)", "warning") self._last_register_password_error = ( "该邮箱已存在 OpenAI 账号。" "若是刚刚注册中断,请优先使用上一轮任务日志里的“生成密码”走登录续跑;" @@ -2094,6 +2176,7 @@ def _send_verification_code(self, referer: Optional[str] = None) -> bool: ) self._log(f"验证码发送状态: {response.status_code}") + self._log(f"验证码发送信息: {response.text}") return response.status_code == 200 except Exception as e: @@ -2209,7 +2292,7 @@ def _validate_verification_code(self, code: str) -> bool: else: self._last_otp_validation_outcome = "network_error" self._log(f"验证验证码失败: {e}", "error") - return False + return False def _verify_email_otp_with_retry( self, @@ -2273,6 +2356,12 @@ def _verify_email_otp_with_retry( attempted_codes.add(code) if self._validate_verification_code(code): + if stage_label and ( + "登录验证码" in stage_label + or "login" in stage_label.lower() + or "会话桥接" in stage_label + ): + self._touch_otp_continue_url(stage_label) return True if attempt < max_attempts: @@ -2309,10 +2398,54 @@ def _create_user_account(self) -> bool: try: data = response.json() or {} + response_text = "" + try: + response_text = str(response.text or "") + except Exception: + response_text = "" continue_url = str(data.get("continue_url") or "").strip() if continue_url: self._create_account_continue_url = continue_url self._log(f"create_account 返回 continue_url,已缓存: {continue_url[:100]}...") + if "/api/auth/callback/openai" in continue_url and "code=" in continue_url: + self._create_account_callback_url = continue_url + self._log("create_account 返回 OAuth callback(含 code),将优先走直连会话交换") + page_info = data.get("page") if isinstance(data, dict) else None + if isinstance(page_info, dict): + page_type = str(page_info.get("type") or "").strip() + if page_type: + self._create_account_page_type = page_type + self._log(f"create_account 返回 page.type: {page_type}") + page_url = str( + page_info.get("url") + or page_info.get("href") + or page_info.get("external_url") + or "" + ).strip() + if page_url: + self._log(f"create_account 返回 page.url: {page_url[:100]}...") + if "/api/auth/callback/openai" in page_url and "code=" in page_url: + self._create_account_callback_url = page_url + self._log("create_account 返回 OAuth callback(page.url),将优先走直连会话交换") + + if not self._create_account_callback_url: + # 兜底:从原始响应中提取 callback + try: + haystacks = [response_text, json.dumps(data, ensure_ascii=False)] + for raw in haystacks: + if not raw: + continue + match = re.search( + r"https://chatgpt\.com/api/auth/callback/openai\?[^\"'\\s>]+", + raw, + re.IGNORECASE, + ) + if match: + self._create_account_callback_url = match.group(0) + self._log("create_account 响应体命中 OAuth callback,已缓存") + break + except Exception: + pass account_id = str( data.get("account_id") or data.get("chatgpt_account_id") @@ -2346,6 +2479,93 @@ def _create_user_account(self) -> bool: self._log(f"创建账户失败: {e}", "error") return False + def _decode_jwt_payload(self, token: str) -> Dict[str, Any]: + """解析 JWT payload(不验证签名)。""" + try: + raw = str(token or "").strip() + if raw.count(".") < 2: + return {} + payload = raw.split(".")[1] + import base64 + pad = "=" * ((4 - (len(payload) % 4)) % 4) + decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii")) + data = json.loads(decoded.decode("utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _consume_create_account_callback(self, result: RegistrationResult) -> bool: + """ + create_account 已返回 chatgpt.com/api/auth/callback/openai?code=... 时, + 直接完成 code exchange + auth/session 抓取,跳过二次登录。 + """ + callback_url = str(self._create_account_callback_url or "").strip() + if not callback_url: + return False + + self._log("create_account 已返回 OAuth callback,尝试直连完成会话交换...") + try: + self._warmup_chatgpt_session() + resp = self.session.get( + callback_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://chatgpt.com/", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + }, + allow_redirects=True, + timeout=25, + ) + self._log(f"create_account callback 状态: {resp.status_code}") + except Exception as e: + self._log(f"create_account callback 请求失败: {e}", "warning") + return False + + self._warmup_chatgpt_session() + captured = self._capture_auth_session_tokens(result) + if not captured: + self._log("callback 后 auth/session 未完全命中,尝试轻量补抓 access_token...", "warning") + self._capture_access_token_light(result) + if not result.session_token: + result.session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + + if not result.account_id and result.access_token: + result.account_id = self._extract_account_id_from_access_token(result.access_token) + if not result.workspace_id: + workspace_id = str(self._get_workspace_id() or "").strip() + if workspace_id: + result.workspace_id = workspace_id + elif self._create_account_workspace_id: + result.workspace_id = str(self._create_account_workspace_id or "").strip() + if not result.refresh_token and self._create_account_refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + + if result.access_token: + claims = self._decode_jwt_payload(result.access_token) + if claims: + auth_claim = claims.get("https://api.openai.com/auth") or {} + email_claim = str(claims.get("email") or auth_claim.get("email") or "").strip() + exp_claim = claims.get("exp") + account_claim = str( + auth_claim.get("chatgpt_account_id") or claims.get("chatgpt_account_id") or "" + ).strip() + if email_claim: + self._log(f"access_token 解析: email={email_claim}") + if not result.email: + result.email = email_claim + if account_claim and not result.account_id: + result.account_id = account_claim + if exp_claim: + self._log(f"access_token 解析: exp={exp_claim}") + + result.password = self.password or "" + result.source = "register" + result.device_id = result.device_id or str(self.device_id or "") + return bool(result.access_token) + def _get_workspace_id(self) -> Optional[str]: """获取 Workspace ID""" try: @@ -2365,7 +2585,16 @@ def _extract_workspace_id(payload: Any) -> str: return str((workspaces[0] or {}).get("id") or "").strip() return "" - auth_cookie = str(self.session.cookies.get("oai-client-auth-session") or "").strip() + auth_cookie = "" + try: + auth_cookie = str( + self.session.cookies.get("oai-client-auth-session", domain=".auth.openai.com") + or self.session.cookies.get("oai-client-auth-session", domain="auth.openai.com") + or self.session.cookies.get("oai-client-auth-session") + or "" + ).strip() + except Exception: + auth_cookie = str(self.session.cookies.get("oai-client-auth-session") or "").strip() if not auth_cookie: self._log("未能获取到授权 Cookie,尝试从 auth-info 里取 workspace", "warning") @@ -2416,7 +2645,64 @@ def _extract_workspace_id(payload: Any) -> str: break auth_info_text = decoded try: - auth_info_json = json_module.loads(auth_info_text) + stripped = str(auth_info_text or "").strip() + if not stripped: + raise ValueError("empty auth-info after decode") + # 去掉可能的引号包裹 + if (len(stripped) >= 2) and (stripped[0] == stripped[-1]) and (stripped[0] in ("'", '"')): + stripped = stripped[1:-1].strip() + + if stripped and stripped[0] in "{[": + auth_info_json = json_module.loads(stripped) + else: + # 兼容 base64/url-safe JSON + auth_info_json = None + try: + import base64 + candidates_raw = [] + if stripped: + candidates_raw.append(stripped) + # 兼容 JWT/签名格式:取分段尝试解码 + if "." in stripped: + for seg in stripped.split("."): + seg = seg.strip() + if seg: + candidates_raw.append(seg) + + for candidate in candidates_raw: + if not candidate: + continue + pad = "=" * ((4 - (len(candidate) % 4)) % 4) + candidates = [] + try: + candidates.append(base64.urlsafe_b64decode((candidate + pad).encode("ascii"))) + except Exception: + pass + try: + candidates.append(base64.b64decode((candidate + pad).encode("ascii"))) + except Exception: + pass + for decoded in candidates: + try: + text = decoded.decode("utf-8") + except Exception: + continue + # 可能再次 URL 编码 + for _ in range(2): + decoded_text = urlparse.unquote(text) + if decoded_text == text: + break + text = decoded_text + text = text.strip() + if text and text[0] in "{[": + auth_info_json = json_module.loads(text) + break + if auth_info_json is not None: + break + except Exception: + auth_info_json = None + if auth_info_json is None: + raise ValueError(f"auth-info not json: {stripped}") workspace_id = _extract_workspace_id(auth_info_json) if workspace_id: self._log(f"Workspace ID (auth-info): {workspace_id}") @@ -2616,153 +2902,87 @@ def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]: def run(self) -> RegistrationResult: """ - 执行完整的注册流程 - - 支持已注册账号自动登录: - - 如果检测到邮箱已注册,自动切换到登录流程 - - 已注册账号跳过:设置密码、发送验证码、创建用户账户 - - 共用步骤:获取验证码、验证验证码、Workspace 和 OAuth 回调 - - Returns: - RegistrationResult: 注册结果 + 执行 any-auto-register 风格的注册流程 """ result = RegistrationResult(success=False, logs=self.logs) - try: - self._is_existing_account = False - self._token_acquisition_requires_login = False - self._otp_sent_at = None - self._create_account_continue_url = None - self._create_account_workspace_id = None - self._create_account_account_id = None - self._create_account_refresh_token = None - self._last_validate_otp_continue_url = None - self._last_validate_otp_workspace_id = None - - self._log("=" * 60) - self._log("注册流程启动,开始替你敲门") - self._log("=" * 60) - self._log(f"注册入口链路配置: {self.registration_entry_flow}") - configured_entry_flow = self.registration_entry_flow - service_type_raw = getattr(self.email_service, "service_type", "") - service_type_value = str(getattr(service_type_raw, "value", service_type_raw) or "").strip().lower() - effective_entry_flow = configured_entry_flow - if service_type_value == "outlook": - self._log("检测到 Outlook 邮箱,自动使用 Outlook 入口链路(无需在设置中选择)") - effective_entry_flow = "outlook" - - # 1. 检查 IP 地理位置 - self._log("1. 先看看这条网络从哪儿来,别一开局就站错片场...") - ip_ok, location = self._check_ip_location() - if not ip_ok: - result.error_message = f"IP 地理位置不支持: {location}" - self._log(f"IP 检查失败: {location}", "error") - return result + settings = get_settings() + max_retries = int(getattr(settings, "registration_max_retries", 3) or 3) + + flow_engine = AnyAutoRegistrationEngine( + email_service=self.email_service, + proxy_url=self.proxy_url, + callback_logger=self._log, + max_retries=max_retries, + browser_mode="protocol", + extra_config=None, + ) - self._log(f"IP 位置: {location}") + flow_result = flow_engine.run() - # 2. 创建邮箱 - self._log("2. 开个新邮箱,准备收信...") - if not self._create_email(): - result.error_message = "创建邮箱失败" - return result + # 同步关键状态供后续存库/导出 + self.email_info = flow_engine.email_info + self.email = flow_engine.email + self.inbox_email = flow_engine.inbox_email + self.password = flow_engine.password + self.session = flow_engine.session + self.device_id = flow_engine.device_id - result.email = self.email + result.email = self.email or "" + result.password = self.password or "" + result.device_id = self.device_id or "" - # 3. 准备首轮授权流程 - did, sen_token = self._prepare_authorize_flow("首次授权") - if not did: - result.error_message = "获取 Device ID 失败" + if not flow_result or not flow_result.get("success"): + result.error_message = (flow_result or {}).get("error_message", "注册失败") return result - result.device_id = did - if not sen_token: - result.error_message = "Sentinel POW 验证失败" - return result - - # 4. 提交注册入口邮箱 - self._log("4. 递上邮箱,看看 OpenAI 这球怎么接...") - signup_result = self._submit_signup_form(did, sen_token) - if not signup_result.success: - result.error_message = f"提交注册表单失败: {signup_result.error_message}" - return result - - if self._is_existing_account: - self._log("检测到这是老朋友账号,直接切去登录拿 token,不走弯路") - else: - self._log("5. 设置密码,别让小偷偷笑...") - password_ok, _ = self._register_password(did, sen_token) - if not password_ok: - result.error_message = self._last_register_password_error or "注册密码失败" - return result - - self._log("6. 催一下注册验证码出门,邮差该冲刺了...") - if not self._send_verification_code(): - result.error_message = "发送验证码失败" - return result - - self._log("7. 等验证码飞来,邮箱请注意查收...") - self._log("8. 对一下验证码,看看是不是本人...") - if not self._verify_email_otp_with_retry(stage_label="注册验证码", max_attempts=3): - result.error_message = "验证验证码失败" - return result - - self._log("9. 给账号办个正式户口,名字写档案里...") - if not self._create_user_account(): - result.error_message = "创建用户账户失败" - return result - - if effective_entry_flow in {"native", "outlook"}: - login_ready, login_error = self._restart_login_flow() - if not login_ready: - result.error_message = login_error - return result - if effective_entry_flow == "outlook": - self._log("注册入口链路: Outlook(迁移版,按朋友版 Outlook 主流程收尾)") - else: - self._log("注册入口链路: ABCard(新账号不重登,直接抓取会话)") - - if effective_entry_flow == "native": - if not self._complete_token_exchange_native_backup(result): - return result - elif effective_entry_flow == "outlook": - if not self._complete_token_exchange_outlook(result): - return result - else: - use_abcard_entry = (effective_entry_flow == "abcard") and (not self._is_existing_account) - if not self._complete_token_exchange(result, require_login_otp=not use_abcard_entry): - return result - - # 10. 完成 - self._log("=" * 60) - if self._is_existing_account: - self._log("登录成功,老朋友顺利回家") - else: - self._log("注册成功,账号已经稳稳落地,可以开香槟了") - self._log(f"邮箱: {result.email}") - self._log(f"Device ID: {result.device_id or '-'}") - self._log(f"Account ID: {result.account_id}") - self._log(f"Workspace ID: {result.workspace_id}") - self._log("=" * 60) result.success = True - settings = get_settings() - client_id = str(getattr(settings, "openai_client_id", "") or getattr(self.oauth_manager, "client_id", "") or "").strip() - result.metadata = { + result.access_token = flow_result.get("access_token", "") + result.refresh_token = flow_result.get("refresh_token", "") + result.id_token = flow_result.get("id_token", "") + result.session_token = flow_result.get("session_token", "") + result.account_id = flow_result.get("account_id", "") or "" + result.workspace_id = flow_result.get("workspace_id", "") or "" + result.source = "register" + + def _extract_account_id_from_token(token: str) -> str: + try: + text = str(token or "").strip() + if "." not in text: + return "" + payload_part = text.split(".")[1] + pad = "=" * (-len(payload_part) % 4) + import base64, json as _json + decoded = base64.urlsafe_b64decode(payload_part + pad).decode("utf-8") + payload = _json.loads(decoded) + if not isinstance(payload, dict): + return "" + auth_claims = payload.get("https://api.openai.com/auth") or {} + for key in ("chatgpt_account_id", "account_id", "workspace_id"): + value = str(auth_claims.get(key) or payload.get(key) or "").strip() + if value: + return value + except Exception: + return "" + return "" + + if not result.account_id: + result.account_id = _extract_account_id_from_token(result.access_token or result.id_token) + if not result.account_id and result.workspace_id: + result.account_id = result.workspace_id + if not result.workspace_id and result.account_id: + result.workspace_id = result.account_id + + metadata = flow_result.get("metadata") or {} + metadata.update({ "email_service": self.email_service.service_type.value, "proxy_used": self.proxy_url, "registered_at": datetime.now().isoformat(), - "is_existing_account": self._is_existing_account, - "token_acquired_via_relogin": self._token_acquisition_requires_login, - "client_id": client_id, - "device_id": result.device_id, + "registration_flow": "any-auto-register", "has_session_token": bool(result.session_token), "has_access_token": bool(result.access_token), - "has_refresh_token": bool(result.refresh_token), - "registration_entry_flow": configured_entry_flow, - "registration_entry_flow_effective": effective_entry_flow, - # 对齐 K:\1\2:原生入口允许无 session_token 成功,但会标记待补。 - "session_token_pending": (effective_entry_flow == "native") and (not bool(result.session_token)), - } + }) + result.metadata = metadata return result @@ -2770,7 +2990,6 @@ def run(self) -> RegistrationResult: self._log(f"注册过程中发生未预期错误: {e}", "error") result.error_message = str(e) return result - def save_to_database(self, result: RegistrationResult) -> bool: """ 保存注册结果到数据库 diff --git a/src/services/__init__.py b/src/services/__init__.py index 2e6a19f2..e48b28f1 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -19,6 +19,7 @@ from .freemail import FreemailService from .imap_mail import ImapMailService from .cloudmail import CloudMailService +from .luckmail_mail import LuckMailService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) @@ -30,6 +31,7 @@ EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService) EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService) EmailServiceFactory.register(EmailServiceType.CLOUDMAIL, CloudMailService) +EmailServiceFactory.register(EmailServiceType.LUCKMAIL, LuckMailService) # 导出 Outlook 模块的额外内容 from .outlook.base import ( @@ -65,6 +67,7 @@ 'FreemailService', 'ImapMailService', 'CloudMailService', + 'LuckMailService', # Outlook 模块 'ProviderType', 'EmailMessage', diff --git a/src/services/freemail.py b/src/services/freemail.py index e1e0587b..12d417c1 100644 --- a/src/services/freemail.py +++ b/src/services/freemail.py @@ -54,7 +54,7 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None): timeout=self.config["timeout"], max_retries=self.config["max_retries"], ) - self.http_client = HTTPClient(proxy_url=None, config=http_config) + self.http_client = HTTPClient(proxy_url=self.config.get("proxy_url"), config=http_config) # 缓存 domain 列表 self._domains = [] diff --git a/src/services/luckmail_mail.py b/src/services/luckmail_mail.py new file mode 100644 index 00000000..97cfd8a0 --- /dev/null +++ b/src/services/luckmail_mail.py @@ -0,0 +1,1051 @@ +""" +LuckMail 邮箱服务实现 +""" + +import logging +import json +import re +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..config.constants import OTP_CODE_PATTERN + + +logger = logging.getLogger(__name__) +_STATE_LOCK = threading.RLock() +# 申诉硬编码开关(临时):False=关闭申诉提交;True=开启申诉提交。 +LUCKMAIL_APPEAL_ENABLED = False + + +def _load_luckmail_client_class(): + """ + 兼容两种来源: + 1) 环境已安装 luckmail 包 + 2) 本地 vendored 目录(优先 codex-console/luckmail,其次 ../tools/luckmail) + """ + try: + from luckmail import LuckMailClient # type: ignore + + return LuckMailClient + except Exception: + pass + + candidates = [ + Path(__file__).resolve().parents[2] / "luckmail", + Path(__file__).resolve().parents[3] / "tools" / "luckmail", + ] + for path in candidates: + if not path.is_dir(): + continue + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + try: + from luckmail import LuckMailClient # type: ignore + + return LuckMailClient + except Exception: + continue + return None + + +class LuckMailService(BaseEmailService): + """LuckMail 接码邮箱服务""" + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + super().__init__(EmailServiceType.LUCKMAIL, name) + + default_config = { + "base_url": "https://mails.luckyous.com/", + "api_key": "", + "project_code": "openai", + "email_type": "ms_graph", + "preferred_domain": "", + # purchase: 购买邮箱 + token 拉码(可多次) + # order: 创建接码订单 + 订单拉码(通常一次) + "inbox_mode": "purchase", + # 任务开始时优先复用“未在账号库且不在本地黑名单”的已购邮箱 + "reuse_existing_purchases": True, + "purchase_scan_pages": 5, + "purchase_scan_page_size": 100, + "timeout": 30, + "max_retries": 3, + "poll_interval": 3.0, + "code_reuse_ttl": 600, + } + self.config = {**default_config, **(config or {})} + + self.config["base_url"] = str(self.config.get("base_url") or "").strip() + if not self.config["base_url"]: + raise ValueError("LuckMail 配置缺少 base_url") + self.config["api_key"] = str(self.config.get("api_key") or "").strip() + self.config["project_code"] = str(self.config.get("project_code") or "openai").strip() + self.config["email_type"] = str(self.config.get("email_type") or "ms_graph").strip() + self.config["preferred_domain"] = str(self.config.get("preferred_domain") or "").strip().lstrip("@") + self.config["inbox_mode"] = self._normalize_inbox_mode(self.config.get("inbox_mode")) + self.config["reuse_existing_purchases"] = bool(self.config.get("reuse_existing_purchases", True)) + self.config["purchase_scan_pages"] = max(int(self.config.get("purchase_scan_pages") or 5), 1) + self.config["purchase_scan_page_size"] = max(int(self.config.get("purchase_scan_page_size") or 100), 1) + self.config["poll_interval"] = float(self.config.get("poll_interval") or 3.0) + self.config["code_reuse_ttl"] = int(self.config.get("code_reuse_ttl") or 600) + + if not self.config["api_key"]: + raise ValueError("LuckMail 配置缺少 api_key") + if not self.config["project_code"]: + raise ValueError("LuckMail 配置缺少 project_code") + + client_cls = _load_luckmail_client_class() + if client_cls is None: + raise ValueError( + "未找到 LuckMail SDK,请先安装 luckmail 包或确保本地存在 tools/luckmail" + ) + + try: + self.client = client_cls( + base_url=self.config["base_url"], + api_key=self.config["api_key"], + ) + except Exception as exc: + raise ValueError(f"初始化 LuckMail 客户端失败: {exc}") + + self._orders_by_no: Dict[str, Dict[str, Any]] = {} + self._orders_by_email: Dict[str, Dict[str, Any]] = {} + # 记录每个订单/Token 最近返回过的验证码,避免后续阶段反复拿到旧码。 + self._recent_codes_by_order: Dict[str, Dict[str, float]] = {} + self._data_dir = Path(__file__).resolve().parents[2] / "data" + self._registered_file = self._data_dir / "luckmail_registered_emails.json" + self._failed_file = self._data_dir / "luckmail_failed_emails.json" + + def _normalize_inbox_mode(self, raw: Any) -> str: + mode = str(raw or "").strip().lower() + aliases = { + "purchase": "purchase", + "token": "purchase", + "buy": "purchase", + "purchased": "purchase", + "order": "order", + "code": "order", + } + return aliases.get(mode, "purchase") + + def _extract_field(self, obj: Any, *keys: str) -> Any: + if obj is None: + return None + if isinstance(obj, dict): + for k in keys: + if k in obj: + return obj.get(k) + return None + for k in keys: + if hasattr(obj, k): + return getattr(obj, k) + return None + + def _cache_order(self, info: Dict[str, Any]) -> None: + order_key = str(info.get("order_no") or info.get("service_id") or "").strip() + email = str(info.get("email") or "").strip().lower() + if order_key: + self._orders_by_no[order_key] = info + if email: + self._orders_by_email[email] = info + + def _find_order(self, email: Optional[str], email_id: Optional[str]) -> Optional[Dict[str, Any]]: + if email_id: + item = self._orders_by_no.get(str(email_id).strip()) + if item: + return item + if email: + item = self._orders_by_email.get(str(email).strip().lower()) + if item: + return item + return None + + def _is_recent_code(self, order_key: str, code: str, now: Optional[float] = None) -> bool: + if not order_key or not code: + return False + now_ts = now or time.time() + ttl = max(int(self.config.get("code_reuse_ttl") or 600), 0) + order_cache = self._recent_codes_by_order.get(order_key) or {} + if ttl <= 0: + return code in order_cache + used_at = order_cache.get(code) + if used_at is None: + return False + return (now_ts - used_at) <= ttl + + def _remember_code(self, order_key: str, code: str, now: Optional[float] = None) -> None: + if not order_key or not code: + return + now_ts = now or time.time() + ttl = max(int(self.config.get("code_reuse_ttl") or 600), 0) + order_cache = self._recent_codes_by_order.setdefault(order_key, {}) + order_cache[code] = now_ts + if ttl > 0: + expire_before = now_ts - ttl + stale = [k for k, v in order_cache.items() if v < expire_before] + for key in stale: + order_cache.pop(key, None) + + def _now_iso(self) -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + def _normalize_email(self, email: Optional[str]) -> str: + return str(email or "").strip().lower() + + def _is_resumable_failure_reason(self, reason: str) -> bool: + text = str(reason or "").strip().lower() + if not text: + return False + keywords = ( + "该邮箱已存在 openai", + "邮箱已存在 openai", + "user_already_exists", + "already exists", + "创建用户账户失败", + ) + return any(k in text for k in keywords) + + def _extract_password_from_task_logs(self, logs_text: str) -> str: + if not logs_text: + return "" + matches = re.findall(r"生成密码[::]\s*([^\s]+)", str(logs_text)) + if not matches: + return "" + return str(matches[-1] or "").strip() + + def _recover_password_from_recent_task_logs(self, email: str, max_tasks: int = 30) -> str: + email_norm = self._normalize_email(email) + if not email_norm: + return "" + try: + from sqlalchemy import desc + from ..database.models import RegistrationTask as RegistrationTaskModel + from ..database.session import get_db + + with get_db() as db: + tasks = ( + db.query(RegistrationTaskModel) + .filter(RegistrationTaskModel.logs.isnot(None)) + .order_by(desc(RegistrationTaskModel.created_at)) + .limit(max_tasks) + .all() + ) + + for task in tasks: + logs_text = str(getattr(task, "logs", "") or "") + if email_norm not in logs_text.lower(): + continue + recovered = self._extract_password_from_task_logs(logs_text) + if recovered: + return recovered + except Exception as exc: + logger.warning(f"LuckMail 从任务日志恢复密码失败: {exc}") + return "" + + def _load_email_index(self, path: Path) -> Dict[str, Dict[str, Any]]: + with _STATE_LOCK: + try: + if not path.exists(): + return {} + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, list): + return { + self._normalize_email(e): {"email": self._normalize_email(e), "updated_at": self._now_iso()} + for e in raw + if self._normalize_email(e) + } + if not isinstance(raw, dict): + return {} + payload = raw.get("emails", raw) + if isinstance(payload, list): + return { + self._normalize_email(e): {"email": self._normalize_email(e), "updated_at": self._now_iso()} + for e in payload + if self._normalize_email(e) + } + if isinstance(payload, dict): + result: Dict[str, Dict[str, Any]] = {} + for email_key, meta in payload.items(): + email_norm = self._normalize_email(email_key) + if not email_norm: + continue + if isinstance(meta, dict): + record = meta.copy() + else: + record = {"value": meta} + record["email"] = email_norm + if "updated_at" not in record: + record["updated_at"] = self._now_iso() + result[email_norm] = record + return result + except Exception as exc: + logger.warning(f"LuckMail 读取状态文件失败: {path} - {exc}") + return {} + return {} + + def _save_email_index(self, path: Path, index: Dict[str, Dict[str, Any]]) -> None: + with _STATE_LOCK: + try: + self._data_dir.mkdir(parents=True, exist_ok=True) + payload = { + "updated_at": self._now_iso(), + "count": len(index), + "emails": index, + } + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp_path.replace(path) + except Exception as exc: + logger.warning(f"LuckMail 写入状态文件失败: {path} - {exc}") + + def _mark_registered_email(self, email: str, extra: Optional[Dict[str, Any]] = None) -> None: + email_norm = self._normalize_email(email) + if not email_norm: + return + registered = self._load_email_index(self._registered_file) + failed = self._load_email_index(self._failed_file) + record = registered.get(email_norm, {"email": email_norm}) + record["updated_at"] = self._now_iso() + if extra: + for k, v in extra.items(): + if v is not None and v != "": + record[k] = v + registered[email_norm] = record + failed.pop(email_norm, None) + self._save_email_index(self._registered_file, registered) + self._save_email_index(self._failed_file, failed) + + def _should_force_failed_record(self, reason: str) -> bool: + text = str(reason or "").strip().lower() + if not text: + return False + keywords = ( + "该邮箱已存在 openai", + "邮箱已存在 openai", + "user_already_exists", + "already exists", + "failed to register username", + "用户名注册失败", + "创建用户账户失败", + ) + return any(k in text for k in keywords) + + def _reconcile_failed_over_registered( + self, + registered: Dict[str, Dict[str, Any]], + failed: Dict[str, Dict[str, Any]], + ) -> bool: + changed = False + for email, failed_meta in list(failed.items()): + if email not in registered: + continue + failed_reason = str((failed_meta or {}).get("reason") or "") + if self._should_force_failed_record(failed_reason): + registered.pop(email, None) + changed = True + return changed + + def _mark_failed_email( + self, + email: str, + reason: str = "", + extra: Optional[Dict[str, Any]] = None, + prefer_failed: bool = False, + ) -> Dict[str, Any]: + email_norm = self._normalize_email(email) + if not email_norm: + return {} + + registered = self._load_email_index(self._registered_file) + registered_record: Dict[str, Any] = {} + if email_norm in registered: + if not prefer_failed: + return registered.get(email_norm) or {} + registered_record = dict(registered.get(email_norm) or {}) + registered.pop(email_norm, None) + self._save_email_index(self._registered_file, registered) + + failed = self._load_email_index(self._failed_file) + record = failed.get(email_norm, {"email": email_norm, "fail_count": 0}) + for k, v in registered_record.items(): + if k not in record and v not in (None, ""): + record[k] = v + record["fail_count"] = int(record.get("fail_count") or 0) + 1 + record["updated_at"] = self._now_iso() + if reason: + record["reason"] = reason[:500] + if extra: + for k, v in extra.items(): + if v is not None and v != "": + record[k] = v + failed[email_norm] = record + self._save_email_index(self._failed_file, failed) + return record + + def mark_registration_outcome( + self, + email: str, + success: bool, + reason: str = "", + context: Optional[Dict[str, Any]] = None, + ) -> None: + """供任务调度层调用:把注册结果落盘,避免后续重复尝试同邮箱。""" + if success: + self._mark_registered_email(email, extra=context) + else: + prefer_failed = self._should_force_failed_record(reason) + context_copy = dict(context or {}) + password = str(context_copy.get("generated_password") or context_copy.get("password") or "").strip() + if password: + context_copy["password"] = password + record = self._mark_failed_email( + email, + reason=reason, + extra=context_copy, + prefer_failed=prefer_failed, + ) + self._try_submit_appeal(email=email, reason=reason, context=context_copy, failed_record=record) + + def _resolve_order_id_by_order_no(self, order_no: str, max_pages: int = 3, page_size: int = 50) -> Optional[int]: + order_no_text = str(order_no or "").strip() + if not order_no_text: + return None + try: + for page in range(1, max_pages + 1): + result = self.client.user.get_orders(page=page, page_size=page_size) + items = list(getattr(result, "list", []) or []) + if not items: + break + for item in items: + current_order_no = str(self._extract_field(item, "order_no") or "").strip() + if current_order_no != order_no_text: + continue + order_id_raw = self._extract_field(item, "id", "order_id") + if order_id_raw in (None, ""): + continue + try: + return int(order_id_raw) + except Exception: + continue + if len(items) < page_size: + break + except Exception as exc: + logger.warning(f"LuckMail 查询订单ID失败: {exc}") + return None + + def _build_appeal_payload( + self, + reason: str, + context: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + reason_text = str(reason or "").strip() + reason_lower = reason_text.lower() + + purchase_id_raw = context.get("purchase_id") + order_id_raw = context.get("order_id") + order_no = str(context.get("order_no") or "").strip() + + appeal_type = None + order_id = None + purchase_id = None + + if purchase_id_raw not in (None, ""): + try: + purchase_id = int(purchase_id_raw) + appeal_type = 2 + except Exception: + purchase_id = None + + if appeal_type is None and order_id_raw not in (None, ""): + try: + order_id = int(order_id_raw) + appeal_type = 1 + except Exception: + order_id = None + + if appeal_type is None and order_no: + order_id = self._resolve_order_id_by_order_no(order_no) + if order_id is not None: + appeal_type = 1 + + if appeal_type is None: + return None + + if "429" in reason_lower or "limit" in reason_lower or "限流" in reason_text: + appeal_reason = "no_code" + elif "exists" in reason_lower or "already" in reason_lower or "已存在" in reason_text: + appeal_reason = "email_invalid" + elif "验证码" in reason_text or "otp" in reason_lower: + appeal_reason = "wrong_code" + else: + appeal_reason = "no_code" + + desc = reason_text or "注册任务失败,申请人工核查并处理。" + payload: Dict[str, Any] = { + "appeal_type": appeal_type, + "reason": appeal_reason, + "description": desc[:300], + } + if appeal_type == 1 and order_id is not None: + payload["order_id"] = int(order_id) + if appeal_type == 2 and purchase_id is not None: + payload["purchase_id"] = int(purchase_id) + return payload + + def _try_submit_appeal( + self, + email: str, + reason: str, + context: Dict[str, Any], + failed_record: Optional[Dict[str, Any]] = None, + ) -> None: + if not LUCKMAIL_APPEAL_ENABLED: + return + + reason_text = str(reason or "").strip() + if not reason_text: + return + + reason_lower = reason_text.lower() + should_appeal = ( + "429" in reason_lower + or "限流" in reason_text + or "验证码" in reason_text + or "otp" in reason_lower + or "failed to register username" in reason_lower + or "用户名注册失败" in reason_text + or "创建用户账户失败" in reason_text + or "该邮箱已存在 openai" in reason_lower + or "user_already_exists" in reason_lower + or "already exists" in reason_lower + ) + if not should_appeal: + return + + email_norm = self._normalize_email(email) + if not email_norm: + return + + failed_index = self._load_email_index(self._failed_file) + current = failed_index.get(email_norm, {}) + if not current and failed_record: + current = failed_record + + # 申诉不代表删除:仅记录状态,不从 failed 名单移除。 + last_appeal_status = str(current.get("appeal_status") or "").strip().lower() + if last_appeal_status == "submitted": + return + + payload = self._build_appeal_payload(reason_text, context) + if not payload: + return + + try: + response = self.client.user.create_appeal(**payload) + appeal_no = str(self._extract_field(response, "appeal_no") or "").strip() + current["appeal_status"] = "submitted" + current["appeal_at"] = self._now_iso() + if appeal_no: + current["appeal_no"] = appeal_no + failed_index[email_norm] = current + self._save_email_index(self._failed_file, failed_index) + logger.info(f"LuckMail 已提交申诉: email={email_norm}, appeal_no={appeal_no or '-'}") + except Exception as exc: + current["appeal_status"] = "failed" + current["appeal_error"] = str(exc)[:500] + current["appeal_at"] = self._now_iso() + failed_index[email_norm] = current + self._save_email_index(self._failed_file, failed_index) + logger.warning(f"LuckMail 提交申诉失败: email={email_norm}, error={exc}") + + def _query_existing_account_emails(self, emails: Set[str]) -> Set[str]: + if not emails: + return set() + try: + from sqlalchemy import func + from ..database.models import Account as AccountModel + from ..database.session import get_db + + normalized = [self._normalize_email(e) for e in emails if self._normalize_email(e)] + if not normalized: + return set() + + with get_db() as db: + rows = ( + db.query(func.lower(AccountModel.email)) + .filter(func.lower(AccountModel.email).in_(normalized)) + .all() + ) + result = set() + for row in rows: + try: + value = row[0] + except Exception: + value = "" + email_norm = self._normalize_email(value) + if email_norm: + result.add(email_norm) + return result + except Exception as exc: + logger.warning(f"LuckMail 查询账号库邮箱失败: {exc}") + return set() + + def _iter_purchase_items(self, scan_pages: int, page_size: int): + for page in range(1, scan_pages + 1): + try: + page_result = self.client.user.get_purchases( + page=page, + page_size=page_size, + user_disabled=0, + ) + except Exception as exc: + logger.warning(f"LuckMail 拉取已购邮箱失败: page={page}, error={exc}") + break + + items = list(getattr(page_result, "list", []) or []) + if not items: + break + + for item in items: + yield item + + if len(items) < page_size: + break + + def _build_purchase_order_info( + self, + item: Any, + project_code: str, + email_type: str, + preferred_domain: str, + source: str, + ) -> Optional[Dict[str, Any]]: + email = self._normalize_email(self._extract_field(item, "email_address", "address", "email")) + token = str(self._extract_field(item, "token") or "").strip() + purchase_id_raw = self._extract_field(item, "id", "purchase_id") + purchase_id = str(purchase_id_raw).strip() if purchase_id_raw not in (None, "") else "" + + if not email or not token: + return None + + if preferred_domain: + domain = email.split("@", 1)[1] if "@" in email else "" + if domain != preferred_domain.lower(): + return None + + return { + "id": purchase_id or token, + "service_id": token, + "order_no": "", + "email": email, + "token": token, + "purchase_id": purchase_id or None, + "inbox_mode": "purchase", + "project_code": project_code, + "email_type": email_type, + "preferred_domain": preferred_domain, + "expired_at": "", + "created_at": time.time(), + "source": source, + } + + def _pick_reusable_purchase_inbox( + self, + project_code: str, + email_type: str, + preferred_domain: str, + ) -> Optional[Dict[str, Any]]: + registered = self._load_email_index(self._registered_file) + failed = self._load_email_index(self._failed_file) + if self._reconcile_failed_over_registered(registered, failed): + self._save_email_index(self._registered_file, registered) + + candidates: List[Dict[str, Any]] = [] + for item in self._iter_purchase_items( + scan_pages=int(self.config.get("purchase_scan_pages") or 5), + page_size=int(self.config.get("purchase_scan_page_size") or 100), + ): + info = self._build_purchase_order_info( + item=item, + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + source="reuse_purchase", + ) + if not info: + continue + email = self._normalize_email(info.get("email")) + if not email: + continue + if email in registered: + continue + if email in failed: + failed_meta = failed.get(email) or {} + failed_reason = str(failed_meta.get("reason") or "") + if not self._is_resumable_failure_reason(failed_reason): + continue + + resume_password = str( + failed_meta.get("password") + or failed_meta.get("generated_password") + or "" + ).strip() + if not resume_password: + resume_password = self._recover_password_from_recent_task_logs(email) + if not resume_password: + continue + + info["resume_password"] = resume_password + info["source"] = "resume_failed" + candidates.append(info) + + if not candidates: + return None + + existing_in_db = self._query_existing_account_emails({self._normalize_email(c.get("email")) for c in candidates}) + for info in candidates: + email = self._normalize_email(info.get("email")) + if email in existing_in_db: + self._mark_registered_email( + email, + extra={ + "source": "accounts_db", + "token": info.get("token"), + "purchase_id": info.get("purchase_id"), + }, + ) + continue + return info + return None + + def _create_order_inbox( + self, + project_code: str, + email_type: str, + preferred_domain: str, + specified_email: Optional[str] = None, + ) -> Dict[str, Any]: + try: + kwargs: Dict[str, Any] = { + "project_code": project_code, + "email_type": email_type, + } + if preferred_domain: + kwargs["domain"] = preferred_domain + if specified_email: + kwargs["specified_email"] = specified_email + order = self.client.user.create_order(**kwargs) + except Exception as exc: + self.update_status(False, exc) + raise EmailServiceError(f"LuckMail 创建订单失败: {exc}") + + order_no = str(self._extract_field(order, "order_no") or "").strip() + email = str(self._extract_field(order, "email_address", "email") or "").strip().lower() + if not order_no or not email: + raise EmailServiceError("LuckMail 返回订单信息不完整") + + return { + "id": order_no, + "service_id": order_no, + "order_no": order_no, + "email": email, + "token": "", + "purchase_id": None, + "inbox_mode": "order", + "project_code": project_code, + "email_type": email_type, + "preferred_domain": preferred_domain, + "expired_at": str(self._extract_field(order, "expired_at") or "").strip(), + "created_at": time.time(), + "source": "new_order", + } + + def _extract_first_purchase_item(self, purchased: Any) -> Any: + if purchased is None: + return None + + if isinstance(purchased, list): + return purchased[0] if purchased else None + + if isinstance(purchased, dict): + for key in ("purchases", "list", "items"): + arr = purchased.get(key) + if isinstance(arr, list) and arr: + return arr[0] + data = purchased.get("data") + if isinstance(data, dict): + for key in ("purchases", "list", "items"): + arr = data.get(key) + if isinstance(arr, list) and arr: + return arr[0] + return None + + for key in ("purchases", "list", "items"): + arr = getattr(purchased, key, None) + if isinstance(arr, list) and arr: + return arr[0] + + return None + + def _create_purchase_inbox( + self, + project_code: str, + email_type: str, + preferred_domain: str, + ) -> Dict[str, Any]: + try: + kwargs: Dict[str, Any] = { + "project_code": project_code, + "quantity": 1, + "email_type": email_type, + } + if preferred_domain: + kwargs["domain"] = preferred_domain + purchased = self.client.user.purchase_emails(**kwargs) + except Exception as exc: + self.update_status(False, exc) + raise EmailServiceError(f"LuckMail 购买邮箱失败: {exc}") + + item = self._extract_first_purchase_item(purchased) + if item is None: + raise EmailServiceError("LuckMail 购买邮箱返回为空") + + email = str(self._extract_field(item, "email_address", "address", "email") or "").strip().lower() + token = str(self._extract_field(item, "token") or "").strip() + purchase_id_raw = self._extract_field(item, "id", "purchase_id") + purchase_id = str(purchase_id_raw).strip() if purchase_id_raw not in (None, "") else None + + if not email or not token: + raise EmailServiceError("LuckMail 购买邮箱返回字段不完整(缺少 email/token)") + + return { + "id": purchase_id or token, + "service_id": token, + "order_no": "", + "email": email, + "token": token, + "purchase_id": purchase_id, + "inbox_mode": "purchase", + "project_code": project_code, + "email_type": email_type, + "preferred_domain": preferred_domain, + "expired_at": "", + "created_at": time.time(), + "source": "new_purchase", + } + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + request_config = config or {} + project_code = str(request_config.get("project_code") or self.config["project_code"]).strip() + email_type = str(request_config.get("email_type") or self.config["email_type"]).strip() + preferred_domain = str( + request_config.get("preferred_domain") + or request_config.get("domain") + or self.config.get("preferred_domain") + or "" + ).strip().lstrip("@") + + inbox_mode = self._normalize_inbox_mode( + request_config.get("inbox_mode") or request_config.get("mode") or self.config.get("inbox_mode") + ) + + if inbox_mode == "order": + order_info = self._create_order_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + else: + if bool(self.config.get("reuse_existing_purchases", True)): + reused = self._pick_reusable_purchase_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + if reused: + order_info = reused + else: + order_info = self._create_purchase_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + else: + order_info = self._create_purchase_inbox( + project_code=project_code, + email_type=email_type, + preferred_domain=preferred_domain, + ) + + self._cache_order(order_info) + self.update_status(True) + return order_info + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN, + otp_sent_at: Optional[float] = None, + ) -> Optional[str]: + order_info = self._find_order(email=email, email_id=email_id) + + token = "" + order_no = "" + inbox_mode = self._normalize_inbox_mode(self.config.get("inbox_mode")) + if order_info: + token = str(order_info.get("token") or "").strip() + order_no = str(order_info.get("order_no") or order_info.get("service_id") or "").strip() + inbox_mode = self._normalize_inbox_mode(order_info.get("inbox_mode") or inbox_mode) + + if not token and email_id and str(email_id).strip().startswith("tok_"): + token = str(email_id).strip() + inbox_mode = "purchase" + + if not order_no and email_id and not token: + order_no = str(email_id).strip() + + if inbox_mode == "purchase": + if not token: + logger.warning(f"LuckMail 未找到 token,无法拉取验证码: email={email}, email_id={email_id}") + return None + code_key = f"token:{token}" + else: + if not order_no: + logger.warning(f"LuckMail 未找到订单号,无法拉取验证码: email={email}, email_id={email_id}") + return None + code_key = f"order:{order_no}" + + poll_interval = float(self.config.get("poll_interval") or 3.0) + timeout_s = max(int(timeout or 120), 1) + deadline = time.time() + timeout_s + # OTP 刚发送后的短窗口内更容易读到旧码;配合“最近已用验证码”一起过滤。 + otp_guard_until = (float(otp_sent_at) + 1.5) if otp_sent_at else None + + while time.time() < deadline: + try: + if inbox_mode == "purchase": + result = self.client.user.get_token_code(token) + status = "success" if bool(self._extract_field(result, "has_new_mail")) else "pending" + else: + result = self.client.user.get_order_code(order_no) + status = str(self._extract_field(result, "status") or "").strip().lower() + except Exception as exc: + logger.warning(f"LuckMail 拉取验证码失败: {exc}") + self.update_status(False, exc) + time.sleep(min(poll_interval, 1.0)) + continue + + code = str(self._extract_field(result, "verification_code") or "").strip() + + # token 模式下,部分平台会在 has_new_mail=false 时也返回最近一次 code。 + # 这里以 code 为准,再配合“最近已用验证码”过滤旧码。 + if inbox_mode == "purchase" and code: + status = "success" + + if status in ("timeout", "cancelled"): + ref = token if inbox_mode == "purchase" else order_no + logger.info(f"LuckMail 未拿到验证码: {ref}, status={status}") + return None + + if status == "success" and code: + if pattern and not re.search(pattern, code): + logger.warning(f"LuckMail 返回验证码格式不匹配: {code}") + return None + + now_ts = time.time() + if otp_guard_until and now_ts < otp_guard_until and self._is_recent_code(code_key, code, now_ts): + time.sleep(poll_interval) + continue + + if self._is_recent_code(code_key, code, now_ts): + # 同一 token/订单在不同流程阶段会复用查询接口,这里阻断旧码重复返回。 + time.sleep(poll_interval) + continue + + self._remember_code(code_key, code, now_ts) + self.update_status(True) + return code + + time.sleep(poll_interval) + + return None + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + _ = kwargs + return list(self._orders_by_no.values()) + + def delete_email(self, email_id: str) -> bool: + order_info = self._find_order(email=email_id, email_id=email_id) + token = str((order_info or {}).get("token") or "").strip() + purchase_id = str((order_info or {}).get("purchase_id") or "").strip() + order_no = str((order_info or {}).get("order_no") or "").strip() + + if not token and not order_no: + raw_id = str(email_id or "").strip() + if raw_id.startswith("tok_"): + token = raw_id + else: + order_no = raw_id + + if not token and not order_no: + return False + + try: + if token and purchase_id.isdigit(): + # 购买邮箱通常不支持直接删除,标记禁用即可。 + try: + self.client.user.set_purchase_disabled(int(purchase_id), 1) + except Exception: + pass + elif order_no: + self.client.user.cancel_order(order_no) + + key = token or order_no + item = self._orders_by_no.pop(key, None) + if item: + email = str(item.get("email") or "").strip().lower() + if email: + self._orders_by_email.pop(email, None) + if token: + self._recent_codes_by_order.pop(f"token:{token}", None) + if order_no: + self._recent_codes_by_order.pop(f"order:{order_no}", None) + self.update_status(True) + return True + except Exception as exc: + logger.warning(f"LuckMail 删除邮箱失败: {exc}") + self.update_status(False, exc) + return False + + def check_health(self) -> bool: + try: + self.client.user.get_balance() + self.update_status(True) + return True + except Exception as exc: + logger.warning(f"LuckMail 健康检查失败: {exc}") + self.update_status(False, exc) + return False + + def get_service_info(self) -> Dict[str, Any]: + return { + "service_type": self.service_type.value, + "name": self.name, + "base_url": self.config.get("base_url"), + "project_code": self.config.get("project_code"), + "email_type": self.config.get("email_type"), + "preferred_domain": self.config.get("preferred_domain"), + "inbox_mode": self.config.get("inbox_mode"), + "cached_orders": len(self._orders_by_no), + "status": self.status.value, + } diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index 62908a8c..4d341d73 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -67,7 +67,7 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None): timeout=self.config["timeout"], max_retries=self.config["max_retries"], ) - self.http_client = HTTPClient(proxy_url=None, config=http_config) + self.http_client = HTTPClient(proxy_url=self.config.get("proxy_url"), config=http_config) # 邮箱缓存:email -> {jwt, address} self._email_cache: Dict[str, Dict[str, Any]] = {} diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 4024d90e..0ca20da9 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -19,7 +19,7 @@ from ...config.constants import AccountStatus from ...config.settings import get_settings -from ...core.openai.overview import fetch_codex_overview +from ...core.openai.overview import fetch_codex_overview, AccountDeactivatedError from ...core.openai.token_refresh import refresh_account_token as do_refresh from ...core.openai.token_refresh import validate_account_token as do_validate from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa @@ -603,6 +603,17 @@ def _get_account_overview_data( account.extra_data = merged_extra updated = True return overview, updated + except AccountDeactivatedError as exc: + logger.warning("账号被停用: email=%s err=%s", account.email, exc) + account.status = AccountStatus.BANNED.value + merged_extra = dict(extra_data) + merged_extra[OVERVIEW_EXTRA_DATA_KEY] = _fallback_overview( + account, error_message="account_deactivated", stale=True + ) + merged_extra["account_deactivated_at"] = datetime.now(timezone.utc).isoformat() + account.extra_data = merged_extra + updated = True + return merged_extra[OVERVIEW_EXTRA_DATA_KEY], updated except Exception as exc: logger.warning(f"刷新账号[{account.email}]总览失败: {exc}") if cached: @@ -2279,6 +2290,7 @@ def _build_inbox_config(db, service_type, email: str) -> dict: EST.FREEMAIL: "freemail", EST.IMAP_MAIL: "imap_mail", EST.OUTLOOK: "outlook", + EST.LUCKMAIL: "luckmail", } db_type = type_map.get(service_type) if not db_type: diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 8dd4ef1f..8be3da8a 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -188,6 +188,7 @@ async def get_email_services_stats(): 'freemail_count': 0, 'imap_mail_count': 0, 'cloudmail_count': 0, + 'luckmail_count': 0, 'tempmail_available': tempmail_enabled or yyds_enabled, 'yyds_mail_available': yyds_enabled, 'enabled_count': enabled_count @@ -210,6 +211,8 @@ async def get_email_services_stats(): stats['imap_mail_count'] = count elif service_type == 'cloudmail': stats['cloudmail_count'] = count + elif service_type == 'luckmail': + stats['luckmail_count'] = count return stats @@ -304,6 +307,19 @@ async def get_service_types(): {"name": "email", "label": "邮箱地址", "required": True}, {"name": "password", "label": "密码/授权码", "required": True, "secret": True}, ] + }, + { + "value": "luckmail", + "label": "LuckMail", + "description": "LuckMail 接码服务(下单 + 轮询验证码)", + "config_fields": [ + {"name": "base_url", "label": "平台地址", "required": False, "default": "https://mails.luckyous.com/"}, + {"name": "api_key", "label": "API Key", "required": True, "secret": True}, + {"name": "project_code", "label": "项目编码", "required": False, "default": "openai"}, + {"name": "email_type", "label": "邮箱类型", "required": False, "default": "ms_graph"}, + {"name": "preferred_domain", "label": "优先域名", "required": False, "placeholder": "outlook.com"}, + {"name": "poll_interval", "label": "轮询间隔(秒)", "required": False, "default": 3.0}, + ] } ] } diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index 17c30729..c511c362 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -746,6 +746,9 @@ def _normalize_email_service_config_for_session_bootstrap( elif service_type == EmailServiceType.DUCK_MAIL: if "domain" in normalized and "default_domain" not in normalized: normalized["default_domain"] = normalized.pop("domain") + elif service_type == EmailServiceType.LUCKMAIL: + if "domain" in normalized and "preferred_domain" not in normalized: + normalized["preferred_domain"] = normalized.pop("domain") # IMAP/Outlook 等可按需使用代理;Temp-Mail/Freemail 强制直连。 if proxy_url and "proxy_url" not in normalized and service_type not in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index cb89f056..5163d1e9 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -217,6 +217,9 @@ def _normalize_email_service_config( elif service_type == EmailServiceType.DUCK_MAIL: if 'domain' in normalized and 'default_domain' not in normalized: normalized['default_domain'] = normalized.pop('domain') + elif service_type == EmailServiceType.LUCKMAIL: + if 'domain' in normalized and 'preferred_domain' not in normalized: + normalized['preferred_domain'] = normalized.pop('domain') if proxy_url and 'proxy_url' not in normalized: normalized['proxy_url'] = proxy_url @@ -403,6 +406,22 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: logger.info(f"使用数据库 IMAP 邮箱服务: {db_service.name}") else: raise ValueError("没有可用的 IMAP 邮箱服务,请先在邮箱服务中添加") + elif service_type == EmailServiceType.LUCKMAIL: + from ...database.models import EmailService as EmailServiceModel + + db_service = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "luckmail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).first() + + if db_service and db_service.config: + config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + crud.update_registration_task(db, task_uuid, email_service_id=db_service.id) + logger.info(f"使用数据库 LuckMail 服务: {db_service.name}") + else: + config = _normalize_email_service_config(service_type, email_service_config or {}, actual_proxy_url) + if not config.get("api_key"): + raise ValueError("没有可用的 LuckMail 服务,请先在邮箱服务中添加并填写 API Key") else: config = email_service_config or {} @@ -420,6 +439,16 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: # 执行注册 result = engine.run() + marker = getattr(email_service, "mark_registration_outcome", None) + marker_context = {} + try: + info = getattr(engine, "email_info", None) or {} + for key in ("service_id", "order_no", "token", "purchase_id", "source"): + value = info.get(key) if isinstance(info, dict) else None + if value not in (None, ""): + marker_context[key] = value + except Exception: + marker_context = {} if result.success: # 更新代理使用时间 @@ -428,6 +457,16 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: # 保存到数据库 engine.save_to_database(result) + if callable(marker) and result.email: + try: + marker( + email=result.email, + success=True, + context=marker_context, + ) + except Exception as mark_err: + logger.warning(f"记录邮箱成功状态失败: {mark_err}") + # 自动上传到 CPA(可多服务) if auto_upload_cpa: try: @@ -524,6 +563,17 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: logger.info(f"注册任务完成: {task_uuid}, 邮箱: {result.email}") else: + if callable(marker) and result.email: + try: + marker( + email=result.email, + success=False, + reason=result.error_message or "", + context=marker_context, + ) + except Exception as mark_err: + logger.warning(f"记录邮箱失败状态失败: {mark_err}") + # 更新任务状态为失败 crud.update_registration_task( db, task_uuid, @@ -1175,6 +1225,11 @@ async def get_available_email_services(): "available": False, "count": 0, "services": [] + }, + "luckmail": { + "available": False, + "count": 0, + "services": [] } } @@ -1333,6 +1388,26 @@ async def get_available_email_services(): result["imap_mail"]["count"] = len(imap_mail_services) result["imap_mail"]["available"] = len(imap_mail_services) > 0 + luckmail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "luckmail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in luckmail_services: + config = service.config or {} + result["luckmail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "luckmail", + "project_code": config.get("project_code"), + "email_type": config.get("email_type"), + "preferred_domain": config.get("preferred_domain"), + "priority": service.priority + }) + + result["luckmail"]["count"] = len(luckmail_services) + result["luckmail"]["available"] = len(luckmail_services) > 0 + return result diff --git a/static/js/app.js b/static/js/app.js index 8fd65b6d..bc6608f4 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -27,6 +27,7 @@ let availableServices = { moe_mail: { available: false, services: [] }, temp_mail: { available: false, services: [] }, duck_mail: { available: false, services: [] }, + luckmail: { available: false, services: [] }, freemail: { available: false, services: [] } }; @@ -390,6 +391,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + // LuckMail + if (availableServices.luckmail && availableServices.luckmail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `✉️ LuckMail (${availableServices.luckmail.count} 个服务)`; + + availableServices.luckmail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `luckmail:${service.id}`; + option.textContent = service.name + (service.preferred_domain ? ` (@${service.preferred_domain})` : ''); + option.dataset.type = 'luckmail'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } + // Freemail if (availableServices.freemail && availableServices.freemail.available) { const optgroup = document.createElement('optgroup'); @@ -456,6 +474,11 @@ function handleServiceChange(e) { if (service) { addLog('info', `[系统] 已选择 DuckMail 服务: ${service.name}`); } + } else if (type === 'luckmail') { + const service = availableServices.luckmail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 LuckMail 服务: ${service.name}`); + } } else if (type === 'freemail') { const service = availableServices.freemail.services.find(s => s.id == id); if (service) { diff --git a/static/js/email_services.js b/static/js/email_services.js index 919f4012..180d5e6f 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -4,7 +4,7 @@ // 状态 let outlookServices = []; -let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail +let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + luckmail + freemail + imap_mail let selectedOutlook = new Set(); let selectedCustom = new Set(); @@ -58,6 +58,7 @@ const elements = { addYydsMailFields: document.getElementById('add-yydsmail-fields'), addTempmailFields: document.getElementById('add-tempmail-fields'), addDuckmailFields: document.getElementById('add-duckmail-fields'), + addLuckmailFields: document.getElementById('add-luckmail-fields'), addFreemailFields: document.getElementById('add-freemail-fields'), addImapFields: document.getElementById('add-imap-fields'), @@ -70,6 +71,7 @@ const elements = { editYydsMailFields: document.getElementById('edit-yydsmail-fields'), editTempmailFields: document.getElementById('edit-tempmail-fields'), editDuckmailFields: document.getElementById('edit-duckmail-fields'), + editLuckmailFields: document.getElementById('edit-luckmail-fields'), editFreemailFields: document.getElementById('edit-freemail-fields'), editImapFields: document.getElementById('edit-imap-fields'), editCustomTypeBadge: document.getElementById('edit-custom-type-badge'), @@ -87,6 +89,7 @@ const CUSTOM_SUBTYPE_LABELS = { moemail: '🔗 MoeMail(自定义域名 API)', tempmail: '📮 TempMail(自部署 Cloudflare Worker)', duckmail: '🦆 DuckMail(DuckMail API)', + luckmail: '✉️ LuckMail(接码平台)', freemail: 'Freemail(自部署 Cloudflare Worker)', imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' }; @@ -196,6 +199,7 @@ function switchAddSubType(subType) { elements.addYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none'; elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; + elements.addLuckmailFields.style.display = subType === 'luckmail' ? '' : 'none'; elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; } @@ -207,6 +211,7 @@ function switchEditSubType(subType) { elements.editYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none'; elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; + elements.editLuckmailFields.style.display = subType === 'luckmail' ? '' : 'none'; elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; @@ -217,7 +222,7 @@ async function loadStats() { try { const data = await api.get('/email-services/stats'); elements.outlookCount.textContent = data.outlook_count || 0; - elements.customCount.textContent = (data.custom_count || 0) + (data.yyds_mail_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); + elements.customCount.textContent = (data.custom_count || 0) + (data.yyds_mail_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.luckmail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; elements.totalEnabled.textContent = data.enabled_count || 0; } catch (error) { @@ -320,6 +325,9 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'duckmail') { return 'DuckMail'; } + if (subType === 'luckmail') { + return 'LuckMail'; + } if (subType === 'freemail') { return 'Freemail'; } @@ -332,6 +340,14 @@ function getCustomServiceAddress(service) { const emailAddr = service.config?.email || ''; return `${escapeHtml(host)}