diff --git a/README.md b/README.md index 122562e..7cf5f51 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ 一百五十个号,一个401: -进官网,获取最新交流群: - - + @@ -74,7 +75,6 @@ - 至少准备一种验证码接收方式: - DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发 - Cloudflare 自定义域邮箱前缀 + QQ / 163 / Inbucket 转发 - - iCloud Hide My Email,可选择直接从 iCloud 收件箱收码,或转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码 - 手动填写一个可收信邮箱 - 如果使用 `QQ` / `163` / `163 VIP` / `126` / `Inbucket`,对应页面需要提前能正常打开 @@ -592,7 +592,6 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 支持: - `Hotmail`(远程服务 / 本地助手) -- `iCloud` 收件箱,或 iCloud Hide My Email 转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码 - `content/qq-mail.js` - `content/mail-163.js`(163 / 163 VIP / 126) - `content/inbucket-mail.js` @@ -774,7 +773,7 @@ content/utils.js 通用工具:等待元素、点击、日志、停 content/vps-panel.js CPA 面板步骤:内部 OAuth 刷新 / Step 10 content/signup-page.js ChatGPT 官网 + OpenAI 注册/登录页步骤:Step 1 / 2 / 3 / 5 / 7 / 9 hotmail-utils.js Hotmail 收信相关通用辅助 -mail-provider-utils.js 网页邮箱 provider 与 iCloud 转发收码配置辅助 +mail-provider-utils.js 网页邮箱 provider 配置辅助 content/duck-mail.js Duck 邮箱自动获取 content/qq-mail.js QQ 邮箱验证码轮询 content/mail-163.js 163 / 163 VIP / 126 邮箱验证码轮询 diff --git a/background.js b/background.js index 9ddb88f..b7fb329 100644 --- a/background.js +++ b/background.js @@ -551,6 +551,8 @@ const DEFAULT_STATE = { lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。 lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。 localhostUrl: null, // 运行时捕获到的 localhost 回调地址,不要手动预填。 + cpaOAuthState: null, // CPA OAuth state。 + cpaManagementOrigin: null, // CPA 管理接口 origin。 sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。 sub2apiOAuthState: null, // SUB2API OpenAI Auth state。 sub2apiGroupId: null, // SUB2API 目标分组 ID。 @@ -6233,6 +6235,8 @@ function getDownstreamStateResets(step, state = {}) { return { ...plusRuntimeResets, oauthUrl: null, + cpaOAuthState: null, + cpaManagementOrigin: null, sub2apiSessionId: null, sub2apiOAuthState: null, sub2apiGroupId: null, @@ -7084,6 +7088,8 @@ async function handleStepData(step, payload) { if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; + if (payload.cpaManagementOrigin !== undefined) updates.cpaManagementOrigin = payload.cpaManagementOrigin || null; if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; if (Object.keys(updates).length) { diff --git a/background/contribution-oauth.js b/background/contribution-oauth.js index 81a1131..3928dda 100644 --- a/background/contribution-oauth.js +++ b/background/contribution-oauth.js @@ -3,19 +3,15 @@ })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() { const API_BASE_URL = 'https://apikey.qzz.io/oauth/api'; const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']); - const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'expired', 'error']); + const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']); const CALLBACK_FINAL_STATUSES = new Set(['submitted']); const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']); - const CONTRIBUTION_SOURCE_CPA = 'cpa'; - const CONTRIBUTION_SOURCE_SUB2API = 'sub2api'; - const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池'; - const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus'; const RUNTIME_DEFAULTS = { contributionMode: false, contributionModeExpected: false, - contributionSource: CONTRIBUTION_SOURCE_SUB2API, - contributionTargetGroupName: CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME, + contributionSource: 'sub2api', + contributionTargetGroupName: 'codex号池', contributionNickname: '', contributionQq: '', contributionSessionId: '', @@ -44,7 +40,8 @@ } = deps; let listenersBound = false; - const inFlightCapturedCallbackTasks = new Map(); + const pendingCallbackSubmissions = new Map(); + const pendingCapturedCallbacks = new Map(); function normalizeString(value = '') { return String(value || '').trim(); @@ -71,6 +68,9 @@ case 'auto_rejected': case 'rejected': return 'auto_rejected'; + case 'manual_review_required': + case 'manual_review': + return 'manual_review_required'; case 'expired': case 'timeout': return 'expired'; @@ -111,63 +111,6 @@ return FINAL_STATUSES.has(normalizeContributionStatus(status)); } - function runCapturedCallbackOnce(callbackUrl, executor) { - const normalizedUrl = normalizeString(callbackUrl); - if (!normalizedUrl) { - return Promise.resolve().then(executor); - } - - const existingTask = inFlightCapturedCallbackTasks.get(normalizedUrl); - if (existingTask) { - return existingTask; - } - - let task = null; - task = Promise.resolve() - .then(executor) - .finally(() => { - if (inFlightCapturedCallbackTasks.get(normalizedUrl) === task) { - inFlightCapturedCallbackTasks.delete(normalizedUrl); - } - }); - inFlightCapturedCallbackTasks.set(normalizedUrl, task); - return task; - } - - function normalizeContributionSource(value = '') { - const normalized = normalizeString(value).toLowerCase(); - return normalized === CONTRIBUTION_SOURCE_SUB2API - ? CONTRIBUTION_SOURCE_SUB2API - : CONTRIBUTION_SOURCE_CPA; - } - - function resolveContributionRouting(state = {}) { - const currentStatus = normalizeContributionStatus(state.contributionStatus); - const currentSource = normalizeContributionSource(state.contributionSource); - const hasActiveSession = Boolean( - normalizeString(state.contributionSessionId) - && currentStatus - && !FINAL_STATUSES.has(currentStatus) - ); - - if (hasActiveSession) { - return { - source: currentSource, - targetGroupName: currentSource === CONTRIBUTION_SOURCE_SUB2API - ? (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME) - : '', - }; - } - - const source = CONTRIBUTION_SOURCE_SUB2API; - return { - source, - targetGroupName: Boolean(state.plusModeEnabled) - ? CONTRIBUTION_SUB2API_PLUS_GROUP_NAME - : (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME), - }; - } - function getStatusLabel(status = '') { switch (normalizeContributionStatus(status)) { case 'started': @@ -175,11 +118,13 @@ case 'waiting': return '等待提交回调'; case 'processing': - return '已提交回调,等待服务端确认'; + return '已提交回调,等待 CPA 确认'; case 'auto_approved': - return '贡献成功,服务端已确认'; + return '贡献成功,CPA 已确认'; case 'auto_rejected': return '贡献未通过确认'; + case 'manual_review_required': + return '已提交,等待人工处理'; case 'expired': return '贡献会话已超时'; case 'error': @@ -315,6 +260,41 @@ return qq; } + function isPlusModeState(state = {}) { + return Boolean(state?.plusModeEnabled); + } + + function normalizeContributionModeSource(value = '') { + const normalized = normalizeString(value).toLowerCase(); + return normalized === 'sub2api' ? 'sub2api' : 'cpa'; + } + + function resolveContributionModeRoutingState(state = {}) { + const currentStatus = normalizeString(state?.contributionStatus).toLowerCase(); + const currentSource = normalizeContributionModeSource(state?.contributionSource); + const hasActiveSession = Boolean( + normalizeString(state?.contributionSessionId) + && currentStatus + && !FINAL_STATUSES.has(currentStatus) + ); + + if (hasActiveSession) { + return { + source: currentSource, + targetGroupName: currentSource === 'sub2api' + ? (normalizeString(state?.contributionTargetGroupName) || 'codex号池') + : '', + }; + } + + return { + source: 'sub2api', + targetGroupName: isPlusModeState(state) + ? 'openai-plus' + : (normalizeString(state?.contributionTargetGroupName) || 'codex号池'), + }; + } + function buildStatusMessage(status, payload = {}) { const label = getStatusLabel(status); const details = [ @@ -494,68 +474,87 @@ return currentState; } - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: 'submitting', - contributionCallbackMessage: buildCallbackMessage('submitting'), - }); - - try { - const payload = await fetchContributionJson('/submit-callback', { - method: 'POST', - body: { - session_id: sessionId, - callback_url: normalizedUrl, - }, - }); - - const nextStatus = 'submitted'; - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: nextStatus, - contributionCallbackMessage: buildCallbackMessage(nextStatus, payload), - }); - - if (typeof closeLocalhostCallbackTabs === 'function') { - await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); - } - - return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); - } catch (error) { - await applyRuntimeUpdates({ - contributionCallbackUrl: normalizedUrl, - contributionCallbackStatus: 'failed', - contributionCallbackMessage: `回调提交失败:${error.message}`, - }); - - if (typeof addLog === 'function') { - await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn'); - } - - throw error; + const dedupeKey = `${sessionId}::${normalizedUrl}`; + if (pendingCallbackSubmissions.has(dedupeKey)) { + return pendingCallbackSubmissions.get(dedupeKey); } + + const task = (async () => { + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'submitting', + contributionCallbackMessage: buildCallbackMessage('submitting'), + }); + + try { + const payload = await fetchContributionJson('/submit-callback', { + method: 'POST', + body: { + session_id: sessionId, + callback_url: normalizedUrl, + }, + }); + + const nextStatus = 'submitted'; + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: nextStatus, + contributionCallbackMessage: buildCallbackMessage(nextStatus, payload), + }); + + if (typeof closeLocalhostCallbackTabs === 'function') { + await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {}); + } + + return await pollContributionStatus({ reason: options.reason || 'submit_callback' }); + } catch (error) { + await applyRuntimeUpdates({ + contributionCallbackUrl: normalizedUrl, + contributionCallbackStatus: 'failed', + contributionCallbackMessage: `回调提交失败:${error.message}`, + }); + + if (typeof addLog === 'function') { + await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn'); + } + + throw error; + } finally { + pendingCallbackSubmissions.delete(dedupeKey); + } + })(); + + pendingCallbackSubmissions.set(dedupeKey, task); + return task; } async function handleCapturedCallback(rawUrl, metadata = {}) { + const currentState = await getState(); + if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { + return currentState; + } + if (!isContributionCallbackUrl(rawUrl, currentState)) { + return currentState; + } + const normalizedUrl = normalizeString(rawUrl); - return runCapturedCallbackOnce(normalizedUrl, async () => { - const currentState = await getState(); - if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) { - return currentState; - } - if (!isContributionCallbackUrl(normalizedUrl, currentState)) { - return currentState; - } - - const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); - if ( - normalizedUrl - && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl - && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') - ) { - return currentState; - } + const callbackDedupeKey = `${normalizeString(currentState.contributionSessionId)}::${normalizedUrl}`; + if (pendingCapturedCallbacks.has(callbackDedupeKey)) { + return pendingCapturedCallbacks.get(callbackDedupeKey); + } + if (pendingCallbackSubmissions.has(callbackDedupeKey)) { + return pendingCallbackSubmissions.get(callbackDedupeKey); + } + const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus); + if ( + normalizedUrl + && normalizeString(currentState.contributionCallbackUrl) === normalizedUrl + && (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') + ) { + return currentState; + } + const task = (async () => { await applyRuntimeUpdates({ contributionCallbackUrl: normalizedUrl, contributionCallbackStatus: 'captured', @@ -573,8 +572,13 @@ }); } catch { return getState(); + } finally { + pendingCapturedCallbacks.delete(callbackDedupeKey); } - }); + })(); + + pendingCapturedCallbacks.set(callbackDedupeKey, task); + return task; } async function pollContributionStatus(options = {}) { @@ -597,16 +601,6 @@ const callbackState = deriveCallbackState(mergedPayload, currentState); const updates = { contributionLastPollAt: Date.now(), - contributionSource: normalizeContributionSource( - mergedPayload.source - || mergedPayload.source_kind - || currentState.contributionSource - ), - contributionTargetGroupName: normalizeString( - mergedPayload.target_group_name - || mergedPayload.group_name - || currentState.contributionTargetGroupName - ), contributionStatus: normalizedStatus, contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload), contributionCallbackUrl: callbackState.callbackUrl, @@ -648,7 +642,6 @@ async function startContributionFlow(options = {}) { const currentState = options.stateOverride || await getState(); const shouldOpenAuthTab = options.openAuthTab !== false; - const routing = resolveContributionRouting(currentState); if (!currentState.contributionMode) { throw new Error('请先进入贡献模式。'); } @@ -666,14 +659,15 @@ return pollContributionStatus({ reason: 'resume_existing' }); } + const routingState = resolveContributionModeRoutingState(currentState); const payload = await fetchContributionJson('/start', { method: 'POST', body: { nickname: buildNickname(currentState, options.nickname), qq: buildContributionQq(currentState, options.qq), email: normalizeString(currentState.email), - source: routing.source, - target_group_name: routing.targetGroupName, + source: routingState.source, + target_group_name: routingState.targetGroupName, channel: 'codex-extension', }, }); @@ -686,12 +680,6 @@ } await applyRuntimeUpdates({ - contributionSource: normalizeContributionSource(payload.source || routing.source), - contributionTargetGroupName: normalizeString( - payload.target_group_name - || payload.group_name - || routing.targetGroupName - ), contributionSessionId: sessionId, contributionAuthUrl: authUrl, contributionAuthState: authState, @@ -722,7 +710,7 @@ } function onTabUpdated(tabId, changeInfo, tab) { - const candidateUrl = normalizeString(changeInfo?.url); + const candidateUrl = normalizeString(changeInfo?.url || tab?.url); if (!candidateUrl) { return; } diff --git a/background/message-router.js b/background/message-router.js index 1af516a..7ce7a6e 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -216,6 +216,27 @@ } async function handleStepData(step, payload) { + if (step === 1) { + const updates = {}; + if (payload.oauthUrl) { + updates.oauthUrl = payload.oauthUrl; + broadcastDataUpdate({ oauthUrl: payload.oauthUrl }); + } + if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; + if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; + if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; + if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; + if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; + if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; + if (payload.cpaManagementOrigin !== undefined) updates.cpaManagementOrigin = payload.cpaManagementOrigin || null; + if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; + if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; + if (Object.keys(updates).length) { + await setState(updates); + } + return; + } + const stateForStep = await getState(); const stepKey = getStepKeyForState(step, stateForStep); @@ -262,24 +283,6 @@ } switch (step) { - case 1: { - const updates = {}; - if (payload.oauthUrl) { - updates.oauthUrl = payload.oauthUrl; - broadcastDataUpdate({ oauthUrl: payload.oauthUrl }); - } - if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; - if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; - if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; - if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; - if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; - if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null; - if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null; - if (Object.keys(updates).length) { - await setState(updates); - } - break; - } case 2: if (payload.email) { await setEmailState(payload.email); diff --git a/background/panel-bridge.js b/background/panel-bridge.js index a667960..2b0f077 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -43,6 +43,78 @@ return message || `Codex2API 请求失败(HTTP ${responseStatus})。`; } + function deriveCpaManagementOrigin(vpsUrl) { + const normalizedUrl = String(vpsUrl || '').trim(); + if (!normalizedUrl) { + throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。'); + } + let parsed; + try { + parsed = new URL(normalizedUrl); + } catch { + throw new Error('CPA 地址格式无效,请先在侧边栏检查。'); + } + return parsed.origin; + } + + function getCpaApiErrorMessage(payload, responseStatus = 500) { + const candidates = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ]; + const message = candidates + .map((value) => String(value || '').trim()) + .find(Boolean); + return message || `CPA 管理接口请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCpaManagementJson(origin, path, options = {}) { + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const managementKey = String(options.managementKey || '').trim(); + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (managementKey) { + headers.Authorization = `Bearer ${managementKey}`; + headers['X-Management-Key'] = managementKey; + } + + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCpaApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('CPA 管理接口请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function fetchCodex2ApiJson(origin, path, options = {}) { const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); const controller = new AbortController(); @@ -97,49 +169,48 @@ if (!state.vpsUrl) { throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。'); } - - await addLog(`${logLabel}:正在打开 CPA 面板...`); - - const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js']; - await closeConflictingTabsForSource('vps-panel', state.vpsUrl); - - const tab = await chrome.tabs.create({ url: state.vpsUrl, active: true }); - const tabId = tab.id; - await rememberSourceLastUrl('vps-panel', state.vpsUrl); - - await addLog(`${logLabel}:CPA 面板已打开,正在等待页面进入目标地址...`); - const matchedTab = await waitForTabUrlFamily('vps-panel', tabId, state.vpsUrl, { - timeoutMs: 15000, - retryDelayMs: 400, - }); - if (!matchedTab) { - await addLog(`${logLabel}:CPA 页面尚未完全进入目标地址,继续尝试连接内容脚本...`, 'warn'); + const managementKey = String(state.vpsPassword || '').trim(); + if (!managementKey) { + throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。'); } - await ensureContentScriptReadyOnTab('vps-panel', tabId, { - inject: injectFiles, - timeoutMs: 45000, - retryDelayMs: 900, - logMessage: `${logLabel}:CPA 面板仍在加载,正在重试连接内容脚本...`, + const origin = deriveCpaManagementOrigin(state.vpsUrl); + + await addLog(`${logLabel}:正在通过 CPA 管理接口获取 OAuth 授权链接...`); + const result = await fetchCpaManagementJson(origin, '/v0/management/codex-auth-url', { + method: 'GET', + managementKey, }); - const result = await sendToContentScriptResilient('vps-panel', { - type: 'REQUEST_OAUTH_URL', - source: 'background', - payload: { - vpsPassword: state.vpsPassword, - logStep: 7, - }, - }, { - timeoutMs: 30000, - retryDelayMs: 700, - logMessage: `${logLabel}:CPA 面板通信未就绪,正在等待页面恢复...`, - }); + const oauthUrl = String( + result?.url + || result?.auth_url + || result?.authUrl + || result?.data?.url + || result?.data?.auth_url + || result?.data?.authUrl + || '' + ).trim(); + const oauthState = String( + result?.state + || result?.auth_state + || result?.authState + || result?.data?.state + || result?.data?.auth_state + || result?.data?.authState + || '' + ).trim() + || extractStateFromAuthUrl(oauthUrl); - if (result?.error) { - throw new Error(result.error); + if (!oauthUrl || !oauthUrl.startsWith('http')) { + throw new Error('CPA 管理接口未返回有效的 auth_url。'); } - return result || {}; + + return { + oauthUrl, + cpaOAuthState: oauthState || null, + cpaManagementOrigin: origin, + }; } async function requestCodex2ApiOAuthUrl(state, options = {}) { diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index d12ca49..f24178e 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -40,17 +40,14 @@ return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message); } - function getVisibleStep(state, fallback = 7) { - const visibleStep = Math.floor(Number(state?.visibleStep) || 0); - return visibleStep > 0 ? visibleStep : fallback; - } - async function executeStep7(state) { - const visibleStep = getVisibleStep(state, 7); if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); } + const visibleStep = Math.floor(Number(state?.visibleStep) || 0); + const completionStep = visibleStep > 0 ? visibleStep : 7; + let attempt = 0; let lastError = null; @@ -60,22 +57,22 @@ try { const currentState = attempt === 1 ? state : await getState(); const password = currentState.password || currentState.customPassword || ''; - const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState, { visibleStep }); + const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); if (typeof startOAuthFlowTimeoutWindow === 'function') { - await startOAuthFlowTimeoutWindow({ step: visibleStep, oauthUrl }); + await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl }); } const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(180000, { - step: visibleStep, + step: 7, actionLabel: 'OAuth 登录并进入验证码页', oauthUrl, }) : 180000; if (attempt === 1) { - await addLog(`步骤 ${visibleStep}:正在打开最新 OAuth 链接并登录...`); + await addLog('步骤 7:正在打开最新 OAuth 链接并登录...'); } else { - await addLog(`步骤 ${visibleStep}:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn'); + await addLog(`步骤 7:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn'); } await reuseOrCreateTab('signup-page', oauthUrl); @@ -89,14 +86,13 @@ payload: { email: currentState.email, password, - visibleStep, }, }, { timeoutMs: loginTimeoutMs, responseTimeoutMs: loginTimeoutMs, retryDelayMs: 700, - logMessage: `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪后继续登录...`, + logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...', } ); @@ -108,13 +104,14 @@ const completionPayload = { loginVerificationRequestedAt: result.loginVerificationRequestedAt || null, }; - if (result.skipLoginVerificationStep) { - completionPayload.skipLoginVerificationStep = true; + if (Object.prototype.hasOwnProperty.call(result || {}, 'skipLoginVerificationStep')) { + completionPayload.skipLoginVerificationStep = Boolean(result.skipLoginVerificationStep); } - if (result.directOAuthConsentPage) { - completionPayload.directOAuthConsentPage = true; + if (Object.prototype.hasOwnProperty.call(result || {}, 'directOAuthConsentPage')) { + completionPayload.directOAuthConsentPage = Boolean(result.directOAuthConsentPage); } - await completeStepFromBackground(visibleStep, completionPayload); + + await completeStepFromBackground(completionStep, completionPayload); return; } @@ -132,7 +129,7 @@ } if (isManagementSecretConfigError(err)) { await addLog( - `步骤 ${visibleStep}:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, + `步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`, 'error' ); throw err; @@ -142,11 +139,11 @@ break; } - await addLog(`步骤 ${visibleStep}:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn'); + await addLog(`步骤 7:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn'); } } - throw new Error(`步骤 ${visibleStep}:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`); + throw new Error(`步骤 7:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`); } return { executeStep7 }; diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index b88f295..1637dc8 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -26,31 +26,23 @@ return String(value || '').trim(); } - function getVisibleStep(state, fallback = 10) { + function resolvePlatformVerifyStep(state = {}) { const visibleStep = Math.floor(Number(state?.visibleStep) || 0); - return visibleStep > 0 ? visibleStep : fallback; + return visibleStep >= 10 ? visibleStep : 10; } - function getConfirmStepForVisibleStep(visibleStep) { - return visibleStep >= 13 ? 12 : 9; - } - - function getAuthLoginStepForVisibleStep(visibleStep) { - return visibleStep >= 13 ? 10 : 7; - } - - function parseLocalhostCallback(rawUrl, visibleStep = 10, confirmStep = 9) { + function parseLocalhostCallback(rawUrl) { let parsed; try { parsed = new URL(rawUrl); } catch { - throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。'); } const code = normalizeString(parsed.searchParams.get('code')); const state = normalizeString(parsed.searchParams.get('state')); if (!code || !state) { - throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。'); } return { @@ -72,6 +64,77 @@ return details || `Codex2API 请求失败(HTTP ${responseStatus})。`; } + function deriveCpaManagementOrigin(vpsUrl) { + const normalizedUrl = normalizeString(vpsUrl); + if (!normalizedUrl) { + throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。'); + } + let parsed; + try { + parsed = new URL(normalizedUrl); + } catch { + throw new Error('CPA 地址格式无效,请先在侧边栏检查。'); + } + return parsed.origin; + } + + function getCpaApiErrorMessage(payload, responseStatus = 500) { + const details = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ] + .map((value) => normalizeString(value)) + .find(Boolean); + return details || `CPA 管理接口请求失败(HTTP ${responseStatus})。`; + } + + async function fetchCpaManagementJson(origin, path, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const managementKey = normalizeString(options.managementKey); + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (managementKey) { + headers.Authorization = `Bearer ${managementKey}`; + headers['X-Management-Key'] = managementKey; + } + + const response = await fetch(`${origin}${path}`, { + method: options.method || 'POST', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + + if (!response.ok) { + throw new Error(getCpaApiErrorMessage(payload, response.status)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error('CPA 管理接口请求超时,请稍后重试。'); + } + throw error; + } finally { + clearTimeout(timer); + } + } + async function fetchCodex2ApiJson(origin, path, options = {}) { const controller = new AbortController(); const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); @@ -122,95 +185,88 @@ } async function executeCpaStep10(state) { - const visibleStep = getVisibleStep(state, 10); - const confirmStep = getConfirmStepForVisibleStep(visibleStep); + const platformVerifyStep = resolvePlatformVerifyStep(state); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } if (!state.localhostUrl) { - throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); } if (!state.vpsUrl) { throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。'); } if (shouldBypassStep9ForLocalCpa(state)) { - await addLog(`步骤 ${visibleStep}:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。`, 'info'); - await completeStepFromBackground(visibleStep, { + await addLog('步骤 10:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。', 'info'); + await completeStepFromBackground(platformVerifyStep, { localhostUrl: state.localhostUrl, verifiedStatus: 'local-auto', }); return; } - await addLog(`步骤 ${visibleStep}:正在打开 CPA 面板...`); - - const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js']; - let tabId = await getTabId('vps-panel'); - const alive = tabId && await isTabAlive('vps-panel'); - - if (!alive) { - tabId = await reuseOrCreateTab('vps-panel', state.vpsUrl, { - inject: injectFiles, - reloadIfSameUrl: true, - }); - } else { - await closeConflictingTabsForSource('vps-panel', state.vpsUrl, { excludeTabIds: [tabId] }); - await chrome.tabs.update(tabId, { active: true }); - await rememberSourceLastUrl('vps-panel', state.vpsUrl); + const callback = parseLocalhostCallback(state.localhostUrl); + const expectedState = normalizeString(state.cpaOAuthState); + if (expectedState && expectedState !== callback.state) { + throw new Error('CPA 回调 state 与当前授权会话不匹配,请重新执行步骤 7。'); + } + const managementKey = normalizeString(state.vpsPassword); + if (!managementKey) { + throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。'); } - await ensureContentScriptReadyOnTab('vps-panel', tabId, { - inject: injectFiles, - timeoutMs: 45000, - retryDelayMs: 900, - logMessage: `步骤 ${visibleStep}:CPA 面板仍在加载,正在重试连接...`, - }); + await addLog('步骤 10:正在通过 CPA 管理接口提交回调地址...'); + try { + const origin = normalizeString(state.cpaManagementOrigin) || deriveCpaManagementOrigin(state.vpsUrl); + const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', { + method: 'POST', + managementKey, + body: { + provider: 'codex', + redirect_url: callback.url, + }, + }); - await addLog(`步骤 ${visibleStep}:正在填写回调地址...`); - const result = await sendToContentScriptResilient('vps-panel', { - type: 'EXECUTE_STEP', - step: visibleStep, - source: 'background', - payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword, visibleStep }, - }, { - timeoutMs: 125000, - responseTimeoutMs: 125000, - retryDelayMs: 700, - logMessage: `步骤 ${visibleStep}:CPA 面板通信未就绪,正在等待页面恢复...`, - }); - - if (result?.error) { - throw new Error(result.error); + const verifiedStatus = normalizeString(result?.message) + || normalizeString(result?.status_message) + || 'CPA 已通过接口提交回调'; + await addLog(`步骤 10:${verifiedStatus}`, 'ok'); + await completeStepFromBackground(platformVerifyStep, { + localhostUrl: callback.url, + verifiedStatus, + }); + } catch (error) { + const reason = normalizeString(error?.message) || 'unknown error'; + await addLog(`步骤 10:CPA 接口提交失败:${reason}`, 'error'); + throw error; } } async function executeCodex2ApiStep10(state) { - const visibleStep = getVisibleStep(state, 10); - const confirmStep = getConfirmStepForVisibleStep(visibleStep); + const platformVerifyStep = resolvePlatformVerifyStep(state); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } if (!state.localhostUrl) { - throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); } if (!state.codex2apiSessionId) { - throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); + throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。'); } if (!normalizeString(state.codex2apiAdminKey)) { throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。'); } - const callback = parseLocalhostCallback(state.localhostUrl, visibleStep, confirmStep); + const callback = parseLocalhostCallback(state.localhostUrl); const expectedState = normalizeString(state.codex2apiOAuthState); if (expectedState && expectedState !== callback.state) { - throw new Error(`Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`); + throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。'); } const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl); const origin = new URL(codex2apiUrl).origin; - await addLog(`步骤 ${visibleStep}:正在向 Codex2API 提交回调并创建账号...`); + await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...'); const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', { adminKey: state.codex2apiAdminKey, method: 'POST', @@ -222,21 +278,19 @@ }); const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功'; - await addLog(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok'); - await completeStepFromBackground(visibleStep, { + await addLog(`步骤 10:${verifiedStatus}`, 'ok'); + await completeStepFromBackground(platformVerifyStep, { localhostUrl: callback.url, verifiedStatus, }); } async function executeSub2ApiStep10(state) { - const visibleStep = getVisibleStep(state, 10); - const confirmStep = getConfirmStepForVisibleStep(visibleStep); if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { - throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`); + throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。'); } if (!state.localhostUrl) { - throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`); + throw new Error('缺少 localhost 回调地址,请先完成步骤 9。'); } if (!state.sub2apiSessionId) { throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。'); @@ -251,7 +305,7 @@ const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; - await addLog(`步骤 ${visibleStep}:正在打开 SUB2API 后台...`); + await addLog('步骤 10:正在打开 SUB2API 后台...'); let tabId = await getTabId('sub2api-panel'); const alive = tabId && await isTabAlive('sub2api-panel'); @@ -273,13 +327,12 @@ injectSource: 'sub2api-panel', }); - await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`); + await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...'); const result = await sendToContentScript('sub2api-panel', { type: 'EXECUTE_STEP', - step: visibleStep, + step: 10, source: 'background', payload: { - visibleStep, localhostUrl: state.localhostUrl, sub2apiUrl, sub2apiEmail: state.sub2apiEmail, diff --git a/docs/使用教程.md b/docs/使用教程.md index a9a4d84..92ef7c2 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -225,7 +225,7 @@ ### 第七部分:`HeroSMS` 手机接码扩展使用教程(新增) -本部分用于说明扩展中 `Step 9` 手机号验证链路的最新接码能力与推荐用法。 +本部分用于说明扩展中 `Step 9` 手机号验证链路的最新接码能力与推荐用法。 目标是减少“重复重发导致封禁”与“同国家持续拿不到码”的失败率,并让失败可以在 `Step 9` 内部自愈。 #### 一、入口与启用 @@ -236,7 +236,7 @@ #### 二、国家与优先级(新增能力) -1. 在 `接码国家` 中多选国家(最多 `3` 个),按你的业务顺序排列。 +1. 在 `接码国家` 中多选国家(最多 `3` 个),按你的业务顺序排列。 2. 在 `国家优先级` 中选择策略: - `国家优先`:严格按你选择的顺序先后尝试。 - `价格优先`:在候选国家里先选可用价格更低的国家,再尝试拿号。 @@ -280,11 +280,11 @@ #### 七、常见问题与处理 -1. `no numbers available across ...` +1. `no numbers available across ...` 表示候选国家当前无号或价格上限过低。先提高价格上限,再调整国家顺序。 -2. `NO_NUMBERS` 频繁出现 +2. `NO_NUMBERS` 频繁出现 增加候选国家数量(最多 3 个),并优先启用 `价格优先`。 -3. 页面提示重发过多 +3. 页面提示重发过多 不要手工持续点重发,让流程自动换号/换国家即可。 ### 第八部分:`PayPal` 注册与绑卡使用教程 diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 3383903..bd7f742 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -2381,9 +2381,16 @@ function collectSettingsPayload() { const heroSmsReuseEnabledValue = typeof inputHeroSmsReuseEnabled !== 'undefined' && inputHeroSmsReuseEnabled ? normalizeHeroSmsReuseEnabledValue(inputHeroSmsReuseEnabled.checked) : defaultHeroSmsReuseEnabled; + const normalizeHeroSmsAcquirePriorityValue = typeof normalizeHeroSmsAcquirePriority === 'function' + ? normalizeHeroSmsAcquirePriority + : (value) => (String(value || '').trim().toLowerCase() === 'price' ? 'price' : 'country'); const heroSmsAcquirePriorityValue = typeof selectHeroSmsAcquirePriority !== 'undefined' && selectHeroSmsAcquirePriority - ? normalizeHeroSmsAcquirePriority(selectHeroSmsAcquirePriority.value) - : DEFAULT_HERO_SMS_ACQUIRE_PRIORITY; + ? normalizeHeroSmsAcquirePriorityValue(selectHeroSmsAcquirePriority.value) + : normalizeHeroSmsAcquirePriorityValue( + typeof DEFAULT_HERO_SMS_ACQUIRE_PRIORITY !== 'undefined' + ? DEFAULT_HERO_SMS_ACQUIRE_PRIORITY + : 'country' + ); const heroSmsMaxPriceValue = typeof inputHeroSmsMaxPrice !== 'undefined' && inputHeroSmsMaxPrice ? normalizeHeroSmsMaxPriceValue(inputHeroSmsMaxPrice.value) : ''; diff --git a/tests/background-cpa-state-plumbing.test.js b/tests/background-cpa-state-plumbing.test.js new file mode 100644 index 0000000..c894bd5 --- /dev/null +++ b/tests/background-cpa-state-plumbing.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('background step-1 state plumbing persists and resets cpa oauth runtime keys', () => { + const source = fs.readFileSync('background.js', 'utf8'); + + assert.match(source, /cpaOAuthState:\s*null/); + assert.match(source, /cpaManagementOrigin:\s*null/); + assert.match(source, /payload\.cpaOAuthState[^\n]*updates\.cpaOAuthState/); + assert.match(source, /payload\.cpaManagementOrigin[^\n]*updates\.cpaManagementOrigin/); + assert.match(source, /if \(step <= 1\) \{[\s\S]*cpaOAuthState:\s*null,[\s\S]*cpaManagementOrigin:\s*null,/); +}); + +test('message router step-1 handler stores cpa oauth runtime keys', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + + const updates = []; + const router = api.createMessageRouter({ + broadcastDataUpdate: () => {}, + setState: async (payload) => { + updates.push(payload); + }, + }); + + await router.handleStepData(1, { + cpaOAuthState: 'oauth-state-1', + cpaManagementOrigin: 'http://localhost:8317', + }); + + assert.deepStrictEqual(updates, [ + { + cpaOAuthState: 'oauth-state-1', + cpaManagementOrigin: 'http://localhost:8317', + }, + ]); +}); diff --git a/tests/background-message-router-plus-final-step.test.js b/tests/background-message-router-plus-final-step.test.js new file mode 100644 index 0000000..723f3f2 --- /dev/null +++ b/tests/background-message-router-plus-final-step.test.js @@ -0,0 +1,105 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/message-router.js', 'utf8'); +const globalScope = {}; +const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + +test('message router appends success record on Plus final step instead of hard-coded step 10', async () => { + const appendCalls = []; + const router = api.createMessageRouter({ + addLog: async () => {}, + appendAccountRunRecord: async (...args) => { + appendCalls.push(args); + }, + batchUpdateLuckmailPurchases: async () => {}, + buildLocalhostCleanupPrefix: () => '', + buildLuckmailSessionSettingsPayload: () => ({}), + buildPersistentSettingsPayload: () => ({}), + broadcastDataUpdate: () => {}, + cancelScheduledAutoRun: async () => {}, + checkIcloudSession: async () => {}, + clearAutoRunTimerAlarm: async () => {}, + clearLuckmailRuntimeState: async () => {}, + clearStopRequest: () => {}, + closeLocalhostCallbackTabs: async () => {}, + closeTabsByUrlPrefix: async () => {}, + deleteHotmailAccount: async () => {}, + deleteHotmailAccounts: async () => {}, + deleteIcloudAlias: async () => {}, + deleteUsedIcloudAliases: async () => {}, + disableUsedLuckmailPurchases: async () => {}, + doesStepUseCompletionSignal: () => false, + ensureManualInteractionAllowed: async () => ({}), + executeStep: async () => {}, + executeStepViaCompletionSignal: async () => {}, + exportSettingsBundle: async () => ({}), + fetchGeneratedEmail: async () => '', + finalizeStep3Completion: async () => {}, + finalizeIcloudAliasAfterSuccessfulFlow: async () => {}, + findHotmailAccount: async () => null, + flushCommand: async () => {}, + getCurrentLuckmailPurchase: () => null, + getPendingAutoRunTimerPlan: () => null, + getSourceLabel: () => '', + getState: async () => ({ plusModeEnabled: true, stepStatuses: { 13: 'pending' } }), + getLastStepIdForState: () => 13, + getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'oauth-login' : 'platform-verify' }), + getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + getTabId: async () => null, + getStopRequested: () => false, + handleAutoRunLoopUnhandledError: async () => {}, + handleCloudflareSecurityBlocked: async () => '', + importSettingsBundle: async () => {}, + invalidateDownstreamAfterStepRestart: async () => {}, + isCloudflareSecurityBlockedError: () => false, + isAutoRunLockedState: () => false, + isHotmailProvider: () => false, + isLocalhostOAuthCallbackUrl: () => true, + isLuckmailProvider: () => false, + isStopError: () => false, + isTabAlive: async () => false, + launchAutoRunTimerPlan: async () => {}, + listIcloudAliases: async () => [], + listLuckmailPurchasesForManagement: async () => [], + normalizeHotmailAccounts: (items) => items, + normalizeRunCount: (value) => value, + AUTO_RUN_TIMER_KIND_SCHEDULED_START: 'scheduled', + notifyStepComplete: () => {}, + notifyStepError: () => {}, + patchHotmailAccount: async () => {}, + patchMail2925Account: async () => {}, + registerTab: async () => {}, + requestStop: async () => {}, + resetState: async () => {}, + resumeAutoRun: async () => {}, + scheduleAutoRun: async () => {}, + selectLuckmailPurchase: async () => {}, + setCurrentHotmailAccount: async () => {}, + setCurrentMail2925Account: async () => {}, + setContributionMode: async () => {}, + setEmailState: async () => {}, + setEmailStateSilently: async () => {}, + setIcloudAliasPreservedState: async () => {}, + setIcloudAliasUsedState: async () => {}, + setLuckmailPurchaseDisabledState: async () => {}, + setLuckmailPurchasePreservedState: async () => {}, + setLuckmailPurchaseUsedState: async () => {}, + setPersistentSettings: async () => {}, + setState: async () => {}, + setStepStatus: async () => {}, + skipAutoRunCountdown: async () => false, + skipStep: async () => {}, + startAutoRunLoop: async () => {}, + syncHotmailAccounts: async () => {}, + testHotmailAccountMailAccess: async () => {}, + upsertHotmailAccount: async () => {}, + verifyHotmailAccount: async () => {}, + }); + + await router.handleMessage({ type: 'STEP_COMPLETE', step: 13, payload: {} }, {}); + + assert.equal(appendCalls.length, 1); + assert.equal(appendCalls[0][0], 'success'); +}); diff --git a/tests/background-message-router-step2-skip.test.js b/tests/background-message-router-step2-skip.test.js index 7b72c22..27e2a0a 100644 --- a/tests/background-message-router-step2-skip.test.js +++ b/tests/background-message-router-step2-skip.test.js @@ -65,6 +65,7 @@ function createRouter(overrides = {}) { getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } }, getStepDefinitionForState: overrides.getStepDefinitionForState, getStepIdsForState: overrides.getStepIdsForState, + getLastStepIdForState: overrides.getLastStepIdForState, getTabId: overrides.getTabId || (async () => null), getStopRequested: () => false, handleAutoRunLoopUnhandledError: async () => {}, diff --git a/tests/background-panel-bridge-module.test.js b/tests/background-panel-bridge-module.test.js index 353c990..3e655af 100644 --- a/tests/background-panel-bridge-module.test.js +++ b/tests/background-panel-bridge-module.test.js @@ -71,3 +71,48 @@ test('panel bridge can request codex2api oauth url via protocol', async () => { globalThis.fetch = originalFetch; } }); + +test('panel bridge can request cpa oauth url via management api', async () => { + const source = fs.readFileSync('background/panel-bridge.js', 'utf8'); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options = {}) => { + assert.equal(url, 'http://localhost:8317/v0/management/codex-auth-url'); + assert.equal(options.method, 'GET'); + assert.equal(options.headers.Authorization, 'Bearer cpa-key'); + assert.equal(options.headers['X-Management-Key'], 'cpa-key'); + return { + ok: true, + json: async () => ({ + status: 'ok', + url: 'https://auth.openai.com/authorize?state=cpa-oauth-state', + state: 'cpa-oauth-state', + }), + }; + }; + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({}); + const bridge = api.createPanelBridge({ + addLog: async () => {}, + getPanelMode: () => 'cpa', + normalizeCodex2ApiUrl: (value) => value, + normalizeSub2ApiUrl: (value) => value, + DEFAULT_SUB2API_GROUP_NAME: 'codex', + SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000, + }); + + const result = await bridge.requestOAuthUrlFromPanel({ + panelMode: 'cpa', + vpsUrl: 'http://localhost:8317/admin/oauth', + vpsPassword: 'cpa-key', + }, { logLabel: '步骤 7' }); + + assert.deepStrictEqual(result, { + oauthUrl: 'https://auth.openai.com/authorize?state=cpa-oauth-state', + cpaOAuthState: 'cpa-oauth-state', + cpaManagementOrigin: 'http://localhost:8317', + }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/background-platform-verify-cpa-api.test.js b/tests/background-platform-verify-cpa-api.test.js new file mode 100644 index 0000000..0fd9002 --- /dev/null +++ b/tests/background-platform-verify-cpa-api.test.js @@ -0,0 +1,239 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); + +function createDeps(overrides = {}) { + const logs = []; + const completed = []; + const uiCalls = []; + + const deps = { + addLog: async (message, level = 'info') => { + logs.push({ message, level }); + }, + chrome: { + tabs: { + update: async () => {}, + }, + }, + closeConflictingTabsForSource: async () => {}, + completeStepFromBackground: async (step, payload) => { + completed.push({ step, payload }); + }, + ensureContentScriptReadyOnTab: async () => {}, + getPanelMode: () => 'cpa', + getTabId: async () => 0, + isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='), + isTabAlive: async () => false, + normalizeCodex2ApiUrl: (value) => value, + normalizeSub2ApiUrl: (value) => value, + rememberSourceLastUrl: async () => {}, + reuseOrCreateTab: async () => 91, + sendToContentScript: async () => ({}), + sendToContentScriptResilient: async (source, message, options) => { + uiCalls.push({ source, message, options }); + return {}; + }, + shouldBypassStep9ForLocalCpa: () => false, + SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000, + ...overrides, + }; + + return { deps, logs, completed, uiCalls }; +} + +test('platform verify module submits CPA callback via management API first', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const originalFetch = globalThis.fetch; + let uiCalled = false; + globalThis.fetch = async (url, options = {}) => { + assert.equal(url, 'http://localhost:8317/v0/management/oauth-callback'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers.Authorization, 'Bearer cpa-key'); + assert.equal(options.headers['X-Management-Key'], 'cpa-key'); + assert.deepStrictEqual(JSON.parse(options.body), { + provider: 'codex', + redirect_url: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + }); + return { + ok: true, + json: async () => ({ + message: 'CPA API 回调提交成功', + }), + }; + }; + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + const { deps, logs, completed } = createDeps({ + sendToContentScriptResilient: async () => { + uiCalled = true; + return {}; + }, + }); + const executor = api.createStep10Executor(deps); + + await executor.executeStep10({ + panelMode: 'cpa', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + vpsUrl: 'http://localhost:8317/admin/oauth', + vpsPassword: 'cpa-key', + }); + + assert.equal(uiCalled, false); + assert.deepStrictEqual(completed, [ + { + step: 10, + payload: { + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + verifiedStatus: 'CPA API 回调提交成功', + }, + }, + ]); + assert.deepStrictEqual(logs, [ + { message: '步骤 10:正在通过 CPA 管理接口提交回调地址...', level: 'info' }, + { message: '步骤 10:CPA API 回调提交成功', level: 'ok' }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('platform verify module prefers cpaManagementOrigin when provided', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, options = {}) => { + assert.equal(url, 'http://localhost:9999/v0/management/oauth-callback'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers.Authorization, 'Bearer cpa-key'); + assert.equal(options.headers['X-Management-Key'], 'cpa-key'); + return { + ok: true, + json: async () => ({ + message: 'CPA API 回调提交成功', + }), + }; + }; + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + const { deps, completed } = createDeps(); + const executor = api.createStep10Executor(deps); + + await executor.executeStep10({ + panelMode: 'cpa', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + cpaManagementOrigin: 'http://localhost:9999', + vpsUrl: 'http://localhost:8317/admin/oauth', + vpsPassword: 'cpa-key', + }); + + assert.equal(completed.length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('platform verify module fails fast when CPA API submit fails', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: false, + status: 500, + json: async () => ({ message: 'failed to persist oauth callback' }), + }); + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + const { deps, logs, completed, uiCalls } = createDeps(); + const executor = api.createStep10Executor(deps); + + await assert.rejects( + () => executor.executeStep10({ + panelMode: 'cpa', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + vpsUrl: 'http://localhost:8317/admin/oauth', + vpsPassword: 'cpa-key', + }), + /failed to persist oauth callback/ + ); + + assert.equal(uiCalls.length, 0); + assert.equal(completed.length, 0); + assert.equal(logs[0].message, '步骤 10:正在通过 CPA 管理接口提交回调地址...'); + assert.match(logs[1].message, /步骤 10:CPA 接口提交失败:failed to persist oauth callback/); + assert.equal(logs[1].level, 'error'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('platform verify module requires management key for CPA API-only flow', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const originalFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = async () => { + fetchCalled = true; + return { + ok: true, + json: async () => ({}), + }; + }; + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + const { deps, logs, completed, uiCalls } = createDeps(); + const executor = api.createStep10Executor(deps); + + await assert.rejects( + () => executor.executeStep10({ + panelMode: 'cpa', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + vpsUrl: 'http://localhost:8317/admin/oauth', + vpsPassword: ' ', + }), + /尚未配置 CPA 管理密钥/ + ); + + assert.equal(fetchCalled, false); + assert.equal(uiCalls.length, 0); + assert.equal(completed.length, 0); + assert.equal(logs.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('platform verify module rejects callback when cpa oauth state mismatches', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const originalFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = async () => { + fetchCalled = true; + return { + ok: true, + json: async () => ({ message: 'should not happen' }), + }; + }; + + try { + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + const { deps } = createDeps(); + const executor = api.createStep10Executor(deps); + + await assert.rejects( + () => executor.executeStep10({ + panelMode: 'cpa', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=callback-state', + cpaOAuthState: 'expected-state', + vpsUrl: 'http://localhost:8317/admin/oauth', + vpsPassword: 'cpa-key', + }), + /CPA 回调 state 与当前授权会话不匹配/ + ); + + assert.equal(fetchCalled, false); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index c754ac6..e25f8c4 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -23,7 +23,6 @@ - 轮询登录验证码 - 自动确认 OAuth 同意页 - 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API -- 可选开启 Plus 模式:先完成 Plus Checkout、账单地址、PayPal 登录授权与订阅回跳确认,再复用 OAuth 后半段链路 ## 2. 核心运行参与者 @@ -118,7 +117,6 @@ - 步骤顺序靠 `order` - 步骤文件名靠语义 - 新增步骤时不需要重命名后续文件 -- 普通模式使用 10 步定义;Plus 模式使用 13 步定义,其中 Plus 可见步骤 10/11/12/13 复用原 OAuth 登录、登录验证码、OAuth 同意页与平台回调验证执行器,但按 Plus 可见步骤编号记录状态 ## 4. 状态与存储链路 @@ -141,7 +139,6 @@ - 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail` - 当前手机号验证激活记录 `currentPhoneActivation` - 可复用的手机号验证激活记录 `reusablePhoneActivation` -- Plus checkout / PayPal 运行态:`plusCheckoutTabId`、`plusCheckoutUrl`、`plusCheckoutCountry`、`plusCheckoutCurrency`、`plusBillingCountryText`、`plusBillingAddress`、`plusPaypalApprovedAt`、`plusReturnUrl` - localhost 回调地址 - 自动运行轮次信息 - 当前自动运行 session 标识 `autoRunSessionId` @@ -171,8 +168,7 @@ - 2925 当前选中的号池账号 ID `currentMail2925AccountId` - Cloudflare / Temp Email 设置 - 接码开关,以及 HeroSMS 的 API Key 与默认国家设置 -- iCloud 相关偏好:Host、获取策略、成功后自动删除、目标邮箱类型与转发收码邮箱 provider -- iCloud Hide My Email 别名缓存:`icloudAliasCache` / `icloudAliasCacheAt`,用于在 iCloud 会话或网络上下文短暂波动时回退展示最近可用列表 +- iCloud 相关偏好 - LuckMail API 配置 - 自动运行默认配置 - 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止) @@ -457,7 +453,6 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 补充: - HeroSMS 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。 -- HeroSMS 新号码申请会先查询当前国家与 OpenAI 服务的最低价并携带固定价格参数;如果 `getNumber` 返回 `NO_NUMBERS`,会回退到 `getNumberV2`,后续用 `getStatusV2` 轮询验证码。 - 如果同一个号码在重发短信后 60 秒仍收不到验证码,后台会抛出“回到步骤 7 重新拿新号码”的恢复错误,而不是把当前号码无限重试下去。 ### Step 10 @@ -646,7 +641,6 @@ Plus 模式可见步骤: - `163`、`163 VIP`、`126` 都走同一条“网易网页邮箱”验证码链路。 - sidepanel 只负责切换 provider 与展示登录入口;后台根据 provider 选择对应网页邮箱首页。 - 内容脚本来源统一归类到 `mail-163`,这样 Step 4 / Step 8 继续复用同一套验证码读取与邮件清理逻辑。 -- `mail-provider-utils.js` 同时承接 iCloud 转发收码可选 provider 的归一化与入口配置,避免 background / sidepanel 重新复制 QQ、网易和 Gmail 的收码地址、label 与注入脚本。 - `manifest.json` 需要同时覆盖: - `https://mail.163.com/*` - `https://webmail.vip.163.com/*` @@ -741,7 +735,6 @@ Plus 模式可见步骤: 组成: - [icloud-utils.js](c:/Users/projectf/Downloads/codex注册扩展/icloud-utils.js) -- [mail-provider-utils.js](c:/Users/projectf/Downloads/codex注册扩展/mail-provider-utils.js) - [content/icloud-mail.js](c:/Users/projectf/Downloads/codex注册扩展/content/icloud-mail.js) 配置: @@ -803,7 +796,6 @@ Hide My Email 获取与管理链路: - UI 层不能凭输入值直接判断“代理已接管”,只能展示后台返回的 `ipProxyApplied*` 状态。 - 新增代理服务商时,应优先新增 provider 规则模块,并让共享解析/运行态继续走 `background/ip-proxy-core.js`。 - 修改代理字段、权限或链路时,需要同步更新 [docs/ip-proxy-module.md](c:/Users/projectf/Downloads/codex注册扩展/docs/ip-proxy-module.md)、当前完整链路说明和结构说明。 - ## 8. 自动运行完整链路 文件: @@ -819,11 +811,9 @@ Hide My Email 获取与管理链路: - 如果当前 `Mail = 自定义邮箱` 且配置了 `customMailProviderPool`,会先按当前目标轮次把号池中的对应邮箱写回运行态 - 如果当前生成方式是 `custom-pool`,会先按当前目标轮次把邮箱池中的对应邮箱写回运行态 5. 执行 `runAutoSequenceFromStep` - - 自动运行会按当前 `plusModeEnabled` 选择普通 10 步或 Plus 13 步可见步骤;Plus 模式下第 6~9 步走 checkout / PayPal,第 10~13 步复用 OAuth 后半段 - 步骤 7 内部仍保留登录态恢复的有限重试,但 `add-phone / 手机号页` 属于立即跳出的不可重试错误 - 步骤 8 若在验证码提交后进入 `add-phone / 手机号页`,会直接抛出 fatal 错误,不再先标记步骤成功 - - 普通模式一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开 - - Plus 模式在 checkout / PayPal 结束后,如果 OAuth 后半段遇到普通报错且认证流程未进入 `add-phone`,则自动回到 Plus 可见步骤 10 重新开始授权链路 + - 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开 - 如果命中 `add-phone / 手机号页` 这类 fatal 错误,则不会再做当前轮的内部重试;当开启自动重试/跳过失败时,会直接结束当前轮并继续下一轮,而不是把整条自动流程暂停 - 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开” 6. 如果失败,根据设置决定:
+ + QQ交流群,便于大家交流 + 最新版本运行日志