From 807c452225f34db286fff969726aa1c631d8ed75 Mon Sep 17 00:00:00 2001 From: icesugar Date: Tue, 31 Mar 2026 00:36:45 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat;=20=E6=B7=BB=E5=8A=A0=20Sub2API=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E5=9B=9E=E5=A1=AB=20CPA=20=E5=92=8C=20Sub2AP?= =?UTF-8?q?I=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=92=8C=E5=89=8D=E7=AB=AF=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/chatgpt.py | 21 ++++ api/config.py | 1 + frontend/src/pages/Settings.tsx | 8 ++ platforms/chatgpt/plugin.py | 185 +++++++++++++++++++--------- platforms/chatgpt/sub2api_upload.py | 164 ++++++++++++++++++++++++ services/external_sync.py | 43 +++++-- 6 files changed, 355 insertions(+), 67 deletions(-) create mode 100644 platforms/chatgpt/sub2api_upload.py diff --git a/api/chatgpt.py b/api/chatgpt.py index 26059f3..a3c8aa1 100644 --- a/api/chatgpt.py +++ b/api/chatgpt.py @@ -134,3 +134,24 @@ def upload_cpa(account_id: int, req: CpaUploadReq, token_data = generate_token_json(codex_acc) ok, msg = upload_to_cpa(token_data, api_url=req.api_url, api_key=req.api_key) return {"ok": ok, "message": msg} + + +class Sub2ApiUploadReq(BaseModel): + api_url: str + api_key: str = "" + + +@router.post("/{account_id}/upload-sub2api") +def upload_sub2api(account_id: int, req: Sub2ApiUploadReq, + session: Session = Depends(get_session)): + acc = _get_account(account_id, session) + codex_acc = _to_codex_account(acc) + + from platforms.chatgpt.sub2api_upload import upload_to_sub2api + + ok, msg = upload_to_sub2api( + codex_acc, + api_url=req.api_url, + api_key=req.api_key, + ) + return {"ok": ok, "message": msg} diff --git a/api/config.py b/api/config.py index d6dac62..228fff9 100644 --- a/api/config.py +++ b/api/config.py @@ -15,6 +15,7 @@ CONFIG_KEYS = [ "cfworker_api_url", "cfworker_admin_token", "cfworker_domain", "cfworker_fingerprint", "luckmail_base_url", "luckmail_api_key", "luckmail_email_type", "luckmail_domain", "cpa_api_url", "cpa_api_key", + "sub2api_api_url", "sub2api_api_key", "team_manager_url", "team_manager_key", "cliproxyapi_management_key", "grok2api_url", "grok2api_app_key", "grok2api_pool", "grok2api_quota", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c158c60..9c0e766 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -146,6 +146,14 @@ const TAB_ITEMS = [ { key: 'cpa_api_key', label: 'API Key', secret: true }, ], }, + { + title: 'Sub2API 面板', + desc: '注册完成后自动上传到 Sub2API 管理后台', + fields: [ + { key: 'sub2api_api_url', label: 'API URL', placeholder: 'https://your-sub2api.example.com' }, + { key: 'sub2api_api_key', label: 'API Key', secret: true }, + ], + }, { title: 'Team Manager', desc: '上传到自建 Team Manager 系统', diff --git a/platforms/chatgpt/plugin.py b/platforms/chatgpt/plugin.py index e0583ea..ec3dc52 100644 --- a/platforms/chatgpt/plugin.py +++ b/platforms/chatgpt/plugin.py @@ -1,7 +1,10 @@ """ChatGPT / Codex CLI 平台插件""" -import random, string -from core.base_platform import BasePlatform, Account, AccountStatus, RegisterConfig + +import random +import string + from core.base_mailbox import BaseMailbox +from core.base_platform import Account, AccountStatus, BasePlatform, RegisterConfig from core.registry import register @@ -18,7 +21,10 @@ class ChatGPTPlatform(BasePlatform): def check_valid(self, account: Account) -> bool: try: from platforms.chatgpt.payment import check_subscription_status - class _A: pass + + class _A: + pass + a = _A() extra = account.extra or {} a.access_token = extra.get("access_token") or account.token @@ -30,14 +36,13 @@ class ChatGPTPlatform(BasePlatform): def register(self, email: str = None, password: str = None) -> Account: if not password: - password = "".join(random.choices( - string.ascii_letters + string.digits + "!@#$", k=16)) + password = "".join(random.choices(string.ascii_letters + string.digits + "!@#$", k=16)) proxy = self.config.proxy if self.config else None browser_mode = (self.config.executor_type if self.config else None) or "protocol" - log_fn = getattr(self, '_log_fn', print) + log_fn = getattr(self, "_log_fn", print) from platforms.chatgpt.register_v2 import RegistrationEngineV2 as RegistrationEngine - log_fn = getattr(self, '_log_fn', print) + max_retries = 3 if self.config and getattr(self.config, "extra", None): try: @@ -46,25 +51,35 @@ class ChatGPTPlatform(BasePlatform): max_retries = 3 if self.mailbox: - # 通用 EmailService 适配器,支持所有 BaseMailbox 实现 (cfworker, duckmail, laoudo 等) _mailbox = self.mailbox _fixed_email = email class GenericEmailService: - service_type = type('ST', (), {'value': 'custom_provider'})() + service_type = type("ST", (), {"value": "custom_provider"})() + def __init__(self): self._acct = None self._email = _fixed_email + def create_email(self, config=None): if self._email and self._acct and _fixed_email: - return {'email': self._email, 'service_id': self._acct.account_id, 'token': ''} + return {"email": self._email, "service_id": self._acct.account_id, "token": ""} self._acct = _mailbox.get_email() if not self._email: self._email = self._acct.email elif not _fixed_email: self._email = self._acct.email - return {'email': self._email, 'service_id': self._acct.account_id, 'token': ''} - def get_verification_code(self, email=None, email_id=None, timeout=120, pattern=None, otp_sent_at=None, exclude_codes=None): + return {"email": self._email, "service_id": self._acct.account_id, "token": ""} + + def get_verification_code( + self, + email=None, + email_id=None, + timeout=120, + pattern=None, + otp_sent_at=None, + exclude_codes=None, + ): if not self._acct: raise RuntimeError("邮箱账户尚未创建,无法获取验证码") return _mailbox.wait_for_code( @@ -74,9 +89,13 @@ class ChatGPTPlatform(BasePlatform): otp_sent_at=otp_sent_at, exclude_codes=exclude_codes, ) - def update_status(self, success, error=None): pass + + def update_status(self, success, error=None): + pass + @property - def status(self): return None + def status(self): + return None engine = RegistrationEngine( email_service=GenericEmailService(), @@ -88,17 +107,27 @@ class ChatGPTPlatform(BasePlatform): engine.email = email engine.password = password else: - # 兼容逻辑:若未传入 mailbox 则默认使用 tempmail_lol from core.base_mailbox import TempMailLolMailbox + _tmail = TempMailLolMailbox(proxy=proxy) class TempMailEmailService: - service_type = type('ST', (), {'value': 'tempmail_lol'})() + service_type = type("ST", (), {"value": "tempmail_lol"})() + def create_email(self, config=None): acct = _tmail.get_email() self._acct = acct - return {'email': acct.email, 'service_id': acct.account_id, 'token': acct.account_id} - def get_verification_code(self, email=None, email_id=None, timeout=120, pattern=None, otp_sent_at=None, exclude_codes=None): + return {"email": acct.email, "service_id": acct.account_id, "token": acct.account_id} + + def get_verification_code( + self, + email=None, + email_id=None, + timeout=120, + pattern=None, + otp_sent_at=None, + exclude_codes=None, + ): return _tmail.wait_for_code( self._acct, keyword="", @@ -106,9 +135,13 @@ class ChatGPTPlatform(BasePlatform): otp_sent_at=otp_sent_at, exclude_codes=exclude_codes, ) - def update_status(self, success, error=None): pass + + def update_status(self, success, error=None): + pass + @property - def status(self): return None + def status(self): + return None engine = RegistrationEngine( email_service=TempMailEmailService(), @@ -123,51 +156,68 @@ class ChatGPTPlatform(BasePlatform): result = engine.run() if not result or not result.success: - raise RuntimeError(result.error_message if result else '注册失败') + raise RuntimeError(result.error_message if result else "注册失败") return Account( - platform='chatgpt', + platform="chatgpt", email=result.email, password=result.password or password, user_id=result.account_id, token=result.access_token, status=AccountStatus.REGISTERED, extra={ - 'access_token': result.access_token, - 'refresh_token': result.refresh_token, - 'id_token': result.id_token, - 'session_token': result.session_token, - 'workspace_id': result.workspace_id, + "access_token": result.access_token, + "refresh_token": result.refresh_token, + "id_token": result.id_token, + "session_token": result.session_token, + "workspace_id": result.workspace_id, }, ) def get_platform_actions(self) -> list: return [ {"id": "refresh_token", "label": "刷新 Token", "params": []}, - {"id": "payment_link", "label": "生成支付链接", - "params": [ - {"key": "country", "label": "地区", "type": "select", - "options": ["US","SG","TR","HK","JP","GB","AU","CA"]}, - {"key": "plan", "label": "套餐", "type": "select", - "options": ["plus", "team"]}, - ]}, - {"id": "upload_cpa", "label": "上传 CPA", - "params": [ - {"key": "api_url", "label": "CPA API URL", "type": "text"}, - {"key": "api_key", "label": "CPA API Key", "type": "text"}, - ]}, - {"id": "upload_tm", "label": "上传 Team Manager", - "params": [ - {"key": "api_url", "label": "TM API URL", "type": "text"}, - {"key": "api_key", "label": "TM API Key", "type": "text"}, - ]}, + { + "id": "payment_link", + "label": "生成支付链接", + "params": [ + {"key": "country", "label": "地区", "type": "select", "options": ["US", "SG", "TR", "HK", "JP", "GB", "AU", "CA"]}, + {"key": "plan", "label": "套餐", "type": "select", "options": ["plus", "team"]}, + ], + }, + { + "id": "upload_cpa", + "label": "上传 CPA", + "params": [ + {"key": "api_url", "label": "CPA API URL", "type": "text"}, + {"key": "api_key", "label": "CPA API Key", "type": "text"}, + ], + }, + { + "id": "upload_sub2api", + "label": "上传 Sub2API", + "params": [ + {"key": "api_url", "label": "Sub2API API URL", "type": "text"}, + {"key": "api_key", "label": "Sub2API API Key", "type": "text"}, + ], + }, + { + "id": "upload_tm", + "label": "上传 Team Manager", + "params": [ + {"key": "api_url", "label": "TM API URL", "type": "text"}, + {"key": "api_key", "label": "TM API Key", "type": "text"}, + ], + }, ] def execute_action(self, action_id: str, account: Account, params: dict) -> dict: proxy = self.config.proxy if self.config else None extra = account.extra or {} - class _A: pass + class _A: + pass + a = _A() a.email = account.email a.access_token = extra.get("access_token") or account.token @@ -179,15 +229,22 @@ class ChatGPTPlatform(BasePlatform): if action_id == "refresh_token": from platforms.chatgpt.token_refresh import TokenRefreshManager + manager = TokenRefreshManager(proxy_url=proxy) result = manager.refresh_account(a) if result.success: - return {"ok": True, "data": {"access_token": result.access_token, - "refresh_token": result.refresh_token}} + return { + "ok": True, + "data": { + "access_token": result.access_token, + "refresh_token": result.refresh_token, + }, + } return {"ok": False, "error": result.error_message} - elif action_id == "payment_link": + if action_id == "payment_link": from platforms.chatgpt.payment import generate_plus_link, generate_team_link + plan = params.get("plan", "plus") country = params.get("country", "US") if plan == "plus": @@ -196,17 +253,35 @@ class ChatGPTPlatform(BasePlatform): url = generate_team_link(a, proxy=proxy, country=country) return {"ok": bool(url), "data": {"url": url}} - elif action_id == "upload_cpa": - from platforms.chatgpt.cpa_upload import upload_to_cpa, generate_token_json + if action_id == "upload_cpa": + from platforms.chatgpt.cpa_upload import generate_token_json, upload_to_cpa + token_data = generate_token_json(a) - ok, msg = upload_to_cpa(token_data, api_url=params.get("api_url"), - api_key=params.get("api_key")) + ok, msg = upload_to_cpa( + token_data, + api_url=params.get("api_url"), + api_key=params.get("api_key"), + ) return {"ok": ok, "data": msg} - elif action_id == "upload_tm": + if action_id == "upload_sub2api": + from platforms.chatgpt.sub2api_upload import upload_to_sub2api + + ok, msg = upload_to_sub2api( + a, + api_url=params.get("api_url"), + api_key=params.get("api_key"), + ) + return {"ok": ok, "data": msg} + + if action_id == "upload_tm": from platforms.chatgpt.cpa_upload import upload_to_team_manager - ok, msg = upload_to_team_manager(a, api_url=params.get("api_url"), - api_key=params.get("api_key")) + + ok, msg = upload_to_team_manager( + a, + api_url=params.get("api_url"), + api_key=params.get("api_key"), + ) return {"ok": ok, "data": msg} raise NotImplementedError(f"未知操作: {action_id}") diff --git a/platforms/chatgpt/sub2api_upload.py b/platforms/chatgpt/sub2api_upload.py new file mode 100644 index 0000000..0ed880e --- /dev/null +++ b/platforms/chatgpt/sub2api_upload.py @@ -0,0 +1,164 @@ +""" +Sub2API 上传功能 +""" + +from __future__ import annotations + +import base64 +import json +import logging +import time +from typing import Any, Tuple + +from curl_cffi import requests as cffi_requests + +from platforms.chatgpt.cpa_upload import generate_token_json + +logger = logging.getLogger(__name__) + +DEFAULT_GROUP_IDS = [2] +DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" + + +def _get_config_value(key: str) -> str: + try: + from core.config_store import config_store + + return str(config_store.get(key, "") or "").strip() + except Exception: + return "" + + +def _decode_jwt_payload(token: str) -> dict[str, Any]: + try: + parts = str(token or "").split(".") + if len(parts) < 2: + return {} + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + decoded = base64.urlsafe_b64decode(payload) + data = json.loads(decoded) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _extract_auth(payload: dict[str, Any]) -> dict[str, Any]: + auth_info = payload.get("https://api.openai.com/auth") + return auth_info if isinstance(auth_info, dict) else {} + + +def _extract_organization_id(id_token_payload: dict[str, Any]) -> str: + auth_info = _extract_auth(id_token_payload) + organization_id = str(auth_info.get("organization_id") or "").strip() + if organization_id: + return organization_id + + organizations = auth_info.get("organizations") or [] + if isinstance(organizations, list): + for item in organizations: + if isinstance(item, dict): + organization_id = str(item.get("id") or "").strip() + if organization_id: + return organization_id + return "" + + +def _build_sub2api_account_payload(account, group_ids: list[int] | None = None) -> dict[str, Any]: + token_data = generate_token_json(account) + access_token = str(token_data.get("access_token") or "").strip() + refresh_token = str(token_data.get("refresh_token") or "").strip() + id_token = str(token_data.get("id_token") or "").strip() + email = str(token_data.get("email") or getattr(account, "email", "") or "").strip() + + access_payload = _decode_jwt_payload(access_token) + access_auth = _extract_auth(access_payload) + expires_at = access_payload.get("exp") + if not isinstance(expires_at, int) or expires_at <= 0: + expires_at = int(time.time()) + 863999 + + # 关键逻辑:Sub2API 依赖 OpenAI OAuth 结构化字段,这里尽量从现有 token 自动补齐。 + organization_id = _extract_organization_id(_decode_jwt_payload(id_token)) + + return { + "name": email, + "notes": "", + "platform": "openai", + "type": "oauth", + "credentials": { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_in": 863999, + "expires_at": expires_at, + "chatgpt_account_id": str( + access_auth.get("chatgpt_account_id") or token_data.get("account_id") or "" + ).strip(), + "chatgpt_user_id": str(access_auth.get("chatgpt_user_id") or "").strip(), + "organization_id": organization_id, + "client_id": str(getattr(account, "client_id", "") or DEFAULT_CLIENT_ID).strip() or DEFAULT_CLIENT_ID, + "id_token": id_token, + }, + "extra": {"email": email}, + "group_ids": group_ids or DEFAULT_GROUP_IDS, + "concurrency": 10, + "priority": 1, + "auto_pause_on_expired": True, + } + + +def upload_to_sub2api( + account, + api_url: str | None = None, + api_key: str | None = None, + group_ids: list[int] | None = None, +) -> Tuple[bool, str]: + """上传单个账号到 Sub2API 管理后台。""" + api_url = str(api_url or _get_config_value("sub2api_api_url")).strip() + api_key = str(api_key or _get_config_value("sub2api_api_key")).strip() + + if not api_url: + return False, "Sub2API API URL 未配置" + if not api_key: + return False, "Sub2API API Key 未配置" + + payload = _build_sub2api_account_payload(account, group_ids=group_ids) + url = f"{api_url.rstrip('/')}/api/v1/admin/accounts" + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Referer": f"{api_url.rstrip('/')}/admin/accounts", + "x-api-key": api_key, + } + + try: + response = cffi_requests.post( + url, + headers=headers, + json=payload, + proxies=None, + verify=False, + timeout=30, + impersonate="chrome110", + ) + + if response.status_code in (200, 201): + return True, "上传成功" + + error_msg = f"上传失败: HTTP {response.status_code}" + try: + detail = response.json() + if isinstance(detail, dict): + error_msg = str( + detail.get("message") + or detail.get("msg") + or detail.get("error") + or error_msg + ) + except Exception: + error_msg = f"{error_msg} - {response.text[:200]}" + return False, error_msg + except Exception as exc: + logger.error("Sub2API 上传异常: %s", exc) + return False, f"上传异常: {exc}" diff --git a/services/external_sync.py b/services/external_sync.py index 9bc2303..6d5ace6 100644 --- a/services/external_sync.py +++ b/services/external_sync.py @@ -12,24 +12,43 @@ def sync_account(account) -> list[dict[str, Any]]: platform = getattr(account, "platform", "") results: list[dict[str, Any]] = [] + def _build_chatgpt_upload_account(): + class _A: + pass + + a = _A() + a.email = account.email + extra = account.extra or {} + a.access_token = extra.get("access_token") or account.token + a.refresh_token = extra.get("refresh_token", "") + a.id_token = extra.get("id_token", "") + a.session_token = extra.get("session_token", "") + a.client_id = extra.get("client_id", "app_EMoamEEZ73f0CkXaXp7hrann") + return a + if platform == "chatgpt": - cpa_url = config_store.get("cpa_api_url", "") + upload_account = _build_chatgpt_upload_account() + + cpa_url = str(config_store.get("cpa_api_url", "") or "").strip() if cpa_url: from platforms.chatgpt.cpa_upload import generate_token_json, upload_to_cpa - class _A: - pass - - a = _A() - a.email = account.email - extra = account.extra or {} - a.access_token = extra.get("access_token") or account.token - a.refresh_token = extra.get("refresh_token", "") - a.id_token = extra.get("id_token", "") - - ok, msg = upload_to_cpa(generate_token_json(a)) + ok, msg = upload_to_cpa(generate_token_json(upload_account)) results.append({"name": "CPA", "ok": ok, "msg": msg}) + # 关键逻辑:ChatGPT 现在支持同时回填 CPA 和 Sub2API,互不覆盖、分别上报结果。 + sub2api_url = str(config_store.get("sub2api_api_url", "") or "").strip() + sub2api_key = str(config_store.get("sub2api_api_key", "") or "").strip() + if sub2api_url and sub2api_key: + from platforms.chatgpt.sub2api_upload import upload_to_sub2api + + ok, msg = upload_to_sub2api( + upload_account, + api_url=sub2api_url, + api_key=sub2api_key, + ) + results.append({"name": "Sub2API", "ok": ok, "msg": msg}) + elif platform == "grok": grok2api_url = str(config_store.get("grok2api_url", "") or "").strip() if grok2api_url: From f2fb6a9b84a0a958084d321b4fa6562e9929a562 Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Tue, 31 Mar 2026 03:57:40 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0gpt=E6=B3=A8=E5=86=8Crt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- platforms/chatgpt/oauth_pkce_client.py | 467 ++++++++++++++++++++++++ platforms/chatgpt/register_v2.py | 474 ++++++++++++++----------- services/external_sync.py | 10 +- 3 files changed, 734 insertions(+), 217 deletions(-) create mode 100644 platforms/chatgpt/oauth_pkce_client.py diff --git a/platforms/chatgpt/oauth_pkce_client.py b/platforms/chatgpt/oauth_pkce_client.py new file mode 100644 index 0000000..2aafdd3 --- /dev/null +++ b/platforms/chatgpt/oauth_pkce_client.py @@ -0,0 +1,467 @@ +""" +OAuth PKCE 注册客户端 + +完整实现 auth.openai.com 注册状态机 + 登录获取 Token 的全生命周期。 +每个步骤封装为独立方法,调用方按编号依次调用即可完成整个注册流程。 +""" + +import json +import re +import time +import urllib.parse +from typing import Optional + +from curl_cffi import requests as curl_requests + +from .oauth import ( + OAuthStart, + _decode_jwt_segment, + generate_oauth_url, + submit_callback_url, +) + +AUTH_BASE = "https://auth.openai.com" +SENTINEL_API = "https://sentinel.openai.com/backend-api/sentinel/req" +SENTINEL_REFERER = "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6" +CLOUDFLARE_TRACE = "https://cloudflare.com/cdn-cgi/trace" + + +class OAuthPkceClient: + """ + OAuth PKCE 注册客户端 + + 完整注册流程(12 步): + 1. 检查 IP 地区 + 2. 访问 OAuth 授权 URL,获取 oai-did Cookie + 3. 获取 Sentinel Token + 4. 提交邮箱 (authorize/continue) + 5. 提交密码 (user/register) + 6. 发送 OTP (email-otp/send) + 7. 验证 OTP (email-otp/validate) + 8. 创建账户 (create_account) + 9. 注册后重新 OAuth 登录 + 10. 解析 workspace_id + 11. 选择 workspace + 12. 跟踪重定向链,交换 OAuth code → access_token + """ + + def __init__(self, proxy: Optional[str] = None, log_fn=None): + self.proxy = proxy + self._log = log_fn or (lambda msg: None) + self._proxies = {"http": proxy, "https": proxy} if proxy else None + + # 主会话:贯穿整个注册 + 登录流程 + self.session = curl_requests.Session( + proxies=self._proxies, + impersonate="chrome", + ) + + self._device_id: Optional[str] = None + self._sentinel: Optional[str] = None + + # ══════════════════════════════════════════════════════════════════ + # 内部方法:获取 Sentinel Token(极简模式) + # ══════════════════════════════════════════════════════════════════ + + def _fetch_sentinel_token(self, device_id: str, flow: str = "authorize_continue") -> str: + """ + 获取 Sentinel Token。 + + 使用独立连接(不复用 session cookie),请求体 p 字段留空, + 只取响应中的 token 字段拼装为 openai-sentinel-token header 值。 + + Returns: + JSON 格式的 sentinel token 字符串。 + """ + req_body = json.dumps({"p": "", "id": device_id, "flow": flow}) + + resp = curl_requests.post( + SENTINEL_API, + headers={ + "origin": "https://sentinel.openai.com", + "referer": SENTINEL_REFERER, + "content-type": "text/plain;charset=UTF-8", + }, + data=req_body, + proxies=self._proxies, + impersonate="chrome", + timeout=15, + ) + + if resp.status_code != 200: + raise RuntimeError(f"Sentinel 获取失败: HTTP {resp.status_code}") + + c_value = resp.json().get("token", "") + if not c_value: + raise RuntimeError("Sentinel 响应缺少 token 字段") + + return json.dumps({ + "p": "", "t": "", "c": c_value, + "id": device_id, "flow": flow, + }, separators=(",", ":")) + + # ══════════════════════════════════════════════════════════════════ + # 步骤 1:检查 IP 地区 + # ══════════════════════════════════════════════════════════════════ + + def check_ip_region(self) -> str: + """检查当前 IP 地区,CN/HK 不支持。""" + try: + resp = self.session.get(CLOUDFLARE_TRACE, timeout=10) + match = re.search(r"^loc=(.+)$", resp.text, re.MULTILINE) + loc = match.group(1).strip() if match else "UNKNOWN" + self._log(f"当前 IP 地区: {loc}") + if loc in ("CN", "HK"): + raise RuntimeError(f"IP 地区不支持: {loc}") + return loc + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"IP 地区检查失败: {e}") from e + + # ══════════════════════════════════════════════════════════════════ + # 步骤 2:访问 OAuth 授权 URL,获取 oai-did Cookie + # ══════════════════════════════════════════════════════════════════ + + def init_oauth_session(self) -> OAuthStart: + """生成 OAuth PKCE URL 并访问,建立 auth.openai.com 会话。""" + oauth = generate_oauth_url() + self._log("访问 OAuth 授权 URL...") + self.session.get(oauth.auth_url, timeout=15) + self._device_id = self.session.cookies.get("oai-did") or "" + self._log(f"oai-did: {self._device_id[:16]}..." if self._device_id else "oai-did: (未获取到)") + return oauth + + # ══════════════════════════════════════════════════════════════════ + # 步骤 3:获取 Sentinel Token + # ══════════════════════════════════════════════════════════════════ + + def refresh_sentinel(self) -> str: + """获取新的 Sentinel Token 并缓存。""" + if not self._device_id: + raise RuntimeError("尚未初始化 oai-did(请先调用 init_oauth_session)") + self._sentinel = self._fetch_sentinel_token(self._device_id) + self._log("Sentinel Token 已获取") + return self._sentinel + + # ══════════════════════════════════════════════════════════════════ + # 步骤 4:提交邮箱 + # ══════════════════════════════════════════════════════════════════ + + def submit_email(self, email: str) -> dict: + """向 authorize/continue 提交邮箱,触发注册状态机。""" + if not self._sentinel: + raise RuntimeError("Sentinel Token 未初始化") + + payload = json.dumps({ + "username": {"value": email, "kind": "email"}, + "screen_hint": "signup", + }) + self._log(f"提交邮箱: {email}") + + resp = self.session.post( + f"{AUTH_BASE}/api/accounts/authorize/continue", + headers={ + "referer": f"{AUTH_BASE}/create-account", + "accept": "application/json", + "content-type": "application/json", + "openai-sentinel-token": self._sentinel, + }, + data=payload, + timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"提交邮箱失败: HTTP {resp.status_code} {resp.text[:300]}") + + data = resp.json() + self._log(f"邮箱提交成功") + return data + + # ══════════════════════════════════════════════════════════════════ + # 步骤 5:提交密码 + # ══════════════════════════════════════════════════════════════════ + + def submit_password(self, email: str, password: str) -> str: + """向 user/register 提交密码,返回 continue_url。""" + payload = json.dumps({"password": password, "username": email}) + self._log("提交密码...") + + resp = self.session.post( + f"{AUTH_BASE}/api/accounts/user/register", + headers={ + "referer": f"{AUTH_BASE}/create-account/password", + "accept": "application/json", + "content-type": "application/json", + "openai-sentinel-token": self._sentinel or "", + }, + data=payload, + timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"提交密码失败: HTTP {resp.status_code} {resp.text[:300]}") + + continue_url = resp.json().get("continue_url") or "" + self._log(f"密码提交成功{', continue_url 已获取' if continue_url else ''}") + return continue_url + + # ══════════════════════════════════════════════════════════════════ + # 步骤 6:发送 OTP + # ══════════════════════════════════════════════════════════════════ + + def send_otp(self, continue_url: str = "") -> bool: + """触发发送邮箱验证码。""" + url = continue_url or f"{AUTH_BASE}/api/accounts/email-otp/send" + self._log(f"发送验证码: {url}") + + try: + resp = self.session.post( + url, + headers={ + "referer": f"{AUTH_BASE}/create-account/password", + "accept": "application/json", + "content-type": "application/json", + "openai-sentinel-token": self._sentinel or "", + }, + timeout=30, + ) + self._log(f"验证码发送状态: {resp.status_code}") + return resp.status_code == 200 + except Exception as e: + self._log(f"发送验证码异常(非致命): {e}") + return False + + # ══════════════════════════════════════════════════════════════════ + # 步骤 7:验证 OTP + # ══════════════════════════════════════════════════════════════════ + + def validate_otp(self, code: str) -> None: + """提交邮箱验证码。""" + self._log(f"验证 OTP: {code}") + + resp = self.session.post( + f"{AUTH_BASE}/api/accounts/email-otp/validate", + headers={ + "referer": f"{AUTH_BASE}/email-verification", + "accept": "application/json", + "content-type": "application/json", + }, + data=json.dumps({"code": code}), + timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"OTP 验证失败: HTTP {resp.status_code} {resp.text[:300]}") + self._log("OTP 验证通过") + + # ══════════════════════════════════════════════════════════════════ + # 步骤 8:创建账户 + # ══════════════════════════════════════════════════════════════════ + + def create_account(self, name: str, birthdate: str) -> None: + """提交姓名和生日完成账户创建。""" + self._log(f"创建账户: {name} ({birthdate})") + + resp = self.session.post( + f"{AUTH_BASE}/api/accounts/create_account", + headers={ + "referer": f"{AUTH_BASE}/about-you", + "accept": "application/json", + "content-type": "application/json", + }, + data=json.dumps({"name": name, "birthdate": birthdate}), + timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"创建账户失败: HTTP {resp.status_code} {resp.text[:300]}") + self._log("账户创建成功") + + # ══════════════════════════════════════════════════════════════════ + # 步骤 9:注册后重新 OAuth 登录 + # ══════════════════════════════════════════════════════════════════ + + def login_after_register( + self, email: str, password: str, otp_code: str = "" + ) -> OAuthStart: + """ + 注册完成后重走 OAuth 登录流程。 + + 注册阶段的 session 不含 workspace 信息,必须重新走一次 + OAuth 登录获取 oai-client-auth-session Cookie。 + + Returns: + 登录阶段的 OAuthStart(含 code_verifier 等,用于步骤 12 Token 交换)。 + """ + self._log("=" * 40) + self._log("开始 OAuth 登录(获取 workspace)...") + + # 9-1. 访问新 OAuth URL + login_oauth = generate_oauth_url() + self.session.get(login_oauth.auth_url, timeout=15) + login_did = self.session.cookies.get("oai-did") or self._device_id or "" + self._log(f"登录阶段 oai-did: {login_did[:16]}..." if login_did else "登录阶段 oai-did: (空)") + + # 9-2. 获取登录阶段 Sentinel + login_sentinel = self._fetch_sentinel_token(login_did) + + # 9-3. 提交邮箱(screen_hint=login) + login_email_resp = self.session.post( + f"{AUTH_BASE}/api/accounts/authorize/continue", + headers={ + "referer": f"{AUTH_BASE}/sign-in", + "accept": "application/json", + "content-type": "application/json", + "openai-sentinel-token": login_sentinel, + }, + data=json.dumps({ + "username": {"value": email, "kind": "email"}, + "screen_hint": "login", + }), + timeout=30, + ) + if login_email_resp.status_code != 200: + raise RuntimeError(f"登录提交邮箱失败: HTTP {login_email_resp.status_code}") + + page_type = (login_email_resp.json().get("page") or {}).get("type", "") + self._log(f"登录页面类型: {page_type}") + + # 9-4. 提交密码(login_password 页面) + if "password" in page_type: + self._log("提交密码...") + pwd_resp = self.session.post( + f"{AUTH_BASE}/api/accounts/password/verify", + headers={ + "referer": f"{AUTH_BASE}/log-in/password", + "accept": "application/json", + "content-type": "application/json", + "openai-sentinel-token": login_sentinel, + }, + data=json.dumps({"password": password}), + timeout=30, + ) + if pwd_resp.status_code != 200: + raise RuntimeError(f"登录密码验证失败: HTTP {pwd_resp.status_code}") + page_type = (pwd_resp.json().get("page") or {}).get("type", "") + self._log(f"密码验证后页面类型: {page_type}") + + # 9-5. 二次 OTP(复用注册阶段验证码) + if "otp" in page_type or "verification" in page_type: + if not otp_code: + raise RuntimeError("登录需要二次 OTP 验证,但未提供验证码") + self._log(f"提交登录二次验证码: {otp_code}") + # 触发发信请求以满足后端状态机(可忽略报错) + try: + self.session.post( + f"{AUTH_BASE}/api/accounts/passwordless/send-otp", + headers={ + "referer": f"{AUTH_BASE}/log-in/password", + "accept": "application/json", + "content-type": "application/json", + }, + timeout=10, + ) + except Exception: + pass + + otp_resp = self.session.post( + f"{AUTH_BASE}/api/accounts/email-otp/validate", + headers={ + "referer": f"{AUTH_BASE}/email-verification", + "accept": "application/json", + "content-type": "application/json", + "openai-sentinel-token": login_sentinel, + }, + data=json.dumps({"code": otp_code}), + timeout=30, + ) + if otp_resp.status_code != 200: + raise RuntimeError(f"登录二次 OTP 失败: HTTP {otp_resp.status_code} {otp_resp.text[:200]}") + self._log("登录二次验证通过") + + self._log("OAuth 登录流程完成") + return login_oauth + + # ══════════════════════════════════════════════════════════════════ + # 步骤 10:解析 workspace_id + # ══════════════════════════════════════════════════════════════════ + + def extract_workspace_id(self) -> str: + """从 oai-client-auth-session Cookie(JWT)中解析 workspace_id。""" + auth_cookie = self.session.cookies.get("oai-client-auth-session") or "" + if not auth_cookie: + raise RuntimeError("未找到 oai-client-auth-session Cookie") + + # JWT 段遍历(workspace 可能在第一段或第二段) + segments = auth_cookie.split(".") + for i in range(min(len(segments), 2)): + data = _decode_jwt_segment(segments[i]) + workspaces = data.get("workspaces") or [] + if workspaces: + wid = str((workspaces[0] or {}).get("id") or "").strip() + if wid: + self._log(f"成功解析 workspace_id: {wid}") + return wid + + # 调试信息 + first_data = _decode_jwt_segment(segments[0]) if segments else {} + self._log(f"Cookie 字段: {list(first_data.keys())}") + raise RuntimeError("无法从 Cookie 中解析 workspace_id") + + # ══════════════════════════════════════════════════════════════════ + # 步骤 11:选择 workspace + # ══════════════════════════════════════════════════════════════════ + + def select_workspace(self, workspace_id: str) -> str: + """选择 workspace,返回 continue_url。""" + self._log(f"选择 workspace: {workspace_id}") + + resp = self.session.post( + f"{AUTH_BASE}/api/accounts/workspace/select", + headers={ + "referer": f"{AUTH_BASE}/sign-in-with-chatgpt/codex/consent", + "content-type": "application/json", + }, + data=json.dumps({"workspace_id": workspace_id}), + timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"workspace/select 失败: HTTP {resp.status_code} {resp.text[:300]}") + + continue_url = str((resp.json() or {}).get("continue_url") or "").strip() + if not continue_url: + raise RuntimeError("workspace/select 响应缺少 continue_url") + self._log("workspace 选择成功,continue_url 已获取") + return continue_url + + # ══════════════════════════════════════════════════════════════════ + # 步骤 12:跟踪重定向链,交换 OAuth code → access_token + # ══════════════════════════════════════════════════════════════════ + + def follow_redirects_and_exchange_token( + self, continue_url: str, oauth_start: OAuthStart + ) -> dict: + """跟踪重定向链,捕获 code= 回调 URL,交换 access_token。""" + current_url = continue_url + + for hop in range(8): + resp = self.session.get(current_url, allow_redirects=False, timeout=15) + location = resp.headers.get("Location") or "" + + if resp.status_code not in (301, 302, 303, 307, 308) or not location: + break + + next_url = urllib.parse.urljoin(current_url, location) + self._log(f"重定向 [{hop + 1}] → {next_url[:100]}...") + + if "code=" in next_url and "state=" in next_url: + self._log("捕获到 OAuth 回调 URL,交换 Token...") + token_json = submit_callback_url( + callback_url=next_url, + expected_state=oauth_start.state, + code_verifier=oauth_start.code_verifier, + redirect_uri=oauth_start.redirect_uri, + proxy_url=self.proxy, + ) + return json.loads(token_json) + + current_url = next_url + + raise RuntimeError("未能在重定向链中捕获到 OAuth 回调 URL(含 code= 参数)") diff --git a/platforms/chatgpt/register_v2.py b/platforms/chatgpt/register_v2.py index e83446c..153647a 100644 --- a/platforms/chatgpt/register_v2.py +++ b/platforms/chatgpt/register_v2.py @@ -1,8 +1,13 @@ """ 注册流程引擎 V2 -基于 curl_cffi 的注册状态机,注册成功后直接复用同一会话提取 ChatGPT Session。 + +采用策略模式封装注册核心(OAuthPkceRegisterStrategy), +走 auth.openai.com OAuth PKCE 直通注册流程。 + +外部接口与 plugin.py 完全兼容,无需改动邮箱适配层。 """ +import random import time import logging from datetime import datetime @@ -11,34 +16,211 @@ from typing import Optional, Callable from core.base_platform import AccountStatus from platforms.chatgpt.register import RegistrationResult -from .chatgpt_client import ChatGPTClient -from .oauth_client import OAuthClient +from .oauth_pkce_client import OAuthPkceClient from .utils import generate_random_name, generate_random_birthday logger = logging.getLogger(__name__) -class EmailServiceAdapter: - """\u5c06 V1 \u7684 email_service \u9002\u914d\u6210 V2 \u6240\u9700\u7684\u63a5\u7801\u63a5\u53e3\u3002""" - def __init__(self, email_service, email, log_fn): - self.es = email_service - self.email = email - self.log_fn = log_fn - self._used_codes = set() - def wait_for_verification_code(self, email, timeout=60, otp_sent_at=None, exclude_codes=None): - msg = f"\u6b63\u5728\u7b49\u5f85\u90ae\u7bb1 {email} \u7684\u9a8c\u8bc1\u7801 ({timeout}s)..." - self.log_fn(msg) - code = self.es.get_verification_code( +# --------------------------------------------------------------------------- +# 名字/生日数据 +# --------------------------------------------------------------------------- + +_FIRST_NAMES = [ + "James", "John", "Robert", "Michael", "David", "William", "Richard", + "Joseph", "Thomas", "Charles", "Mary", "Patricia", "Jennifer", "Linda", + "Barbara", "Elizabeth", "Susan", "Jessica", "Sarah", "Karen", "Daniel", + "Matthew", "Anthony", "Mark", "Steven", "Andrew", "Paul", "Emily", + "Emma", "Olivia", "Sophia", "Ava", "Isabella", "Mia", +] +_LAST_NAMES = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", + "Davis", "Rodriguez", "Martinez", "Anderson", "Taylor", "Thomas", + "Wilson", "Moore", "Jackson", "Martin", "Lee", "Thompson", "White", +] + + +def _random_name_and_birthday() -> tuple[str, str]: + """随机生成全名和生日。""" + name = f"{random.choice(_FIRST_NAMES)} {random.choice(_LAST_NAMES)}" + year = random.randint(1985, 2004) + month = random.randint(1, 12) + day = random.randint(1, 28) + return name, f"{year}-{month:02d}-{day:02d}" + + +# --------------------------------------------------------------------------- +# 策略:OAuth PKCE 注册策略 +# --------------------------------------------------------------------------- + +class OAuthPkceRegisterStrategy: + """ + OAuth PKCE 注册策略(策略模式) + + 完整注册流程(12 步): + 1. 检查 IP 地区 + 2. 访问 OAuth 授权 URL,获取 oai-did Cookie + 3. 获取 Sentinel Token + 4. 提交邮箱 (authorize/continue) + 5. 提交密码 (user/register) + 6. 发送 OTP (email-otp/send) + 7. 验证 OTP (email-otp/validate) + 8. 创建账户 (create_account) + 9. 注册后重新 OAuth 登录 + 10. 解析 workspace_id + 11. 选择 workspace + 12. 跟踪重定向链,交换 OAuth code → access_token + """ + + def execute( + self, + client: OAuthPkceClient, + email_service, + email: str, + password: str, + log: Callable[[str], None], + ) -> RegistrationResult: + result = RegistrationResult(success=False) + + # ── 步骤 1:IP 检查 ────────────────────────────────────────────── + log("步骤 1/12: 检查 IP 地区...") + client.check_ip_region() + + # ── 步骤 2:创建邮箱 ──────────────────────────────────────────── + log("步骤 2/12: 创建邮箱接码订单...") + email_data = email_service.create_email() + email_addr = email or (email_data.get("email") if email_data else None) + if not email_addr: + result.error_message = "创建邮箱失败" + return result + result.email = email_addr + result.password = password + log(f"邮箱: {email_addr}") + + # ── 步骤 3:初始化 OAuth 会话 ──────────────────────────────────── + log("步骤 3/12: 访问 OAuth 授权 URL,获取 oai-did...") + client.init_oauth_session() + + # ── 步骤 4:获取 Sentinel Token ────────────────────────────────── + log("步骤 4/12: 获取 Sentinel Token...") + client.refresh_sentinel() + + # ── 步骤 5:提交邮箱 ──────────────────────────────────────────── + log("步骤 5/12: 提交邮箱...") + client.submit_email(email_addr) + + # ── 步骤 6:提交密码 ──────────────────────────────────────────── + log("步骤 6/12: 提交密码...") + continue_url = client.submit_password(email_addr, password) + + # ── 步骤 7:发送 OTP + 等待验证码 ──────────────────────────────── + log("步骤 7/12: 发送验证码...") + otp_sent_at = time.time() + client.send_otp(continue_url) + + log("步骤 7/12: 等待邮箱验证码(最多 120s)...") + otp_code = email_service.wait_for_verification_code( + email_addr, + timeout=120, + otp_sent_at=otp_sent_at, + ) + if not otp_code: + result.error_message = "未收到邮箱验证码" + return result + log(f"验证码: {otp_code}") + + # ── 步骤 8:验证 OTP ───────────────────────────────────────────── + log("步骤 8/12: 验证 OTP...") + client.validate_otp(otp_code) + + # ── 步骤 9:创建账户 ──────────────────────────────────────────── + log("步骤 9/12: 填写账户信息(姓名/生日)...") + name, birthdate = _random_name_and_birthday() + log(f"账户信息: {name} ({birthdate})") + client.create_account(name, birthdate) + + # ── 步骤 10:注册后重新 OAuth 登录 ─────────────────────────────── + log("步骤 10/12: 注册后重新 OAuth 登录...") + login_oauth = client.login_after_register(email_addr, password, otp_code) + + # ── 步骤 11:解析 workspace_id ─────────────────────────────────── + log("步骤 11/12: 解析 workspace_id...") + workspace_id = client.extract_workspace_id() + result.workspace_id = workspace_id + + # ── 步骤 12:选择 workspace → 交换 Token ───────────────────────── + log("步骤 12/12: 选择 workspace...") + ws_continue_url = client.select_workspace(workspace_id) + + log("步骤 12/12: 跟踪重定向链,交换 OAuth Token...") + token_data = client.follow_redirects_and_exchange_token(ws_continue_url, login_oauth) + + # ── 组装结果 ───────────────────────────────────────────────────── + result.success = True + result.access_token = token_data.get("access_token", "") + result.refresh_token = token_data.get("refresh_token", "") + result.id_token = token_data.get("id_token", "") + result.account_id = token_data.get("account_id", "") or workspace_id + result.metadata = { + "type": token_data.get("type", "codex"), + "expired": token_data.get("expired", ""), + } + + log("=" * 50) + log("注册流程成功完成!") + log("=" * 50) + return result + + +# --------------------------------------------------------------------------- +# Email Service 适配器(为 OAuthPkceClient 提供统一接码接口) +# --------------------------------------------------------------------------- + +class _EmailServiceAdapter: + """ + 将 V1 email_service(含 create_email / get_verification_code) + 适配为 OAuthPkceRegisterStrategy 期待的接口。 + + 接口: + - create_email() → {'email': str, ...} + - wait_for_verification_code(email, timeout, otp_sent_at) → str | None + """ + + def __init__(self, email_service, log: Callable[[str], None]): + self._svc = email_service + self._log = log + self._used_codes: set = set() + + def create_email(self, config=None): + return self._svc.create_email(config) + + def wait_for_verification_code( + self, email: str, timeout: int = 120, otp_sent_at=None + ) -> Optional[str]: + self._log(f"等待邮箱 {email} 的验证码(timeout={timeout}s)...") + code = self._svc.get_verification_code( + email=email, timeout=timeout, otp_sent_at=otp_sent_at, - exclude_codes=exclude_codes or self._used_codes, + exclude_codes=self._used_codes, ) if code: self._used_codes.add(code) - self.log_fn(f"\u6210\u529f\u83b7\u53d6\u9a8c\u8bc1\u7801: {code}") + self._log(f"成功获取验证码: {code}") return code + +# --------------------------------------------------------------------------- +# 注册引擎(对外暴露给 plugin.py,接口完全向后兼容) +# --------------------------------------------------------------------------- + class RegistrationEngineV2: + """ + 注册引擎 V2(外部接口层) + + plugin.py 通过此类发起注册,不感知内部策略变化。 + """ + def __init__( self, email_service, @@ -56,12 +238,12 @@ class RegistrationEngineV2: self.task_uuid = task_uuid self.max_retries = max(1, int(max_retries or 1)) self.extra_config = dict(extra_config or {}) - - self.email = None - self.password = None - self.logs = [] - - def _log(self, message: str, level: str = "info"): + + self.email: Optional[str] = None + self.password: Optional[str] = None + self.logs: list[str] = [] + + def _log(self, message: str, level: str = "info") -> None: timestamp = datetime.now().strftime("%H:%M:%S") log_message = f"[{timestamp}] {message}" self.logs.append(log_message) @@ -72,199 +254,63 @@ class RegistrationEngineV2: else: logger.info(log_message) - def _format_oauth_failure(self, oauth_client: OAuthClient) -> str: - detail = str(getattr(oauth_client, "last_error", "") or "").strip() - if not detail: - detail = "获取最终 OAuth Tokens 失败" - return f"账号已创建成功,但 {detail}" + def run(self) -> RegistrationResult: + """执行注册流程,支持整流程重试。""" + result = RegistrationResult(success=False, logs=self.logs) + last_error = "" - def _should_retry(self, message: str) -> bool: + for attempt in range(self.max_retries): + try: + if attempt > 0: + self._log(f"整流程重试 {attempt + 1}/{self.max_retries}...") + time.sleep(2) + + adapter = _EmailServiceAdapter(self.email_service, self._log) + client = OAuthPkceClient(proxy=self.proxy_url, log_fn=self._log) + strategy = OAuthPkceRegisterStrategy() + + result = strategy.execute( + client=client, + email_service=adapter, + email=self.email or "", + password=self.password or "AAb1234567890!", + log=self._log, + ) + result.logs = self.logs + + if result.success: + return result + + last_error = result.error_message or "注册失败" + self._log(f"注册失败: {last_error}", "error") + + if attempt < self.max_retries - 1 and self._should_retry(last_error): + self._log("准备重试...") + continue + + return result + + except Exception as e: + last_error = str(e) + self._log(f"注册异常: {last_error}", "error") + if attempt < self.max_retries - 1 and self._should_retry(last_error): + continue + result.error_message = last_error + result.logs = self.logs + return result + + result.error_message = last_error or "注册失败" + result.logs = self.logs + return result + + @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", + "tls", "ssl", "curl: (35)", + "ip 地区检查失败", "sentinel", + "timeout", "timed out", "connection", + "验证码", "otp", ] - return any(marker.lower() in text for marker in retriable_markers) - - def run(self) -> RegistrationResult: - result = RegistrationResult(success=False, logs=self.logs) - try: - last_error = "" - 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. 创建邮箱 - email_data = self.email_service.create_email() - email_addr = self.email or (email_data.get('email') if email_data else None) - if not email_addr: - result.error_message = "创建邮箱失败" - return result - - result.email = email_addr - - pwd = self.password or "AAb1234567890!" - result.password = pwd - - # 随机姓名、生日 - first_name, last_name = generate_random_name() - birthdate = generate_random_birthday() - - self._log(f"邮箱: {email_addr}, 密码: {pwd}") - self._log(f"注册信息: {first_name} {last_name}, 生日: {birthdate}") - - # 使用包装器为底层客户端提供接码服务 - skymail_adapter = EmailServiceAdapter(self.email_service, email_addr, self._log) - - # 2. 初始化 V2 客户端 - 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( - email_addr, pwd, 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 - result.error_message = last_error - return result - - self._log("步骤 2/2: 优先复用注册会话提取 ChatGPT Session / AccessToken...") - session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens() - - if session_ok: - self._log("Token 提取完成!") - result.success = True - result.access_token = session_result.get("access_token", "") - result.session_token = session_result.get("session_token", "") - result.account_id = ( - session_result.get("account_id") - or session_result.get("user_id") - or ("v2_acct_" + chatgpt_client.device_id[:8]) - ) - result.workspace_id = session_result.get("workspace_id", "") - result.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 {}, - } - - if result.workspace_id: - self._log(f"Session Workspace ID: {result.workspace_id}") - - self._log("=" * 60) - self._log("注册流程成功结束!") - self._log("=" * 60) - return result - - 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=self.extra_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( - email_addr, - pwd, - 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 回退补全成功!") - result.success = True - result.access_token = tokens.get("access_token") - result.refresh_token = tokens.get("refresh_token") - result.id_token = tokens.get("id_token") - result.account_id = "v2_acct_" + chatgpt_client.device_id[:8] - - session_data = oauth_client._decode_oauth_session_cookie() - if session_data: - workspaces = session_data.get("workspaces", []) - if workspaces: - result.workspace_id = str((workspaces[0] or {}).get("id") or "") - self._log(f"成功萃取 Workspace ID: {result.workspace_id}") - - session_cookie = None - for cookie in oauth_client.session.cookies.jar: - if cookie.name == "__Secure-next-auth.session-token": - session_cookie = cookie.value - break - result.session_token = session_cookie - - self._log("=" * 60) - self._log("注册流程成功结束!") - self._log("=" * 60) - return result - - last_error = self._format_oauth_failure(oauth_client) - result.error_message = last_error - return result - 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 - raise - - result.error_message = last_error or "注册失败" - return result - - except Exception as e: - self._log(f"V2 注册全流程执行异常: {e}", "error") - import traceback - traceback.print_exc() - result.error_message = str(e) - return result + return any(m in text for m in retriable_markers) diff --git a/services/external_sync.py b/services/external_sync.py index 602b79d..72f5460 100644 --- a/services/external_sync.py +++ b/services/external_sync.py @@ -4,7 +4,11 @@ from __future__ import annotations from typing import Any -from services.chatgpt_sync import persist_cpa_sync_result, upload_chatgpt_account_to_cpa +from services.chatgpt_sync import ( + _get_account_extra, + persist_cpa_sync_result, + upload_chatgpt_account_to_cpa, +) def sync_account(account) -> list[dict[str, Any]]: @@ -20,7 +24,7 @@ def sync_account(account) -> list[dict[str, Any]]: a = _A() a.email = account.email - extra = account.extra or {} + extra = _get_account_extra(account) a.access_token = extra.get("access_token") or account.token a.refresh_token = extra.get("refresh_token", "") a.id_token = extra.get("id_token", "") @@ -40,7 +44,7 @@ def sync_account(account) -> list[dict[str, Any]]: codex_proxy_url = str(config_store.get("codex_proxy_url", "") or "").strip() if codex_proxy_url: upload_type = str(config_store.get("codex_proxy_upload_type", "at") or "at").strip().lower() - extra = account.extra or {} + extra = _get_account_extra(account) class _CP: pass From 2005f48b373068ea1ea07be189b3f4b9f4e1fecc Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Tue, 31 Mar 2026 04:17:58 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=8E=BB=E6=8E=89cursor=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/platforms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/platforms.py b/api/platforms.py index daa963d..0d34369 100644 --- a/api/platforms.py +++ b/api/platforms.py @@ -6,4 +6,5 @@ router = APIRouter(prefix="/platforms", tags=["platforms"]) @router.get("") def get_platforms(): - return list_platforms() + platforms = list_platforms() + return [p for p in platforms if p["name"] not in ("cursor", "tavily")] From 45691505c39e78e705f54af5bd1c8f32b68bed3b Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Tue, 31 Mar 2026 13:07:29 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8C=E6=AD=A5bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/external_sync.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/external_sync.py b/services/external_sync.py index 602b79d..72f5460 100644 --- a/services/external_sync.py +++ b/services/external_sync.py @@ -4,7 +4,11 @@ from __future__ import annotations from typing import Any -from services.chatgpt_sync import persist_cpa_sync_result, upload_chatgpt_account_to_cpa +from services.chatgpt_sync import ( + _get_account_extra, + persist_cpa_sync_result, + upload_chatgpt_account_to_cpa, +) def sync_account(account) -> list[dict[str, Any]]: @@ -20,7 +24,7 @@ def sync_account(account) -> list[dict[str, Any]]: a = _A() a.email = account.email - extra = account.extra or {} + extra = _get_account_extra(account) a.access_token = extra.get("access_token") or account.token a.refresh_token = extra.get("refresh_token", "") a.id_token = extra.get("id_token", "") @@ -40,7 +44,7 @@ def sync_account(account) -> list[dict[str, Any]]: codex_proxy_url = str(config_store.get("codex_proxy_url", "") or "").strip() if codex_proxy_url: upload_type = str(config_store.get("codex_proxy_upload_type", "at") or "at").strip().lower() - extra = account.extra or {} + extra = _get_account_extra(account) class _CP: pass From 8abe7e3f2480c04ab062782a2a16e91400fc9cd4 Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Tue, 31 Mar 2026 13:24:56 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=8E=BB=E6=8E=89cursor=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 684718e..5fd65d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,7 +43,7 @@ function AppContent() { fetch('/api/platforms') .then(r => r.json()) .then(d => setPlatforms((d || []) - .filter((p: any) => p.name !== 'tavily') + .filter((p: any) => !['tavily', 'cursor'].includes(p.name)) .map((p: any) => ({ key: p.name, label: p.display_name })))) }, []) From d4716eb5c60502074b20cdccba6e8e4c3d44e753 Mon Sep 17 00:00:00 2001 From: zhouye <2025team2@beeplay123.com> Date: Tue, 31 Mar 2026 13:35:50 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20DuckMail=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=9F=9F=E5=90=8D=E5=92=8C=20API=20?= =?UTF-8?q?Key=20=E7=9B=B4=E8=BF=9E=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 domain.duckmail.sbs 配置私有域名后,可使用 API Key 直连 DuckMail API, 无需经过前端代理转发。新增 duckmail_domain 和 duckmail_api_key 配置项。 --- api/config.py | 2 + core/base_mailbox.py | 70 ++++++++++++++++++++++----------- frontend/src/pages/Settings.tsx | 2 + 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/api/config.py b/api/config.py index 41950fe..f22e4eb 100644 --- a/api/config.py +++ b/api/config.py @@ -15,6 +15,8 @@ CONFIG_KEYS = [ "duckmail_api_url", "duckmail_provider_url", "duckmail_bearer", + "duckmail_domain", + "duckmail_api_key", "freemail_api_url", "freemail_admin_token", "freemail_username", diff --git a/core/base_mailbox.py b/core/base_mailbox.py index 5dbe04b..1e3773b 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -105,6 +105,8 @@ def create_mailbox(provider: str, extra: dict = None, proxy: str = None) -> 'Bas api_url=(extra.get("duckmail_api_url") or "https://www.duckmail.sbs"), provider_url=(extra.get("duckmail_provider_url") or "https://api.duckmail.sbs"), bearer=(extra.get("duckmail_bearer") or "kevin273945"), + domain=extra.get("duckmail_domain", ""), + api_key=extra.get("duckmail_api_key", ""), proxy=proxy, ) elif provider == "freemail": @@ -493,62 +495,85 @@ class DuckMailMailbox(BaseMailbox): def __init__(self, api_url: str = "https://www.duckmail.sbs", provider_url: str = "https://api.duckmail.sbs", bearer: str = "kevin273945", + domain: str = "", + api_key: str = "", proxy: str = None): self.api = (api_url or "https://www.duckmail.sbs").rstrip("/") - self.provider_url = provider_url or "https://api.duckmail.sbs" + self.provider_url = (provider_url or "https://api.duckmail.sbs").rstrip("/") self.bearer = bearer or "kevin273945" + self.domain = str(domain or "").strip() + self.api_key = str(api_key or "").strip() self.proxy = {"http": proxy, "https": proxy} if proxy else None self._token = None self._address = None + # 如果配置了 API Key,直接请求 DuckMail API;否则走前端代理 + self._direct = bool(self.api_key) - def _common_headers(self) -> dict: + def _proxy_headers(self) -> dict: return { "authorization": f"Bearer {self.bearer}", "content-type": "application/json", "x-api-provider-base-url": self.provider_url, } + def _direct_headers(self, token: str = "") -> dict: + auth = token or self.api_key + return { + "authorization": f"Bearer {auth}", + "content-type": "application/json", + } + + def _request(self, method: str, endpoint: str, token: str = "", **kwargs): + """统一请求方法,根据模式选择直连或代理""" + import requests + if self._direct: + url = f"{self.provider_url}{endpoint}" + headers = self._direct_headers(token) + else: + from urllib.parse import quote + url = f"{self.api}/api/mail?endpoint={quote(endpoint, safe='')}" + headers = self._proxy_headers() if not token else { + "authorization": f"Bearer {token}", + "x-api-provider-base-url": self.provider_url, + } + r = requests.request(method, url, headers=headers, proxies=self.proxy, timeout=15, **kwargs) + return r + def get_email(self) -> MailboxAccount: - import requests, random, string + import random, string username = "".join(random.choices(string.ascii_lowercase + string.digits, k=10)) password = "Test" + "".join(random.choices(string.digits, k=8)) + "!" - domain = self.provider_url.replace("https://api.", "").replace("https://", "") + domain = self.domain or self.provider_url.replace("https://api.", "").replace("https://", "") address = f"{username}@{domain}" + print(f"[DuckMail] 创建账号: {address} direct={self._direct}") # 创建账号 - r = requests.post(f"{self.api}/api/mail?endpoint=%2Faccounts", - json={"address": address, "password": password}, - headers=self._common_headers(), proxies=self.proxy, timeout=15) + r = self._request("POST", "/accounts", json={"address": address, "password": password}) + if r.status_code >= 400 or not r.text.strip().startswith("{"): + raise RuntimeError(f"[DuckMail] 创建账号失败: HTTP {r.status_code} body={r.text[:300]}") data = r.json() self._address = data.get("address", address) # 登录获取 token - r2 = requests.post(f"{self.api}/api/mail?endpoint=%2Ftoken", - json={"address": self._address, "password": password}, - headers=self._common_headers(), proxies=self.proxy, timeout=15) + r2 = self._request("POST", "/token", json={"address": self._address, "password": password}) + if r2.status_code >= 400 or not r2.text.strip().startswith(("{", "[")): + raise RuntimeError(f"[DuckMail] 登录失败: HTTP {r2.status_code} body={r2.text[:300]}") self._token = r2.json().get("token", "") return MailboxAccount(email=self._address, account_id=self._token) def get_current_ids(self, account: MailboxAccount) -> set: - import requests try: - r = requests.get(f"{self.api}/api/mail?endpoint=%2Fmessages%3Fpage%3D1", - headers={"authorization": f"Bearer {account.account_id}", - "x-api-provider-base-url": self.provider_url}, - proxies=self.proxy, timeout=10) + r = self._request("GET", "/messages?page=1", token=account.account_id) return {str(m["id"]) for m in r.json().get("hydra:member", [])} except Exception: return set() def wait_for_code(self, account: MailboxAccount, keyword: str = "", timeout: int = 120, before_ids: set = None, code_pattern: str = None, **kwargs) -> str: - import re, time, requests + import re, time seen = set(before_ids or []) start = time.time() while time.time() - start < timeout: try: - r = requests.get(f"{self.api}/api/mail?endpoint=%2Fmessages%3Fpage%3D1", - headers={"authorization": f"Bearer {account.account_id}", - "x-api-provider-base-url": self.provider_url}, - proxies=self.proxy, timeout=10) + r = self._request("GET", "/messages?page=1", token=account.account_id) msgs = r.json().get("hydra:member", []) for msg in msgs: mid = str(msg.get("id") or msg.get("msgid") or "") @@ -556,10 +581,7 @@ class DuckMailMailbox(BaseMailbox): seen.add(mid) # 请求邮件详情获取完整 text try: - r2 = requests.get(f"{self.api}/api/mail?endpoint=%2Fmessages%2F{mid}", - headers={"authorization": f"Bearer {account.account_id}", - "x-api-provider-base-url": self.provider_url}, - proxies=self.proxy, timeout=10) + r2 = self._request("GET", f"/messages/{mid}", token=account.account_id) detail = r2.json() body = str(detail.get("text") or "") + " " + str(detail.get("subject") or "") except Exception: diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 587ffc5..59849e1 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -129,6 +129,8 @@ const TAB_ITEMS = [ { key: 'duckmail_api_url', label: 'Web URL', placeholder: 'https://www.duckmail.sbs' }, { key: 'duckmail_provider_url', label: 'Provider URL', placeholder: 'https://api.duckmail.sbs' }, { key: 'duckmail_bearer', label: 'Bearer Token', placeholder: 'kevin273945', secret: true }, + { key: 'duckmail_domain', label: '自定义域名', placeholder: '留空则从 Provider URL 推导' }, + { key: 'duckmail_api_key', label: 'API Key(私有域名)', placeholder: 'dk_xxx(domain.duckmail.sbs 获取)', secret: true }, ], }, { From 5cb29c9a9de5f7b4ea5d74b51069c59a55d41938 Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Tue, 31 Mar 2026 13:50:53 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E4=BF=AE=E4=BA=86=20LuckMailMailbox=20?= =?UTF-8?q?=E5=B7=B2=E8=B4=AD=E9=82=AE=E7=AE=B1=E5=88=86=E6=94=AF=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=BF=87=E6=BB=A4=20exclude=5Fcodes=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20OAuth=20=E9=98=B6=E6=AE=B5=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=8B=BF=E5=88=B0=E7=AC=AC=E4=B8=80=E6=AC=A1=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E7=94=A8=E8=BF=87=E7=9A=84=E6=97=A7=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/base_mailbox.py | 20 +++++++++++--- tests/test_luckmail_mailbox.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/test_luckmail_mailbox.py diff --git a/core/base_mailbox.py b/core/base_mailbox.py index 5dbe04b..1785060 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -1140,13 +1140,14 @@ class LuckMailMailbox(BaseMailbox): return "" def _extract_code_from_token_mails(self, token: str, code_pattern: str = None, - before_ids: set = None) -> Optional[str]: + before_ids: set = None, exclude_codes: set = None) -> Optional[str]: try: mail_list = self._client.user.get_token_mails(token) except Exception: return None seen = {str(mid) for mid in (before_ids or set())} + excluded = {str(code) for code in (exclude_codes or set()) if code} for mail in mail_list.mails: message_id = str(mail.message_id or "") if message_id and message_id in seen: @@ -1157,6 +1158,8 @@ class LuckMailMailbox(BaseMailbox): str(mail.html_body or ""), ]) code = self._safe_extract(body, code_pattern) + if code and code in excluded: + continue if code: return code return None @@ -1271,6 +1274,8 @@ class LuckMailMailbox(BaseMailbox): raise RuntimeError("LuckMail 未找到已购邮箱 Token,无法等待验证码") self._log("[LuckMail] 等验证码分支: 已购邮箱 Token 收码") + exclude_codes = {str(code) for code in (kwargs.get("exclude_codes") or set()) if code} + def on_poll(result): self._log(f"[LuckMail] 轮询中... 新邮件: {'是' if result.has_new_mail else '否'}") @@ -1285,10 +1290,19 @@ class LuckMailMailbox(BaseMailbox): raise TimeoutError(f"LuckMail 等待验证码失败: {e}") from e code = code_result.verification_code + if code and code in exclude_codes: + code = None if not code and code_result.mail: - code = self._safe_extract(json.dumps(code_result.mail, ensure_ascii=False), code_pattern) + parsed_code = self._safe_extract(json.dumps(code_result.mail, ensure_ascii=False), code_pattern) + if parsed_code and parsed_code not in exclude_codes: + code = parsed_code if not code and (code_result.has_new_mail or before_ids is None): - code = self._extract_code_from_token_mails(token, code_pattern, before_ids=before_ids) + code = self._extract_code_from_token_mails( + token, + code_pattern, + before_ids=before_ids, + exclude_codes=exclude_codes, + ) if code: self._log(f"[LuckMail] 收到验证码: {code}") diff --git a/tests/test_luckmail_mailbox.py b/tests/test_luckmail_mailbox.py new file mode 100644 index 0000000..f8e6da7 --- /dev/null +++ b/tests/test_luckmail_mailbox.py @@ -0,0 +1,49 @@ +import unittest +from unittest import mock + +from core.base_mailbox import LuckMailMailbox, MailboxAccount +from core.luckmail.models import TokenCode, TokenMailItem, TokenMailList + + +class LuckMailMailboxTests(unittest.TestCase): + def _build_mailbox(self): + mailbox = LuckMailMailbox.__new__(LuckMailMailbox) + mailbox._client = mock.Mock() + mailbox._project_code = "openai" + mailbox._email_type = None + mailbox._domain = None + mailbox._order_no = None + mailbox._token = "tok_demo" + mailbox._email = "demo@example.com" + mailbox._log_fn = None + return mailbox + + def test_wait_for_code_skips_excluded_purchase_code_and_returns_fresh_mail_code(self): + mailbox = self._build_mailbox() + mailbox._client.user.wait_for_token_code.return_value = TokenCode( + email_address="demo@example.com", + project="openai", + has_new_mail=True, + verification_code="111111", + mail={"subject": "Your OpenAI code is 111111"}, + ) + mailbox._client.user.get_token_mails.return_value = TokenMailList( + email_address="demo@example.com", + project="openai", + mails=[ + TokenMailItem(message_id="m1", subject="Your OpenAI code is 111111"), + TokenMailItem(message_id="m2", subject="Your OpenAI code is 222222"), + ], + ) + + code = mailbox.wait_for_code( + MailboxAccount(email="demo@example.com", account_id="tok_demo"), + timeout=8, + exclude_codes={"111111"}, + ) + + self.assertEqual(code, "222222") + + +if __name__ == "__main__": + unittest.main()