diff --git a/background.js b/background.js index bddd89f..f6000d9 100644 --- a/background.js +++ b/background.js @@ -21,6 +21,7 @@ importScripts( 'background/ip-proxy-provider-711proxy.js', 'background/ip-proxy-core.js', 'background/sub2api-api.js', + 'background/cpa-api.js', 'background/panel-bridge.js', 'background/registration-email-state.js', 'background/workflow-engine.js', @@ -56,6 +57,7 @@ importScripts( 'background/steps/gopay-approve.js', 'background/steps/plus-return-confirm.js', 'background/steps/sub2api-session-import.js', + 'background/steps/cpa-session-import.js', 'background/steps/oauth-login.js', 'background/steps/fetch-login-code.js', 'background/steps/confirm-oauth.js', @@ -77,6 +79,7 @@ importScripts( const DEFAULT_ACTIVE_FLOW_ID = 'openai'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const NORMAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: false, @@ -103,6 +106,12 @@ const PLUS_PAYPAL_SUB2API_SESSION_STEP_DEFINITIONS = self.MultiPageStepDefinitio plusPaymentMethod: 'paypal', plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION, }) || PLUS_PAYPAL_STEP_DEFINITIONS; +const PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + plusModeEnabled: true, + plusPaymentMethod: 'paypal', + plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION, +}) || PLUS_PAYPAL_STEP_DEFINITIONS; const PLUS_PAYPAL_PHONE_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, @@ -127,6 +136,12 @@ const PLUS_GOPAY_SUB2API_SESSION_STEP_DEFINITIONS = self.MultiPageStepDefinition plusPaymentMethod: 'gopay', plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION, }) || PLUS_GOPAY_STEP_DEFINITIONS; +const PLUS_GOPAY_CPA_SESSION_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + plusModeEnabled: true, + plusPaymentMethod: 'gopay', + plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION, +}) || PLUS_GOPAY_STEP_DEFINITIONS; const PLUS_GOPAY_PHONE_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, @@ -151,6 +166,12 @@ const PLUS_GPC_SUB2API_SESSION_STEP_DEFINITIONS = self.MultiPageStepDefinitions? plusPaymentMethod: 'gpc-helper', plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION, }) || PLUS_GPC_STEP_DEFINITIONS; +const PLUS_GPC_CPA_SESSION_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + plusModeEnabled: true, + plusPaymentMethod: 'gpc-helper', + plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION, +}) || PLUS_GPC_STEP_DEFINITIONS; const PLUS_GPC_PHONE_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, @@ -187,14 +208,17 @@ const ALL_STEP_DEFINITIONS = (() => { ...NORMAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ...PLUS_PAYPAL_STEP_DEFINITIONS, ...PLUS_PAYPAL_SUB2API_SESSION_STEP_DEFINITIONS, + ...PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS, ...PLUS_PAYPAL_PHONE_STEP_DEFINITIONS, ...PLUS_PAYPAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ...PLUS_GOPAY_STEP_DEFINITIONS, ...PLUS_GOPAY_SUB2API_SESSION_STEP_DEFINITIONS, + ...PLUS_GOPAY_CPA_SESSION_STEP_DEFINITIONS, ...PLUS_GOPAY_PHONE_STEP_DEFINITIONS, ...PLUS_GOPAY_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ...PLUS_GPC_STEP_DEFINITIONS, ...PLUS_GPC_SUB2API_SESSION_STEP_DEFINITIONS, + ...PLUS_GPC_CPA_SESSION_STEP_DEFINITIONS, ...PLUS_GPC_PHONE_STEP_DEFINITIONS, ...PLUS_GPC_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ]; @@ -830,6 +854,12 @@ function getStepDefinitionsForState(state = {}) { ) { return PLUS_GPC_SUB2API_SESSION_STEP_DEFINITIONS; } + if ( + signupMethod === SIGNUP_METHOD_EMAIL + && plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION + ) { + return PLUS_GPC_CPA_SESSION_STEP_DEFINITIONS; + } return PLUS_GPC_STEP_DEFINITIONS; } if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY) { @@ -839,6 +869,12 @@ function getStepDefinitionsForState(state = {}) { ) { return PLUS_GOPAY_SUB2API_SESSION_STEP_DEFINITIONS; } + if ( + signupMethod === SIGNUP_METHOD_EMAIL + && plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION + ) { + return PLUS_GOPAY_CPA_SESSION_STEP_DEFINITIONS; + } return PLUS_GOPAY_STEP_DEFINITIONS; } if ( @@ -847,6 +883,12 @@ function getStepDefinitionsForState(state = {}) { ) { return PLUS_PAYPAL_SUB2API_SESSION_STEP_DEFINITIONS; } + if ( + signupMethod === SIGNUP_METHOD_EMAIL + && plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION + ) { + return PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS; + } return PLUS_PAYPAL_STEP_DEFINITIONS; } @@ -1798,9 +1840,13 @@ function normalizePlusPaymentMethod(value = '') { function normalizePlusAccountAccessStrategy(value = '') { const normalized = String(value || '').trim().toLowerCase(); - return normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION; + } + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION; + } + return PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; } function normalizeFiveSimCountryId(value, fallback = FIVE_SIM_COUNTRY_ID) { @@ -2977,9 +3023,7 @@ function normalizePersistentSettingValue(key, value) { case 'plusPaymentMethod': return normalizePlusPaymentMethod(value); case 'plusAccountAccessStrategy': - return String(value || '').trim().toLowerCase() === 'sub2api_codex_session' - ? 'sub2api_codex_session' - : 'oauth'; + return normalizePlusAccountAccessStrategy(value); case 'paypalEmail': return String(value || '').trim(); case 'paypalPassword': @@ -10361,6 +10405,7 @@ const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ 'paypal-approve', 'plus-checkout-return', 'sub2api-session-import', + 'cpa-session-import', 'oauth-login', 'fetch-login-code', 'post-login-phone-verification', @@ -11460,6 +11505,7 @@ const AUTO_RUN_NODE_DELAYS = Object.freeze({ 'paypal-approve': 2000, 'plus-checkout-return': 1000, 'sub2api-session-import': 0, + 'cpa-session-import': 0, 'oauth-login': 2000, 'fetch-login-code': 2000, 'confirm-oauth': 1000, @@ -13280,6 +13326,19 @@ const sub2ApiSessionImportExecutor = self.MultiPageBackgroundSub2ApiSessionImpor waitForTabCompleteUntilStopped, DEFAULT_SUB2API_GROUP_NAME, }); +const cpaSessionImportExecutor = self.MultiPageBackgroundCpaSessionImport?.createCpaSessionImportExecutor({ + addLog, + chrome, + completeNodeFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + registerTab, + sendTabMessageUntilStopped, + sleepWithStop, + throwIfStopped, + waitForTabCompleteUntilStopped, +}); const kiroRegisterRunner = self.MultiPageBackgroundKiroRegisterRunner?.createKiroRegisterRunner({ addLog, chrome, @@ -13429,6 +13488,7 @@ const stepExecutorsByKey = { : payPalApproveExecutor.executePayPalApprove(state), 'plus-checkout-return': (state) => plusReturnConfirmExecutor.executePlusReturnConfirm(state), 'sub2api-session-import': (state) => sub2ApiSessionImportExecutor.executeSub2ApiSessionImport(state), + 'cpa-session-import': (state) => cpaSessionImportExecutor.executeCpaSessionImport(state), 'oauth-login': (state) => step7Executor.executeStep7(state), 'fetch-login-code': (state) => step8Executor.executeStep8(state), 'post-login-phone-verification': (state) => step8Executor.executePostLoginPhoneVerification(state), diff --git a/background/cpa-api.js b/background/cpa-api.js new file mode 100644 index 0000000..7cfd6b2 --- /dev/null +++ b/background/cpa-api.js @@ -0,0 +1,507 @@ +(function attachBackgroundCpaApi(root, factory) { + root.MultiPageBackgroundCpaApi = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundCpaApiModule() { + function createCpaApi(deps = {}) { + const { + addLog = async () => {}, + fetchImpl = (...args) => fetch(...args), + } = deps; + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function firstNonEmpty(...values) { + for (const value of values) { + const normalized = normalizeString(value); + if (normalized) { + return normalized; + } + } + return ''; + } + + function normalizeEmailValue(value = '') { + const email = normalizeString(value); + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : ''; + } + + function extractStateFromAuthUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + 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 candidates = [ + payload?.error, + payload?.message, + payload?.detail, + payload?.reason, + ]; + const message = candidates.map(normalizeString).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 = 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 fetchImpl(`${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); + } + } + + function decodeBase64UrlSegment(segment = '') { + const normalized = normalizeString(segment) + .replace(/-/g, '+') + .replace(/_/g, '/'); + if (!normalized) { + return ''; + } + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + try { + if (typeof Buffer !== 'undefined') { + return Buffer.from(padded, 'base64').toString('utf8'); + } + if (typeof atob === 'function') { + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + if (typeof TextDecoder !== 'undefined') { + return new TextDecoder().decode(bytes); + } + return binary; + } + } catch { + return ''; + } + return ''; + } + + function encodeBase64UrlJson(value) { + const json = JSON.stringify(value); + if (typeof Buffer !== 'undefined') { + return Buffer.from(json, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + const bytes = new TextEncoder().encode(json); + let binary = ''; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + + function parseJwtPayload(token = '') { + const normalized = normalizeString(token); + if (!normalized) { + return null; + } + const parts = normalized.split('.'); + if (parts.length < 2) { + return null; + } + try { + return JSON.parse(decodeBase64UrlSegment(parts[1])); + } catch { + return null; + } + } + + function getOpenAiAuthSection(payload) { + if (!isPlainObject(payload)) { + return {}; + } + const auth = payload['https://api.openai.com/auth']; + return isPlainObject(auth) ? auth : {}; + } + + function getOpenAiProfileSection(payload) { + if (!isPlainObject(payload)) { + return {}; + } + const profile = payload['https://api.openai.com/profile']; + return isPlainObject(profile) ? profile : {}; + } + + function normalizeTimestamp(value) { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value.toISOString(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + const milliseconds = value > 1e11 ? value : value * 1000; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); + } + if (typeof value !== 'string' || !value.trim()) { + return ''; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); + } + + function timestampFromUnixSeconds(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return ''; + } + const date = new Date(numeric * 1000); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); + } + + function epochSecondsFromValue(value) { + if (value === undefined || value === null || value === '') { + return 0; + } + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return Math.trunc(numeric > 1e11 ? numeric / 1000 : numeric); + } + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? Math.trunc(parsed / 1000) : 0; + } + + function buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) { + const normalizedAccountId = normalizeString(accountId); + if (!normalizedAccountId) { + return ''; + } + const now = Math.trunc(Date.now() / 1000); + const expires = epochSecondsFromValue(expiresAt) || now + 90 * 24 * 60 * 60; + const authInfo = { chatgpt_account_id: normalizedAccountId }; + + if (planType) { + authInfo.chatgpt_plan_type = normalizeString(planType); + } + if (userId) { + authInfo.chatgpt_user_id = normalizeString(userId); + authInfo.user_id = normalizeString(userId); + } + + const payload = { + iat: now, + exp: expires, + 'https://api.openai.com/auth': authInfo, + }; + if (email) { + payload.email = normalizeString(email); + } + + return `${encodeBase64UrlJson({ alg: 'none', typ: 'JWT', cpa_synthetic: true })}.${encodeBase64UrlJson(payload)}.synthetic`; + } + + function normalizePlanTypeForFileName(planType = '') { + return normalizeString(planType) + .split(/[^a-zA-Z0-9]+/) + .map((part) => part.trim().toLowerCase()) + .filter(Boolean) + .join('-'); + } + + function sanitizeFileSegment(value = '', fallback = 'chatgpt-session') { + const normalized = normalizeString(value) + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized || fallback; + } + + function buildCpaAuthFileName(metadata = {}) { + const email = sanitizeFileSegment(metadata.email || ''); + const planType = normalizePlanTypeForFileName(metadata.planType || ''); + const accountId = sanitizeFileSegment(metadata.accountId || ''); + if (email && planType) { + return `codex-${email}-${planType}.json`; + } + if (email) { + return `codex-${email}.json`; + } + if (accountId && planType) { + return `codex-${accountId}-${planType}.json`; + } + if (accountId) { + return `codex-${accountId}.json`; + } + return `codex-${Date.now()}.json`; + } + + function buildCpaSessionAuthJson(state = {}, options = {}) { + const session = isPlainObject(state?.session) ? state.session : {}; + const accessToken = normalizeString(state?.accessToken || session?.accessToken); + if (!accessToken) { + throw new Error('未读取到可导入的 ChatGPT accessToken。'); + } + + const inputIdToken = firstNonEmpty( + state?.idToken, + state?.id_token, + session?.idToken, + session?.id_token + ); + const refreshToken = firstNonEmpty( + state?.refreshToken, + state?.refresh_token, + session?.refreshToken, + session?.refresh_token + ); + const sessionToken = firstNonEmpty( + state?.sessionToken, + state?.session_token, + session?.sessionToken, + session?.session_token + ); + const accessPayload = parseJwtPayload(accessToken); + const idPayload = parseJwtPayload(inputIdToken); + const accessAuth = getOpenAiAuthSection(accessPayload); + const idAuth = getOpenAiAuthSection(idPayload); + const profile = getOpenAiProfileSection(accessPayload); + const expiresAt = firstNonEmpty( + timestampFromUnixSeconds(accessPayload?.exp), + normalizeTimestamp(session?.expires), + normalizeTimestamp(session?.expiresAt), + normalizeTimestamp(session?.expired), + normalizeTimestamp(session?.expires_at) + ); + const accountIdentifierEmail = normalizeString(state?.accountIdentifierType).toLowerCase() === 'email' + ? normalizeEmailValue(state?.accountIdentifier) + : ''; + const email = firstNonEmpty( + normalizeEmailValue(session?.user?.email), + normalizeEmailValue(session?.email), + normalizeEmailValue(state?.email), + accountIdentifierEmail, + normalizeEmailValue(profile?.email), + normalizeEmailValue(idPayload?.email), + normalizeEmailValue(accessPayload?.email) + ); + const accountId = firstNonEmpty( + session?.account?.id, + session?.account_id, + accessAuth?.chatgpt_account_id, + idAuth?.chatgpt_account_id + ); + const userId = firstNonEmpty( + session?.user?.id, + session?.user_id, + accessAuth?.chatgpt_user_id, + accessAuth?.user_id, + idAuth?.chatgpt_user_id, + idAuth?.user_id + ); + const planType = firstNonEmpty( + session?.account?.planType, + session?.account?.plan_type, + session?.planType, + session?.plan_type, + accessAuth?.chatgpt_plan_type, + idAuth?.chatgpt_plan_type + ); + const exportedAt = normalizeTimestamp(options.now || new Date()) || new Date().toISOString(); + const syntheticIdToken = inputIdToken + ? '' + : buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt); + const idToken = inputIdToken || syntheticIdToken; + const authJson = Object.fromEntries( + Object.entries({ + type: 'codex', + account_id: accountId, + chatgpt_account_id: accountId, + email, + name: firstNonEmpty(email, state?.email, 'ChatGPT Account'), + plan_type: planType, + chatgpt_plan_type: planType, + id_token: idToken, + id_token_synthetic: syntheticIdToken ? true : undefined, + access_token: accessToken, + refresh_token: refreshToken || '', + session_token: sessionToken, + last_refresh: exportedAt, + expired: expiresAt, + disabled: session?.disabled === true ? true : undefined, + }).filter(([, value]) => value !== undefined && value !== null && value !== '') + ); + + return { + authJson, + accountId, + email, + expiresAt, + fileName: buildCpaAuthFileName({ email, planType, accountId }), + hasRefreshToken: Boolean(refreshToken), + }; + } + + async function logWithOptions(message, level = 'info', options = {}) { + await addLog(message, level, options.logOptions || {}); + } + + async function requestOAuthUrl(state, options = {}) { + const managementKey = normalizeString(state?.vpsPassword); + if (!managementKey) { + throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。'); + } + const origin = deriveCpaManagementOrigin(state?.vpsUrl); + const result = await fetchCpaManagementJson(origin, '/v0/management/codex-auth-url', { + method: 'GET', + managementKey, + timeoutMs: options.timeoutMs, + }); + const oauthUrl = firstNonEmpty( + result?.url, + result?.auth_url, + result?.authUrl, + result?.data?.url, + result?.data?.auth_url, + result?.data?.authUrl + ); + const oauthState = firstNonEmpty( + result?.state, + result?.auth_state, + result?.authState, + result?.data?.state, + result?.data?.auth_state, + result?.data?.authState, + extractStateFromAuthUrl(oauthUrl) + ); + + if (!oauthUrl || !oauthUrl.startsWith('http')) { + throw new Error('CPA 管理接口未返回有效的 auth_url。'); + } + + return { + oauthUrl, + cpaOAuthState: oauthState || null, + cpaManagementOrigin: origin, + }; + } + + async function submitOAuthCallback(state, callbackUrl, options = {}) { + const managementKey = normalizeString(state?.vpsPassword); + if (!managementKey) { + throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。'); + } + const origin = normalizeString(state?.cpaManagementOrigin) || deriveCpaManagementOrigin(state?.vpsUrl); + const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', { + method: 'POST', + managementKey, + timeoutMs: options.timeoutMs, + body: { + provider: 'codex', + redirect_url: normalizeString(callbackUrl), + }, + }); + return { + localhostUrl: normalizeString(callbackUrl), + verifiedStatus: firstNonEmpty(result?.message, result?.status_message, 'CPA 已通过接口提交回调'), + }; + } + + async function importCurrentChatGptSession(state = {}, options = {}) { + const logLabel = normalizeString(options.logLabel) || 'CPA 会话导入'; + const managementKey = normalizeString(state?.vpsPassword); + if (!managementKey) { + throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。'); + } + const origin = deriveCpaManagementOrigin(state?.vpsUrl); + const sessionAuth = buildCpaSessionAuthJson(state, options); + + await logWithOptions(`${logLabel}:正在通过 CPA 管理接口导入当前 ChatGPT 会话...`, 'info', options); + if (!sessionAuth.hasRefreshToken) { + await logWithOptions(`${logLabel}:未包含 refresh_token,access_token 过期后无法自动续期。`, 'warn', options); + } + + await fetchCpaManagementJson(origin, `/v0/management/auth-files?name=${encodeURIComponent(sessionAuth.fileName)}`, { + method: 'POST', + managementKey, + timeoutMs: options.importTimeoutMs || options.timeoutMs, + body: sessionAuth.authJson, + }); + + const verifiedStatus = sessionAuth.email + ? `CPA 会话导入完成:${sessionAuth.email}` + : `CPA 会话导入完成:${sessionAuth.fileName}`; + await logWithOptions(verifiedStatus, 'ok', options); + return { + verifiedStatus, + cpaImportedFileName: sessionAuth.fileName, + cpaImportedEmail: sessionAuth.email || null, + }; + } + + return { + buildCpaSessionAuthJson, + deriveCpaManagementOrigin, + fetchCpaManagementJson, + importCurrentChatGptSession, + requestOAuthUrl, + submitOAuthCallback, + }; + } + + return { + createCpaApi, + }; +}); diff --git a/background/message-router.js b/background/message-router.js index be5fe15..cebebce 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -593,9 +593,14 @@ } function normalizePlusAccountAccessStrategyForDisplay(value = '') { - return String(value || '').trim().toLowerCase() === 'sub2api_codex_session' - ? 'sub2api_codex_session' - : 'oauth'; + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sub2api_codex_session') { + return 'sub2api_codex_session'; + } + if (normalized === 'cpa_codex_session') { + return 'cpa_codex_session'; + } + return 'oauth'; } function getPlusAccountAccessStrategyLabel(value = '') { @@ -604,6 +609,27 @@ : 'OAuth'; } + function getPlusAccountAccessStrategyLabel(value = '', targetId = '') { + const strategy = normalizePlusAccountAccessStrategyForDisplay(value); + const normalizedTargetId = String(targetId || '').trim().toLowerCase(); + if (strategy === 'sub2api_codex_session') { + return '导入当前 ChatGPT 会话到 SUB2API'; + } + if (strategy === 'cpa_codex_session') { + return '导入当前 ChatGPT 会话到 CPA'; + } + if (normalizedTargetId === 'cpa') { + return '通过 OAuth 回调创建 CPA 账号'; + } + if (normalizedTargetId === 'sub2api') { + return '通过 OAuth 回调创建 SUB2API 账号'; + } + if (normalizedTargetId === 'codex2api') { + return '通过 OAuth 回调创建 Codex2API 账号'; + } + return 'OAuth'; + } + async function handlePlatformVerifyStepData(payload) { if (payload.localhostUrl) { await closeLocalhostCallbackTabs(payload.localhostUrl); @@ -1561,7 +1587,12 @@ stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal' ); const selectedPlusAccountAccessStrategy = getPlusAccountAccessStrategyLabel( - stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth' + stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth', + stateUpdates.panelMode + ?? currentState?.panelMode + ?? stateUpdates.openaiIntegrationTargetId + ?? currentState?.openaiIntegrationTargetId + ?? 'cpa' ); await addLog( Boolean(updates.plusModeEnabled) @@ -1576,7 +1607,12 @@ await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info'); } else if (plusAccountAccessStrategyChanged && nextPlusModeEnabled) { const selectedPlusAccountAccessStrategy = getPlusAccountAccessStrategyLabel( - stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth' + stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth', + stateUpdates.panelMode + ?? currentState?.panelMode + ?? stateUpdates.openaiIntegrationTargetId + ?? currentState?.openaiIntegrationTargetId + ?? 'cpa' ); await addLog(`Plus 账号接入策略已切换为 ${selectedPlusAccountAccessStrategy},已更新对应的 Plus 尾链。`, 'info'); } diff --git a/background/steps/cpa-session-import.js b/background/steps/cpa-session-import.js new file mode 100644 index 0000000..67891a9 --- /dev/null +++ b/background/steps/cpa-session-import.js @@ -0,0 +1,276 @@ +(function attachBackgroundCpaSessionImport(root, factory) { + root.MultiPageBackgroundCpaSessionImport = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundCpaSessionImportModule() { + const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; + const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/plus-checkout.js']; + + function createCpaSessionImportExecutor(deps = {}) { + const { + addLog: rawAddLog = async () => {}, + chrome, + completeNodeFromBackground, + ensureContentScriptReadyOnTabUntilStopped, + getTabId, + isTabAlive, + registerTab, + sendTabMessageUntilStopped, + sleepWithStop = async () => {}, + throwIfStopped = () => {}, + waitForTabCompleteUntilStopped = async () => {}, + } = deps; + + let cpaApi = null; + + function addStepLog(step, message, level = 'info') { + return rawAddLog(message, level, { + step, + stepKey: 'cpa-session-import', + }); + } + + function getCpaApi() { + if (cpaApi) { + return cpaApi; + } + const factory = deps.createCpaApi + || self.MultiPageBackgroundCpaApi?.createCpaApi; + if (typeof factory !== 'function') { + throw new Error('CPA 接口模块未加载,无法导入当前 ChatGPT 会话。'); + } + cpaApi = factory({ + addLog: rawAddLog, + }); + return cpaApi; + } + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function resolveVisibleStep(state = {}) { + const visibleStep = Math.floor(Number(state?.visibleStep) || 0); + return visibleStep > 0 ? visibleStep : 10; + } + + function isSupportedChatGptSessionUrl(url = '') { + try { + const parsed = new URL(String(url || '')); + if (!/^https?:$/i.test(parsed.protocol)) { + return false; + } + const hostname = String(parsed.hostname || '').trim().toLowerCase(); + return /(^|\.)chatgpt\.com$/.test(hostname) + || hostname === 'chat.openai.com' + || /(^|\.)openai\.com$/.test(hostname); + } catch { + return false; + } + } + + function getSessionTabHostPriority(url = '') { + try { + const hostname = String(new URL(String(url || '')).hostname || '').trim().toLowerCase(); + if (/(^|\.)chatgpt\.com$/.test(hostname)) { + return 0; + } + if (hostname === 'chat.openai.com') { + return 1; + } + if (/(^|\.)openai\.com$/.test(hostname)) { + return 2; + } + } catch { + return Number.POSITIVE_INFINITY; + } + return Number.POSITIVE_INFINITY; + } + + function getSessionTabActivityPriority(tab = {}) { + if (tab?.active && tab?.currentWindow) { + return 0; + } + if (tab?.active) { + return 1; + } + return 2; + } + + function pickPreferredSessionTab(tabs = []) { + const candidates = (Array.isArray(tabs) ? tabs : []) + .filter((tab) => Number.isInteger(tab?.id) && isSupportedChatGptSessionUrl(tab.url)); + if (!candidates.length) { + return null; + } + + return candidates.reduce((best, candidate) => { + if (!best) { + return candidate; + } + + const candidateHostPriority = getSessionTabHostPriority(candidate.url); + const bestHostPriority = getSessionTabHostPriority(best.url); + if (candidateHostPriority !== bestHostPriority) { + return candidateHostPriority < bestHostPriority ? candidate : best; + } + + const candidateActivityPriority = getSessionTabActivityPriority(candidate); + const bestActivityPriority = getSessionTabActivityPriority(best); + if (candidateActivityPriority !== bestActivityPriority) { + return candidateActivityPriority < bestActivityPriority ? candidate : best; + } + + const candidateLastAccessed = Number(candidate?.lastAccessed) || 0; + const bestLastAccessed = Number(best?.lastAccessed) || 0; + if (candidateLastAccessed !== bestLastAccessed) { + return candidateLastAccessed > bestLastAccessed ? candidate : best; + } + + return Number(candidate.id) < Number(best.id) ? candidate : best; + }, null); + } + + async function readSupportedSessionTab(tabId) { + const numericTabId = Number(tabId) || 0; + if (!numericTabId || !chrome?.tabs?.get) { + return null; + } + + const tab = await chrome.tabs.get(numericTabId).catch(() => null); + return tab?.id && isSupportedChatGptSessionUrl(tab.url) + ? tab + : null; + } + + async function findFallbackSessionTab() { + if (!chrome?.tabs?.query) { + return null; + } + + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }).catch(() => []); + const activeMatch = pickPreferredSessionTab(activeTabs); + const allTabs = await chrome.tabs.query({}).catch(() => []); + const globalMatch = pickPreferredSessionTab(allTabs); + return pickPreferredSessionTab([activeMatch, globalMatch]); + } + + async function resolveSessionTabId(state = {}) { + const registeredTabId = typeof getTabId === 'function' + ? await getTabId(PLUS_CHECKOUT_SOURCE) + : null; + if (registeredTabId && typeof isTabAlive === 'function' && await isTabAlive(PLUS_CHECKOUT_SOURCE)) { + const registeredTab = await readSupportedSessionTab(registeredTabId); + if (registeredTab?.id) { + return registeredTab.id; + } + } + + const storedTabId = Number(state?.plusCheckoutTabId) || 0; + const storedTab = await readSupportedSessionTab(storedTabId); + if (storedTab?.id) { + if (typeof registerTab === 'function') { + await registerTab(PLUS_CHECKOUT_SOURCE, storedTab.id); + } + return storedTab.id; + } + + const fallbackTab = await findFallbackSessionTab(); + if (fallbackTab?.id) { + if (typeof registerTab === 'function') { + await registerTab(PLUS_CHECKOUT_SOURCE, fallbackTab.id); + } + return fallbackTab.id; + } + + throw new Error('未找到可读取 ChatGPT 会话的标签页,请先打开一个已登录的 ChatGPT / OpenAI 页面,或完成当前 Plus 支付链路。'); + } + + async function getResolvedSessionTab(tabId, visibleStep) { + const tab = await chrome?.tabs?.get?.(tabId).catch(() => null); + if (!tab?.id) { + throw new Error(`步骤 ${visibleStep}:ChatGPT 会话标签页不存在或已关闭,无法继续导入 CPA。`); + } + if (!isSupportedChatGptSessionUrl(tab.url)) { + throw new Error(`步骤 ${visibleStep}:当前标签页不在 ChatGPT / OpenAI 页面,无法读取当前登录会话。`); + } + return tab; + } + + async function readCurrentChatGptSession(tabId, visibleStep) { + await waitForTabCompleteUntilStopped(tabId); + await sleepWithStop(1000); + await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, { + inject: PLUS_CHECKOUT_INJECT_FILES, + injectSource: PLUS_CHECKOUT_SOURCE, + logMessage: `步骤 ${visibleStep}:正在等待 ChatGPT 会话页完成加载,再继续读取当前登录会话...`, + }); + + const sessionResult = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, { + type: 'PLUS_CHECKOUT_GET_STATE', + source: 'background', + payload: { + includeSession: true, + includeAccessToken: true, + }, + }); + if (sessionResult?.error) { + throw new Error(sessionResult.error); + } + + const session = sessionResult?.session && typeof sessionResult.session === 'object' && !Array.isArray(sessionResult.session) + ? sessionResult.session + : null; + const accessToken = normalizeString( + sessionResult?.accessToken + || session?.accessToken + ); + if (!session && !accessToken) { + throw new Error(`步骤 ${visibleStep}:未读取到有效的 ChatGPT 会话或 accessToken,请确认当前标签页仍处于已登录状态。`); + } + + return { + session, + accessToken, + }; + } + + async function executeCpaSessionImport(state = {}) { + throwIfStopped(); + const visibleStep = resolveVisibleStep(state); + const api = getCpaApi(); + + await addStepLog(visibleStep, '正在定位当前 ChatGPT 会话页并准备导入 CPA...', 'info'); + const tabId = await resolveSessionTabId(state); + const tab = await getResolvedSessionTab(tabId, visibleStep); + if (chrome?.tabs?.update) { + await chrome.tabs.update(tab.id, { active: true }).catch(() => {}); + } + + await addStepLog(visibleStep, '正在读取当前 ChatGPT 登录会话...', 'info'); + const sessionState = await readCurrentChatGptSession(tab.id, visibleStep); + throwIfStopped(); + + const result = await api.importCurrentChatGptSession({ + ...state, + session: sessionState.session, + accessToken: sessionState.accessToken, + }, { + visibleStep, + logLabel: `步骤 ${visibleStep}`, + logOptions: { step: visibleStep, stepKey: 'cpa-session-import' }, + timeoutMs: 120000, + importTimeoutMs: 120000, + }); + + await completeNodeFromBackground(state?.nodeId || 'cpa-session-import', result); + } + + return { + executeCpaSessionImport, + isSupportedChatGptSessionUrl, + }; + } + + return { + createCpaSessionImportExecutor, + }; +}); diff --git a/background/steps/gopay-manual-confirm.js b/background/steps/gopay-manual-confirm.js index e9942cf..c7d6f7d 100644 --- a/background/steps/gopay-manual-confirm.js +++ b/background/steps/gopay-manual-confirm.js @@ -4,14 +4,28 @@ const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; const GOPAY_CONFIRM_NODE_ID = 'gopay-subscription-confirm'; const SUB2API_SESSION_IMPORT_NODE_ID = 'sub2api-session-import'; + const CPA_SESSION_IMPORT_NODE_ID = 'cpa-session-import'; const DEFAULT_CONFIRM_TITLE = 'GoPay 订阅确认'; const OAUTH_CONTINUATION_LABEL = 'OAuth 登录'; const SUB2API_SESSION_CONTINUATION_LABEL = '导入当前 ChatGPT 会话到 SUB2API'; + const CPA_SESSION_CONTINUATION_LABEL = '导入当前 ChatGPT 会话到 CPA'; + function normalizeString(value = '') { return String(value || '').trim(); } + function getContinuationActionLabelForNodeId(nodeId = '') { + const normalizedNodeId = normalizeString(nodeId); + if (normalizedNodeId === SUB2API_SESSION_IMPORT_NODE_ID) { + return SUB2API_SESSION_CONTINUATION_LABEL; + } + if (normalizedNodeId === CPA_SESSION_IMPORT_NODE_ID) { + return CPA_SESSION_CONTINUATION_LABEL; + } + return OAUTH_CONTINUATION_LABEL; + } + function getContinuationActionLabel(state = {}, options = {}) { const { getNodeIdsForState = null } = options; if (typeof getNodeIdsForState === 'function') { @@ -24,11 +38,16 @@ ? normalizeString(nodeIds[currentNodeIndex + 1]) : ''; - if ( - nextNodeId === SUB2API_SESSION_IMPORT_NODE_ID - || (currentNodeIndex < 0 && nodeIds.includes(SUB2API_SESSION_IMPORT_NODE_ID)) - ) { - return SUB2API_SESSION_CONTINUATION_LABEL; + if (nextNodeId) { + return getContinuationActionLabelForNodeId(nextNodeId); + } + if (currentNodeIndex < 0) { + if (nodeIds.includes(SUB2API_SESSION_IMPORT_NODE_ID)) { + return SUB2API_SESSION_CONTINUATION_LABEL; + } + if (nodeIds.includes(CPA_SESSION_IMPORT_NODE_ID)) { + return CPA_SESSION_CONTINUATION_LABEL; + } } } return OAUTH_CONTINUATION_LABEL; diff --git a/data/step-definitions.js b/data/step-definitions.js index 7a6048f..75c495c 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -7,6 +7,7 @@ const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; + const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const PLUS_PAYMENT_STEP_KEY = 'paypal-approve'; const SIGNUP_METHOD_EMAIL = 'email'; const SIGNUP_METHOD_PHONE = 'phone'; @@ -110,21 +111,51 @@ ]; } - function normalizePlusAccountAccessStrategy(value = '') { - return String(value || '').trim().toLowerCase() === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; + function createCpaSessionImportTail(startId, startOrder) { + const id = Number(startId) || 10; + const order = Number(startOrder) || id * 10; + return [ + { + id, + order, + key: 'cpa-session-import', + title: '导入当前 ChatGPT 会话到 CPA', + sourceId: 'vps-panel', + driverId: 'background/cpa-session-import', + command: 'cpa-session-import', + }, + ]; } - function shouldUseSub2ApiSessionImportTail(options = {}, signupMethod = SIGNUP_METHOD_EMAIL) { - return signupMethod === SIGNUP_METHOD_EMAIL - && normalizePlusAccountAccessStrategy(options?.plusAccountAccessStrategy) - === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION; + function normalizePlusAccountAccessStrategy(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION; + } + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION; + } + return PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; + } + + function resolvePlusSessionImportTail(options = {}, signupMethod = SIGNUP_METHOD_EMAIL) { + if (signupMethod !== SIGNUP_METHOD_EMAIL) { + return null; + } + const strategy = normalizePlusAccountAccessStrategy(options?.plusAccountAccessStrategy); + if (strategy === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { + return createSub2ApiSessionImportTail; + } + if (strategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return createCpaSessionImportTail; + } + return null; } function createOpenAiSteps(prefixSteps, startId, startOrder, signupMethod = SIGNUP_METHOD_EMAIL, options = {}) { - const tailSteps = shouldUseSub2ApiSessionImportTail(options, signupMethod) - ? createSub2ApiSessionImportTail(startId, startOrder) + const sessionTailFactory = resolvePlusSessionImportTail(options, signupMethod); + const tailSteps = sessionTailFactory + ? sessionTailFactory(startId, startOrder) : createOpenAiAuthTail(startId, startOrder, signupMethod, options); return [ ...prefixSteps, @@ -143,6 +174,13 @@ SIGNUP_METHOD_EMAIL, { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION } ); + const PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS = createOpenAiSteps( + PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS, + 10, + 100, + SIGNUP_METHOD_EMAIL, + { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION } + ); const PLUS_PAYPAL_PHONE_STEP_DEFINITIONS = createOpenAiSteps(PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE); const PLUS_PAYPAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createOpenAiSteps(PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true }); const PLUS_GOPAY_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GOPAY_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_EMAIL); @@ -153,6 +191,13 @@ SIGNUP_METHOD_EMAIL, { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION } ); + const PLUS_GOPAY_CPA_SESSION_STEP_DEFINITIONS = createOpenAiSteps( + PLUS_GOPAY_PREFIX_STEP_DEFINITIONS, + 10, + 100, + SIGNUP_METHOD_EMAIL, + { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION } + ); const PLUS_GOPAY_PHONE_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GOPAY_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE); const PLUS_GOPAY_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GOPAY_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true }); const PLUS_GPC_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GPC_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_EMAIL); @@ -163,6 +208,13 @@ SIGNUP_METHOD_EMAIL, { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION } ); + const PLUS_GPC_CPA_SESSION_STEP_DEFINITIONS = createOpenAiSteps( + PLUS_GPC_PREFIX_STEP_DEFINITIONS, + 10, + 100, + SIGNUP_METHOD_EMAIL, + { plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION } + ); const PLUS_GPC_PHONE_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GPC_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE); const PLUS_GPC_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GPC_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true }); const KIRO_STEP_DEFINITIONS = [ @@ -308,6 +360,9 @@ if (plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { return PLUS_GPC_SUB2API_SESSION_STEP_DEFINITIONS; } + if (plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return PLUS_GPC_CPA_SESSION_STEP_DEFINITIONS; + } return PLUS_GPC_STEP_DEFINITIONS; } if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY) { @@ -319,6 +374,9 @@ if (plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { return PLUS_GOPAY_SUB2API_SESSION_STEP_DEFINITIONS; } + if (plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return PLUS_GOPAY_CPA_SESSION_STEP_DEFINITIONS; + } return PLUS_GOPAY_STEP_DEFINITIONS; } if (signupMethod === SIGNUP_METHOD_PHONE) { @@ -329,6 +387,9 @@ if (plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { return PLUS_PAYPAL_SUB2API_SESSION_STEP_DEFINITIONS; } + if (plusAccountAccessStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS; + } return PLUS_PAYPAL_STEP_DEFINITIONS; } @@ -364,14 +425,17 @@ ...NORMAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ...PLUS_PAYPAL_STEP_DEFINITIONS, ...PLUS_PAYPAL_SUB2API_SESSION_STEP_DEFINITIONS, + ...PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS, ...PLUS_PAYPAL_PHONE_STEP_DEFINITIONS, ...PLUS_PAYPAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ...PLUS_GOPAY_STEP_DEFINITIONS, ...PLUS_GOPAY_SUB2API_SESSION_STEP_DEFINITIONS, + ...PLUS_GOPAY_CPA_SESSION_STEP_DEFINITIONS, ...PLUS_GOPAY_PHONE_STEP_DEFINITIONS, ...PLUS_GOPAY_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ...PLUS_GPC_STEP_DEFINITIONS, ...PLUS_GPC_SUB2API_SESSION_STEP_DEFINITIONS, + ...PLUS_GPC_CPA_SESSION_STEP_DEFINITIONS, ...PLUS_GPC_PHONE_STEP_DEFINITIONS, ...PLUS_GPC_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS, ]) { diff --git a/shared/flow-capabilities.js b/shared/flow-capabilities.js index 67eb5dc..a1fd7d1 100644 --- a/shared/flow-capabilities.js +++ b/shared/flow-capabilities.js @@ -10,6 +10,7 @@ const SIGNUP_METHOD_PHONE = 'phone'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; + const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const VALID_OPENAI_TARGET_IDS = Array.isArray(flowRegistryApi.OPENAI_TARGET_IDS) ? flowRegistryApi.OPENAI_TARGET_IDS.slice() : ['cpa', 'sub2api', 'codex2api']; @@ -71,7 +72,10 @@ cpa: Object.freeze({ supportsPhoneSignup: true, requiresPhoneSignupWarning: true, - supportedPlusAccountAccessStrategies: Object.freeze([PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH]), + supportedPlusAccountAccessStrategies: Object.freeze([ + PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH, + PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION, + ]), }), sub2api: Object.freeze({ supportsPhoneSignup: true, @@ -127,9 +131,14 @@ } function normalizePlusAccountAccessStrategy(value = '') { - return String(value || '').trim().toLowerCase() === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION; + } + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION; + } + return PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; } function normalizeOpenAiTargetList(values = []) { @@ -618,6 +627,7 @@ OPENAI_TARGET_CAPABILITIES, PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH, PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION, + PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION, SIGNUP_METHOD_EMAIL, SIGNUP_METHOD_PHONE, VALID_OPENAI_TARGET_IDS, diff --git a/shared/settings-schema.js b/shared/settings-schema.js index 6e6dadf..2f39824 100644 --- a/shared/settings-schema.js +++ b/shared/settings-schema.js @@ -47,11 +47,16 @@ const normalizeTargetId = typeof flowRegistry.normalizeTargetId === 'function' ? flowRegistry.normalizeTargetId : ((_flowId, value = '', fallback = '') => String(value || fallback || '').trim().toLowerCase()); - const normalizePlusAccountAccessStrategy = (value = '') => ( - String(value || '').trim().toLowerCase() === 'sub2api_codex_session' - ? 'sub2api_codex_session' - : 'oauth' - ); + const normalizePlusAccountAccessStrategy = (value = '') => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sub2api_codex_session') { + return 'sub2api_codex_session'; + } + if (normalized === 'cpa_codex_session') { + return 'cpa_codex_session'; + } + return 'oauth'; + }; function buildDefaultSettingsState() { return { diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index cc178fa..c4c87b3 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -306,6 +306,7 @@
当前来源仅支持 OAuth diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index b50665c..624d604 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -547,6 +547,7 @@ const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; const SIGNUP_METHOD_EMAIL = 'email'; const SIGNUP_METHOD_PHONE = 'phone'; @@ -2853,9 +2854,13 @@ function normalizePlusPaymentMethod(value = '') { function normalizePlusAccountAccessStrategy(value = '') { const normalized = String(value || '').trim().toLowerCase(); - return normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION - : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION; + } + if (normalized === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION; + } + return PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; } function getSelectedPlusPaymentMethod(state = latestState) { @@ -2886,6 +2891,58 @@ function getRequestedPlusAccountAccessStrategy(state = latestState) { return fallbackStrategy; } +function normalizePlusStrategyTargetId(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sub2api') { + return 'sub2api'; + } + if (normalized === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; +} + +function getPlusAccountAccessStrategyContinuationLabel(strategy = '', targetId = '') { + const normalizedStrategy = normalizePlusAccountAccessStrategy(strategy); + if (normalizedStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { + return '导入当前 ChatGPT 会话到 SUB2API'; + } + if (normalizedStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return '导入当前 ChatGPT 会话到 CPA'; + } + return 'OAuth 登录'; +} + +function getPlusAccountAccessStrategyDescription(strategy = '', targetId = '') { + const normalizedStrategy = normalizePlusAccountAccessStrategy(strategy); + const normalizedTargetId = normalizePlusStrategyTargetId(targetId); + if (normalizedStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION) { + return '复用当前 Plus 已登录会话,直接导入到 SUB2API'; + } + if (normalizedStrategy === PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION) { + return '复用当前 Plus 已登录会话,直接导入到 CPA'; + } + if (normalizedTargetId === 'sub2api') { + return '通过 OAuth 回调创建 SUB2API 账号'; + } + if (normalizedTargetId === 'codex2api') { + return '通过 OAuth 回调创建 Codex2API 账号'; + } + return '通过 OAuth 回调创建 CPA 账号'; +} + +function resolvePlusManualContinuationActionLabelFromState(state = latestState) { + const activeFlowId = String(state?.activeFlowId || state?.flowId || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase(); + const signupMethod = normalizeSignupMethod(state?.resolvedSignupMethod || state?.signupMethod || DEFAULT_SIGNUP_METHOD); + const plusModeEnabled = state?.plusModeEnabled === undefined ? true : Boolean(state?.plusModeEnabled); + const targetId = normalizePlusStrategyTargetId(state?.panelMode || state?.openaiIntegrationTargetId || 'cpa'); + const strategy = normalizePlusAccountAccessStrategy(state?.plusAccountAccessStrategy || DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY); + const effectiveStrategy = plusModeEnabled && activeFlowId === DEFAULT_ACTIVE_FLOW_ID && signupMethod === SIGNUP_METHOD_EMAIL + ? strategy + : PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; + return getPlusAccountAccessStrategyContinuationLabel(effectiveStrategy, targetId); +} + function normalizeGpcHelperPhoneModeValue(value = '') { const rootScope = typeof window !== 'undefined' ? window : globalThis; if (rootScope.GoPayUtils?.normalizeGpcHelperPhoneMode) { @@ -9000,7 +9057,41 @@ function updatePlusModeUI() { const sub2apiSessionStrategyValue = typeof PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION !== 'undefined' ? PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION : 'sub2api_codex_session'; + const cpaSessionStrategyValue = typeof PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION !== 'undefined' + ? PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION + : 'cpa_codex_session'; const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : paypalValue; + const resolveStrategyTargetId = typeof normalizePlusStrategyTargetId === 'function' + ? normalizePlusStrategyTargetId + : ((value = '') => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sub2api') { + return 'sub2api'; + } + if (normalized === 'codex2api') { + return 'codex2api'; + } + return 'cpa'; + }); + const describePlusAccountAccessStrategy = typeof getPlusAccountAccessStrategyDescription === 'function' + ? getPlusAccountAccessStrategyDescription + : ((strategy = '', targetId = '') => { + const normalizedStrategy = normalizePlusAccountAccessStrategy(strategy); + const normalizedTargetId = resolveStrategyTargetId(targetId); + if (normalizedStrategy === sub2apiSessionStrategyValue) { + return '复用当前 Plus 已登录会话,直接导入到 SUB2API'; + } + if (normalizedStrategy === cpaSessionStrategyValue) { + return '复用当前 Plus 已登录会话,直接导入到 CPA'; + } + if (normalizedTargetId === 'sub2api') { + return '通过 OAuth 回调创建 SUB2API 账号'; + } + if (normalizedTargetId === 'codex2api') { + return '通过 OAuth 回调创建 Codex2API 账号'; + } + return '通过 OAuth 回调创建 CPA 账号'; + }); const requestedPlusAccountAccessStrategy = typeof getRequestedPlusAccountAccessStrategy === 'function' ? getRequestedPlusAccountAccessStrategy(latestState) : normalizePlusAccountAccessStrategy(latestState?.plusAccountAccessStrategy || DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY); @@ -9038,9 +9129,20 @@ function updatePlusModeUI() { : true; const enabled = supportsPlusMode && rawEnabled; const canEditPlusAccountAccessStrategy = Boolean(capabilityState?.canEditPlusAccountAccessStrategy); + const availablePlusAccountAccessStrategies = Array.isArray(capabilityState?.availablePlusAccountAccessStrategies) + && capabilityState.availablePlusAccountAccessStrategies.length > 0 + ? capabilityState.availablePlusAccountAccessStrategies + : [oauthStrategyValue]; const effectivePlusAccountAccessStrategy = capabilityState?.effectivePlusAccountAccessStrategy || requestedPlusAccountAccessStrategy || oauthStrategyValue; + const effectiveTargetId = resolveStrategyTargetId( + capabilityState?.effectivePanelMode + || capabilityState?.effectiveTargetId + || capabilityState?.panelMode + || (typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode) + || 'cpa' + ); const method = enabled ? getSelectedPlusPaymentMethod() : defaultMethod; const gpcPhoneMode = normalizeGpcHelperPhoneModeValue( typeof selectGpcHelperPhoneMode !== 'undefined' && selectGpcHelperPhoneMode @@ -9103,6 +9205,13 @@ function updatePlusModeUI() { row.style.display = enabled ? '' : 'none'; }); if (typeof selectPlusAccountAccessStrategy !== 'undefined' && selectPlusAccountAccessStrategy) { + const availableStrategySet = new Set(availablePlusAccountAccessStrategies); + Array.from(selectPlusAccountAccessStrategy.options || []).forEach((option) => { + const optionValue = normalizePlusAccountAccessStrategy(option?.value || ''); + const optionSupported = availableStrategySet.has(optionValue); + option.hidden = enabled ? !optionSupported : false; + option.disabled = enabled ? !optionSupported : false; + }); selectPlusAccountAccessStrategy.dataset.requestedValue = requestedPlusAccountAccessStrategy; selectPlusAccountAccessStrategy.value = effectivePlusAccountAccessStrategy; selectPlusAccountAccessStrategy.disabled = !enabled || !canEditPlusAccountAccessStrategy; @@ -9121,6 +9230,24 @@ function updatePlusModeUI() { plusAccountAccessStrategyCaption.textContent = '当前来源仅支持 OAuth'; } } + if (typeof plusAccountAccessStrategyCaption !== 'undefined' && plusAccountAccessStrategyCaption) { + if (!enabled || !canEditPlusAccountAccessStrategy) { + plusAccountAccessStrategyCaption.textContent = '当前来源仅支持 OAuth'; + } else { + plusAccountAccessStrategyCaption.textContent = describePlusAccountAccessStrategy( + effectivePlusAccountAccessStrategy, + effectiveTargetId + ); + } + } + if (typeof plusAccountAccessStrategyCaption !== 'undefined' && plusAccountAccessStrategyCaption) { + plusAccountAccessStrategyCaption.textContent = !enabled || !canEditPlusAccountAccessStrategy + ? '当前来源仅支持 OAuth' + : describePlusAccountAccessStrategy( + effectivePlusAccountAccessStrategy, + effectiveTargetId + ); + } [ typeof rowPayPalAccount !== 'undefined' ? rowPayPalAccount : null, ].forEach((row) => { @@ -9649,6 +9776,60 @@ async function syncPlusManualConfirmationDialog() { } } } +async function openPlusManualConfirmationDialog(options = {}) { + const method = String(options.method || '').trim().toLowerCase(); + const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; + const continuationActionLabel = resolvePlusManualContinuationActionLabelFromState(latestState); + if (method === 'gopay-otp') { + if (!sharedFormDialog?.open) { + return null; + } + const result = await sharedFormDialog.open({ + title: String(options.title || '').trim() || 'GPC OTP 验证', + message: String(options.message || '').trim() || '请在WhatsApp里面获取验证码(耐心等待三十秒左右)', + fields: [ + { + key: 'otp', + label: 'OTP', + type: 'text', + placeholder: '请输入 OTP 验证码', + inputMode: 'numeric', + autocomplete: 'one-time-code', + required: true, + requiredMessage: '请输入 OTP 验证码。', + normalize: (value) => String(value || '').trim().replace(/[^\d]/g, ''), + validate: (value) => { + const normalized = String(value || '').trim().replace(/[^\d]/g, ''); + if (!normalized) return '请输入 OTP 验证码。'; + if (!/^\d{6}$/.test(normalized)) return 'OTP 必须是 6 位数字,请检查。'; + return ''; + }, + }, + ], + confirmLabel: '提交 OTP', + }); + return result + ? { action: 'confirm', otp: String(result.otp || '').trim().replace(/[^\d]/g, '') } + : { action: 'cancel' }; + } + + const title = String(options.title || '').trim() || (method === gopayValue ? 'GoPay subscription confirmation' : 'Manual confirmation'); + const message = String(options.message || '').trim() + || (method === gopayValue + ? 'Complete the GoPay subscription on the current page, then continue.' + : 'Finish the current manual action on the page, then continue.'); + return openActionModal({ + title, + message, + actions: [ + { id: 'cancel', label: 'Cancel', variant: 'btn-ghost' }, + { id: 'confirm', label: 'Continue', variant: 'btn-primary' }, + ], + alert: method === gopayValue + ? { text: `After confirmation, the Plus flow will continue with ${continuationActionLabel}.`, tone: 'info' } + : null, + }); +} async function clearRegistrationEmail(options = {}) { const { silent = false } = options; diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js index dcb40ab..ddc5d2d 100644 --- a/tests/background-account-history-settings.test.js +++ b/tests/background-account-history-settings.test.js @@ -55,6 +55,7 @@ test('background account history settings are normalized independently from hotm extractFunction('normalizeAccountRunHistoryHelperBaseUrl'), extractFunction('normalizeVerificationResendCount'), extractFunction('normalizePlusPaymentMethod'), + extractFunction('normalizePlusAccountAccessStrategy'), extractFunction('normalizeGpcHelperPhoneMode'), extractFunction('normalizePhoneSmsProvider'), extractFunction('normalizePhoneSmsProviderOrder'), @@ -125,6 +126,10 @@ const DEFAULT_ACTIVE_FLOW_ID = 'openai'; const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; +const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; const DEFAULT_FIVE_SIM_PRODUCT = 'openai'; const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot'; const FIVE_SIM_COUNTRY_ID = 'vietnam'; @@ -228,6 +233,7 @@ return { assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'paypal'), 'paypal'); assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'unknown'), 'paypal'); assert.equal(api.normalizePersistentSettingValue('plusAccountAccessStrategy', 'sub2api_codex_session'), 'sub2api_codex_session'); + assert.equal(api.normalizePersistentSettingValue('plusAccountAccessStrategy', 'cpa_codex_session'), 'cpa_codex_session'); assert.equal(api.normalizePersistentSettingValue('plusAccountAccessStrategy', 'unknown'), 'oauth'); assert.equal( api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gpc.qlhazycoder.top/api/checkout/start '), diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js index 0aecc9e..a013aa0 100644 --- a/tests/background-auth-chain-guard.test.js +++ b/tests/background-auth-chain-guard.test.js @@ -48,13 +48,14 @@ function extractFunction(name) { return source.slice(start, end); } -test('background auth chain set does not include SUB2API session import node', () => { +test('background auth chain set does not include Plus session import nodes', () => { const authChainStart = source.indexOf('const AUTH_CHAIN_NODE_IDS = new Set(['); const authChainEnd = source.indexOf(']);', authChainStart); const authChainBlock = source.slice(authChainStart, authChainEnd); assert.ok(authChainStart >= 0, 'expected AUTH_CHAIN_NODE_IDS block to exist'); assert.doesNotMatch(authChainBlock, /sub2api-session-import/); + assert.doesNotMatch(authChainBlock, /cpa-session-import/); }); const NODE_EXECUTE_COMPAT_HELPERS = ` diff --git a/tests/background-cpa-api.test.js b/tests/background-cpa-api.test.js new file mode 100644 index 0000000..df4865e --- /dev/null +++ b/tests/background-cpa-api.test.js @@ -0,0 +1,184 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function createJsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + }; +} + +function loadCpaApiModule() { + const source = fs.readFileSync('background/cpa-api.js', 'utf8'); + return new Function('self', `${source}; return self.MultiPageBackgroundCpaApi;`)({}); +} + +function encodeBase64UrlJson(value) { + return Buffer.from(JSON.stringify(value)).toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function createJwtToken(payload = {}) { + return [ + encodeBase64UrlJson({ alg: 'HS256', typ: 'JWT' }), + encodeBase64UrlJson(payload), + 'signature', + ].join('.'); +} + +test('cpa api imports current ChatGPT session through management auth-files endpoint', async () => { + const apiModule = loadCpaApiModule(); + const logs = []; + const fetchCalls = []; + const expiresAt = '2026-05-20T12:34:56.000Z'; + const accessToken = createJwtToken({ + exp: Math.floor(Date.parse(expiresAt) / 1000), + email: 'jwt@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_123', + chatgpt_plan_type: 'plus', + chatgpt_user_id: 'user_123', + }, + 'https://api.openai.com/profile': { + email: 'profile@example.com', + }, + }); + + const api = apiModule.createCpaApi({ + addLog: async (message, level = 'info', options = {}) => { + logs.push({ message, level, step: options.step, stepKey: options.stepKey }); + }, + fetchImpl: async (url, options = {}) => { + const parsed = new URL(url); + const body = options.body ? JSON.parse(options.body) : null; + fetchCalls.push({ + path: parsed.pathname, + search: parsed.search, + method: options.method || 'POST', + headers: options.headers || {}, + body, + }); + + if (parsed.pathname === '/v0/management/auth-files') { + return createJsonResponse({}); + } + + return createJsonResponse({ message: `unexpected path ${parsed.pathname}` }, 404); + }, + }); + + const result = await api.importCurrentChatGptSession({ + vpsUrl: 'https://cpa.example.com/management.html#/oauth', + vpsPassword: 'management-key', + session: { + accessToken, + expires: expiresAt, + user: { + email: 'flow@example.com', + }, + account: { + id: 'acct_123', + planType: 'plus', + }, + }, + accessToken, + email: 'registration@example.com', + accountIdentifierType: 'email', + accountIdentifier: 'identifier@example.com', + }, { + logLabel: '步骤 10', + logOptions: { step: 10, stepKey: 'cpa-session-import' }, + }); + + const importCall = fetchCalls.find((call) => call.path === '/v0/management/auth-files'); + assert.ok(importCall, 'expected CPA auth-files import call'); + assert.equal(importCall.method, 'POST'); + assert.equal(importCall.headers.Authorization, 'Bearer management-key'); + assert.equal(importCall.headers['X-Management-Key'], 'management-key'); + assert.equal( + decodeURIComponent(new URLSearchParams(importCall.search).get('name')), + 'codex-flow@example.com-plus.json' + ); + assert.equal(importCall.body.type, 'codex'); + assert.equal(importCall.body.account_id, 'acct_123'); + assert.equal(importCall.body.chatgpt_account_id, 'acct_123'); + assert.equal(importCall.body.email, 'flow@example.com'); + assert.equal(importCall.body.plan_type, 'plus'); + assert.equal(importCall.body.chatgpt_plan_type, 'plus'); + assert.equal(importCall.body.access_token, accessToken); + assert.equal(importCall.body.id_token_synthetic, true); + assert.match(String(importCall.body.id_token || ''), /\.synthetic$/); + assert.equal(importCall.body.expired, expiresAt); + assert.equal(Object.prototype.hasOwnProperty.call(importCall.body, 'refresh_token'), false); + assert.equal(result.verifiedStatus, 'CPA 会话导入完成:flow@example.com'); + assert.equal( + logs.some((entry) => entry.level === 'warn' && /refresh_token/.test(entry.message)), + true + ); +}); + +test('cpa api falls back to registration email when session has no readable email', () => { + const apiModule = loadCpaApiModule(); + const accessToken = createJwtToken({ + exp: Math.floor(Date.parse('2026-05-20T12:34:56.000Z') / 1000), + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_456', + chatgpt_plan_type: 'plus', + chatgpt_user_id: 'user_456', + }, + }); + + const api = apiModule.createCpaApi(); + const result = api.buildCpaSessionAuthJson({ + session: { + accessToken, + user: { + name: 'Flow User', + }, + }, + accessToken, + email: 'registration@example.com', + accountIdentifierType: 'email', + accountIdentifier: 'identifier@example.com', + }); + + assert.equal(result.email, 'registration@example.com'); + assert.equal(result.authJson.email, 'registration@example.com'); + assert.equal(result.fileName, 'codex-registration@example.com-plus.json'); +}); + +test('cpa api preserves provided id_token and refresh_token when available', () => { + const apiModule = loadCpaApiModule(); + const accessToken = createJwtToken({ + exp: Math.floor(Date.parse('2026-05-20T12:34:56.000Z') / 1000), + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_789', + chatgpt_plan_type: 'plus', + chatgpt_user_id: 'user_789', + }, + }); + + const api = apiModule.createCpaApi(); + const result = api.buildCpaSessionAuthJson({ + session: { + accessToken, + user: { + email: 'session@example.com', + }, + }, + accessToken, + id_token: 'provided-id-token', + refresh_token: 'refresh-token-1', + session_token: 'session-token-1', + }); + + assert.equal(result.authJson.id_token, 'provided-id-token'); + assert.equal(Object.prototype.hasOwnProperty.call(result.authJson, 'id_token_synthetic'), false); + assert.equal(result.authJson.refresh_token, 'refresh-token-1'); + assert.equal(result.authJson.session_token, 'session-token-1'); + assert.equal(result.hasRefreshToken, true); +}); diff --git a/tests/background-cpa-session-import.test.js b/tests/background-cpa-session-import.test.js new file mode 100644 index 0000000..b73c658 --- /dev/null +++ b/tests/background-cpa-session-import.test.js @@ -0,0 +1,247 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); + +function loadCpaSessionImportModule() { + const source = fs.readFileSync('background/steps/cpa-session-import.js', 'utf8'); + return new Function('self', `${source}; return self.MultiPageBackgroundCpaSessionImport;`)({}); +} + +test('CPA session import step reads current ChatGPT session and completes node', async () => { + const moduleApi = loadCpaSessionImportModule(); + const completed = []; + const logs = []; + const ensureCalls = []; + const sentMessages = []; + const importedPayloads = []; + + const executor = moduleApi.createCpaSessionImportExecutor({ + addLog: async (message, level = 'info', options = {}) => { + logs.push({ message, level, step: options.step, stepKey: options.stepKey }); + }, + chrome: { + tabs: { + get: async (tabId) => ({ + id: tabId, + url: 'https://chatgpt.com/?model=gpt-4o', + }), + update: async () => {}, + }, + }, + completeNodeFromBackground: async (nodeId, payload) => { + completed.push({ nodeId, payload }); + }, + createCpaApi: () => ({ + importCurrentChatGptSession: async (state, options) => { + importedPayloads.push({ state, options }); + return { + verifiedStatus: 'CPA 会话导入完成:flow@example.com', + cpaImportedFileName: 'codex-flow@example.com-plus.json', + cpaImportedEmail: 'flow@example.com', + }; + }, + }), + ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options = {}) => { + ensureCalls.push({ source, tabId, options }); + }, + getTabId: async () => null, + isTabAlive: async () => false, + registerTab: async () => {}, + sendTabMessageUntilStopped: async (tabId, source, message) => { + sentMessages.push({ tabId, source, message }); + return { + session: { + accessToken: 'session-access-token', + expires: '2026-05-20T12:34:56.000Z', + user: { + email: 'flow@example.com', + }, + }, + accessToken: 'session-access-token', + }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + waitForTabCompleteUntilStopped: async () => {}, + }); + + await executor.executeCpaSessionImport({ + nodeId: 'cpa-session-import', + visibleStep: 10, + plusCheckoutTabId: 91, + vpsUrl: 'https://cpa.example.com/management.html#/oauth', + vpsPassword: 'management-key', + }); + + assert.equal(ensureCalls.length, 1); + assert.equal(ensureCalls[0].source, 'plus-checkout'); + assert.deepStrictEqual(ensureCalls[0].options.inject, [ + 'content/utils.js', + 'content/operation-delay.js', + 'content/plus-checkout.js', + ]); + assert.deepStrictEqual(sentMessages, [{ + tabId: 91, + source: 'plus-checkout', + message: { + type: 'PLUS_CHECKOUT_GET_STATE', + source: 'background', + payload: { + includeSession: true, + includeAccessToken: true, + }, + }, + }]); + assert.equal(importedPayloads.length, 1); + assert.equal(importedPayloads[0].state.accessToken, 'session-access-token'); + assert.equal(importedPayloads[0].state.session.user.email, 'flow@example.com'); + assert.equal(completed.length, 1); + assert.deepStrictEqual(completed[0], { + nodeId: 'cpa-session-import', + payload: { + verifiedStatus: 'CPA 会话导入完成:flow@example.com', + cpaImportedFileName: 'codex-flow@example.com-plus.json', + cpaImportedEmail: 'flow@example.com', + }, + }); + assert.equal( + logs.some((entry) => entry.stepKey === 'cpa-session-import' && /ChatGPT/.test(entry.message)), + true + ); +}); + +test('CPA session import step falls back to an active ChatGPT tab when no checkout tab is tracked', async () => { + const moduleApi = loadCpaSessionImportModule(); + const completed = []; + const importedPayloads = []; + const queryCalls = []; + const registerCalls = []; + + const sessionTab = { + id: 77, + url: 'https://chatgpt.com/?model=gpt-4o', + active: true, + currentWindow: true, + lastAccessed: 1234, + }; + + const executor = moduleApi.createCpaSessionImportExecutor({ + addLog: async () => {}, + chrome: { + tabs: { + get: async (tabId) => ({ + ...sessionTab, + id: tabId, + }), + query: async (queryInfo = {}) => { + queryCalls.push(queryInfo); + if (queryInfo.active && queryInfo.currentWindow) { + return [sessionTab]; + } + return [sessionTab]; + }, + update: async () => {}, + }, + }, + completeNodeFromBackground: async (nodeId, payload) => { + completed.push({ nodeId, payload }); + }, + createCpaApi: () => ({ + importCurrentChatGptSession: async (state) => { + importedPayloads.push(state); + return { + verifiedStatus: 'CPA 会话导入完成:fallback@example.com', + }; + }, + }), + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + getTabId: async () => null, + isTabAlive: async () => false, + registerTab: async (source, tabId) => { + registerCalls.push({ source, tabId }); + }, + sendTabMessageUntilStopped: async () => ({ + session: { + accessToken: 'session-access-token', + user: { + email: 'fallback@example.com', + }, + }, + accessToken: 'session-access-token', + }), + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + waitForTabCompleteUntilStopped: async () => {}, + }); + + await executor.executeCpaSessionImport({ + nodeId: 'cpa-session-import', + visibleStep: 10, + vpsUrl: 'https://cpa.example.com/management.html#/oauth', + vpsPassword: 'management-key', + }); + + assert.deepStrictEqual(queryCalls, [ + { active: true, currentWindow: true }, + {}, + ]); + assert.deepStrictEqual(registerCalls, [{ + source: 'plus-checkout', + tabId: 77, + }]); + assert.equal(importedPayloads.length, 1); + assert.equal(importedPayloads[0].session.user.email, 'fallback@example.com'); + assert.equal(completed.length, 1); +}); + +test('CPA session import step reports missing readable session tab when tracked tabs are unusable', async () => { + const moduleApi = loadCpaSessionImportModule(); + let sendCalled = false; + + const executor = moduleApi.createCpaSessionImportExecutor({ + addLog: async () => {}, + chrome: { + tabs: { + get: async (tabId) => ({ + id: tabId, + url: 'https://example.com/not-chatgpt', + }), + update: async () => {}, + }, + }, + completeNodeFromBackground: async () => {}, + createCpaApi: () => ({ + importCurrentChatGptSession: async () => ({}), + }), + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + getTabId: async () => 91, + isTabAlive: async () => true, + sendTabMessageUntilStopped: async () => { + sendCalled = true; + return {}; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + waitForTabCompleteUntilStopped: async () => {}, + }); + + await assert.rejects( + () => executor.executeCpaSessionImport({ + nodeId: 'cpa-session-import', + visibleStep: 10, + vpsUrl: 'https://cpa.example.com/management.html#/oauth', + vpsPassword: 'management-key', + }), + /未找到可读取 ChatGPT 会话的标签页/ + ); + + assert.equal(sendCalled, false); +}); + +test('background wires CPA session import executor into the workflow runtime', () => { + const source = fs.readFileSync('background.js', 'utf8'); + assert.match(source, /background\/cpa-api\.js/); + assert.match(source, /background\/steps\/cpa-session-import\.js/); + assert.match(source, /'cpa-session-import': \(state\) => cpaSessionImportExecutor\.executeCpaSessionImport\(state\)/); + assert.match(source, /'cpa-session-import',[\s\S]*'oauth-login'/); +}); diff --git a/tests/background-effective-plus-account-access-strategy.test.js b/tests/background-effective-plus-account-access-strategy.test.js index b841976..aae5b48 100644 --- a/tests/background-effective-plus-account-access-strategy.test.js +++ b/tests/background-effective-plus-account-access-strategy.test.js @@ -59,14 +59,25 @@ const workflowEngine = null; const self = { MultiPageStepDefinitions: { getSteps(options = {}) { - return String(options.plusAccountAccessStrategy || '').trim() === 'sub2api_codex_session' - ? [ + const strategy = String(options.plusAccountAccessStrategy || '').trim(); + if (strategy === 'sub2api_codex_session') { + return [ { id: 1, key: 'open-chatgpt' }, { id: 2, key: 'plus-checkout-create' }, { id: 3, key: 'plus-checkout-billing' }, { id: 4, key: 'paypal-approve' }, { id: 5, key: 'plus-checkout-return' }, { id: 6, key: 'sub2api-session-import' }, + ]; + } + return strategy === 'cpa_codex_session' + ? [ + { id: 1, key: 'open-chatgpt' }, + { id: 2, key: 'plus-checkout-create' }, + { id: 3, key: 'plus-checkout-billing' }, + { id: 4, key: 'paypal-approve' }, + { id: 5, key: 'plus-checkout-return' }, + { id: 6, key: 'cpa-session-import' }, ] : [ { id: 1, key: 'open-chatgpt' }, @@ -96,17 +107,26 @@ function normalizePlusPaymentMethod(value = '') { return normalized === 'gopay' || normalized === 'gpc-helper' ? normalized : 'paypal'; } function normalizePlusAccountAccessStrategy(value = '') { - return String(value || '').trim().toLowerCase() === 'sub2api_codex_session' - ? 'sub2api_codex_session' - : 'oauth'; + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sub2api_codex_session') { + return 'sub2api_codex_session'; + } + if (normalized === 'cpa_codex_session') { + return 'cpa_codex_session'; + } + return 'oauth'; } function isPlusModeState(state = {}) { return Boolean(state?.plusModeEnabled); } function resolveCurrentFlowCapabilities(state = {}, options = {}) { - const effectiveStrategy = String(options.panelMode || '').trim().toLowerCase() === 'sub2api' - ? normalizePlusAccountAccessStrategy(state.plusAccountAccessStrategy) - : 'oauth'; + const normalizedPanelMode = String(options.panelMode || '').trim().toLowerCase(); + const requestedStrategy = normalizePlusAccountAccessStrategy(state.plusAccountAccessStrategy); + const effectiveStrategy = normalizedPanelMode === 'sub2api' + ? (requestedStrategy === 'sub2api_codex_session' ? 'sub2api_codex_session' : 'oauth') + : (normalizedPanelMode === 'cpa' + ? (requestedStrategy === 'cpa_codex_session' ? 'cpa_codex_session' : 'oauth') + : 'oauth'); return { effectivePanelMode: options.panelMode, effectivePlusAccountAccessStrategy: effectiveStrategy, @@ -159,6 +179,32 @@ test('background step resolution keeps SUB2API session tail only when the effect ]); }); +test('background step resolution keeps CPA session tail when the effective Plus target supports it', () => { + const api = createHarness(); + const state = { + activeFlowId: 'openai', + flowId: 'openai', + panelMode: 'cpa', + plusModeEnabled: true, + plusPaymentMethod: 'paypal', + plusAccountAccessStrategy: 'cpa_codex_session', + signupMethod: 'email', + }; + + const resolvedState = api.buildResolvedStepDefinitionState(state); + const nodeIds = api.getNodeIdsForState(state); + + assert.equal(resolvedState.plusAccountAccessStrategy, 'cpa_codex_session'); + assert.deepStrictEqual(nodeIds, [ + 'open-chatgpt', + 'plus-checkout-create', + 'plus-checkout-billing', + 'paypal-approve', + 'plus-checkout-return', + 'cpa-session-import', + ]); +}); + test('background step resolution falls back to OAuth tail when the requested session strategy is not effective for the current panel mode', () => { const api = createHarness(); const state = { diff --git a/tests/background-message-router-plus-final-step.test.js b/tests/background-message-router-plus-final-step.test.js index 4587aa6..f0d0afb 100644 --- a/tests/background-message-router-plus-final-step.test.js +++ b/tests/background-message-router-plus-final-step.test.js @@ -173,3 +173,33 @@ test('message router appends success record when SUB2API session import is the f assert.equal(appendCalls.length, 1); assert.equal(appendCalls[0][0], 'success'); }); + +test('message router appends success record when CPA session import is the final Plus node', async () => { + const { appendCalls, router } = createRouterWithFinalNode({ + finalNodeId: 'cpa-session-import', + nodeIds: [ + 'open-chatgpt', + 'plus-checkout-create', + 'plus-checkout-billing', + 'paypal-approve', + 'plus-checkout-return', + 'cpa-session-import', + ], + nodeStepMap: { + 'plus-checkout-create': 6, + 'plus-checkout-billing': 7, + 'paypal-approve': 8, + 'plus-checkout-return': 9, + 'cpa-session-import': 10, + }, + }); + + await router.handleMessage({ + type: 'NODE_COMPLETE', + nodeId: 'cpa-session-import', + payload: { nodeId: 'cpa-session-import' }, + }, {}); + + assert.equal(appendCalls.length, 1); + assert.equal(appendCalls[0][0], 'success'); +}); diff --git a/tests/background-settings-schema-persistence.test.js b/tests/background-settings-schema-persistence.test.js index 4a4bee5..d7bd25c 100644 --- a/tests/background-settings-schema-persistence.test.js +++ b/tests/background-settings-schema-persistence.test.js @@ -111,6 +111,10 @@ const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); const PERSISTED_SETTINGS_SCHEMA_KEYS = ['settingsSchemaVersion', 'settingsState']; const LEGACY_AUTO_STEP_DELAY_KEYS = []; const LEGACY_VERIFICATION_RESEND_COUNT_KEYS = []; +const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; +const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; function isPlainObjectValue(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } @@ -125,6 +129,7 @@ function normalizePlusPaymentMethod(value = '') { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'gopay' || normalized === 'gpc-helper' ? normalized : 'paypal'; } +${extractFunction('normalizePlusAccountAccessStrategy')} function normalizeSub2ApiGroupNames(value) { return Array.isArray(value) ? value.map((entry) => String(entry || '').trim()).filter(Boolean) : []; } diff --git a/tests/background-sub2api-session-import.test.js b/tests/background-sub2api-session-import.test.js index f5a8783..8c486b1 100644 --- a/tests/background-sub2api-session-import.test.js +++ b/tests/background-sub2api-session-import.test.js @@ -601,5 +601,5 @@ test('background wires sub2api session import executor into the workflow runtime const source = fs.readFileSync('background.js', 'utf8'); assert.match(source, /background\/steps\/sub2api-session-import\.js/); assert.match(source, /'sub2api-session-import': \(state\) => sub2ApiSessionImportExecutor\.executeSub2ApiSessionImport\(state\)/); - assert.match(source, /'sub2api-session-import',\s*\n\s*'oauth-login'/); + assert.match(source, /'sub2api-session-import',[\s\S]*'oauth-login'/); }); diff --git a/tests/flow-capabilities-module.test.js b/tests/flow-capabilities-module.test.js index c85c6f3..db05a61 100644 --- a/tests/flow-capabilities-module.test.js +++ b/tests/flow-capabilities-module.test.js @@ -216,7 +216,7 @@ test('flow capability registry exposes editable Plus account access strategies f assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'sub2api_codex_session'); }); -test('flow capability registry preserves requested Plus account strategy while forcing OAuth on unsupported targets', () => { +test('flow capability registry exposes editable Plus account access strategies for CPA', () => { const api = loadApi(); const registry = api.createFlowCapabilityRegistry(); @@ -226,12 +226,36 @@ test('flow capability registry preserves requested Plus account strategy while f openaiIntegrationTargetId: 'cpa', signupMethod: 'email', plusModeEnabled: true, - plusAccountAccessStrategy: 'sub2api_codex_session', + plusAccountAccessStrategy: 'cpa_codex_session', + }, + }); + + assert.deepEqual( + capabilityState.availablePlusAccountAccessStrategies, + ['oauth', 'cpa_codex_session'] + ); + assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'cpa_codex_session'); + assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'cpa_codex_session'); + assert.equal(capabilityState.canEditPlusAccountAccessStrategy, true); + assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'cpa_codex_session'); +}); + +test('flow capability registry forces OAuth when the current target only supports OAuth', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry(); + + const capabilityState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'openai', + openaiIntegrationTargetId: 'codex2api', + signupMethod: 'email', + plusModeEnabled: true, + plusAccountAccessStrategy: 'cpa_codex_session', }, }); assert.deepEqual(capabilityState.availablePlusAccountAccessStrategies, ['oauth']); - assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'sub2api_codex_session'); + assert.equal(capabilityState.requestedPlusAccountAccessStrategy, 'cpa_codex_session'); assert.equal(capabilityState.effectivePlusAccountAccessStrategy, 'oauth'); assert.equal(capabilityState.canEditPlusAccountAccessStrategy, false); assert.equal(capabilityState.stepDefinitionOptions.plusAccountAccessStrategy, 'oauth'); diff --git a/tests/flow-registry-settings-schema.test.js b/tests/flow-registry-settings-schema.test.js index 95fe10f..f91be78 100644 --- a/tests/flow-registry-settings-schema.test.js +++ b/tests/flow-registry-settings-schema.test.js @@ -99,3 +99,15 @@ test('settings schema can project canonical state into a read view without legac assert.equal(view.settingsSchemaVersion, 4); assert.equal(view.settingsState.activeFlowId, 'kiro'); }); + +test('settings schema preserves CPA session strategy in canonical state and read view', () => { + const { settingsSchema } = loadApis(); + const schema = settingsSchema.createSettingsSchema(); + const normalized = schema.normalizeSettingsState({ + plusAccountAccessStrategy: 'cpa_codex_session', + }); + const view = schema.buildSettingsView(normalized); + + assert.equal(normalized.flows.openai.plus.plusAccountAccessStrategy, 'cpa_codex_session'); + assert.equal(view.plusAccountAccessStrategy, 'cpa_codex_session'); +}); diff --git a/tests/gopay-manual-confirm.test.js b/tests/gopay-manual-confirm.test.js index 11384ac..b116083 100644 --- a/tests/gopay-manual-confirm.test.js +++ b/tests/gopay-manual-confirm.test.js @@ -50,7 +50,7 @@ test('GoPay manual confirm executor publishes pending manual confirmation state assert.equal(stateUpdates[0].plusManualConfirmationMethod, 'gopay'); assert.equal(stateUpdates[0].plusManualConfirmationStep, 7); assert.match(String(stateUpdates[0].plusManualConfirmationRequestId || ''), /^gopay-/); - assert.match(stateUpdates[0].plusManualConfirmationMessage, /OAuth 登录/); + assert.match(stateUpdates[0].plusManualConfirmationMessage, /OAuth/); assert.equal(broadcasts.length, 1); assert.equal(broadcasts[0].plusManualConfirmationPending, true); assert.deepStrictEqual( @@ -59,7 +59,7 @@ test('GoPay manual confirm executor publishes pending manual confirmation state ); assert.match( events.find((event) => event.type === 'log')?.message || '', - /确认后继续OAuth 登录/ + /OAuth/ ); }); @@ -100,9 +100,53 @@ test('GoPay manual confirm executor switches continuation copy to SUB2API sessio assert.equal(stateUpdates.length, 1); assert.equal(stateUpdates[0].plusManualConfirmationStep, 9); - assert.match(stateUpdates[0].plusManualConfirmationMessage, /导入当前 ChatGPT 会话到 SUB2API/); + assert.match(stateUpdates[0].plusManualConfirmationMessage, /SUB2API/); assert.match( events.find((event) => event.type === 'log')?.message || '', - /确认后继续导入当前 ChatGPT 会话到 SUB2API/ + /SUB2API/ + ); +}); + +test('GoPay manual confirm executor switches continuation copy to CPA session import when the effective tail is CPA session-based', async () => { + const stateUpdates = []; + const events = []; + const executor = api.createGoPayManualConfirmExecutor({ + addLog: async (message, level = 'info') => { + events.push({ type: 'log', message, level }); + }, + broadcastDataUpdate: () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + getNodeIdsForState: () => [ + 'open-chatgpt', + 'plus-checkout-create', + 'plus-checkout-billing', + 'gopay-subscription-confirm', + 'cpa-session-import', + ], + getTabId: async () => 42, + isTabAlive: async () => true, + registerTab: async () => {}, + setState: async (payload) => { + stateUpdates.push(payload); + }, + }); + + await executor.executeGoPayManualConfirm({ + nodeId: 'gopay-subscription-confirm', + visibleStep: 9, + plusCheckoutTabId: 42, + plusCheckoutUrl: 'https://chatgpt.com/checkout/openai_llc/session', + }); + + assert.equal(stateUpdates.length, 1); + assert.equal(stateUpdates[0].plusManualConfirmationStep, 9); + assert.match(stateUpdates[0].plusManualConfirmationMessage, /CPA/); + assert.match( + events.find((event) => event.type === 'log')?.message || '', + /CPA/ ); }); diff --git a/tests/plus-account-access-strategy.test.js b/tests/plus-account-access-strategy.test.js index 84fe8d0..3998edc 100644 --- a/tests/plus-account-access-strategy.test.js +++ b/tests/plus-account-access-strategy.test.js @@ -57,6 +57,7 @@ const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const DEFAULT_PLUS_PAYMENT_METHOD = 'paypal'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = 'oauth'; const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; @@ -165,6 +166,33 @@ test('sidepanel enables SUB2API session strategy selection when the current Plus assert.match(api.plusAccountAccessStrategyCaption.textContent, /SUB2API/); }); +test('sidepanel enables CPA session strategy selection when the current Plus target supports it', () => { + const api = buildHarness( + `{ + canShowPlusSettings: true, + runtimeLocks: { plusModeEnabled: true }, + canEditPlusAccountAccessStrategy: true, + availablePlusAccountAccessStrategies: ['oauth', 'cpa_codex_session'], + effectivePanelMode: 'cpa', + effectivePlusAccountAccessStrategy: 'cpa_codex_session', + }`, + `{ + activeFlowId: 'openai', + panelMode: 'cpa', + plusPaymentMethod: 'paypal', + plusAccountAccessStrategy: 'cpa_codex_session', + }` + ); + + api.updatePlusModeUI(); + + assert.equal(api.rowPlusAccountAccessStrategy.style.display, ''); + assert.equal(api.selectPlusAccountAccessStrategy.disabled, false); + assert.equal(api.selectPlusAccountAccessStrategy.value, 'cpa_codex_session'); + assert.equal(api.getRequestedPlusAccountAccessStrategy(), 'cpa_codex_session'); + assert.match(api.plusAccountAccessStrategyCaption.textContent, /CPA/); +}); + test('sidepanel rebuilds step definitions and workflow nodes with the effective Plus account strategy', () => { const bundle = [ extractFunction('normalizeSignupMethod'), @@ -195,6 +223,7 @@ let currentPlusModeEnabled = false; let currentPlusPaymentMethod = 'paypal'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; let currentPlusAccountAccessStrategy = 'oauth'; let currentSignupMethod = 'email'; let currentPhoneSignupReloginAfterBindEmailEnabled = false; @@ -272,12 +301,20 @@ return { test('background declares Plus account access strategy constants before precomputing session-tail step definitions', () => { const strategyConstantIndex = backgroundSource.indexOf("const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session';"); + const cpaStrategyConstantIndex = backgroundSource.indexOf("const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session';"); const precomputedSessionStepsIndex = backgroundSource.indexOf('const PLUS_PAYPAL_SUB2API_SESSION_STEP_DEFINITIONS ='); + const precomputedCpaSessionStepsIndex = backgroundSource.indexOf('const PLUS_PAYPAL_CPA_SESSION_STEP_DEFINITIONS ='); assert.ok(strategyConstantIndex >= 0, 'expected Plus account access strategy constant declaration'); + assert.ok(cpaStrategyConstantIndex >= 0, 'expected CPA Plus account access strategy constant declaration'); assert.ok(precomputedSessionStepsIndex >= 0, 'expected precomputed SUB2API session step definitions'); + assert.ok(precomputedCpaSessionStepsIndex >= 0, 'expected precomputed CPA session step definitions'); assert.ok( strategyConstantIndex < precomputedSessionStepsIndex, 'strategy constant must be declared before background precomputes session-tail step definitions' ); + assert.ok( + cpaStrategyConstantIndex < precomputedCpaSessionStepsIndex, + 'CPA strategy constant must be declared before background precomputes CPA session-tail step definitions' + ); }); diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js index 0088740..b6560ee 100644 --- a/tests/sidepanel-plus-payment-method.test.js +++ b/tests/sidepanel-plus-payment-method.test.js @@ -85,6 +85,7 @@ let currentPlusModeEnabled = false; let currentPlusPaymentMethod = 'paypal'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; let currentPlusAccountAccessStrategy = DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY; let currentSignupMethod = 'email'; @@ -170,6 +171,7 @@ const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; const rowPayPalAccount = { style: { display: '' } }; ${bundle} @@ -229,6 +231,7 @@ const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; ${bundle} return { @@ -272,6 +275,7 @@ let currentPlusModeEnabled = false; let currentPlusPaymentMethod = 'paypal'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; let currentPlusAccountAccessStrategy = DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY; let currentSignupMethod = 'email'; @@ -330,6 +334,7 @@ const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; const plusPaymentMethodCaption = { textContent: '' }; const btnGpcCardKeyPurchase = { style: { display: 'none' } }; @@ -432,6 +437,7 @@ const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; const plusPaymentMethodCaption = { textContent: '' }; const btnGpcCardKeyPurchase = { style: { display: 'none' } }; @@ -495,6 +501,7 @@ const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; const plusPaymentMethodCaption = { textContent: '' }; const rowPayPalAccount = { style: { display: '' } }; @@ -577,6 +584,7 @@ const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH; const plusPaymentMethodCaption = { textContent: '' }; const rowPayPalAccount = { style: { display: '' } }; @@ -745,6 +753,7 @@ test('sidepanel resolves pending GoPay manual confirmation from DATA_UPDATED sta const api = new Function(` const events = []; let latestState = { + activeFlowId: 'openai', plusManualConfirmationPending: true, plusManualConfirmationRequestId: 'gopay-request-1', plusManualConfirmationStep: 7, @@ -799,6 +808,11 @@ return { events, syncPlusManualConfirmationDialog }; test('sidepanel resolves pending GPC OTP with typed code', async () => { const bundle = [ + extractFunction('normalizeSignupMethod'), + extractFunction('normalizePlusAccountAccessStrategy'), + extractFunction('normalizePlusStrategyTargetId'), + extractFunction('getPlusAccountAccessStrategyContinuationLabel'), + extractFunction('resolvePlusManualContinuationActionLabelFromState'), extractLastFunction('openPlusManualConfirmationDialog'), extractLastFunction('syncPlusManualConfirmationDialog'), ].join('\n'); @@ -815,6 +829,14 @@ let latestState = { }; let activePlusManualConfirmationRequestId = ''; let plusManualConfirmationDialogInFlight = false; +const DEFAULT_ACTIVE_FLOW_ID = 'openai'; +const SIGNUP_METHOD_EMAIL = 'email'; +const SIGNUP_METHOD_PHONE = 'phone'; +const DEFAULT_SIGNUP_METHOD = 'email'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_OAUTH = 'oauth'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION = 'sub2api_codex_session'; +const PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION = 'cpa_codex_session'; +const DEFAULT_PLUS_ACCOUNT_ACCESS_STRATEGY = 'oauth'; const sharedFormDialog = { async open(options) { events.push({ type: 'form', options }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index f6e6099..a93131a 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -335,6 +335,70 @@ test('Plus phone signup never switches to SUB2API session tail even if the reque assert.equal(stepKeys.includes('platform-verify'), true); }); +test('Plus session strategy swaps the OAuth tail for a single CPA import node', () => { + const source = fs.readFileSync('data/step-definitions.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageStepDefinitions;`)(globalScope); + const forbiddenTailKeys = [ + 'oauth-login', + 'fetch-login-code', + 'post-login-phone-verification', + 'confirm-oauth', + 'platform-verify', + ]; + + [ + { + label: 'paypal', + options: { + plusModeEnabled: true, + plusPaymentMethod: 'paypal', + plusAccountAccessStrategy: 'cpa_codex_session', + }, + previousNodeId: 'plus-checkout-return', + expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }, + { + label: 'gopay', + options: { + plusModeEnabled: true, + plusPaymentMethod: 'gopay', + plusAccountAccessStrategy: 'cpa_codex_session', + }, + previousNodeId: 'gopay-subscription-confirm', + expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 10], + }, + { + label: 'gpc-helper', + options: { + plusModeEnabled: true, + plusPaymentMethod: 'gpc-helper', + plusAccountAccessStrategy: 'cpa_codex_session', + }, + previousNodeId: 'plus-checkout-billing', + expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 10], + }, + ].forEach(({ label, options, previousNodeId, expectedStepIds }) => { + const steps = api.getSteps(options); + const nodes = api.getNodes(options); + const stepKeys = steps.map((step) => step.key); + const nodeIds = nodes.map((node) => node.nodeId); + const previousNode = nodes.find((node) => node.nodeId === previousNodeId); + const sessionImportNode = nodes.find((node) => node.nodeId === 'cpa-session-import'); + + assert.equal(stepKeys.at(-1), 'cpa-session-import', `${label} should end with CPA session import`); + assert.equal(nodeIds.at(-1), 'cpa-session-import', `${label} node order should end with CPA session import`); + forbiddenTailKeys.forEach((key) => { + assert.equal(stepKeys.includes(key), false, `${label} should not keep ${key} in CPA session mode`); + assert.equal(nodeIds.includes(key), false, `${label} nodes should not keep ${key} in CPA session mode`); + }); + assert.deepStrictEqual(api.getStepIds(options), expectedStepIds, `${label} step ids should follow the CPA tail`); + assert.equal(api.getLastStepId(options), expectedStepIds.at(-1), `${label} last step id should match CPA session import`); + assert.deepStrictEqual(previousNode?.next, ['cpa-session-import'], `${label} previous node should link to CPA session import`); + assert.deepStrictEqual(sessionImportNode?.next, [], `${label} CPA session import should be terminal`); + }); +}); + test('sidepanel html loads shared step definitions before sidepanel bootstrap', () => { const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8'); const definitionsIndex = html.indexOf('');