From a148d0b82498cfa32a72a767ef2db122ed9f73b8 Mon Sep 17 00:00:00 2001 From: sam123 Date: Mon, 30 Mar 2026 06:23:11 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E6=94=B9=E4=B8=BA=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20uv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/external_apps.py | 72 +++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/services/external_apps.py b/services/external_apps.py index 76e5a36..d2de377 100644 --- a/services/external_apps.py +++ b/services/external_apps.py @@ -300,6 +300,19 @@ def _conda_exe() -> str | None: return None +def _uv_exe() -> str | None: + candidate = shutil.which("uv") + if candidate and Path(candidate).exists(): + return candidate + return None + + +def _venv_python(repo: Path) -> Path: + if os.name == "nt": + return repo / ".venv" / "Scripts" / "python.exe" + return repo / ".venv" / "bin" / "python" + + def _resolve_kiro_exe() -> str | None: try: from core.config_store import config_store @@ -398,6 +411,34 @@ def _ensure_grok2api_conda_env(repo: Path) -> str: return env_name +def _ensure_grok2api_uv_env(repo: Path) -> str: + uv = _uv_exe() + if not uv: + raise RuntimeError("未找到 uv,无法为 grok2api 自动创建项目虚拟环境") + + marker = repo / ".grok2api-env-ready" + if not marker.exists() or marker.read_text(encoding="utf-8").strip() != "uv": + subprocess.run( + [ + uv, + "sync", + "--frozen", + "--no-dev", + "--no-install-project", + "--python", + "3.13", + ], + cwd=str(repo), + check=True, + creationflags=_creationflags(), + ) + marker.write_text("uv", encoding="utf-8") + venv_python = _venv_python(repo) + if not venv_python.exists(): + raise RuntimeError("grok2api 的 uv 环境创建失败,未找到 .venv/python") + return str(venv_python) + + def _ensure_cliproxyapi_runtime_config(repo: Path): config_path = repo / "config.local.yaml" if not config_path.exists(): @@ -479,15 +520,32 @@ def _build_command(name: str) -> tuple[list[str], Path]: if name == "grok2api": _ensure_grok2api_runtime_config(repo) - env_name = _ensure_grok2api_conda_env(repo) conda = _conda_exe() + if conda: + env_name = _ensure_grok2api_conda_env(repo) + return [ + conda, + "run", + "--no-capture-output", + "-n", + env_name, + "python", + "-m", + "granian", + "--interface", + "asgi", + "--host", + "127.0.0.1", + "--port", + "8011", + "--workers", + "1", + "main:app", + ], repo + + python_exe = _ensure_grok2api_uv_env(repo) return [ - conda, - "run", - "--no-capture-output", - "-n", - env_name, - "python", + python_exe, "-m", "granian", "--interface", From 3db9d380149a8442fb125d8011b102c308fae8fa Mon Sep 17 00:00:00 2001 From: Zenfish <1905907140@qq.com> Date: Mon, 30 Mar 2026 12:42:44 +0800 Subject: [PATCH 2/9] feat: add chatgpt cpa backfill flow --- api/actions.py | 13 ++- api/integrations.py | 118 +++++++++++++--------- api/tasks.py | 4 +- frontend/src/pages/Accounts.tsx | 167 +++++++++++++++++++++++++++++++- services/chatgpt_sync.py | 143 +++++++++++++++++++++++++++ services/external_sync.py | 17 +--- 6 files changed, 399 insertions(+), 63 deletions(-) create mode 100644 services/chatgpt_sync.py diff --git a/api/actions.py b/api/actions.py index a1083a7..727a0a1 100644 --- a/api/actions.py +++ b/api/actions.py @@ -52,6 +52,17 @@ def execute_action( try: result = instance.execute_action(action_id, account, body.params) + if platform == "chatgpt" and action_id == "upload_cpa": + from services.chatgpt_sync import update_account_model_cpa_sync + + sync_msg = result.get("data") or result.get("error") or "" + update_account_model_cpa_sync( + acc_model, + bool(result.get("ok")), + str(sync_msg), + session=session, + commit=False, + ) # 若操作返回了新 token,更新数据库 if result.get("ok") and result.get("data", {}) and isinstance(result["data"], dict): data = result["data"] @@ -67,7 +78,7 @@ def execute_action( from datetime import datetime, timezone acc_model.updated_at = datetime.now(timezone.utc) session.add(acc_model) - session.commit() + session.commit() return result except NotImplementedError as e: raise HTTPException(400, str(e)) diff --git a/api/integrations.py b/api/integrations.py index 4efae6e..3324661 100644 --- a/api/integrations.py +++ b/api/integrations.py @@ -9,12 +9,17 @@ from sqlmodel import Session, select from core.base_platform import Account, AccountStatus from core.db import AccountModel, engine from services.external_apps import install, list_status, start, start_all, stop, stop_all +from services.chatgpt_sync import has_cpa_upload_success, upload_account_model_to_cpa router = APIRouter(prefix="/integrations", tags=["integrations"]) class BackfillRequest(BaseModel): platforms: list[str] = Field(default_factory=lambda: ["grok", "kiro"]) + account_ids: list[int] = Field(default_factory=list) + pending_only: bool = False + status: Optional[str] = None + email: Optional[str] = None def _to_account(model: AccountModel) -> Account: @@ -65,58 +70,79 @@ def backfill_integrations(body: BackfillRequest): summary = {"total": 0, "success": 0, "failed": 0, "items": []} targets = set(body.platforms or []) - if "grok" in targets: - from services.grok2api_runtime import ensure_grok2api_ready - - ok, msg = ensure_grok2api_ready() - if not ok: - return { - "total": 0, - "success": 0, - "failed": 0, - "items": [{"platform": "grok", "email": "", "results": [{"name": "grok2api", "ok": False, "msg": msg}]}], - } - with Session(engine) as s: - rows = s.exec( - select(AccountModel).where(AccountModel.platform.in_(targets)) - ).all() + q = select(AccountModel) + if body.account_ids: + q = q.where(AccountModel.id.in_(body.account_ids)) + if targets: + q = q.where(AccountModel.platform.in_(targets)) + elif targets: + q = q.where(AccountModel.platform.in_(targets)) + else: + return summary - for row in rows: - item = {"platform": row.platform, "email": row.email, "results": []} - try: - account = _to_account(row) - results = [] - if row.platform == "grok": - from core.config_store import config_store - from platforms.grok.grok2api_upload import upload_to_grok2api + if body.status: + q = q.where(AccountModel.status == body.status) + if body.email: + q = q.where(AccountModel.email.contains(body.email)) - api_url = str(config_store.get("grok2api_url", "") or "").strip() or "http://127.0.0.1:8011" - app_key = str(config_store.get("grok2api_app_key", "") or "").strip() or "grok2api" - ok, msg = upload_to_grok2api(account, api_url=api_url, app_key=app_key) - results.append({"name": "grok2api", "ok": ok, "msg": msg}) + rows = s.exec(q).all() + if body.pending_only: + rows = [row for row in rows if row.platform != "chatgpt" or not has_cpa_upload_success(row)] - elif row.platform == "kiro": - from core.config_store import config_store - from platforms.kiro.account_manager_upload import upload_to_kiro_manager + if any(row.platform == "grok" for row in rows): + from services.grok2api_runtime import ensure_grok2api_ready - configured_path = str(config_store.get("kiro_manager_path", "") or "").strip() or None - ok, msg = upload_to_kiro_manager(account, path=configured_path) - results.append({"name": "Kiro Manager", "ok": ok, "msg": msg}) + ok, msg = ensure_grok2api_ready() + if not ok: + return { + "total": 0, + "success": 0, + "failed": 0, + "items": [{"platform": "grok", "email": "", "results": [{"name": "grok2api", "ok": False, "msg": msg}]}], + } - if not results: - item["results"].append({"name": "skip", "ok": False, "msg": "未配置对应导入目标"}) - summary["failed"] += 1 - else: - item["results"] = results - if all(r.get("ok") for r in results): - summary["success"] += 1 - else: + for row in rows: + item = {"platform": row.platform, "email": row.email, "results": []} + try: + results = [] + if row.platform == "chatgpt": + ok, msg = upload_account_model_to_cpa(row, session=s, commit=True) + results.append({"name": "CPA", "ok": ok, "msg": msg}) + + elif row.platform == "grok": + from core.config_store import config_store + from platforms.grok.grok2api_upload import upload_to_grok2api + + account = _to_account(row) + api_url = str(config_store.get("grok2api_url", "") or "").strip() or "http://127.0.0.1:8011" + app_key = str(config_store.get("grok2api_app_key", "") or "").strip() or "grok2api" + ok, msg = upload_to_grok2api(account, api_url=api_url, app_key=app_key) + results.append({"name": "grok2api", "ok": ok, "msg": msg}) + + elif row.platform == "kiro": + from core.config_store import config_store + from platforms.kiro.account_manager_upload import upload_to_kiro_manager + + account = _to_account(row) + configured_path = str(config_store.get("kiro_manager_path", "") or "").strip() or None + ok, msg = upload_to_kiro_manager(account, path=configured_path) + results.append({"name": "Kiro Manager", "ok": ok, "msg": msg}) + + if not results: + item["results"].append({"name": "skip", "ok": False, "msg": "未配置对应导入目标"}) summary["failed"] += 1 - except Exception as e: - item["results"].append({"name": "error", "ok": False, "msg": str(e)}) - summary["failed"] += 1 - summary["items"].append(item) - summary["total"] += 1 + else: + item["results"] = results + if all(r.get("ok") for r in results): + summary["success"] += 1 + else: + summary["failed"] += 1 + except Exception as e: + s.rollback() + item["results"].append({"name": "error", "ok": False, "msg": str(e)}) + summary["failed"] += 1 + summary["items"].append(item) + summary["total"] += 1 return summary diff --git a/api/tasks.py b/api/tasks.py index 62488b9..ad252b8 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -168,11 +168,11 @@ def _run_register(task_id: str, req: RegisterTaskRequest): account.extra.setdefault("luckmail_domain", merged_extra.get("luckmail_domain")) if merged_extra.get("luckmail_base_url"): account.extra.setdefault("luckmail_base_url", merged_extra.get("luckmail_base_url")) - save_account(account) + saved_account = save_account(account) if _proxy: proxy_pool.report_success(_proxy) _log(task_id, f"✓ 注册成功: {account.email}") _save_task_log(req.platform, account.email, "success") - _auto_upload_integrations(task_id, account) + _auto_upload_integrations(task_id, saved_account or account) cashier_url = (account.extra or {}).get("cashier_url", "") if cashier_url: _log(task_id, f" [升级链接] {cashier_url}") diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 22565ec..59a9628 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -39,6 +39,29 @@ const STATUS_COLORS: Record = { invalid: 'error', } +function parseExtraJson(raw: string | undefined) { + if (!raw) return {} + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function normalizeAccount(account: any) { + const extra = parseExtraJson(account.extra_json) + const syncStatuses = extra.sync_statuses && typeof extra.sync_statuses === 'object' ? extra.sync_statuses : {} + const cpaSync = syncStatuses.cpa && typeof syncStatuses.cpa === 'object' ? syncStatuses.cpa : {} + return { ...account, extra, cpaSync } +} + +function formatSyncTime(value?: string) { + if (!value) return '' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString() +} + function LogPanel({ taskId, onDone }: { taskId: string; onDone: () => void }) { const [lines, setLines] = useState([]) const [done, setDone] = useState(false) @@ -186,6 +209,7 @@ export default function Accounts() { const [importLoading, setImportLoading] = useState(false) const [taskId, setTaskId] = useState(null) const [registerLoading, setRegisterLoading] = useState(false) + const [cpaSyncLoading, setCpaSyncLoading] = useState<'pending' | 'selected' | ''>('') useEffect(() => { if (platform) setCurrentPlatform(platform) @@ -198,7 +222,7 @@ export default function Accounts() { if (search) params.set('email', search) if (filterStatus) params.set('status', filterStatus) const data = await apiFetch(`/accounts?${params}`) - setAccounts(data.items) + setAccounts((data.items || []).map(normalizeAccount)) setTotal(data.total) } finally { setLoading(false) @@ -331,6 +355,96 @@ export default function Accounts() { load() } + const showCpaSyncResult = (title: string, result: any) => { + const lines = (result.items || []) + .flatMap((item: any) => + (item.results || []).map((syncResult: any) => ({ + email: item.email, + platform: item.platform, + ok: Boolean(syncResult.ok), + name: syncResult.name || 'CPA', + msg: syncResult.msg || '', + })), + ) + .filter((item: any) => !item.ok) + .map((item: any) => `[${item.platform}] ${item.email || '-'} / ${item.name}: ${item.msg || '失败'}`) + + if (lines.length === 0) return + + Modal.info({ + title, + width: 760, + content: ( +
+          {lines.join('\n')}
+        
+ ), + }) + } + + const handleCpaBackfill = async (mode: 'pending' | 'selected') => { + if (currentPlatform !== 'chatgpt') return + + const body: Record = { + platforms: ['chatgpt'], + } + + if (mode === 'selected') { + const accountIds = Array.from(selectedRowKeys) + .map((value) => Number(value)) + .filter((value) => Number.isInteger(value) && value > 0) + + if (accountIds.length === 0) { + message.warning('请先选择要上传的账号') + return + } + body.account_ids = accountIds + } else { + body.pending_only = true + if (filterStatus) body.status = filterStatus + if (search) body.email = search + } + + setCpaSyncLoading(mode) + try { + const result = await apiFetch('/integrations/backfill', { + method: 'POST', + body: JSON.stringify(body), + }) + + const actionLabel = mode === 'selected' ? '所选账号 CPA 上传' : '未上传账号 CPA 补传' + if (!result.total) { + message.info('没有可处理的账号') + } else if (!result.failed) { + message.success(`${actionLabel}完成:成功 ${result.success} / ${result.total}`) + } else if (!result.success) { + message.error(`${actionLabel}失败:成功 ${result.success} / ${result.total}`) + } else { + message.warning(`${actionLabel}部分完成:成功 ${result.success} / ${result.total}`) + } + + showCpaSyncResult(`${actionLabel}结果`, result) + await load() + } catch (e: any) { + message.error(`CPA 上传失败: ${e.message}`) + } finally { + setCpaSyncLoading('') + } + } + const columns: any[] = [ { title: '邮箱', @@ -404,6 +518,37 @@ export default function Accounts() { }, ] + if (currentPlatform === 'chatgpt') { + columns.splice(4, 0, { + title: 'CPA', + key: 'cpa_sync', + render: (_: any, record: any) => { + const sync = record.cpaSync || {} + const uploaded = Boolean(sync.uploaded || sync.uploaded_at) + const attempted = Boolean(sync.last_attempt_at) + const color = uploaded ? 'success' : attempted ? 'error' : 'default' + const label = uploaded ? '已上传' : attempted ? '最近失败' : '未上传' + const time = uploaded ? sync.uploaded_at : sync.last_attempt_at + + return ( +
+ {label} + {time ? ( + + {formatSyncTime(time)} + + ) : null} + {sync.last_message ? ( + + {sync.last_message} + + ) : null} +
+ ) + }, + }) + } + return (
@@ -433,6 +578,26 @@ export default function Accounts() { )} + {currentPlatform === 'chatgpt' && selectedRowKeys.length > 0 && ( + handleCpaBackfill('selected')} + > + + + )} + {currentPlatform === 'chatgpt' && ( + handleCpaBackfill('pending')} + > + + + )} {selectedRowKeys.length > 0 && ( diff --git a/services/chatgpt_sync.py b/services/chatgpt_sync.py new file mode 100644 index 0000000..7a66d17 --- /dev/null +++ b/services/chatgpt_sync.py @@ -0,0 +1,143 @@ +"""ChatGPT 账号与 CPA 的同步辅助逻辑。""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlmodel import Session + +from core.db import AccountModel, engine + +CPA_SYNC_NAME = "cpa" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _utcnow_iso() -> str: + return _utcnow().isoformat() + + +def _get_account_extra(account: Any) -> dict[str, Any]: + if hasattr(account, "get_extra"): + try: + extra = account.get_extra() + if isinstance(extra, dict): + return extra + except Exception: + pass + extra = getattr(account, "extra", {}) + return extra if isinstance(extra, dict) else {} + + +def get_cpa_sync_state(extra_or_account: Any) -> dict[str, Any]: + extra = extra_or_account if isinstance(extra_or_account, dict) else _get_account_extra(extra_or_account) + sync_statuses = extra.get("sync_statuses", {}) + if not isinstance(sync_statuses, dict): + return {} + state = sync_statuses.get(CPA_SYNC_NAME, {}) + return state if isinstance(state, dict) else {} + + +def has_cpa_upload_success(extra_or_account: Any) -> bool: + state = get_cpa_sync_state(extra_or_account) + return bool(state.get("uploaded") or state.get("uploaded_at")) + + +def record_cpa_sync_result(extra: dict[str, Any], ok: bool, msg: str) -> dict[str, Any]: + sync_statuses = extra.get("sync_statuses") + if not isinstance(sync_statuses, dict): + sync_statuses = {} + + state = sync_statuses.get(CPA_SYNC_NAME) + if not isinstance(state, dict): + state = {} + + now = _utcnow_iso() + state["last_attempt_ok"] = bool(ok) + state["last_message"] = msg + state["last_attempt_at"] = now + state["uploaded"] = bool(state.get("uploaded")) or bool(ok) + if ok: + state["uploaded_at"] = now + + sync_statuses[CPA_SYNC_NAME] = state + extra["sync_statuses"] = sync_statuses + return state + + +def build_chatgpt_sync_account(account: Any): + extra = _get_account_extra(account) + + class _SyncAccount: + pass + + obj = _SyncAccount() + obj.email = getattr(account, "email", "") + obj.access_token = extra.get("access_token") or getattr(account, "token", "") + obj.refresh_token = extra.get("refresh_token", "") + obj.id_token = extra.get("id_token", "") + obj.session_token = extra.get("session_token", "") + obj.client_id = extra.get("client_id", "app_EMoamEEZ73f0CkXaXp7hrann") + obj.cookies = extra.get("cookies", "") + return obj + + +def upload_chatgpt_account_to_cpa(account: Any, api_url: str | None = None, api_key: str | None = None) -> tuple[bool, str]: + try: + sync_account = build_chatgpt_sync_account(account) + if not getattr(sync_account, "access_token", ""): + return False, "账号缺少 access_token" + + from platforms.chatgpt.cpa_upload import generate_token_json, upload_to_cpa + + token_data = generate_token_json(sync_account) + return upload_to_cpa(token_data, api_url=api_url, api_key=api_key) + except Exception as exc: + return False, f"上传异常: {exc}" + + +def update_account_model_cpa_sync( + account: AccountModel, + ok: bool, + msg: str, + session: Session | None = None, + commit: bool = True, +) -> dict[str, Any]: + extra = account.get_extra() + state = record_cpa_sync_result(extra, ok, msg) + account.set_extra(extra) + account.updated_at = _utcnow() + if session is not None: + session.add(account) + if commit: + session.commit() + session.refresh(account) + return state + + +def persist_cpa_sync_result(account: Any, ok: bool, msg: str) -> None: + if isinstance(account, AccountModel) and account.id is not None: + with Session(engine) as session: + row = session.get(AccountModel, account.id) + if row: + update_account_model_cpa_sync(row, ok, msg, session=session, commit=True) + return + + extra = getattr(account, "extra", None) + if isinstance(extra, dict): + record_cpa_sync_result(extra, ok, msg) + + +def upload_account_model_to_cpa( + account: AccountModel, + session: Session | None = None, + api_url: str | None = None, + api_key: str | None = None, + commit: bool = True, +) -> tuple[bool, str]: + ok, msg = upload_chatgpt_account_to_cpa(account, api_url=api_url, api_key=api_key) + update_account_model_cpa_sync(account, ok, msg, session=session, commit=commit) + return ok, msg diff --git a/services/external_sync.py b/services/external_sync.py index 9bc2303..5d28d3f 100644 --- a/services/external_sync.py +++ b/services/external_sync.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from services.chatgpt_sync import persist_cpa_sync_result, upload_chatgpt_account_to_cpa + def sync_account(account) -> list[dict[str, Any]]: """根据平台将账号同步到外部系统。""" @@ -15,19 +17,8 @@ def sync_account(account) -> list[dict[str, Any]]: if platform == "chatgpt": cpa_url = config_store.get("cpa_api_url", "") 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_chatgpt_account_to_cpa(account) + persist_cpa_sync_result(account, ok, msg) results.append({"name": "CPA", "ok": ok, "msg": msg}) elif platform == "grok": From e09730deb6d85a4866db4c72aacb3a4dae512fb5 Mon Sep 17 00:00:00 2001 From: highkay Date: Mon, 30 Mar 2026 17:38:55 +0800 Subject: [PATCH 3/9] Add CFWorker site auth and SMS config fields --- api/config.py | 3 +- core/base_mailbox.py | 57 ++++++++++++++++++++++++++------- frontend/src/pages/Accounts.tsx | 5 +++ frontend/src/pages/Register.tsx | 13 ++++++++ frontend/src/pages/Settings.tsx | 11 +++++++ requirements.txt | 2 ++ 6 files changed, 79 insertions(+), 12 deletions(-) diff --git a/api/config.py b/api/config.py index d6dac62..bd71bbb 100644 --- a/api/config.py +++ b/api/config.py @@ -12,7 +12,8 @@ CONFIG_KEYS = [ "freemail_api_url", "freemail_admin_token", "freemail_username", "freemail_password", "moemail_api_url", "mail_provider", - "cfworker_api_url", "cfworker_admin_token", "cfworker_domain", "cfworker_fingerprint", + "cfworker_api_url", "cfworker_admin_token", "cfworker_custom_auth", "cfworker_domain", "cfworker_fingerprint", + "smstome_cookie", "smstome_country_slugs", "smstome_phone_attempts", "smstome_otp_timeout_seconds", "luckmail_base_url", "luckmail_api_key", "luckmail_email_type", "luckmail_domain", "cpa_api_url", "cpa_api_key", "team_manager_url", "team_manager_key", diff --git a/core/base_mailbox.py b/core/base_mailbox.py index a2298e5..12fbc92 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -118,6 +118,7 @@ def create_mailbox(provider: str, extra: dict = None, proxy: str = None) -> 'Bas admin_token=extra.get("cfworker_admin_token", ""), domain=extra.get("cfworker_domain", ""), fingerprint=extra.get("cfworker_fingerprint", ""), + custom_auth=extra.get("cfworker_custom_auth", ""), proxy=proxy, ) elif provider == "luckmail": @@ -410,11 +411,12 @@ class CFWorkerMailbox(BaseMailbox): """Cloudflare Worker 自建临时邮箱服务""" def __init__(self, api_url: str, admin_token: str = "", domain: str = "", - fingerprint: str = "", proxy: str = None): + fingerprint: str = "", custom_auth: str = "", proxy: str = None): self.api = api_url.rstrip("/") self.admin_token = admin_token self.domain = domain self.fingerprint = fingerprint + self.custom_auth = custom_auth self.proxy = {"http": proxy, "https": proxy} if proxy else None self._token = None @@ -426,8 +428,43 @@ class CFWorkerMailbox(BaseMailbox): } if self.fingerprint: h["x-fingerprint"] = self.fingerprint + if self.custom_auth: + h["x-custom-auth"] = self.custom_auth return h + def _request_json(self, method: str, path: str, *, params: dict | None = None, + payload: dict | None = None, timeout: int = 15): + import requests + + url = f"{self.api}{path}" + response = requests.request( + method, + url, + params=params, + json=payload, + headers=self._headers(), + proxies=self.proxy, + timeout=timeout, + ) + body = (response.text or "").strip() + preview = body[:200] or "" + + if response.status_code >= 400: + if "private site password" in body.lower(): + raise RuntimeError( + "CFWorker API 需要私有站点密码,请配置 cfworker_custom_auth" + ) + raise RuntimeError( + f"CFWorker API {path} 失败: HTTP {response.status_code} {preview}" + ) + + try: + return response.json() + except Exception as e: + raise RuntimeError( + f"CFWorker API {path} 返回非 JSON: HTTP {response.status_code} {preview}" + ) from e + def _generate_local_part(self) -> str: import random, string # 避免纯数字开头,提高邮箱格式“像真人”的程度 @@ -436,28 +473,26 @@ class CFWorkerMailbox(BaseMailbox): return f"{prefix}{suffix}" def get_email(self) -> MailboxAccount: - import requests name = self._generate_local_part() payload = {"enablePrefix": True, "name": name} if self.domain: payload["domain"] = self.domain - r = requests.post(f"{self.api}/admin/new_address", - json=payload, headers=self._headers(), - proxies=self.proxy, timeout=15) - print(f"[CFWorker] new_address status={r.status_code} resp={r.text[:200]}") - data = r.json() + data = self._request_json("POST", "/admin/new_address", payload=payload, timeout=15) email = data.get("email", data.get("address", "")) token = data.get("token", data.get("jwt", "")) + if not email or not token: + raise RuntimeError(f"CFWorker API /admin/new_address 返回缺少 email/jwt: {data}") self._token = token print(f"[CFWorker] 生成邮箱: {email} token={token[:40] if token else 'NONE'}...") return MailboxAccount(email=email, account_id=token) def _get_mails(self, email: str) -> list: - import requests - r = requests.get(f"{self.api}/admin/mails", + data = self._request_json( + "GET", + "/admin/mails", params={"limit": 20, "offset": 0, "address": email}, - headers=self._headers(), proxies=self.proxy, timeout=10) - data = r.json() + timeout=10, + ) return data.get("results", data) if isinstance(data, dict) else data def get_current_ids(self, account: MailboxAccount) -> set: diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 22565ec..0afe5bd 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -309,8 +309,13 @@ export default function Accounts() { freemail_password: cfg.freemail_password, cfworker_api_url: cfg.cfworker_api_url, cfworker_admin_token: cfg.cfworker_admin_token, + cfworker_custom_auth: cfg.cfworker_custom_auth, cfworker_domain: cfg.cfworker_domain, cfworker_fingerprint: cfg.cfworker_fingerprint, + smstome_cookie: cfg.smstome_cookie, + smstome_country_slugs: cfg.smstome_country_slugs, + smstome_phone_attempts: cfg.smstome_phone_attempts, + smstome_otp_timeout_seconds: cfg.smstome_otp_timeout_seconds, }, }), }) diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 428f3ae..bffb502 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -48,8 +48,13 @@ export default function Register() { freemail_password: cfg.freemail_password || '', cfworker_api_url: cfg.cfworker_api_url || '', cfworker_admin_token: cfg.cfworker_admin_token || '', + cfworker_custom_auth: cfg.cfworker_custom_auth || '', cfworker_domain: cfg.cfworker_domain || '', cfworker_fingerprint: cfg.cfworker_fingerprint || '', + smstome_cookie: cfg.smstome_cookie || '', + smstome_country_slugs: cfg.smstome_country_slugs || '', + smstome_phone_attempts: cfg.smstome_phone_attempts || '', + smstome_otp_timeout_seconds: cfg.smstome_otp_timeout_seconds || '', luckmail_base_url: cfg.luckmail_base_url || 'https://mails.luckyous.com/', luckmail_api_key: cfg.luckmail_api_key || '', luckmail_email_type: cfg.luckmail_email_type || '', @@ -86,8 +91,13 @@ export default function Register() { freemail_password: values.freemail_password, cfworker_api_url: values.cfworker_api_url, cfworker_admin_token: values.cfworker_admin_token, + cfworker_custom_auth: values.cfworker_custom_auth, cfworker_domain: values.cfworker_domain, cfworker_fingerprint: values.cfworker_fingerprint, + smstome_cookie: values.smstome_cookie, + smstome_country_slugs: values.smstome_country_slugs, + smstome_phone_attempts: values.smstome_phone_attempts, + smstome_otp_timeout_seconds: values.smstome_otp_timeout_seconds, luckmail_base_url: values.luckmail_base_url, luckmail_api_key: values.luckmail_api_key, luckmail_email_type: values.luckmail_email_type, @@ -221,6 +231,9 @@ export default function Register() { + + + diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c158c60..d59bfb1 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -102,6 +102,7 @@ const TAB_ITEMS = [ fields: [ { key: 'cfworker_api_url', label: 'API URL', placeholder: 'https://apimail.example.com' }, { key: 'cfworker_admin_token', label: '管理员 Token', secret: true }, + { key: 'cfworker_custom_auth', label: '站点密码', secret: true }, { key: 'cfworker_domain', label: '邮箱域名', placeholder: 'example.com' }, { key: 'cfworker_fingerprint', label: 'Fingerprint', placeholder: '6703363b...' }, ], @@ -154,6 +155,16 @@ const TAB_ITEMS = [ { key: 'team_manager_key', label: 'API Key', secret: true }, ], }, + { + title: 'SMSToMe 手机验证', + desc: 'ChatGPT add_phone 阶段自动取号并轮询短信验证码', + fields: [ + { key: 'smstome_cookie', label: 'SMSToMe Cookie', secret: true }, + { key: 'smstome_country_slugs', label: '国家列表', placeholder: 'united-kingdom,poland' }, + { key: 'smstome_phone_attempts', label: '手机号尝试次数', placeholder: '3' }, + { key: 'smstome_otp_timeout_seconds', label: '短信等待秒数', placeholder: '45' }, + ], + }, ], }, { diff --git a/requirements.txt b/requirements.txt index 980b8a4..e7674cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ camoufox>=0.4.0 aiofiles>=23.0.0 rich>=13.7.1 pyinstaller>=6.0.0 +httpx>=0.27.0 +selectolax>=0.3.21 From d905b2f7d8992f4813216cdc4429f2b8ea5d3451 Mon Sep 17 00:00:00 2001 From: highkay Date: Mon, 30 Mar 2026 17:41:24 +0800 Subject: [PATCH 4/9] Add ChatGPT phone verification with SMSToMe --- .gitignore | 5 +- api/config.py | 1 + core/config_store.py | 121 ++- frontend/src/pages/Accounts.tsx | 2 + frontend/src/pages/Register.tsx | 30 + frontend/src/pages/Settings.tsx | 2 + platforms/chatgpt/chatgpt_client.py | 2 +- platforms/chatgpt/oauth_client.py | 448 ++++++++- platforms/chatgpt/phone_service.py | 98 ++ platforms/chatgpt/plugin.py | 2 + platforms/chatgpt/register_v2.py | 75 +- smstome_tool.py | 1122 +++++++++++++++++++++++ tests/test_chatgpt_phone_flow.py | 203 ++++ tests/test_config_store_env_fallback.py | 72 ++ 14 files changed, 2136 insertions(+), 47 deletions(-) create mode 100644 platforms/chatgpt/phone_service.py create mode 100644 smstome_tool.py create mode 100644 tests/test_chatgpt_phone_flow.py create mode 100644 tests/test_config_store_env_fallback.py diff --git a/.gitignore b/.gitignore index d27c268..d87880b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,7 @@ data/ *.swp .claude/ .docs/superpowers/ -CLAUDE.md \ No newline at end of file +CLAUDE.md +.ace-tool/ +smstome*_numbers.txt +smstome_used/ diff --git a/api/config.py b/api/config.py index bd71bbb..f59f2b8 100644 --- a/api/config.py +++ b/api/config.py @@ -14,6 +14,7 @@ CONFIG_KEYS = [ "mail_provider", "cfworker_api_url", "cfworker_admin_token", "cfworker_custom_auth", "cfworker_domain", "cfworker_fingerprint", "smstome_cookie", "smstome_country_slugs", "smstome_phone_attempts", "smstome_otp_timeout_seconds", + "smstome_poll_interval_seconds", "smstome_sync_max_pages_per_country", "luckmail_base_url", "luckmail_api_key", "luckmail_email_type", "luckmail_domain", "cpa_api_url", "cpa_api_key", "team_manager_url", "team_manager_key", diff --git a/core/config_store.py b/core/config_store.py index 198271d..cb7da7d 100644 --- a/core/config_store.py +++ b/core/config_store.py @@ -1,9 +1,118 @@ -"""全局配置持久化 - 存储在 SQLite""" +"""全局配置持久化 - 存储在 SQLite,并在缺省时回退到环境变量/.env。""" +import os +import re +from pathlib import Path from typing import Optional from sqlmodel import Field, SQLModel, Session, select from .db import engine +_ENV_FILE = Path(__file__).resolve().parent.parent / ".env" + + +def _normalize_config_value(value) -> str: + text = str(value or "").strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}: + return text[1:-1] + return text + + +def _canonical_config_key(key: str) -> str: + value = str(key or "").strip() + if not value: + return "" + return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") + + +def _config_key_candidates(key: str) -> list[str]: + raw = str(key or "").strip() + if not raw: + return [] + + normalized = re.sub(r"[^A-Za-z0-9]+", "_", raw).strip("_") + candidates: list[str] = [] + seen = set() + for item in ( + raw, + raw.lower(), + raw.upper(), + normalized, + normalized.lower(), + normalized.upper(), + ): + value = str(item or "").strip() + if value and value not in seen: + seen.add(value) + candidates.append(value) + return candidates + + +def _load_env_file(path: Path | str | None = None) -> dict[str, str]: + env_path = Path(path or _ENV_FILE) + if not env_path.exists(): + return {} + + try: + lines = env_path.read_text(encoding="utf-8", errors="ignore").splitlines() + except Exception: + return {} + + values: dict[str, str] = {} + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.lower().startswith("export "): + line = line[7:].strip() + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key: + continue + values[key] = _normalize_config_value(value) + return values + + +def _runtime_env_values() -> dict[str, str]: + values: dict[str, str] = {} + for key, value in _load_env_file().items(): + text = _normalize_config_value(value) + if text: + values[key] = text + for key, value in os.environ.items(): + text = _normalize_config_value(value) + if text: + values[key] = text + return values + + +def _get_env_fallback_value(key: str, env_values: Optional[dict[str, str]] = None) -> str: + values = env_values if env_values is not None else _runtime_env_values() + for candidate in _config_key_candidates(key): + text = str(values.get(candidate, "") or "").strip() + if text: + return text + return "" + + +def _merge_env_fallback(values: dict[str, str], env_values: Optional[dict[str, str]] = None) -> dict[str, str]: + merged = dict(values or {}) + runtime_values = env_values if env_values is not None else _runtime_env_values() + for env_key, env_value in runtime_values.items(): + text = str(env_value or "").strip() + if not text: + continue + canonical_key = _canonical_config_key(env_key) + for target_key in (env_key, canonical_key): + if not target_key: + continue + if str(merged.get(target_key, "") or "").strip(): + continue + merged[target_key] = text + return merged + + class ConfigItem(SQLModel, table=True): __tablename__ = "configs" key: str = Field(primary_key=True) @@ -14,9 +123,14 @@ class ConfigStore: """简单 key-value 配置存储""" def get(self, key: str, default: str = "") -> str: + env_values = _runtime_env_values() with Session(engine) as s: item = s.get(ConfigItem, key) - return item.value if item else default + value = str(item.value if item else "" or "").strip() + if value: + return value + fallback = _get_env_fallback_value(key, env_values=env_values) + return fallback or default def set(self, key: str, value: str) -> None: with Session(engine) as s: @@ -31,7 +145,8 @@ class ConfigStore: def get_all(self) -> dict: with Session(engine) as s: items = s.exec(select(ConfigItem)).all() - return {i.key: i.value for i in items} + values = {i.key: i.value for i in items} + return _merge_env_fallback(values) def set_many(self, data: dict) -> None: with Session(engine) as s: diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 0afe5bd..ac3365c 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -316,6 +316,8 @@ export default function Accounts() { smstome_country_slugs: cfg.smstome_country_slugs, smstome_phone_attempts: cfg.smstome_phone_attempts, smstome_otp_timeout_seconds: cfg.smstome_otp_timeout_seconds, + smstome_poll_interval_seconds: cfg.smstome_poll_interval_seconds, + smstome_sync_max_pages_per_country: cfg.smstome_sync_max_pages_per_country, }, }), }) diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index bffb502..ab95a35 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -55,6 +55,8 @@ export default function Register() { smstome_country_slugs: cfg.smstome_country_slugs || '', smstome_phone_attempts: cfg.smstome_phone_attempts || '', smstome_otp_timeout_seconds: cfg.smstome_otp_timeout_seconds || '', + smstome_poll_interval_seconds: cfg.smstome_poll_interval_seconds || '', + smstome_sync_max_pages_per_country: cfg.smstome_sync_max_pages_per_country || '', luckmail_base_url: cfg.luckmail_base_url || 'https://mails.luckyous.com/', luckmail_api_key: cfg.luckmail_api_key || '', luckmail_email_type: cfg.luckmail_email_type || '', @@ -98,6 +100,8 @@ export default function Register() { smstome_country_slugs: values.smstome_country_slugs, smstome_phone_attempts: values.smstome_phone_attempts, smstome_otp_timeout_seconds: values.smstome_otp_timeout_seconds, + smstome_poll_interval_seconds: values.smstome_poll_interval_seconds, + smstome_sync_max_pages_per_country: values.smstome_sync_max_pages_per_country, luckmail_base_url: values.luckmail_base_url, luckmail_api_key: values.luckmail_api_key, luckmail_email_type: values.luckmail_email_type, @@ -260,6 +264,32 @@ export default function Register() { )} + {platform === 'chatgpt' && ( + + + 仅在 OAuth 流程进入 `add_phone` 时使用,用于自动取号并轮询短信验证码。 + + + + + + + + + + + + + + + + + + + + + )} + {captchaSolver === 'yescaptcha' && ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d59bfb1..42b8bc1 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -163,6 +163,8 @@ const TAB_ITEMS = [ { key: 'smstome_country_slugs', label: '国家列表', placeholder: 'united-kingdom,poland' }, { key: 'smstome_phone_attempts', label: '手机号尝试次数', placeholder: '3' }, { key: 'smstome_otp_timeout_seconds', label: '短信等待秒数', placeholder: '45' }, + { key: 'smstome_poll_interval_seconds', label: '轮询间隔秒数', placeholder: '5' }, + { key: 'smstome_sync_max_pages_per_country', label: '每国同步页数', placeholder: '5' }, ], }, ], diff --git a/platforms/chatgpt/chatgpt_client.py b/platforms/chatgpt/chatgpt_client.py index 59ec3d7..0f4cb82 100644 --- a/platforms/chatgpt/chatgpt_client.py +++ b/platforms/chatgpt/chatgpt_client.py @@ -823,7 +823,7 @@ class ChatGPTClient: if self._state_is_email_otp(state): self._log("等待邮箱验证码...") - otp_code = skymail_client.wait_for_verification_code(email, timeout=30) + otp_code = skymail_client.wait_for_verification_code(email, timeout=90) if not otp_code: return False, "未收到验证码" diff --git a/platforms/chatgpt/oauth_client.py b/platforms/chatgpt/oauth_client.py index d6b4726..0b735b8 100644 --- a/platforms/chatgpt/oauth_client.py +++ b/platforms/chatgpt/oauth_client.py @@ -11,6 +11,7 @@ try: except ImportError: import requests as curl_requests +from .phone_service import SMSToMePhoneService from .utils import ( FlowState, build_browser_headers, @@ -38,12 +39,14 @@ class OAuthClient: verbose: 是否输出详细日志 browser_mode: protocol | headless | headed """ - self.oauth_issuer = config.get("oauth_issuer", "https://auth.openai.com") - self.oauth_client_id = config.get("oauth_client_id", "app_EMoamEEZ73f0CkXaXp7hrann") - self.oauth_redirect_uri = config.get("oauth_redirect_uri", "http://localhost:1455/auth/callback") + 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() @@ -55,11 +58,110 @@ class OAuthClient: 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, @@ -142,6 +244,10 @@ class OAuthClient: 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": @@ -335,7 +441,7 @@ class OAuthClient: impersonate=impersonate, ) if not sentinel_token: - self._log("无法获取 sentinel token (authorize_continue)") + self._set_error("无法获取 sentinel token (authorize_continue)") return None request_url = f"{self.oauth_issuer}/api/accounts/authorize/continue" @@ -391,7 +497,7 @@ class OAuthClient: self._log(f"/authorize/continue(重试) -> {r.status_code}") if r.status_code != 200: - self._log(f"提交邮箱失败: {r.text[:180]}") + self._set_error(f"提交邮箱失败: {r.status_code} - {r.text[:180]}") return None data = r.json() @@ -399,7 +505,7 @@ class OAuthClient: self._log(describe_flow_state(flow_state)) return flow_state except Exception as e: - self._log(f"提交邮箱异常: {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): @@ -415,7 +521,7 @@ class OAuthClient: impersonate=impersonate, ) if not sentinel_pwd: - self._log("无法获取 sentinel token (password_verify)") + self._set_error("无法获取 sentinel token (password_verify)") return None request_url = f"{self.oauth_issuer}/api/accounts/password/verify" @@ -445,7 +551,7 @@ class OAuthClient: self._log(f"/password/verify -> {r.status_code}") if r.status_code != 200: - self._log(f"密码验证失败: {r.text[:180]}") + self._set_error(f"密码验证失败: {r.status_code} - {r.text[:180]}") return None data = r.json() @@ -453,7 +559,7 @@ class OAuthClient: self._log(f"verify {describe_flow_state(flow_state)}") return flow_state except Exception as e: - self._log(f"密码验证异常: {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): @@ -472,6 +578,7 @@ class OAuthClient: Returns: dict: tokens 字典,包含 access_token, refresh_token, id_token """ + self.last_error = "" self._log("开始 OAuth 登录流程...") code_verifier, code_challenge = generate_pkce() @@ -499,7 +606,7 @@ class OAuthClient: impersonate=impersonate, ) if not authorize_final_url: - self._log("Bootstrap 失败") + self._set_error("Bootstrap 失败") return None continue_referer = ( @@ -519,6 +626,8 @@ class OAuthClient: 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)}") @@ -529,7 +638,7 @@ class OAuthClient: signature = self._state_signature(state) seen_states[signature] = seen_states.get(signature, 0) + 1 if seen_states[signature] > 2: - self._log(f"OAuth 状态卡住: {describe_flow_state(state)}") + self._set_error(f"OAuth 状态卡住: {describe_flow_state(state)}") return None code = self._extract_code_from_state(state) @@ -553,6 +662,8 @@ class OAuthClient: 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 @@ -560,7 +671,7 @@ class OAuthClient: if self._state_is_email_otp(state): if not skymail_client: - self._log("当前流程需要邮箱 OTP,但缺少接码客户端") + self._set_error("当前流程需要邮箱 OTP,但缺少接码客户端") return None next_state = self._handle_otp_verification( email, @@ -572,6 +683,24 @@ class OAuthClient: 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 @@ -621,10 +750,14 @@ class OAuthClient: self._log(f"workspace state -> {describe_flow_state(state)}") continue - self._log(f"未支持的 OAuth 状态: {describe_flow_state(state)}") + 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._log("OAuth 状态机超出最大步数") + self._set_error("OAuth 状态机超出最大步数") return None def _extract_code_from_url(self, url): @@ -664,17 +797,17 @@ class OAuthClient: self._log(f"无法获取 consent session 数据 (尝试 {attempt + 1}/{max_retries})") time.sleep(0.3) else: - self._log("无法获取 consent session 数据") + self._set_error("无法获取 consent session 数据") return None, None workspaces = session_data.get("workspaces", []) if not workspaces: - self._log("session 中没有 workspace 信息") + self._set_error("session 中没有 workspace 信息") return None, None workspace_id = (workspaces[0] or {}).get("id") if not workspace_id: - self._log("workspace_id 为空") + self._set_error("workspace_id 为空") return None, None self._log(f"选择 workspace: {workspace_id}") @@ -794,7 +927,7 @@ class OAuthClient: return self._extract_code_from_state(org_state), org_state return None, org_state except Exception as e: - self._log(f"解析 organization/select 响应异常: {e}") + self._set_error(f"解析 organization/select 响应异常: {e}") # 如果有 continue_url,跟随它 if continue_url: @@ -804,10 +937,12 @@ class OAuthClient: return None, workspace_state except Exception as e: - self._log(f"处理 workspace/select 响应异常: {e}") + self._set_error(f"处理 workspace/select 响应异常: {e}") + return None, None except Exception as e: - self._log(f"workspace/select 异常: {e}") + self._set_error(f"workspace/select 异常: {e}") + return None, None return None, None @@ -951,9 +1086,6 @@ class OAuthClient: def _decode_oauth_session_cookie(self): """解码 oai-client-auth-session cookie""" - import json - import base64 - try: for cookie in self.session.cookies: try: @@ -961,19 +1093,44 @@ class OAuthClient: if name == "oai-client-auth-session": value = cookie.value if hasattr(cookie, 'value') else self.session.cookies.get(name) if value: - padded = value + "=" * (-len(value) % 4) - try: - decoded = base64.b64decode(padded).decode('utf-8') - except Exception: - decoded = base64.urlsafe_b64decode(padded).decode('utf-8') - data = json.loads(decoded) - return data + 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""" @@ -1008,12 +1165,222 @@ class OAuthClient: if r.status_code == 200: return r.json() else: - self._log(f"换取 tokens 失败: {r.status_code} - {r.text[:200]}") + self._set_error(f"换取 tokens 失败: {r.status_code} - {r.text[:200]}") except Exception as e: - self._log(f"换取 tokens 异常: {e}") + self._set_error(f"换取 tokens 异常: {e}") return None + + 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): + phone_service = SMSToMePhoneService(self.config, log_fn=self._log) + if not phone_service.enabled: + self._set_error("OAuth 登录被 add_phone 阻断,当前账号需要手机号验证;未配置可用的 SMSToMe 号码池") + return None + + excluded_prefixes = set() + last_failure = "" + + for attempt in range(phone_service.max_attempts): + try: + entry = phone_service.acquire_phone(exclude_prefixes=excluded_prefixes) + except Exception as e: + last_failure = f"获取手机号失败: {e}" + self._log(last_failure) + break + + if not entry: + last_failure = last_failure or "SMSToMe 号码池中无可用手机号" + break + + prefix = phone_service.prefix_hint(entry.phone) + self._log( + f"步骤5: add_phone 选择手机号 {attempt + 1}/{phone_service.max_attempts}: {entry.phone} ({entry.country_slug})" + ) + + sent, next_state, detail = self._send_phone_number( + entry.phone, + device_id, + user_agent, + sec_ch_ua, + impersonate, + ) + if not sent or not next_state: + last_failure = detail or "add-phone/send 未返回有效状态" + self._log(last_failure) + self._blacklist_phone_if_needed(phone_service, entry, last_failure) + excluded_prefixes.add(prefix) + continue + + if next_state.page_type != "phone_otp_verification" and "phone-verification" not in f"{next_state.continue_url} {next_state.current_url}".lower(): + last_failure = f"add-phone/send 未进入手机验证码页: {describe_flow_state(next_state)}" + self._log(last_failure) + self._blacklist_phone_if_needed(phone_service, entry, last_failure, next_state) + excluded_prefixes.add(prefix) + continue + + session_data = self._decode_oauth_session_cookie() or {} + verification_channel = str(session_data.get("phone_verification_channel") or "sms").strip().lower() or "sms" + bound_phone = str(session_data.get("phone_number") or entry.phone).strip() or entry.phone + self._log(f"add_phone 发码成功: phone={bound_phone}, channel={verification_channel}") + + if verification_channel != "sms": + last_failure = f"add_phone 已切到 {verification_channel} 通道,当前 SMSToMe 仅支持短信接码" + self._log(last_failure) + excluded_prefixes.add(prefix) + continue + + code = phone_service.wait_for_code(entry) + if not code: + self._log("手机号验证码暂未收到,尝试重发一次...") + resend_ok, resend_detail = self._resend_phone_otp( + device_id, + user_agent, + sec_ch_ua, + impersonate, + next_state, + ) + if resend_ok: + code = phone_service.wait_for_code(entry) + if not code: + last_failure = resend_detail or f"手机号 {entry.phone} 未收到短信验证码" + self._log(last_failure) + excluded_prefixes.add(prefix) + continue + + valid, validated_state, detail = self._validate_phone_otp( + code, + device_id, + user_agent, + sec_ch_ua, + impersonate, + next_state, + ) + if not valid or not validated_state: + last_failure = detail or "手机号 OTP 验证失败" + self._log(last_failure) + excluded_prefixes.add(prefix) + continue + + return validated_state + + self._set_error(f"add_phone 阶段失败: {last_failure or '未完成手机号验证'}") + return None def _handle_otp_verification(self, email, device_id, user_agent, sec_ch_ua, impersonate, skymail_client, state): """处理 OAuth 阶段的邮箱 OTP 验证,返回服务端声明的下一步状态。""" @@ -1039,7 +1406,7 @@ class OAuthClient: skymail_client._used_codes = set() tried_codes = set(getattr(skymail_client, "_used_codes", set())) - otp_deadline = time.time() + 30 + otp_deadline = time.time() + 60 otp_sent_at = time.time() def validate_otp(code): @@ -1085,7 +1452,7 @@ class OAuthClient: self._log("使用 wait_for_verification_code 进行阻塞式获取新验证码...") while time.time() < otp_deadline: remaining = max(1, int(otp_deadline - time.time())) - wait_time = min(8, remaining) + wait_time = min(10, remaining) try: code = skymail_client.wait_for_verification_code( email, @@ -1099,6 +1466,8 @@ class OAuthClient: if not code: self._log("暂未收到新的 OTP,继续等待...") + if self.last_error: + break continue if code in tried_codes: @@ -1108,6 +1477,8 @@ class OAuthClient: 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 [] @@ -1120,8 +1491,8 @@ class OAuthClient: candidate_codes.append(code) if not candidate_codes: - elapsed = int(30 - max(0, otp_deadline - time.time())) - self._log(f"等待新的 OTP... ({elapsed}s/30s)") + elapsed = int(60 - max(0, otp_deadline - time.time())) + self._log(f"等待新的 OTP... ({elapsed}s/60s)") time.sleep(2) continue @@ -1131,6 +1502,9 @@ class OAuthClient: return next_state time.sleep(2) + if self.last_error: + break - self._log(f"OAuth 阶段 OTP 验证失败,已尝试 {len(tried_codes)} 个验证码") + if not self.last_error: + self._set_error(f"OAuth 阶段 OTP 验证失败,已尝试 {len(tried_codes)} 个验证码") return None diff --git a/platforms/chatgpt/phone_service.py b/platforms/chatgpt/phone_service.py new file mode 100644 index 0000000..2d8be05 --- /dev/null +++ b/platforms/chatgpt/phone_service.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Iterable, Optional + +from smstome_tool import ( + PhoneEntry, + get_unused_phone, + mark_phone_blacklisted, + parse_country_slugs, + update_global_phone_list, + wait_for_otp, +) + + +def _to_positive_int(value, default: int, *, minimum: int = 1) -> int: + try: + parsed = int(str(value).strip()) + except Exception: + return default + return parsed if parsed >= minimum else default + + +def _prefix_hint(phone: str, width: int = 7) -> str: + value = str(phone or "").strip() + return value[: min(len(value), width)] if value else "" + + +class SMSToMePhoneService: + def __init__(self, config: Optional[dict] = None, log_fn: Optional[Callable[[str], None]] = None): + self.config = dict(config or {}) + self.log_fn = log_fn or (lambda _msg: None) + self.cookie_header = str(self.config.get("smstome_cookie", "") or "").strip() or None + self.country_slugs = parse_country_slugs(self.config.get("smstome_country_slugs")) + self.global_file = Path(str(self.config.get("smstome_global_file") or "smstome_all_numbers.txt")) + self.used_numbers_dir = Path(str(self.config.get("smstome_used_numbers_dir") or "smstome_used")) + self.task_name = str(self.config.get("smstome_task_name") or "chatgpt_add_phone").strip() or "chatgpt_add_phone" + self.max_attempts = _to_positive_int(self.config.get("smstome_phone_attempts"), 3) + self.otp_timeout_seconds = _to_positive_int(self.config.get("smstome_otp_timeout_seconds"), 45, minimum=10) + self.poll_interval_seconds = _to_positive_int(self.config.get("smstome_poll_interval_seconds"), 5, minimum=1) + self.sync_max_pages_per_country = _to_positive_int( + self.config.get("smstome_sync_max_pages_per_country"), + 5, + ) + + @property + def enabled(self) -> bool: + return self._has_pool_file() or bool(self.cookie_header) + + def prefix_hint(self, phone: str) -> str: + return _prefix_hint(phone) + + def _has_pool_file(self) -> bool: + try: + return self.global_file.exists() and self.global_file.stat().st_size > 0 + except OSError: + return False + + def ensure_pool_ready(self) -> None: + if self._has_pool_file(): + return + if not self.cookie_header: + raise RuntimeError("未找到 SMSToMe 号码池文件,且未配置 smstome_cookie") + + self.log_fn("SMSToMe 号码池不存在,开始自动同步...") + count = update_global_phone_list( + cookie_header=self.cookie_header, + countries=self.country_slugs or None, + output_path=self.global_file, + max_pages_per_country=self.sync_max_pages_per_country, + ) + if count <= 0: + raise RuntimeError("SMSToMe 号码池同步后为空") + self.log_fn(f"SMSToMe 号码池同步完成,共 {count} 个号码") + + def acquire_phone(self, *, exclude_prefixes: Optional[Iterable[str]] = None) -> Optional[PhoneEntry]: + self.ensure_pool_ready() + return get_unused_phone( + self.task_name, + country_slug=self.country_slugs or None, + global_file=self.global_file, + used_numbers_dir=self.used_numbers_dir, + exclude_prefixes=exclude_prefixes, + ) + + def mark_blacklisted(self, phone: str) -> None: + mark_phone_blacklisted(self.task_name, phone, used_numbers_dir=self.used_numbers_dir) + + def wait_for_code(self, entry: PhoneEntry, *, timeout: Optional[int] = None) -> Optional[str]: + wait_seconds = _to_positive_int(timeout, self.otp_timeout_seconds, minimum=10) + return wait_for_otp( + entry, + cookie_header=self.cookie_header, + timeout=wait_seconds, + poll_interval=self.poll_interval_seconds, + trace=lambda message: self.log_fn(f"[SMSToMe] {message}"), + raise_on_timeout=False, + ) diff --git a/platforms/chatgpt/plugin.py b/platforms/chatgpt/plugin.py index e0583ea..c10adb3 100644 --- a/platforms/chatgpt/plugin.py +++ b/platforms/chatgpt/plugin.py @@ -84,6 +84,7 @@ class ChatGPTPlatform(BasePlatform): browser_mode=browser_mode, callback_logger=log_fn, max_retries=max_retries, + extra_config=(self.config.extra or {}), ) engine.email = email engine.password = password @@ -116,6 +117,7 @@ class ChatGPTPlatform(BasePlatform): browser_mode=browser_mode, callback_logger=log_fn, max_retries=max_retries, + extra_config=(self.config.extra or {}), ) if email: engine.email = email diff --git a/platforms/chatgpt/register_v2.py b/platforms/chatgpt/register_v2.py index 33b99b0..e83446c 100644 --- a/platforms/chatgpt/register_v2.py +++ b/platforms/chatgpt/register_v2.py @@ -12,6 +12,7 @@ from core.base_platform import AccountStatus from platforms.chatgpt.register import RegistrationResult from .chatgpt_client import ChatGPTClient +from .oauth_client import OAuthClient from .utils import generate_random_name, generate_random_birthday logger = logging.getLogger(__name__) @@ -46,6 +47,7 @@ class RegistrationEngineV2: callback_logger: Optional[Callable[[str], None]] = None, task_uuid: Optional[str] = None, max_retries: int = 3, + extra_config: Optional[dict] = None, ): self.email_service = email_service self.proxy_url = proxy_url @@ -53,6 +55,7 @@ class RegistrationEngineV2: self.callback_logger = callback_logger 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 @@ -69,6 +72,12 @@ 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 _should_retry(self, message: str) -> bool: text = str(message or "").lower() retriable_markers = [ @@ -151,7 +160,7 @@ class RegistrationEngineV2: result.error_message = last_error return result - self._log("步骤 2/2: 复用注册会话,直接获取 ChatGPT Session / AccessToken...") + self._log("步骤 2/2: 优先复用注册会话提取 ChatGPT Session / AccessToken...") session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens() if session_ok: @@ -181,10 +190,66 @@ class RegistrationEngineV2: self._log("=" * 60) return result - last_error = f"注册成功,但复用会话获取 AccessToken 失败: {session_result}" - if attempt < self.max_retries - 1: - self._log(f"{last_error},准备整流程重试") - continue + 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: diff --git a/smstome_tool.py b/smstome_tool.py new file mode 100644 index 0000000..fbd3f39 --- /dev/null +++ b/smstome_tool.py @@ -0,0 +1,1122 @@ +from __future__ import annotations + +"""SMSToMe phone pool + OTP helper. + +该文件是一个**单独的工具脚本**,负责: + +1. `update_global_phone_list`:抓取多个国家的全部可用手机号,写入本地 txt。 +2. `get_unused_phone`:针对某个任务名,返回一个尚未使用过的手机号。 +3. `wait_for_otp`:轮询该手机号的短信页面,提取验证码。 + +实现细节: + - 基于 `httpx` + `selectolax` 的 HTTP + HTML 解析方案; + - 默认使用浏览器风格 UA,禁用系统代理 (`trust_env=False`),避免影响 Tavily 相关代理行为; + - 支持通过环境变量 `SMSTOME_COOKIE`、仓库根目录 `config.yaml` 或显式参数注入 Cookie; + - 使用简单的循环 + 退避重试,避免额外引入 tenacity 依赖。 + +注意: + - txt 持久化仅做简单记录,不做数据库级别的状态管理; + - 全量号码文件中会额外保存国家 slug 与详情页 URL,方便后续获取验证码; + - 每个任务的“已使用号码列表”是独立的 txt 文件,仅按手机号一行记录。 +""" + +import os +import random +import re +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Iterable, List, Optional + +import httpx +from selectolax.parser import HTMLParser +from urllib.parse import urljoin, urlsplit + +try: + from runtime_support import get_nonempty_str, load_yaml_config +except ImportError: + def get_nonempty_str(mapping, *keys): + data = mapping if isinstance(mapping, dict) else {} + for key in keys: + value = str(data.get(key, "") or "").strip() + if value: + return value + return "" + + def load_yaml_config(config_path): + path = Path(config_path) + if not path.exists(): + return {} + try: + import yaml + except ImportError: + return {} + try: + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return {} + return loaded if isinstance(loaded, dict) else {} + + +SMSTOME_BASE_URL = "https://smstome.com" +DEFAULT_CONFIG_PATH = Path(__file__).with_name("config.yaml") + +# 当前支持的国家 slug(来自站点 URL) +DEFAULT_COUNTRY_SLUGS: List[str] = [ + "poland", + "united-kingdom", + "slovenia", + "sweden", + "finland", + "belgium", +] + + +# 全量号码列表文件(每行:phone\tcountry_slug\tdetail_url) +GLOBAL_PHONE_FILE = Path("smstome_all_numbers.txt") +DEFAULT_SYNC_MAX_PAGES_PER_COUNTRY = 5 + +# 每个任务自己的“已使用号码”目录(文件名:_used_numbers.txt) +USED_NUMBERS_DIR = Path("smstome_used") +BLACKLISTED_NUMBERS_SUFFIX = "_blacklisted_numbers.txt" +USED_NUMBERS_SUFFIX = "_used_numbers.txt" +PHONE_PREFIX_WIDTH = 7 + + +DEFAULT_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Connection": "keep-alive", +} + +OTP_SEPARATOR_CHARS = r"[\s\-]" +OTP_BIDI_CHARS_RE = re.compile(r"[\u200e\u200f\u202a-\u202e\u2066-\u2069]") +OTP_SPLIT_CANDIDATE_RE = re.compile(r"(? int: + value = int(start_page or 1) + if value < 1: + raise ValueError(f"start_page must be >= 1, got {start_page}") + return value + + +def _resolve_country_page_window( + *, + detected_max_page: int, + start_page: int = 1, + max_pages_per_country: Optional[int] = DEFAULT_SYNC_MAX_PAGES_PER_COUNTRY, +) -> list[int]: + start = _normalize_start_page(start_page) + if detected_max_page < start: + return [] + if max_pages_per_country is None: + end_page = detected_max_page + else: + if max_pages_per_country < 1: + raise ValueError(f"max_pages_per_country must be >= 1, got {max_pages_per_country}") + end_page = min(detected_max_page, start + max_pages_per_country - 1) + return list(range(start, end_page + 1)) + + +def _normalize_message_text_for_otp(message_text: str) -> str: + text = OTP_BIDI_CHARS_RE.sub("", message_text or "") + return text.strip() + + +def _extract_otp_from_text( + message_text: str, + *, + min_digits: int = 4, + max_digits: int = 8, +) -> Optional[str]: + text = _normalize_message_text_for_otp(message_text) + if not text: + return None + + for match in OTP_SPLIT_CANDIDATE_RE.finditer(text): + digits = re.sub(OTP_SEPARATOR_CHARS, "", match.group(1)) + if min_digits <= len(digits) <= max_digits: + return digits + return None + + +def _extract_recent_6digit_otp(message_text: str, received_text: str) -> Optional[str]: + """优先匹配“最近约 1 分钟内”的 6 位验证码。""" + + msg = (message_text or "").strip() + recv = (received_text or "").strip().lower() + if not msg: + return None + + recent_markers = ( + "just now", + "few seconds", + "second ago", + "seconds ago", + "sec ago", + "secs ago", + "now", + ) + is_recent = any(marker in recv for marker in recent_markers) + + if not is_recent: + # 兼容 "1 min ago" / "1 minute ago" 等形式 + minute_match = re.search(r"(\d+)\s*(m|min|mins|minute|minutes)\b", recv) + if minute_match: + is_recent = int(minute_match.group(1)) <= 1 + + if not is_recent: + return None + + return _extract_otp_from_text(msg, min_digits=6, max_digits=6) + + +def _parse_received_age_minutes(received_text: str) -> Optional[float]: + recv = (received_text or "").strip().lower() + if not recv: + return None + + immediate_markers = ( + "just now", + "few seconds", + "second ago", + "seconds ago", + "sec ago", + "secs ago", + "moments ago", + "now", + ) + if any(marker in recv for marker in immediate_markers): + return 0.0 + + if re.search(r"\ban?\s+(m|min|mins|minute|minutes)\b", recv): + return 1.0 + if re.search(r"\ban?\s+(h|hr|hrs|hour|hours)\b", recv): + return 60.0 + if "yesterday" in recv: + return 24.0 * 60.0 + + match = re.search( + r"(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)\b", + recv, + ) + if not match: + return None + + value = int(match.group(1)) + unit = match.group(2) + if unit.startswith("s"): + return value / 60.0 + if unit.startswith("m"): + return float(value) + if unit.startswith("h"): + return float(value) * 60.0 + if unit.startswith("d"): + return float(value) * 24.0 * 60.0 + return None + + +@dataclass(frozen=True) +class PhoneEntry: + """代表一个 SMSToMe 手机号记录。""" + + country_slug: str + phone: str # e.g. "+48573583699" + detail_url: str # e.g. "https://smstome.com/poland/phone/48573583699/sms/14642" + + +@dataclass(frozen=True) +class SmsMessage: + """单条短信记录。""" + + from_label: str + received_text: str + message_text: str + + +class SmsOtpPollingError(RuntimeError): + pass + + +class SmsInboxEmptyError(SmsOtpPollingError): + pass + + +class SmsOtpTimeoutError(SmsOtpPollingError): + pass + + +class SmsOtpFetchError(SmsOtpPollingError): + pass + + +def _summarize_sms_message(message: SmsMessage | None, *, max_len: int = 96) -> str: + if message is None: + return "none" + snippet = " ".join((message.message_text or "").split()) + if len(snippet) > max_len: + snippet = snippet[: max_len - 3] + "..." + return ( + f"from={message.from_label!r}, received={message.received_text!r}, " + f"text={snippet!r}" + ) + + +def _classify_timeout_state( + *, + latest_message: SmsMessage | None, + unmatched_new_message_count: int, +) -> str: + if latest_message is None: + return "empty-inbox" + if unmatched_new_message_count > 0: + return "new-messages-no-otp" + return "stale-inbox-no-new-messages" + + +def _has_recent_sms_history( + messages: Iterable[SmsMessage], + *, + max_age_minutes: float = DEFAULT_RECENT_HISTORY_MINUTES, +) -> bool: + for message in messages: + age_minutes = _parse_received_age_minutes(message.received_text) + if age_minutes is None: + continue + if age_minutes <= max_age_minutes: + return True + return False + + +def _parse_cookie_header(cookie_header: str) -> Dict[str, str]: + """将浏览器复制的 Cookie 字符串解析为字典。 + + 例如: + "a=1; b=2; cf_clearance=xxx" -> {"a": "1", "b": "2", "cf_clearance": "xxx"} + """ + + cookies: Dict[str, str] = {} + for part in cookie_header.split(";"): + part = part.strip() + if not part or "=" not in part: + continue + name, value = part.split("=", 1) + name = name.strip() + value = value.strip() + if name: + cookies[name] = value + return cookies + + +def _load_cookie_from_config(config_path: Path | str | None = None) -> Optional[str]: + try: + from core.config_store import config_store + + stored = str(config_store.get("smstome_cookie", "") or "").strip() + if stored: + return stored + except Exception: + pass + + config = load_yaml_config(config_path or DEFAULT_CONFIG_PATH) + return get_nonempty_str(config, "SMSTOME_COOKIE", "smstome_cookie") + + +def _resolve_cookie_header(cookie_header: Optional[str]) -> str: + explicit_cookie = (cookie_header or "").strip() + if explicit_cookie: + return explicit_cookie + + env_cookie = os.getenv("SMSTOME_COOKIE", "").strip() + if env_cookie: + return env_cookie + + return _load_cookie_from_config() or "" + + +def _build_client(*, cookie_header: Optional[str], timeout: float) -> httpx.Client: + """构造 httpx.Client,注入 UA 和可选 Cookie,禁用系统代理。""" + + headers = dict(DEFAULT_HEADERS) + cookie_header = _resolve_cookie_header(cookie_header) + + cookies: Dict[str, str] = {} + if cookie_header: + cookies.update(_parse_cookie_header(cookie_header)) + + client = httpx.Client( + headers=headers, + cookies=cookies, + timeout=timeout, + follow_redirects=True, + trust_env=False, # 不继承环境代理,避免影响 Tavily 流量策略 + ) + return client + + +def _polite_sleep(base_delay: float, jitter: float) -> None: + """在请求之间添加一点随机延迟,用于简单规避风控。 + + Args: + base_delay: 基础延迟秒数,<=0 表示不等待。 + jitter: 抖动上限秒数,>0 时会在 [0, jitter] 之间随机增加额外延迟。 + """ + + if base_delay <= 0: + return + extra = random.uniform(0, jitter) if jitter > 0 else 0.0 + time.sleep(base_delay + extra) + + +def _fetch_with_retries( + client: httpx.Client, + url: str, + *, + max_attempts: int = 3, + backoff_factor: float = 0.5, +) -> str: + """带简单重试的 GET 请求,返回文本内容。 + + - 对网络异常 / 5xx 做有限次重试; + - 对 4xx(例如 403/404)不做额外特殊处理,直接抛出。 + """ + + last_exc: Optional[Exception] = None + for attempt in range(1, max_attempts + 1): + try: + resp = client.get(url) + resp.raise_for_status() + return resp.text + except (httpx.RequestError, httpx.HTTPStatusError) as exc: # noqa: PERF203 + last_exc = exc + # 4xx 错误通常不需要重试 + status = getattr(exc, "response", None) + status_code = getattr(status, "status_code", None) + if isinstance(status_code, int) and 400 <= status_code < 500: + raise + + if attempt >= max_attempts: + raise + sleep_s = backoff_factor * attempt + time.sleep(sleep_s) + + # 正常逻辑不会走到这里 + raise RuntimeError(f"Failed to fetch {url!r}: {last_exc}") + + +def _detect_max_page(tree: HTMLParser) -> int: + """从国家列表页中解析最大页码,若没有分页则返回 1。""" + + max_page = 1 + # 仅关注包含 `?page=` 的链接,避免抓到其它数字 + for a in tree.css("a[href*='?page=']"): + text = (a.text() or "").strip() + if text.isdigit(): + try: + value = int(text) + except ValueError: + continue + if value > max_page: + max_page = value + return max_page + + +def _collect_numbers_from_country_page( + tree: HTMLParser, + country_slug: str, + phone_map: Dict[str, PhoneEntry], +) -> None: + """从单个国家页解析所有号码并写入 phone_map。""" + + for article in tree.css("article"): + link = article.css_first("a[href*='/phone/']") + if link is None: + continue + phone_text = (link.text() or "").strip() + if not phone_text: + continue + href = (link.attributes.get("href") or "").strip() + if not href: + continue + + detail_url = urljoin(SMSTOME_BASE_URL + "/", href) + # 以手机号去重,后出现的记录会覆盖之前的(一般无影响) + phone_map[phone_text] = PhoneEntry( + country_slug=country_slug, + phone=phone_text, + detail_url=detail_url, + ) + + +def _find_phone_entry_on_country_page( + tree: HTMLParser, + *, + phone: str, + country_slug: str, +) -> Optional[PhoneEntry]: + target_phone = (phone or "").strip() + if not target_phone: + return None + + phone_map: Dict[str, PhoneEntry] = {} + _collect_numbers_from_country_page(tree, country_slug, phone_map) + return phone_map.get(target_phone) + + +def resolve_live_phone_entry( + entry: PhoneEntry, + *, + cookie_header: Optional[str] = None, + request_timeout: float = 20.0, + http_max_attempts: int = 3, + max_pages_per_country: Optional[int] = None, + start_page: int = 1, + per_page_delay: float = 0.0, + jitter: float = 0.0, +) -> Optional[PhoneEntry]: + detail_host = (urlsplit(entry.detail_url).netloc or "").lower() + if "smstome.com" not in detail_host: + return entry + + client = _build_client(cookie_header=cookie_header, timeout=request_timeout) + try: + first_url = f"{SMSTOME_BASE_URL}/country/{entry.country_slug}" + first_page_html = _fetch_with_retries(client, first_url, max_attempts=http_max_attempts) + first_tree = HTMLParser(first_page_html) + page_window = _resolve_country_page_window( + detected_max_page=_detect_max_page(first_tree), + start_page=start_page, + max_pages_per_country=max_pages_per_country, + ) + if not page_window: + return entry + + for index, page in enumerate(page_window): + if page == 1: + html = first_page_html + else: + if index > 0: + _polite_sleep(per_page_delay, jitter) + html = _fetch_with_retries( + client, + f"{first_url}?page={page}", + max_attempts=http_max_attempts, + ) + tree = HTMLParser(html) + resolved = _find_phone_entry_on_country_page( + tree, + phone=entry.phone, + country_slug=entry.country_slug, + ) + if resolved is not None: + return resolved + if index + 1 < len(page_window): + _polite_sleep(per_page_delay, jitter) + return entry + finally: + client.close() + + +def update_global_phone_list( + *, + cookie_header: Optional[str] = None, + countries: Optional[Iterable[str]] = None, + output_path: Path | str = GLOBAL_PHONE_FILE, + request_timeout: float = 20.0, + http_max_attempts: int = 3, + max_pages_per_country: Optional[int] = DEFAULT_SYNC_MAX_PAGES_PER_COUNTRY, + start_page: int = 1, + per_page_delay: float = 1.0, + per_country_delay: float = 3.0, + jitter: float = 0.5, + require_recent_history: bool = True, + recent_history_minutes: float = DEFAULT_RECENT_HISTORY_MINUTES, +) -> int: + """抓取多个国家的号码并写入 txt 文件。 + + txt 格式:每行 `phone\tcountry_slug\tdetail_url`,例如: + + +48573583699 poland https://smstome.com/poland/phone/48573583699/sms/14642 + + Args: + cookie_header: 可选的 Cookie 字符串;若为 None,则尝试从 + `SMSTOME_COOKIE` 环境变量,再回退到仓库根目录 `config.yaml` + 读取。 + countries: 需要同步的国家 slug 列表;若为 None,则使用 + DEFAULT_COUNTRY_SLUGS。 + output_path: 全量号码 txt 文件路径。 + request_timeout: HTTP 请求超时时间(秒)。 + http_max_attempts: 单个请求的最大重试次数。 + max_pages_per_country: 从 start_page 开始,最多抓取多少页,默认 5。 + start_page: 每个国家从第几页开始抓,默认 1。 + per_page_delay: 每翻一页之间的基础延迟(秒),默认 1s。 + per_country_delay: 每个国家抓取完成后的基础延迟(秒),默认 3s。 + jitter: 额外抖动上限(秒),会在 [0, jitter] 内随机增加到延迟上, + 用于让访问节奏更“人类化”。 + + Returns: + 写入文件的去重后手机号数量。 + """ + + if countries is None: + countries = DEFAULT_COUNTRY_SLUGS + + client = _build_client(cookie_header=cookie_header, timeout=request_timeout) + try: + phone_map: Dict[str, PhoneEntry] = {} + + for country_slug in countries: + first_url = f"{SMSTOME_BASE_URL}/country/{country_slug}" + first_page_html = _fetch_with_retries(client, first_url, max_attempts=http_max_attempts) + first_tree = HTMLParser(first_page_html) + page_window = _resolve_country_page_window( + detected_max_page=_detect_max_page(first_tree), + start_page=start_page, + max_pages_per_country=max_pages_per_country, + ) + + for index, page in enumerate(page_window): + if page == 1: + html = first_page_html + else: + if index > 0: + _polite_sleep(per_page_delay, jitter) + url = f"{first_url}?page={page}" + html = _fetch_with_retries(client, url, max_attempts=http_max_attempts) + tree = HTMLParser(html) + _collect_numbers_from_country_page(tree, country_slug, phone_map) + if page == 1 and index + 1 < len(page_window): + _polite_sleep(per_page_delay, jitter) + + # 每个国家抓取完后再稍微停顿一下 + _polite_sleep(per_country_delay, jitter) + + if require_recent_history: + filtered_phone_map: Dict[str, PhoneEntry] = {} + for phone in sorted(phone_map.keys()): + entry = phone_map[phone] + try: + messages = _fetch_sms_messages( + client, + entry.detail_url, + http_max_attempts=http_max_attempts, + ) + except Exception: + continue + if _has_recent_sms_history( + messages, + max_age_minutes=recent_history_minutes, + ): + filtered_phone_map[phone] = entry + phone_map = filtered_phone_map + + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + + # 仅要求“记录全量号码”,但为了后续方便,额外保存国家与详情 URL。 + with output.open("w", encoding="utf-8") as f: + for phone in sorted(phone_map.keys()): + entry = phone_map[phone] + f.write(f"{entry.phone}\t{entry.country_slug}\t{entry.detail_url}\n") + + return len(phone_map) + finally: + client.close() + + +def load_global_phone_index(path: Path | str = GLOBAL_PHONE_FILE) -> Dict[str, PhoneEntry]: + """从全量号码 txt 文件中加载索引。""" + + phone_index: Dict[str, PhoneEntry] = {} + file_path = Path(path) + if not file_path.exists(): + raise FileNotFoundError(f"Global phone list not found: {file_path}") + + with file_path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) < 3: + continue + phone, country_slug, detail_url = parts[0], parts[1], parts[2] + phone_index[phone] = PhoneEntry( + country_slug=country_slug, + phone=phone, + detail_url=detail_url, + ) + + return phone_index + + +def _sanitize_task_name(task_name: str) -> str: + """将任务名转换为适合作为文件名的形式。""" + + return re.sub(r"[^a-zA-Z0-9_.-]", "_", task_name) + + +def _used_numbers_file(task_name: str, *, base_dir: Path | str = USED_NUMBERS_DIR) -> Path: + """返回某个任务对应的“已使用号码”文件路径。""" + + safe_name = _sanitize_task_name(task_name) + directory = Path(base_dir) + directory.mkdir(parents=True, exist_ok=True) + return directory / f"{safe_name}{USED_NUMBERS_SUFFIX}" + + +def _blacklisted_numbers_file(task_name: str, *, base_dir: Path | str = USED_NUMBERS_DIR) -> Path: + """返回某个任务对应的“黑名单号码”文件路径。""" + + safe_name = _sanitize_task_name(task_name) + directory = Path(base_dir) + directory.mkdir(parents=True, exist_ok=True) + return directory / f"{safe_name}{BLACKLISTED_NUMBERS_SUFFIX}" + + +def _load_phone_set(path: Path) -> set[str]: + values: set[str] = set() + if not path.exists(): + return values + with path.open("r", encoding="utf-8") as f: + for line in f: + value = line.strip() + if value: + values.add(value) + return values + + +def _phone_prefix_hint(phone: str, *, width: int = PHONE_PREFIX_WIDTH) -> str: + value = (phone or "").strip() + if not value: + return "" + return value[: min(len(value), width)] + + +def mark_phone_blacklisted( + task_name: str, + phone: str, + *, + used_numbers_dir: Path | str = USED_NUMBERS_DIR, +) -> None: + phone_value = (phone or "").strip() + if not phone_value: + return + + blacklist_file = _blacklisted_numbers_file(task_name, base_dir=used_numbers_dir) + existing = _load_phone_set(blacklist_file) + if phone_value in existing: + return + with blacklist_file.open("a", encoding="utf-8") as f: + f.write(phone_value + "\n") + + +def parse_country_slugs(country_slug: Optional[str | Iterable[str]]) -> list[str]: + if country_slug is None: + return [] + + if isinstance(country_slug, str): + raw_parts = re.split(r"[\s,;|]+", country_slug.strip()) + else: + raw_parts = [] + for item in country_slug: + raw_parts.extend(re.split(r"[\s,;|]+", str(item).strip())) + + normalized: list[str] = [] + seen: set[str] = set() + for part in raw_parts: + value = part.strip().lower().replace("_", "-") + if not value or value in seen: + continue + seen.add(value) + normalized.append(value) + return normalized + + +def get_unused_phone( + task_name: str, + *, + country_slug: Optional[str | Iterable[str]] = None, + global_file: Path | str = GLOBAL_PHONE_FILE, + used_numbers_dir: Path | str = USED_NUMBERS_DIR, + exclude_prefixes: Optional[Iterable[str]] = None, +) -> Optional[PhoneEntry]: + """返回一个对指定任务尚未使用过的手机号,并立即标记为已使用。 + + 调用者应在调用前先运行一次 `update_global_phone_list`,确保 + `global_file` 是最新的。 + + Args: + task_name: 任务名称(例如目标站点标识),用于区分不同任务的 + 使用记录文件。 + country_slug: 若指定,则仅从该国家或国家列表中选择;支持单个 + slug、逗号分隔字符串或可迭代 slug 集合。为 None 表示任意国家。 + global_file: 全量号码文件路径。 + used_numbers_dir: 每个任务“已使用号码”文件所在目录。 + exclude_prefixes: 可选的手机号前缀黑名单;用于在单次流程里避开 + 已明确被目标站点拒绝的号段。 + + Returns: + 未使用过的 PhoneEntry;若没有可用号码则返回 None。 + """ + + phone_index = load_global_phone_index(global_file) + + used_file = _used_numbers_file(task_name, base_dir=used_numbers_dir) + blacklist_file = _blacklisted_numbers_file(task_name, base_dir=used_numbers_dir) + used_numbers = _load_phone_set(used_file) + blacklisted_numbers = _load_phone_set(blacklist_file) + excluded_prefixes = { + prefix + for prefix in (_phone_prefix_hint(value) for value in (exclude_prefixes or ())) + if prefix + } + + country_slugs = parse_country_slugs(country_slug) + + candidates = [ + entry + for entry in phone_index.values() + if (not country_slugs or entry.country_slug in country_slugs) + and entry.phone not in used_numbers + and entry.phone not in blacklisted_numbers + and _phone_prefix_hint(entry.phone) not in excluded_prefixes + ] + if not candidates: + return None + + remaining = list(candidates) + while remaining: + entry = random.choice(remaining) + remaining.remove(entry) + try: + refreshed_entry = resolve_live_phone_entry(entry) + except Exception: + refreshed_entry = entry + if refreshed_entry is None: + continue + with used_file.open("a", encoding="utf-8") as f: + f.write(refreshed_entry.phone + "\n") + return refreshed_entry + return None + + +def _fetch_sms_messages( + client: httpx.Client, + detail_url: str, + *, + http_max_attempts: int, +) -> List[SmsMessage]: + """抓取某个号码主页(第一页)的短信列表。""" + + html = _fetch_with_retries(client, detail_url, max_attempts=http_max_attempts) + tree = HTMLParser(html) + + # 页面中只有一个主要的短信表格,这里直接取第一个 table 即可。 + table = tree.css_first("table") + if table is None: + return [] + + messages: List[SmsMessage] = [] + for tr in table.css("tr"): + # 跳过表头行(包含 th) + if tr.css_first("th") is not None: + continue + tds = tr.css("td") + if len(tds) < 3: + continue + from_label = tds[0].text(strip=True) + received_text = tds[1].text(strip=True) + message_text = tds[2].text(separator=" ", strip=True) + if not message_text: + continue + messages.append( + SmsMessage( + from_label=from_label, + received_text=received_text, + message_text=message_text, + ) + ) + + return messages + + +def wait_for_otp( + entry: PhoneEntry, + *, + cookie_header: Optional[str] = None, + timeout: float = 120.0, + poll_interval: float = 5.0, + otp_regex: str = r"\b(\d{4,8})\b", + http_max_attempts: int = 3, + trace: Callable[[str], None] | None = None, + raise_on_timeout: bool = False, +) -> Optional[str]: + """轮询指定手机号短信,提取验证码并返回。 + + 基本逻辑: + 1. 启动时抓取一次当前短信列表,记录为已见; + 2. 在给定 `timeout` 内,每隔 `poll_interval` 秒重新抓取; + 3. 对每条“未见过”的短信,用 `otp_regex` 匹配验证码; + 4. 匹配成功则返回第一个验证码;超时则返回 None。 + + Args: + entry: 通过 `get_unused_phone` 或其它方式得到的 PhoneEntry。 + cookie_header: 可选 Cookie 字符串;若为 None,则尝试从 + `SMSTOME_COOKIE` 环境变量,再回退到仓库根目录 `config.yaml` + 读取。 + timeout: 最大等待时间(秒)。 + poll_interval: 轮询间隔(秒)。 + otp_regex: 用于从短信中提取验证码的正则,默认匹配 4–8 位数字。 + http_max_attempts: 每次抓取短信时的 HTTP 重试次数。 + trace: 可选日志回调;若提供,会输出每轮轮询的诊断摘要。 + raise_on_timeout: 若为 True,超时后抛出更具体的异常,而不是返回 None。 + + Returns: + 匹配到的验证码字符串;若超时未获得则返回 None。 + """ + + client = _build_client(cookie_header=cookie_header, timeout=timeout) + pattern = re.compile(otp_regex) + emit = trace or (lambda _msg: None) + + seen_messages: set[str] = set() + unmatched_new_message_count = 0 + latest_unmatched_message: SmsMessage | None = None + + def _fetch_messages(phase: str, *, poll_number: int | None = None) -> List[SmsMessage]: + try: + return _fetch_sms_messages( + client, entry.detail_url, http_max_attempts=http_max_attempts + ) + except Exception as exc: + label = f"{phase} fetch-error" + if poll_number is not None: + label += f" poll={poll_number}" + emit(f"{label} type={type(exc).__name__} error={exc}") + raise SmsOtpFetchError( + f"SMSToMe {phase} fetch failed for {entry.phone}: {exc}" + ) from exc + + # 初始抓取,避免把历史短信误当成“新短信” + initial_messages = _fetch_messages("initial") + latest_message = initial_messages[0] if initial_messages else None + latest_snapshot = ( + latest_message.from_label, + latest_message.received_text, + latest_message.message_text, + ) if latest_message else None + emit( + f"poll start phone={entry.phone} messages={len(initial_messages)} " + f"latest={_summarize_sms_message(latest_message)}" + ) + if initial_messages: + quick_otp = _extract_recent_6digit_otp( + latest_message.message_text, + latest_message.received_text, + ) + if quick_otp: + emit( + "matched quick recent OTP " + f"code={quick_otp} latest={_summarize_sms_message(latest_message)}" + ) + return quick_otp + + for msg in initial_messages: + seen_messages.add(msg.message_text) + + deadline = time.monotonic() + timeout + poll_count = 0 + + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + timeout_state = _classify_timeout_state( + latest_message=latest_message, + unmatched_new_message_count=unmatched_new_message_count, + ) + summary = ( + f"final state={timeout_state} polls={poll_count} " + f"latest={_summarize_sms_message(latest_message)}" + ) + if latest_unmatched_message is not None: + summary += ( + " first_unmatched_new=" + + _summarize_sms_message(latest_unmatched_message) + ) + emit(summary) + emit( + f"timeout after {poll_count} poll(s); latest={_summarize_sms_message(latest_message)}" + ) + if raise_on_timeout: + if latest_message is None: + raise SmsInboxEmptyError( + f"SMSToMe inbox stayed empty for {entry.phone} after {poll_count} poll(s)" + ) + raise SmsOtpTimeoutError( + f"SMSToMe OTP timeout state={timeout_state} for {entry.phone} " + f"after {poll_count} poll(s); latest={_summarize_sms_message(latest_message)}" + ) + return None + + sleep_s = min(poll_interval, max(remaining, 0)) + if sleep_s > 0: + time.sleep(sleep_s) + + poll_count += 1 + messages = _fetch_messages("poll", poll_number=poll_count) + latest_message = messages[0] if messages else None + current_snapshot = ( + latest_message.from_label, + latest_message.received_text, + latest_message.message_text, + ) if latest_message else None + new_count = sum(1 for msg in messages if msg.message_text not in seen_messages) + if poll_count <= 3 or current_snapshot != latest_snapshot or new_count: + emit( + f"poll {poll_count}: messages={len(messages)} new={new_count} " + f"latest={_summarize_sms_message(latest_message)}" + ) + latest_snapshot = current_snapshot + if messages: + quick_otp = _extract_recent_6digit_otp( + latest_message.message_text, + latest_message.received_text, + ) + if quick_otp: + emit( + "matched quick recent OTP " + f"code={quick_otp} latest={_summarize_sms_message(latest_message)}" + ) + return quick_otp + + for msg in messages: + if msg.message_text in seen_messages: + continue + seen_messages.add(msg.message_text) + unmatched_new_message_count += 1 + latest_unmatched_message = msg + normalized_text = _normalize_message_text_for_otp(msg.message_text) + match = pattern.search(normalized_text) + if match: + code = re.sub(OTP_SEPARATOR_CHARS, "", match.group(1)) + emit(f"matched regex OTP code={code} message={_summarize_sms_message(msg)}") + return code + fallback_otp = _extract_otp_from_text(msg.message_text) + if fallback_otp: + emit( + f"matched fallback OTP code={fallback_otp} " + f"message={_summarize_sms_message(msg)}" + ) + return fallback_otp + if new_count and latest_unmatched_message is not None: + emit( + "new messages arrived without OTP match " + f"count={new_count} sample={_summarize_sms_message(latest_unmatched_message)}" + ) + + +if __name__ == "__main__": # pragma: no cover - 简单调试入口 + import argparse + + parser = argparse.ArgumentParser( + description="SMSToMe phone pool & OTP helper", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + sync_parser = subparsers.add_parser( + "sync", help="同步全量手机号到 txt 文件", + ) + sync_parser.add_argument( + "--cookie", + dest="cookie", + help="可选 Cookie 字符串;为空则使用 SMSTOME_COOKIE 环境变量或 config.yaml", + ) + sync_parser.add_argument( + "--max-pages-per-country", + dest="max_pages_per_country", + type=int, + default=DEFAULT_SYNC_MAX_PAGES_PER_COUNTRY, + help=( + "从起始页开始,每个国家最多抓取多少页;" + f"默认 {DEFAULT_SYNC_MAX_PAGES_PER_COUNTRY}" + ), + ) + sync_parser.add_argument( + "--start-page", + dest="start_page", + type=int, + default=1, + help="每个国家从第几页开始抓;默认 1", + ) + sync_parser.add_argument( + "--countries", + dest="countries", + help="可选国家 slug 列表;支持单个 slug 或逗号分隔,例如 united-kingdom,sweden", + ) + sync_parser.add_argument( + "--output", + dest="output_path", + default=str(GLOBAL_PHONE_FILE), + help=f"同步结果输出文件;默认 {GLOBAL_PHONE_FILE}", + ) + sync_parser.add_argument( + "--skip-history-check", + dest="skip_history_check", + action="store_true", + help="不同步详情页历史活跃度;默认会过滤掉没有分钟级历史短信的号码", + ) + sync_parser.add_argument( + "--recent-history-minutes", + dest="recent_history_minutes", + type=float, + default=DEFAULT_RECENT_HISTORY_MINUTES, + help=( + "同步时仅保留最近 N 分钟内有历史短信的号码;" + f"默认 {int(DEFAULT_RECENT_HISTORY_MINUTES)}" + ), + ) + + pick_parser = subparsers.add_parser( + "pick", help="为某个任务选择一个未使用的手机号", + ) + pick_parser.add_argument("task", help="任务名称,用于区分已使用号码文件") + pick_parser.add_argument( + "--country", + dest="country", + help="可选国家 slug(例如 poland、sweden)", + ) + + args = parser.parse_args() + + if args.command == "sync": + count = update_global_phone_list( + cookie_header=args.cookie, + countries=parse_country_slugs(args.countries) or None, + output_path=args.output_path, + max_pages_per_country=args.max_pages_per_country, + start_page=args.start_page, + require_recent_history=not args.skip_history_check, + recent_history_minutes=args.recent_history_minutes, + ) + print(f"Synced {count} phone numbers into {args.output_path}") + elif args.command == "pick": + entry = get_unused_phone( + task_name=args.task, + country_slug=args.country, + ) + if entry is None: + print("No unused phone available.") + else: + print( + f"Task={args.task} -> {entry.phone} " + f"(country={entry.country_slug})", + ) diff --git a/tests/test_chatgpt_phone_flow.py b/tests/test_chatgpt_phone_flow.py new file mode 100644 index 0000000..3a0e502 --- /dev/null +++ b/tests/test_chatgpt_phone_flow.py @@ -0,0 +1,203 @@ +import base64 +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from platforms.chatgpt.oauth_client import OAuthClient +from platforms.chatgpt.phone_service import SMSToMePhoneService +from platforms.chatgpt.utils import FlowState +from smstome_tool import PhoneEntry, parse_country_slugs + + +class OAuthCookieDecodeTests(unittest.TestCase): + def test_decode_signed_cookie_payload(self): + payload = { + "email": "demo@example.com", + "phone_number": "+447456344799", + "phone_verification_channel": "whatsapp", + } + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8").rstrip("=") + cookie_value = f"{encoded}.sig-a.sig-b" + + self.assertEqual(OAuthClient._decode_cookie_json_value(cookie_value), payload) + + def test_decode_invalid_cookie_payload(self): + self.assertIsNone(OAuthClient._decode_cookie_json_value("not-a-valid-cookie")) + + +class SMSToMeConfigTests(unittest.TestCase): + def test_parse_country_slugs_accepts_csv_and_iterables(self): + self.assertEqual( + parse_country_slugs("united-kingdom, poland;finland"), + ["united-kingdom", "poland", "finland"], + ) + self.assertEqual( + parse_country_slugs(["united-kingdom", "poland", "united_kingdom"]), + ["united-kingdom", "poland"], + ) + + def test_phone_service_enabled_when_pool_file_exists(self): + with tempfile.TemporaryDirectory() as tmp_dir: + pool_path = Path(tmp_dir) / "phones.txt" + pool_path.write_text("+447456344799\tunited-kingdom\thttps://example.com\n", encoding="utf-8") + + service = SMSToMePhoneService({"smstome_global_file": str(pool_path)}) + self.assertTrue(service.enabled) + + def test_phone_service_disabled_for_empty_pool_without_cookie(self): + with tempfile.TemporaryDirectory() as tmp_dir: + pool_path = Path(tmp_dir) / "phones.txt" + pool_path.write_text("", encoding="utf-8") + + service = SMSToMePhoneService({"smstome_global_file": str(pool_path)}) + self.assertFalse(service.enabled) + + def test_wait_for_code_forwards_cookie_timeout_and_poll_interval(self): + entry = PhoneEntry( + country_slug="united-kingdom", + phone="+447456344799", + detail_url="https://example.com/phone/1", + ) + service = SMSToMePhoneService( + { + "smstome_cookie": "cf_clearance=demo", + "smstome_otp_timeout_seconds": "66", + "smstome_poll_interval_seconds": "7", + } + ) + + with mock.patch("platforms.chatgpt.phone_service.wait_for_otp", return_value="123456") as mocked: + code = service.wait_for_code(entry) + + self.assertEqual(code, "123456") + mocked.assert_called_once() + kwargs = mocked.call_args.kwargs + self.assertEqual(kwargs["cookie_header"], "cf_clearance=demo") + self.assertEqual(kwargs["timeout"], 66) + self.assertEqual(kwargs["poll_interval"], 7) + self.assertFalse(kwargs["raise_on_timeout"]) + + def test_ensure_pool_ready_syncs_with_configured_page_limit(self): + with tempfile.TemporaryDirectory() as tmp_dir: + pool_path = Path(tmp_dir) / "phones.txt" + service = SMSToMePhoneService( + { + "smstome_cookie": "cf_clearance=demo", + "smstome_country_slugs": "united-kingdom", + "smstome_global_file": str(pool_path), + "smstome_sync_max_pages_per_country": "9", + } + ) + + with mock.patch("platforms.chatgpt.phone_service.update_global_phone_list", return_value=3) as mocked: + service.ensure_pool_ready() + + mocked.assert_called_once() + kwargs = mocked.call_args.kwargs + self.assertEqual(kwargs["cookie_header"], "cf_clearance=demo") + self.assertEqual(kwargs["countries"], ["united-kingdom"]) + self.assertEqual(kwargs["output_path"], pool_path) + self.assertEqual(kwargs["max_pages_per_country"], 9) + + +class OAuthPhoneBlacklistTests(unittest.TestCase): + def test_should_blacklist_explicit_phone_rejection(self): + state = FlowState( + page_type="add_phone", + payload={"error": {"message": "phone number is invalid"}}, + ) + self.assertTrue( + OAuthClient._should_blacklist_phone_failure( + "add-phone/send 失败: 400 - phone number is invalid", + state, + ) + ) + + def test_should_not_blacklist_whatsapp_or_delivery_failures(self): + self.assertFalse( + OAuthClient._should_blacklist_phone_failure( + "add_phone 已切到 whatsapp 通道,当前 SMSToMe 仅支持短信接码" + ) + ) + self.assertFalse( + OAuthClient._should_blacklist_phone_failure("手机号 +447000000001 未收到短信验证码") + ) + + def test_handle_add_phone_blacklists_explicitly_rejected_number(self): + client = OAuthClient(config={}, verbose=False) + client._log = lambda _msg: None + entry = PhoneEntry( + country_slug="united-kingdom", + phone="+447000000001", + detail_url="https://example.com/phone/1", + ) + phone_service = mock.Mock() + phone_service.enabled = True + phone_service.max_attempts = 1 + phone_service.acquire_phone.return_value = entry + phone_service.prefix_hint.return_value = "+447000" + + with mock.patch("platforms.chatgpt.oauth_client.SMSToMePhoneService", return_value=phone_service): + with mock.patch.object( + client, + "_send_phone_number", + return_value=(False, None, "add-phone/send 失败: 400 - phone number is invalid"), + ): + state = client._handle_add_phone_verification( + "device-id", + "Mozilla/5.0", + None, + None, + FlowState(page_type="add_phone"), + ) + + self.assertIsNone(state) + phone_service.mark_blacklisted.assert_called_once_with(entry.phone) + self.assertIn("add_phone 阶段失败", client.last_error) + + def test_handle_add_phone_does_not_blacklist_whatsapp_channel(self): + client = OAuthClient(config={}, verbose=False) + client._log = lambda _msg: None + entry = PhoneEntry( + country_slug="united-kingdom", + phone="+447000000002", + detail_url="https://example.com/phone/2", + ) + phone_service = mock.Mock() + phone_service.enabled = True + phone_service.max_attempts = 1 + phone_service.acquire_phone.return_value = entry + phone_service.prefix_hint.return_value = "+447000" + + next_state = FlowState( + page_type="phone_otp_verification", + continue_url="https://auth.openai.com/phone-verification", + ) + + with mock.patch("platforms.chatgpt.oauth_client.SMSToMePhoneService", return_value=phone_service): + with mock.patch.object(client, "_send_phone_number", return_value=(True, next_state, "")): + with mock.patch.object( + client, + "_decode_oauth_session_cookie", + return_value={ + "phone_verification_channel": "whatsapp", + "phone_number": entry.phone, + }, + ): + state = client._handle_add_phone_verification( + "device-id", + "Mozilla/5.0", + None, + None, + FlowState(page_type="add_phone"), + ) + + self.assertIsNone(state) + phone_service.mark_blacklisted.assert_not_called() + self.assertIn("whatsapp", client.last_error) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config_store_env_fallback.py b/tests/test_config_store_env_fallback.py new file mode 100644 index 0000000..71fc122 --- /dev/null +++ b/tests/test_config_store_env_fallback.py @@ -0,0 +1,72 @@ +import tempfile +import unittest +from pathlib import Path + +from core.config_store import ( + _canonical_config_key, + _get_env_fallback_value, + _load_env_file, + _merge_env_fallback, + _normalize_config_value, +) + + +class ConfigStoreEnvFallbackTests(unittest.TestCase): + def test_normalize_config_value_strips_matching_quotes(self): + self.assertEqual(_normalize_config_value('"quoted"'), "quoted") + self.assertEqual(_normalize_config_value("'quoted'"), "quoted") + self.assertEqual(_normalize_config_value("plain"), "plain") + + def test_load_env_file_supports_export_and_quotes(self): + with tempfile.TemporaryDirectory() as tmp_dir: + env_path = Path(tmp_dir) / ".env" + env_path.write_text( + "\n".join( + [ + "# comment", + "export SMSTOME_COOKIE='cf_clearance=demo'", + 'cfworker_custom_auth="secret-pass"', + ] + ), + encoding="utf-8", + ) + + values = _load_env_file(env_path) + + self.assertEqual(values["SMSTOME_COOKIE"], "cf_clearance=demo") + self.assertEqual(values["cfworker_custom_auth"], "secret-pass") + + def test_get_env_fallback_value_matches_uppercase_env_names(self): + env_values = { + "SMSTOME_COOKIE": "cf_clearance=demo", + "CFWORKER_CUSTOM_AUTH": "secret-pass", + } + + self.assertEqual( + _get_env_fallback_value("smstome_cookie", env_values=env_values), + "cf_clearance=demo", + ) + self.assertEqual( + _get_env_fallback_value("cfworker_custom_auth", env_values=env_values), + "secret-pass", + ) + + def test_merge_env_fallback_uses_canonical_key_without_overriding_db(self): + merged = _merge_env_fallback( + { + "smstome_cookie": "", + "cfworker_custom_auth": "db-value", + }, + env_values={ + "SMSTOME_COOKIE": "cf_clearance=demo", + "CFWORKER_CUSTOM_AUTH": "env-value", + }, + ) + + self.assertEqual(_canonical_config_key("SMSTOME_COOKIE"), "smstome_cookie") + self.assertEqual(merged["smstome_cookie"], "cf_clearance=demo") + self.assertEqual(merged["cfworker_custom_auth"], "db-value") + + +if __name__ == "__main__": + unittest.main() From acd77ba40edccec2c273b6be083561065506770f Mon Sep 17 00:00:00 2001 From: highkay Date: Mon, 30 Mar 2026 17:43:18 +0800 Subject: [PATCH 5/9] Add Docker startup workflow --- .dockerignore | 8 ++++++ Dockerfile | 52 ++++++++++++++++++++++++++++++++++++++ README.md | 30 ++++++++++++++++++++++ docker-compose.yml | 26 +++++++++++++++++++ docker/entrypoint.sh | 20 +++++++++++++++ services/solver_manager.py | 5 ++-- 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore index 65da272..f756c87 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,11 @@ static/ *.egg-info/ dist/ build/ +account_manager.db +backend.stdout.log +backend.stderr.log +services/turnstile_solver/solver.log +smstome_used/ +smstome_all_numbers.txt +smstome_uk_deep_numbers.txt +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bca204c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-bookworm-slim AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + + +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + HOST=0.0.0.0 \ + PORT=8000 \ + APP_CONDA_ENV=docker \ + SOLVER_BROWSER_TYPE=chromium + +WORKDIR /app + +COPY requirements.txt ./ + +RUN pip install --upgrade pip \ + && pip install -r requirements.txt \ + && installed=0 \ + && for attempt in 1 2 3; do \ + if python -m playwright install --with-deps chromium; then \ + installed=1; \ + break; \ + fi; \ + if [ "$attempt" -eq 3 ]; then break; fi; \ + echo "playwright browser install failed, retrying ($attempt/3)..." >&2; \ + sleep 5; \ + done \ + && [ "$installed" -eq 1 ] + +COPY . . +COPY --from=frontend-builder /app/static /app/static + +RUN chmod +x /app/docker/entrypoint.sh \ + && mkdir -p /runtime /runtime/logs /runtime/smstome_used + +EXPOSE 8000 8889 + +VOLUME ["/runtime"] + +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/README.md b/README.md index 007a3ab..1435957 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,36 @@ http://localhost:8000 > 注意:生产/本地构建模式下,前端由 FastAPI 直接托管,访问的是 `8000`,不是 `5173`。 +### Docker 启动 + +如果你只想快速拉起整个项目,可以直接使用仓库根目录的 `Dockerfile` 和 `docker-compose.yml`: + +```bash +docker compose up --build -d +``` + +默认会暴露: + +- Web UI / API:`http://localhost:8000` +- Turnstile Solver:`http://localhost:8889` + +容器内仍然沿用“后端自动拉起本地 Solver”的方式,但 `docker-compose.yml` 默认把 Solver 浏览器切到 `chromium`,避免额外依赖本机 conda/camoufox 环境。 + +运行时数据会持久化到 compose volume 中,包括: + +- SQLite 数据库 `account_manager.db` +- `smstome_used/` 里的已用号 / 黑名单 +- `smstome_all_numbers.txt` + +如果需要传入 `SMSTOME_COOKIE`、`OPENAI_*` 等配置,直接写在仓库根目录 `.env` 即可;`docker compose` 会把它们注入到容器环境中。 + +常用命令: + +```bash +docker compose logs -f +docker compose down +``` + ### 停止后端 #### PowerShell diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8be254c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: any-auto-register + init: true + restart: unless-stopped + env_file: + - .env + environment: + HOST: 0.0.0.0 + PORT: "8000" + APP_RELOAD: "0" + APP_CONDA_ENV: docker + APP_RUNTIME_DIR: /runtime + SOLVER_PORT: "8889" + SOLVER_BROWSER_TYPE: chromium + ports: + - "8000:8000" + - "8889:8889" + volumes: + - app_runtime:/runtime + +volumes: + app_runtime: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..06a37b0 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +APP_DIR="/app" +RUNTIME_DIR="${APP_RUNTIME_DIR:-/runtime}" + +mkdir -p "${RUNTIME_DIR}" "${RUNTIME_DIR}/logs" "${RUNTIME_DIR}/smstome_used" +touch \ + "${RUNTIME_DIR}/account_manager.db" \ + "${RUNTIME_DIR}/smstome_all_numbers.txt" \ + "${RUNTIME_DIR}/smstome_uk_deep_numbers.txt" \ + "${RUNTIME_DIR}/logs/solver.log" + +ln -sfn "${RUNTIME_DIR}/account_manager.db" "${APP_DIR}/account_manager.db" +ln -sfn "${RUNTIME_DIR}/smstome_used" "${APP_DIR}/smstome_used" +ln -sfn "${RUNTIME_DIR}/smstome_all_numbers.txt" "${APP_DIR}/smstome_all_numbers.txt" +ln -sfn "${RUNTIME_DIR}/smstome_uk_deep_numbers.txt" "${APP_DIR}/smstome_uk_deep_numbers.txt" +ln -sfn "${RUNTIME_DIR}/logs/solver.log" "${APP_DIR}/services/turnstile_solver/solver.log" + +exec python main.py diff --git a/services/solver_manager.py b/services/solver_manager.py index 4153d39..4fc1596 100644 --- a/services/solver_manager.py +++ b/services/solver_manager.py @@ -6,8 +6,9 @@ import time import threading import requests -SOLVER_PORT = 8889 +SOLVER_PORT = int(os.getenv("SOLVER_PORT", "8889")) SOLVER_URL = f"http://localhost:{SOLVER_PORT}" +SOLVER_BROWSER_TYPE = os.getenv("SOLVER_BROWSER_TYPE", "camoufox").strip() or "camoufox" _proc: subprocess.Popen = None _log_file = None _lock = threading.Lock() @@ -40,7 +41,7 @@ def start(): "-u", solver_script, "--browser_type", - "camoufox", + SOLVER_BROWSER_TYPE, "--port", str(SOLVER_PORT), ], From c7e7f256369191641aefd554c8400f086e653a9d Mon Sep 17 00:00:00 2001 From: sam123 Date: Mon, 30 Mar 2026 19:38:55 +0800 Subject: [PATCH 6/9] Fix ChatGPT action handling and add payment link helper --- frontend/src/pages/Accounts.tsx | 121 ++++++++++++++++-- platforms/chatgpt/payment.py | 4 +- platforms/chatgpt/plugin.py | 9 +- platforms/chatgpt/token_refresh.py | 4 +- scripts/generate_chatgpt_payment_links.py | 146 ++++++++++++++++++++++ 5 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 scripts/generate_chatgpt_payment_links.py diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 7387db3..e7a31c9 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -14,6 +14,7 @@ import { Popconfirm, Dropdown, Typography, + Alert, } from 'antd' import type { MenuProps } from 'antd' import { @@ -118,6 +119,11 @@ function LogPanel({ taskId, onDone }: { taskId: string; onDone: () => void }) { function ActionMenu({ acc, onRefresh }: { acc: any; onRefresh: () => void }) { const [actions, setActions] = useState([]) + const [resultOpen, setResultOpen] = useState(false) + const [resultTitle, setResultTitle] = useState('') + const [resultStatus, setResultStatus] = useState<'success' | 'error'>('success') + const [resultText, setResultText] = useState('') + const [resultUrl, setResultUrl] = useState('') useEffect(() => { apiFetch(`/actions/${acc.platform}`) @@ -125,40 +131,139 @@ function ActionMenu({ acc, onRefresh }: { acc: any; onRefresh: () => void }) { .catch(() => {}) }, [acc.platform]) + const showResult = (title: string, status: 'success' | 'error', text: string, url = '') => { + setResultTitle(title) + setResultStatus(status) + setResultText(text) + setResultUrl(url) + setResultOpen(true) + } + + const copyResultUrl = async () => { + if (!resultUrl) return + try { + await navigator.clipboard.writeText(resultUrl) + message.success('链接已复制') + } catch { + message.error('复制失败') + } + } + const handleAction = async (actionId: string) => { + const shouldPreopenWindow = actionId === 'payment_link' + const pendingWindow = shouldPreopenWindow ? window.open('', '_blank', 'noopener,noreferrer') : null + const actionLabel = actions.find((item) => item.id === actionId)?.label || actionId + try { const r = await apiFetch(`/actions/${acc.platform}/${acc.id}/${actionId}`, { method: 'POST', body: JSON.stringify({ params: {} }), }) if (!r.ok) { - message.error(r.error || '操作失败') + pendingWindow?.close() + showResult(actionLabel, 'error', r.error || '操作失败') return } const data = r.data || {} if (data.url || data.checkout_url || data.cashier_url) { - window.open(data.url || data.checkout_url || data.cashier_url, '_blank') + const targetUrl = data.url || data.checkout_url || data.cashier_url + if (pendingWindow) { + pendingWindow.location.href = targetUrl + } else { + window.open(targetUrl, '_blank', 'noopener,noreferrer') + } + message.success('链接已打开') + showResult(actionLabel, 'success', '操作成功,已打开链接。', targetUrl) } else { + pendingWindow?.close() message.success(data.message || '操作成功') + const text = + typeof data === 'string' + ? data + : Object.keys(data).length > 0 + ? JSON.stringify(data, null, 2) + : '操作成功' + showResult(actionLabel, 'success', text) } onRefresh() - } catch { - message.error('请求失败') + } catch (e: any) { + pendingWindow?.close() + const detail = e?.message ? String(e.message) : '请求失败' + message.error(detail) + showResult(actionLabel, 'error', detail) } } const menuItems: MenuProps['items'] = actions.map((a) => ({ key: a.id, label: a.label, - onClick: () => handleAction(a.id), })) if (actions.length === 0) return null return ( - - + ) : null, + resultUrl ? ( + + ) : null, + , + ].filter(Boolean)} + maskClosable={false} + > + + {resultUrl ? ( + + + {resultUrl} + + + ) : null} + {resultText ? ( +
+            {resultText}
+          
+ ) : null} + + ) } diff --git a/platforms/chatgpt/payment.py b/platforms/chatgpt/payment.py index 29a0954..b3903f8 100644 --- a/platforms/chatgpt/payment.py +++ b/platforms/chatgpt/payment.py @@ -2,6 +2,8 @@ 支付核心逻辑 — 生成 Plus/Team 支付链接、无痕打开浏览器、检测订阅状态 """ +from __future__ import annotations + import logging import subprocess import sys @@ -258,4 +260,4 @@ def check_subscription_status(account: Account, proxy: Optional[str] = None) -> if settings_.get("workspace_plan_type") in ("team", "enterprise"): return "team" - return "free" \ No newline at end of file + return "free" diff --git a/platforms/chatgpt/plugin.py b/platforms/chatgpt/plugin.py index 0585ef5..52380f8 100644 --- a/platforms/chatgpt/plugin.py +++ b/platforms/chatgpt/plugin.py @@ -184,7 +184,14 @@ class ChatGPTPlatform(BasePlatform): if plan == "plus": url = generate_plus_link(a, proxy=proxy, country=country) else: - url = generate_team_link(a, proxy=proxy, country=country) + url = generate_team_link( + a, + workspace_name=params.get("workspace_name", "MyTeam"), + price_interval=params.get("price_interval", "month"), + seat_quantity=int(params.get("seat_quantity", 5) or 5), + proxy=proxy, + country=country, + ) return {"ok": bool(url), "data": {"url": url}} elif action_id == "upload_cpa": diff --git a/platforms/chatgpt/token_refresh.py b/platforms/chatgpt/token_refresh.py index b08af16..8d7025a 100644 --- a/platforms/chatgpt/token_refresh.py +++ b/platforms/chatgpt/token_refresh.py @@ -3,6 +3,8 @@ Token 刷新模块 支持 Session Token 和 OAuth Refresh Token 两种刷新方式 """ +from __future__ import annotations + import logging import json import time @@ -331,4 +333,4 @@ def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> return False, "账号没有 access_token" manager = TokenRefreshManager(proxy_url=proxy_url) - return manager.validate_token(account.access_token) \ No newline at end of file + return manager.validate_token(account.access_token) diff --git a/scripts/generate_chatgpt_payment_links.py b/scripts/generate_chatgpt_payment_links.py new file mode 100644 index 0000000..c21cb35 --- /dev/null +++ b/scripts/generate_chatgpt_payment_links.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +from pathlib import Path + +from sqlmodel import Session, select + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from core.base_platform import Account, AccountStatus, RegisterConfig +from core.db import AccountModel, engine +from core.registry import get, load_all + + +def load_accounts(ids: list[int]) -> list[AccountModel]: + with Session(engine) as session: + items = [] + for account_id in ids: + acc = session.get(AccountModel, account_id) + if acc and acc.platform == "chatgpt": + items.append(acc) + return items + + +def to_account_model(acc: AccountModel) -> Account: + return Account( + platform=acc.platform, + email=acc.email, + password=acc.password, + user_id=acc.user_id, + token=acc.token, + status=AccountStatus(acc.status), + extra=acc.get_extra(), + ) + + +def parse_id_list(raw: str) -> list[int]: + ids = [] + for part in raw.split(","): + text = part.strip() + if not text: + continue + ids.append(int(text)) + return ids + + +def choose_account(items: list[AccountModel]) -> AccountModel: + print("\n可选 ChatGPT 账号:") + for idx, acc in enumerate(items, start=1): + extra = acc.get_extra() + print( + f"{idx}. id={acc.id} email={acc.email} " + f"refresh={'Y' if extra.get('refresh_token') else 'N'} " + f"session={'Y' if extra.get('session_token') else 'N'} " + f"cookies={'Y' if extra.get('cookies') else 'N'}" + ) + + while True: + raw = input("\n选择序号: ").strip() + try: + pos = int(raw) + except ValueError: + print("请输入数字序号。") + continue + if 1 <= pos <= len(items): + return items[pos - 1] + print("序号超出范围。") + + +def main() -> None: + load_all() + platform_cls = get("chatgpt") + instance = platform_cls(config=RegisterConfig()) + + raw_ids = input("输入 ChatGPT 账号 id 列表,逗号分隔: ").strip() + if not raw_ids: + print("未输入账号 id。") + return + + try: + ids = parse_id_list(raw_ids) + except ValueError: + print("账号 id 列表格式错误。示例: 22,21,18") + return + + accounts = load_accounts(ids) + if not accounts: + print("没有找到匹配的 ChatGPT 账号。") + return + + selected = choose_account(accounts) + plan = input("套餐 [plus/team],默认 plus: ").strip().lower() or "plus" + if plan not in {"plus", "team"}: + print("不支持的套餐。") + return + + country = input("地区代码,默认 US: ").strip().upper() or "US" + + params: dict[str, object] = {"plan": plan, "country": country} + if plan == "team": + workspace_name = input("Workspace 名称,默认 MyTeam: ").strip() or "MyTeam" + price_interval = input("周期 [month/year],默认 month: ").strip().lower() or "month" + seat_quantity_raw = input("席位数,默认 5: ").strip() or "5" + try: + seat_quantity = int(seat_quantity_raw) + except ValueError: + print("席位数必须是整数。") + return + params.update( + { + "workspace_name": workspace_name, + "price_interval": price_interval, + "seat_quantity": seat_quantity, + } + ) + + account = to_account_model(selected) + print(f"\n使用账号 id={selected.id} email={selected.email}") + + refresh_answer = input("先刷新 token? [Y/n]: ").strip().lower() + if refresh_answer in {"", "y", "yes"}: + refresh_result = instance.execute_action("refresh_token", account, {}) + print("refresh_result =", refresh_result) + if not refresh_result.get("ok"): + return + data = refresh_result.get("data") or {} + if data.get("access_token"): + account.token = data["access_token"] + account.extra["access_token"] = data["access_token"] + if data.get("refresh_token"): + account.extra["refresh_token"] = data["refresh_token"] + + result = instance.execute_action("payment_link", account, params) + print("payment_result =", result) + if result.get("ok"): + url = ((result.get("data") or {}).get("url") or "").strip() + if url: + print("\n支付链接:") + print(url) + + +if __name__ == "__main__": + main() From 4e508e0da000e1b573fd15b1699de0976ada2bec Mon Sep 17 00:00:00 2001 From: sam123 Date: Mon, 30 Mar 2026 19:46:22 +0800 Subject: [PATCH 7/9] Stop auto-opening ChatGPT payment link tabs --- frontend/src/pages/Accounts.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index e7a31c9..d4cf170 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -150,8 +150,6 @@ function ActionMenu({ acc, onRefresh }: { acc: any; onRefresh: () => void }) { } const handleAction = async (actionId: string) => { - const shouldPreopenWindow = actionId === 'payment_link' - const pendingWindow = shouldPreopenWindow ? window.open('', '_blank', 'noopener,noreferrer') : null const actionLabel = actions.find((item) => item.id === actionId)?.label || actionId try { @@ -160,22 +158,15 @@ function ActionMenu({ acc, onRefresh }: { acc: any; onRefresh: () => void }) { body: JSON.stringify({ params: {} }), }) if (!r.ok) { - pendingWindow?.close() showResult(actionLabel, 'error', r.error || '操作失败') return } const data = r.data || {} if (data.url || data.checkout_url || data.cashier_url) { const targetUrl = data.url || data.checkout_url || data.cashier_url - if (pendingWindow) { - pendingWindow.location.href = targetUrl - } else { - window.open(targetUrl, '_blank', 'noopener,noreferrer') - } - message.success('链接已打开') - showResult(actionLabel, 'success', '操作成功,已打开链接。', targetUrl) + message.success('链接已生成') + showResult(actionLabel, 'success', '操作成功,请在弹窗中打开或复制链接。', targetUrl) } else { - pendingWindow?.close() message.success(data.message || '操作成功') const text = typeof data === 'string' @@ -187,7 +178,6 @@ function ActionMenu({ acc, onRefresh }: { acc: any; onRefresh: () => void }) { } onRefresh() } catch (e: any) { - pendingWindow?.close() const detail = e?.message ? String(e.message) : '请求失败' message.error(detail) showResult(actionLabel, 'error', detail) From f3fb71570d47e61bd11c0a30b8318cf8c9425853 Mon Sep 17 00:00:00 2001 From: sam123 Date: Mon, 30 Mar 2026 20:21:35 +0800 Subject: [PATCH 8/9] Improve mailbox config handling and update README --- README.md | 70 ++++++++++++++++++++++---------------------- core/base_mailbox.py | 38 +++++++++++++++++++----- 2 files changed, 65 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index af7830a..b126214 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ 项目支持按需安装/启动以下 3 个插件。当前代码里配置的 Git 地址如下: -| 项目 | 用途 | Git 地址 | 当前使用说明 | -| -------------------- | -------------------------- | -------------------------------------------------------- | ---------------------------------- | -| CLIProxyAPI | CPA / 代理池管理服务 | `https://github.com/router-for-me/CLIProxyAPI.git` | 当前使用 **GitHub 直连地址**,未额外套 Git 镜像代理 | -| grok2api | Grok token 管理、回填、聊天/API 服务 | `https://github.com/chenyme/grok2api.git` | 当前使用 **GitHub 直连地址**,未额外套 Git 镜像代理 | -| kiro-account-manager | Kiro 账号管理相关插件 | `https://github.com/hj01857655/kiro-account-manager.git` | 当前使用 **GitHub 直连地址**,未额外套 Git 镜像代理 | +| 项目 | 用途 | Git 地址 | 当前使用说明 | +| -------------------- | ------------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------- | +| CLIProxyAPI | CPA / 代理池管理服务 | `https://github.com/router-for-me/CLIProxyAPI.git` | 当前使用**GitHub 直连地址**,未额外套 Git 镜像代理 | +| grok2api | Grok token 管理、回填、聊天/API 服务 | `https://github.com/chenyme/grok2api.git` | 当前使用**GitHub 直连地址**,未额外套 Git 镜像代理 | +| kiro-account-manager | Kiro 账号管理相关插件 | `https://github.com/hj01857655/kiro-account-manager.git` | 当前使用**GitHub 直连地址**,未额外套 Git 镜像代理 | > 如果后续你要改成 `ghproxy`、`gitclone`、企业 Git 镜像或其他代理地址,需要同步修改: > @@ -68,12 +68,12 @@ ## 技术栈 -| 层级 | 技术 | -| ------ | -------------------------- | -| 后端 | FastAPI + SQLite(SQLModel) | -| 前端 | React + TypeScript + Vite | -| HTTP | curl\_cffi | -| 浏览器自动化 | Playwright / Camoufox | +| 层级 | 技术 | +| ------------ | ---------------------------- | +| 后端 | FastAPI + SQLite(SQLModel) | +| 前端 | React + TypeScript + Vite | +| HTTP | curl\_cffi | +| 浏览器自动化 | Playwright / Camoufox | ## 环境要求 @@ -102,7 +102,7 @@ any-auto-register - `ModuleNotFoundError: quart` - 前端里 Turnstile Solver 一直显示“未运行” -*** +--- ## 安装 @@ -141,7 +141,7 @@ cd .. D:\codemodule\ai\any-auto-register\static ``` -*** +--- ## 启动方式 @@ -200,7 +200,7 @@ stop_backend.bat - 后端端口:`8000` - Solver 端口:`8889` -*** +--- ## 前端开发模式 @@ -227,7 +227,7 @@ http://localhost:5173 Vite 会把 `/api` 代理到本地后端 `http://localhost:8000`。 -*** +--- ## Turnstile Solver 说明 @@ -261,7 +261,7 @@ python services/turnstile_solver/start.py --browser_type camoufox --port 8889 D:\codemodule\ai\any-auto-register\services\turnstile_solver\solver.log ``` -*** +--- ## 常见问题排查 @@ -334,7 +334,7 @@ http://localhost:8889/ .\start_backend.ps1 ``` -*** +--- ## 邮箱服务配置 @@ -355,35 +355,35 @@ http://localhost:8889/ 适合固定邮箱场景。 -| 参数 | 说明 | -| ---------- | -------- | -| 邮箱地址 | 完整邮箱地址 | -| Account ID | 邮箱账号 ID | +| 参数 | 说明 | +| ---------- | ---------------- | +| 邮箱地址 | 完整邮箱地址 | +| Account ID | 邮箱账号 ID | | JWT Token | 登录后的认证令牌 | ### Cloudflare Worker 自建邮箱 -| 参数 | 说明 | -| ----------- | ------------- | -| API URL | Worker API 地址 | -| Admin Token | 管理员密码 | -| 域名 | 收件邮箱域名 | -| Fingerprint | 可选 | +| 参数 | 说明 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| API URL | Worker API 地址(注意这是填写[cloudflare workers后端地址](https://temp-mail-docs.awsl.uk/zh/guide/ui/worker.html) !!不是pages前端地址) | +| Admin Token | 管理员密码 | +| 域名 | 收件邮箱域名 | +| Fingerprint | 可选 | ### DuckMail / Freemail 适合临时邮箱场景,部分区域可能需要代理。 -*** +--- ## 验证码服务配置 -| 服务 | 说明 | -| ---------- | -------------------------------------------- | -| YesCaptcha | 需填写 Client Key | -| 本地 Solver | 依赖 `camoufox` + `quart`,并要求后端运行在正确 conda 环境中 | +| 服务 | 说明 | +| ----------- | ---------------------------------------------------------------- | +| YesCaptcha | 需填写 Client Key | +| 本地 Solver | 依赖 `camoufox` + `quart`,并要求后端运行在正确 conda 环境中 | -*** +--- ## 项目结构 @@ -403,7 +403,7 @@ any-auto-register/ └── static/ ``` -*** +--- ## Electron 开发说明 @@ -417,7 +417,7 @@ Electron 开发模式不会自动启动 Python 后端。 然后再运行 Electron。 -*** +--- ## License diff --git a/core/base_mailbox.py b/core/base_mailbox.py index 00f3bc8..8968a2e 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -92,9 +92,9 @@ def create_mailbox(provider: str, extra: dict = None, proxy: str = None) -> 'Bas return TempMailLolMailbox(proxy=proxy) elif provider == "duckmail": return DuckMailMailbox( - api_url=extra.get("duckmail_api_url", "https://www.duckmail.sbs"), - provider_url=extra.get("duckmail_provider_url", "https://api.duckmail.sbs"), - bearer=extra.get("duckmail_bearer", "kevin273945"), + 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"), proxy=proxy, ) elif provider == "freemail": @@ -317,9 +317,9 @@ class DuckMailMailbox(BaseMailbox): provider_url: str = "https://api.duckmail.sbs", bearer: str = "kevin273945", proxy: str = None): - self.api = api_url.rstrip("/") - self.provider_url = provider_url - self.bearer = bearer + self.api = (api_url or "https://www.duckmail.sbs").rstrip("/") + self.provider_url = provider_url or "https://api.duckmail.sbs" + self.bearer = bearer or "kevin273945" self.proxy = {"http": proxy, "https": proxy} if proxy else None self._token = None self._address = None @@ -418,6 +418,20 @@ class CFWorkerMailbox(BaseMailbox): h["x-fingerprint"] = self.fingerprint return h + def _ensure_api_configured(self) -> None: + if not self.api: + raise RuntimeError("CF Worker API URL 未配置") + + def _read_json(self, response, action: str): + try: + return response.json() + except Exception: + body = (response.text or "").strip() + snippet = body[:200] if body else "" + raise RuntimeError( + f"CF Worker {action} 返回非 JSON 响应: HTTP {response.status_code}, body={snippet}" + ) + def _generate_local_part(self) -> str: import random, string # 避免纯数字开头,提高邮箱格式“像真人”的程度 @@ -427,6 +441,7 @@ class CFWorkerMailbox(BaseMailbox): def get_email(self) -> MailboxAccount: import requests + self._ensure_api_configured() name = self._generate_local_part() payload = {"enablePrefix": True, "name": name} if self.domain: @@ -435,19 +450,26 @@ class CFWorkerMailbox(BaseMailbox): json=payload, headers=self._headers(), proxies=self.proxy, timeout=15) print(f"[CFWorker] new_address status={r.status_code} resp={r.text[:200]}") - data = r.json() + data = self._read_json(r, "new_address") + if r.status_code >= 400: + raise RuntimeError(f"CF Worker 创建邮箱失败: HTTP {r.status_code}, body={str(data)[:200]}") email = data.get("email", data.get("address", "")) token = data.get("token", data.get("jwt", "")) + if not email: + raise RuntimeError(f"CF Worker 创建邮箱失败: 返回缺少 email/address, body={str(data)[:200]}") self._token = token print(f"[CFWorker] 生成邮箱: {email} token={token[:40] if token else 'NONE'}...") return MailboxAccount(email=email, account_id=token) def _get_mails(self, email: str) -> list: import requests + self._ensure_api_configured() r = requests.get(f"{self.api}/admin/mails", params={"limit": 20, "offset": 0, "address": email}, headers=self._headers(), proxies=self.proxy, timeout=10) - data = r.json() + data = self._read_json(r, "mails") + if r.status_code >= 400: + raise RuntimeError(f"CF Worker 拉取邮件失败: HTTP {r.status_code}, body={str(data)[:200]}") return data.get("results", data) if isinstance(data, dict) else data def get_current_ids(self, account: MailboxAccount) -> set: From 4d90d6c4c7e1264ac91086e55f6a60940da049dc Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Mon, 30 Mar 2026 22:51:56 +0800 Subject: [PATCH 9/9] fix: avoid duplicate CFWorker mailbox requests Remove duplicated CFWorker mailbox API calls so address creation and mail listing each issue a single request, and add regression coverage at the factory boundary to prevent the bug from returning. --- core/base_mailbox.py | 19 ------- tests/test_base_mailbox_cfworker.py | 79 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 tests/test_base_mailbox_cfworker.py diff --git a/core/base_mailbox.py b/core/base_mailbox.py index cd545b9..7b38f4c 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -486,23 +486,11 @@ class CFWorkerMailbox(BaseMailbox): return f"{prefix}{suffix}" def get_email(self) -> MailboxAccount: - import requests self._ensure_api_configured() name = self._generate_local_part() payload = {"enablePrefix": True, "name": name} if self.domain: payload["domain"] = self.domain - r = requests.post(f"{self.api}/admin/new_address", - json=payload, headers=self._headers(), - proxies=self.proxy, timeout=15) - print(f"[CFWorker] new_address status={r.status_code} resp={r.text[:200]}") - data = self._read_json(r, "new_address") - if r.status_code >= 400: - raise RuntimeError(f"CF Worker 创建邮箱失败: HTTP {r.status_code}, body={str(data)[:200]}") - email = data.get("email", data.get("address", "")) - token = data.get("token", data.get("jwt", "")) - if not email: - raise RuntimeError(f"CF Worker 创建邮箱失败: 返回缺少 email/address, body={str(data)[:200]}") data = self._request_json("POST", "/admin/new_address", payload=payload, timeout=15) email = data.get("email", data.get("address", "")) token = data.get("token", data.get("jwt", "")) @@ -513,14 +501,7 @@ class CFWorkerMailbox(BaseMailbox): return MailboxAccount(email=email, account_id=token) def _get_mails(self, email: str) -> list: - import requests self._ensure_api_configured() - r = requests.get(f"{self.api}/admin/mails", - params={"limit": 20, "offset": 0, "address": email}, - headers=self._headers(), proxies=self.proxy, timeout=10) - data = self._read_json(r, "mails") - if r.status_code >= 400: - raise RuntimeError(f"CF Worker 拉取邮件失败: HTTP {r.status_code}, body={str(data)[:200]}") data = self._request_json( "GET", "/admin/mails", diff --git a/tests/test_base_mailbox_cfworker.py b/tests/test_base_mailbox_cfworker.py new file mode 100644 index 0000000..f8939cb --- /dev/null +++ b/tests/test_base_mailbox_cfworker.py @@ -0,0 +1,79 @@ +import unittest +from unittest.mock import patch + +from core.base_mailbox import MailboxAccount, create_mailbox + + +class CFWorkerMailboxTests(unittest.TestCase): + def _build_mailbox(self): + return create_mailbox( + "cfworker", + extra={ + "cfworker_api_url": "https://example.invalid", + "cfworker_admin_token": "admin-token", + "cfworker_domain": "mail.example", + }, + ) + + @patch("requests.request") + def test_get_email_issues_single_request_via_factory_mailbox(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.text = '{"email":"user@mail.example","token":"token-123"}' + mock_request.return_value.json.return_value = { + "email": "user@mail.example", + "token": "token-123", + } + + mailbox = self._build_mailbox() + + account = mailbox.get_email() + + self.assertEqual(account.email, "user@mail.example") + self.assertEqual(account.account_id, "token-123") + mock_request.assert_called_once_with( + "POST", + "https://example.invalid/admin/new_address", + params=None, + json={"enablePrefix": True, "name": unittest.mock.ANY, "domain": "mail.example"}, + headers={ + "accept": "application/json, text/plain, */*", + "content-type": "application/json", + "x-admin-auth": "admin-token", + }, + proxies=None, + timeout=15, + ) + + @patch("requests.request") + def test_get_current_ids_issues_single_request_via_factory_mailbox(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.text = '{"results":[{"id":101},{"id":202}]}' + mock_request.return_value.json.return_value = { + "results": [ + {"id": 101}, + {"id": 202}, + ] + } + mailbox = self._build_mailbox() + account = MailboxAccount(email="user@mail.example") + + ids = mailbox.get_current_ids(account) + + self.assertEqual(ids, {"101", "202"}) + mock_request.assert_called_once_with( + "GET", + "https://example.invalid/admin/mails", + params={"limit": 20, "offset": 0, "address": "user@mail.example"}, + json=None, + headers={ + "accept": "application/json, text/plain, */*", + "content-type": "application/json", + "x-admin-auth": "admin-token", + }, + proxies=None, + timeout=10, + ) + + +if __name__ == "__main__": + unittest.main()