From 5d6792f5fb8fbf885f24076094f19fece72bb678 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sat, 9 May 2026 18:22:24 +0800 Subject: [PATCH 01/11] feat: add support for deferring Step 9 callback timeout during auth security verification --- background.js | 24 ++++ background/steps/confirm-oauth.js | 37 +++++ tests/step9-oauth-timeout-toggle.test.js | 172 +++++++++++++++++++++++ 3 files changed, 233 insertions(+) diff --git a/background.js b/background.js index 05e6e94..8034ad5 100644 --- a/background.js +++ b/background.js @@ -12042,6 +12042,29 @@ function throwIfStep8SettledOrStopped(isSettled = false) { } } +function isStep9AuthCallbackWaitPageUrl(rawUrl) { + if (!rawUrl) return false; + try { + const parsed = new URL(rawUrl); + const hostname = String(parsed.hostname || '').toLowerCase(); + if (!['auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(hostname)) { + return false; + } + const pathname = String(parsed.pathname || ''); + return /\/api\/oauth\/oauth2\/auth(?:[/?#]|$)/i.test(pathname) + || /\/oauth\/oauth2\/auth(?:[/?#]|$)/i.test(pathname); + } catch { + return false; + } +} + +async function shouldDeferStep9CallbackTimeout(details = {}) { + const tabId = details?.tabId; + if (!Number.isInteger(tabId)) return false; + const tab = await chrome.tabs.get(tabId).catch(() => null); + return isStep9AuthCallbackWaitPageUrl(tab?.url || ''); +} + async function ensureStep8SignupPageReady(tabId, options = {}) { const visibleStep = Math.floor(Number(options.visibleStep || options.logStep || options.step) || 0); await ensureContentScriptReadyOnTab('signup-page', tabId, { @@ -12496,6 +12519,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ setStep8TabUpdatedListener, setWebNavCommittedListener, setWebNavListener, + shouldDeferStep9CallbackTimeout, sleepWithStop, STEP8_CLICK_RETRY_DELAY_MS, STEP8_MAX_ROUNDS, diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index a2cff0c..52fe3ce 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -33,6 +33,7 @@ setWebNavCommittedListener, setStep8PendingReject, setStep8TabUpdatedListener, + shouldDeferStep9CallbackTimeout, } = deps; const LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS = 240000; @@ -96,6 +97,7 @@ let signupTabId = null; const callbackWaitStartedAt = Date.now(); let timeoutCheckTimer = null; + let timeoutDeferredLogged = false; const cleanupListener = () => { if (timeoutCheckTimer) { @@ -128,11 +130,46 @@ }); }; + const isCallbackTimeoutDeferred = async (elapsedMs) => { + if (typeof shouldDeferStep9CallbackTimeout !== 'function') { + return false; + } + try { + const deferred = await shouldDeferStep9CallbackTimeout({ + tabId: signupTabId, + visibleStep, + elapsedMs, + oauthUrl: activeState?.oauthUrl || '', + }); + if (deferred && !timeoutDeferredLogged) { + timeoutDeferredLogged = true; + await addStepLog( + visibleStep, + '检测到认证页仍在安全验证/授权跳转中,暂停本地回调超时判定,继续等待 localhost 回调...', + 'info' + ); + } + return Boolean(deferred); + } catch (error) { + await addStepLog( + visibleStep, + `复核认证页跳转状态失败(${error?.message || error}),继续按原超时规则等待回调。`, + 'warn' + ); + return false; + } + }; + const checkCallbackTimeout = async () => { if (resolved) { return; } const elapsedMs = Date.now() - callbackWaitStartedAt; + if (await isCallbackTimeoutDeferred(elapsedMs)) { + timeoutCheckTimer = setTimeout(checkCallbackTimeout, CALLBACK_TIMEOUT_CHECK_INTERVAL_MS); + return; + } + if (elapsedMs >= LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS) { rejectStep9(new Error(`${Math.round(LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`)); return; diff --git a/tests/step9-oauth-timeout-toggle.test.js b/tests/step9-oauth-timeout-toggle.test.js index 00803a1..62a4a70 100644 --- a/tests/step9-oauth-timeout-toggle.test.js +++ b/tests/step9-oauth-timeout-toggle.test.js @@ -182,3 +182,175 @@ return { }, }); }); + +test('step9 defers callback timeout while auth security verification is still redirecting', async () => { + const api = new Function('step9ModuleSource', ` +const self = {}; +let webNavListener = null; +let webNavCommittedListener = null; +let step8TabUpdatedListener = null; +let cleanupCalls = 0; +let deferCalls = 0; +let completePayload = null; +const callbackUrl = 'http://localhost:1455/auth/callback?code=abc&state=xyz'; + +const chrome = { + webNavigation: { + onBeforeNavigate: { + addListener(listener) { + webNavListener = listener; + setTimeout(() => { + if (typeof webNavListener === 'function') { + webNavListener({ tabId: 123, url: callbackUrl }); + } + }, 25); + }, + removeListener() {}, + }, + onCommitted: { + addListener(listener) { + webNavCommittedListener = listener; + }, + removeListener() {}, + }, + }, + tabs: { + onUpdated: { + addListener(listener) { + step8TabUpdatedListener = listener; + }, + removeListener() {}, + }, + async update() {}, + }, +}; + +async function addLog() {} +function cleanupStep8NavigationListeners() { + cleanupCalls += 1; + webNavListener = null; + webNavCommittedListener = null; + step8TabUpdatedListener = null; +} +function throwIfStep8SettledOrStopped(resolved) { + if (resolved) throw new Error('already resolved'); +} +async function getTabId() { return 123; } +async function isTabAlive() { return true; } +async function ensureStep8SignupPageReady() {} +async function getOAuthFlowStepTimeoutMs(defaultTimeoutMs, options = {}) { + if (options.actionLabel === 'OAuth localhost 回调') { + return 1; + } + return defaultTimeoutMs; +} +async function shouldDeferStep9CallbackTimeout(details = {}) { + deferCalls += 1; + return Number(details.tabId) === 123; +} +async function waitForStep8Ready() { + return { + consentReady: true, + url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent', + }; +} +async function triggerStep8ContentStrategy() {} +async function waitForStep8ClickEffect() { + return { + progressed: true, + reason: 'url_changed', + url: 'https://auth.openai.com/api/oauth/oauth2/auth?client_id=app_test', + }; +} +function getStep8CallbackUrlFromNavigation(details, signupTabId) { + return Number(details?.tabId) === Number(signupTabId) ? details.url : ''; +} +function getStep8CallbackUrlFromTabUpdate() { return ''; } +function getStep8EffectLabel() { return 'URL 已变化'; } +async function prepareStep8DebuggerClick() { return { rect: { centerX: 10, centerY: 10 } }; } +async function clickWithDebugger() {} +async function reloadStep8ConsentPage() {} +async function reuseOrCreateTab() { return 123; } +async function sleepWithStop() {} +function setWebNavListener(listener) { webNavListener = listener; } +function getWebNavListener() { return webNavListener; } +function setWebNavCommittedListener(listener) { webNavCommittedListener = listener; } +function getWebNavCommittedListener() { return webNavCommittedListener; } +function setStep8TabUpdatedListener(listener) { step8TabUpdatedListener = listener; } +function getStep8TabUpdatedListener() { return step8TabUpdatedListener; } +function setStep8PendingReject() {} +async function completeStepFromBackground(step, payload) { + completePayload = { step, payload }; +} + +const STEP8_CLICK_RETRY_DELAY_MS = 1; +const STEP8_READY_WAIT_TIMEOUT_MS = 5; +const STEP8_MAX_ROUNDS = 1; +const STEP8_STRATEGIES = [ + { mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' }, +]; + +${step9ModuleSource} + +const executor = self.MultiPageBackgroundStep9.createStep9Executor({ + addLog, + chrome, + cleanupStep8NavigationListeners, + clickWithDebugger, + completeStepFromBackground, + ensureStep8SignupPageReady, + getOAuthFlowStepTimeoutMs, + getStep8CallbackUrlFromNavigation, + getStep8CallbackUrlFromTabUpdate, + getStep8EffectLabel, + getTabId, + getWebNavCommittedListener, + getWebNavListener, + getStep8TabUpdatedListener, + isTabAlive, + prepareStep8DebuggerClick, + reloadStep8ConsentPage, + reuseOrCreateTab, + setStep8PendingReject, + setStep8TabUpdatedListener, + setWebNavCommittedListener, + setWebNavListener, + shouldDeferStep9CallbackTimeout, + sleepWithStop, + STEP8_CLICK_RETRY_DELAY_MS, + STEP8_MAX_ROUNDS, + STEP8_READY_WAIT_TIMEOUT_MS, + STEP8_STRATEGIES, + throwIfStep8SettledOrStopped, + triggerStep8ContentStrategy, + waitForStep8ClickEffect, + waitForStep8Ready, +}); + +return { + executeStep9: executor.executeStep9, + snapshot() { + return { + cleanupCalls, + deferCalls, + completePayload, + }; + }, +}; +`)(step9ModuleSource); + + await api.executeStep9({ + oauthUrl: 'https://auth.openai.com/original-oauth', + visibleStep: 12, + }); + + const snapshot = api.snapshot(); + assert.equal(snapshot.deferCalls >= 1, true); + assert.equal(snapshot.cleanupCalls >= 1, true); + assert.deepEqual(snapshot.completePayload, { + step: 12, + payload: { + localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', + }, + }); +}); From dcdb3a3e8d9858760aedcd582303b313b468110c Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sat, 9 May 2026 20:42:36 +0800 Subject: [PATCH 02/11] feat: enhance signup flow with multiple profile page patterns and improved error handling during verification --- background.js | 2 +- background/signup-flow-helpers.js | 2 +- background/verification-flow.js | 79 ++++-- content/signup-page.js | 306 ++++++++++++++++++++++-- tests/step5-age-consent.test.js | 52 +++- tests/step5-direct-complete.test.js | 278 ++++++++++++++++++--- tests/verification-flow-polling.test.js | 152 +++++++++++- 7 files changed, 807 insertions(+), 64 deletions(-) diff --git a/background.js b/background.js index 8034ad5..c700d8d 100644 --- a/background.js +++ b/background.js @@ -10729,7 +10729,7 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe }, isSignupProfilePageUrl: (rawUrl) => { const parsed = parseUrlSafely(rawUrl); - return Boolean(parsed && isSignupPageHost(parsed.hostname) && /\/create-account\/profile(?:[/?#]|$)/i.test(parsed.pathname || '')); + return Boolean(parsed && isSignupPageHost(parsed.hostname) && /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(parsed.pathname || '')); }, isRetryableContentScriptTransportError, isHotmailProvider, diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index b85736e..e6dbbc8 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -85,7 +85,7 @@ function fallbackSignupProfilePageUrl(rawUrl) { const parsed = parseUrlSafely(rawUrl); if (!parsed) return false; - return /\/create-account\/profile(?:[/?#]|$)/i.test(parsed.pathname || ''); + return /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(parsed.pathname || ''); } function resolveSignupPostIdentityState(rawUrl) { diff --git a/background/verification-flow.js b/background/verification-flow.js index d3fd5e5..9a236e6 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -135,7 +135,7 @@ if (!['auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(host)) { return false; } - return /\/create-account\/profile(?:[/?#]|$)/i.test(String(parsed.pathname || '')); + return /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(String(parsed.pathname || '')); } catch { return false; } @@ -225,11 +225,21 @@ const authState = String(result?.state || '').trim(); const authUrl = String(result?.url || '').trim(); + const verificationErrorText = String(result?.verificationErrorText || '').trim(); lastSnapshot = { state: authState || 'unknown', url: authUrl, }; + if (authState === 'verification_page' && verificationErrorText) { + return { + success: false, + reason: 'invalid_code', + invalidCode: true, + errorText: verificationErrorText, + url: authUrl, + }; + } if (authState === 'oauth_consent_page') { return { success: true, @@ -1068,7 +1078,8 @@ }, }; let result; - if (typeof sendToContentScriptResilient === 'function') { + const shouldAvoidReplaySubmit = step === 8; + if (typeof sendToContentScriptResilient === 'function' && !shouldAvoidReplaySubmit) { try { result = await sendToContentScriptResilient('signup-page', message, { timeoutMs: Math.max(baseResponseTimeoutMs + 15000, 30000), @@ -1131,6 +1142,56 @@ } throw err; } + } else if (shouldAvoidReplaySubmit) { + try { + result = await sendToContentScript('signup-page', message, { + responseTimeoutMs: baseResponseTimeoutMs, + }); + } catch (err) { + if (isRetryableVerificationTransportError(err)) { + await addLog('认证页正在切换,等待页面重新就绪后继续确认验证码提交结果...', 'warn', { + step: completionStep, + stepKey: 'fetch-login-code', + }); + const fallback = await detectStep8PostSubmitFallback({ + step, + timeoutMs: 9000, + pollIntervalMs: 300, + }); + if (fallback.invalidCode) { + return { + invalidCode: true, + errorText: fallback.errorText || '验证码被拒绝。', + url: fallback.url || '', + }; + } + if (fallback.success) { + if (fallback.addPhonePage) { + await addLog('验证码提交后通信中断,但页面已进入手机号验证页,按提交成功继续。', 'warn', { + step: completionStep, + stepKey: 'fetch-login-code', + }); + } else { + await addLog('验证码提交后通信中断,但页面已进入 OAuth 授权页,按提交成功继续。', 'warn', { + step: completionStep, + stepKey: 'fetch-login-code', + }); + } + return { + success: true, + assumed: true, + transportRecovered: true, + addPhonePage: Boolean(fallback.addPhonePage), + url: fallback.url || '', + }; + } + if (fallback.restartStep7) { + const urlPart = fallback.url ? ` URL: ${fallback.url}` : ''; + throw new Error(`STEP8_RESTART_STEP7::步骤 ${completionStep}:验证码提交后认证页进入登录超时报错页,请回到步骤 ${authLoginStep} 重新开始。${urlPart}`.trim()); + } + } + throw err; + } } else { result = await sendToContentScript('signup-page', message, { responseTimeoutMs: baseResponseTimeoutMs, @@ -1275,20 +1336,8 @@ continue; } - const remainingBeforeResendMs = resendIntervalMs > 0 && lastResendAt > 0 - ? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt)) - : 0; - if (remainingBeforeResendMs > 0) { - await addLog( - `步骤 ${step}:提交失败后距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,先继续刷新邮箱(${attempt + 1}/${maxSubmitAttempts})...`, - 'warn' - ); - await sleepWithStop(Math.min(remainingBeforeResendMs, 2000)); - continue; - } - if (remainingAutomaticResendCount <= 0) { - await addLog(`步骤 ${step}:已达到自动重新发送验证码次数上限,将直接使用当前时间窗口继续重试。`, 'warn'); + await addLog(`步骤 ${step}:已达到自动重新发送验证码次数上限,将排除已拒绝验证码并继续轮询新邮件。`, 'warn'); continue; } diff --git a/content/signup-page.js b/content/signup-page.js index 03bf259..625955c 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -2627,7 +2627,7 @@ function isSignupProfilePageUrl(rawUrl = location.href) { if (!['auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(host)) { return false; } - return /\/create-account\/profile(?:[/?#]|$)/i.test(String(parsed.pathname || '')); + return /\/(?:create-account\/profile|u\/signup\/profile|signup\/profile)(?:[/?#]|$)/i.test(String(parsed.pathname || '')); } catch { return false; } @@ -3991,6 +3991,7 @@ function serializeLoginAuthState(snapshot) { url: snapshot?.url || location.href, path: snapshot?.path || location.pathname || '', displayedEmail: snapshot?.displayedEmail || '', + verificationErrorText: getVerificationErrorText(), retryEnabled: Boolean(snapshot?.retryEnabled), titleMatched: Boolean(snapshot?.titleMatched), detailMatched: Boolean(snapshot?.detailMatched), @@ -6028,14 +6029,23 @@ function getSerializableRect(el) { // Step 5: Fill Name & Birthday / Age // ============================================================ -function getStep5DirectCompletionPayload({ isAgeMode = false } = {}) { +function getStep5DirectCompletionPayload({ isAgeMode = false, navigationStarted = false, outcome = null } = {}) { const payload = { - skippedPostSubmitCheck: true, - directProceedToStep6: true, + profileSubmitted: true, + postSubmitChecked: true, }; if (isAgeMode) { payload.ageMode = true; } + if (navigationStarted) { + payload.navigationStarted = true; + } + if (outcome?.state) { + payload.outcome = outcome.state; + } + if (outcome?.url) { + payload.url = outcome.url; + } return payload; } @@ -6084,6 +6094,252 @@ async function waitForCombinedSignupVerificationProfilePage(timeout = 2500) { return isCombinedSignupVerificationProfilePage(); } +function getStep5ProfilePathPatterns() { + return [ + /\/create-account\/profile(?:[/?#]|$)/i, + /\/u\/signup\/profile(?:[/?#]|$)/i, + /\/signup\/profile(?:[/?#]|$)/i, + ]; +} + +function getStep5AuthRetryPathPatterns() { + const signupPatterns = typeof getSignupAuthRetryPathPatterns === 'function' + ? getSignupAuthRetryPathPatterns() + : []; + return [ + ...signupPatterns, + ...getStep5ProfilePathPatterns(), + ]; +} + +function isStep5ProfilePageUrl(rawUrl = location.href) { + return isSignupProfilePageUrl(rawUrl); +} + +function getStep5AuthRetryPageState() { + if (typeof getAuthTimeoutErrorPageState === 'function') { + return getAuthTimeoutErrorPageState({ + pathPatterns: getStep5AuthRetryPathPatterns(), + }); + } + + if (typeof getCurrentAuthRetryPageState === 'function') { + return getCurrentAuthRetryPageState('signup'); + } + + return null; +} + +function getStep5SubmitButton() { + const direct = document.querySelector('button[type="submit"], input[type="submit"]'); + if (direct && isVisibleElement(direct)) { + return direct; + } + + const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]'); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el)) return false; + const text = typeof getActionText === 'function' + ? getActionText(el) + : [ + el?.textContent, + el?.value, + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + ] + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + return /完成|创建|create|continue|finish|done|agree/i.test(text); + }) || null; +} + +async function waitForStep5SubmitButton(timeout = 5000) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + const button = getStep5SubmitButton(); + if (button) { + return button; + } + await sleep(150); + } + + return null; +} + +function isStep5SubmitButtonClickable(button) { + return Boolean(button) + && isVisibleElement(button) + && !button.disabled + && button.getAttribute?.('aria-disabled') !== 'true'; +} + +function isStep5ProfileStillVisible() { + if (isStep5ProfilePageUrl()) { + return true; + } + + return typeof isStep5Ready === 'function' ? isStep5Ready() : false; +} + +function getStep5PostSubmitSuccessState() { + if (getStep5AuthRetryPageState()) { + return null; + } + + if (isLikelyLoggedInChatgptHomeUrl()) { + return { + state: 'logged_in_home', + url: location.href, + }; + } + + if (typeof isOAuthConsentPage === 'function' && isOAuthConsentPage()) { + return { + state: 'oauth_consent', + url: location.href, + }; + } + + if (typeof isAddPhonePageReady === 'function' && isAddPhonePageReady()) { + return { + state: 'add_phone', + url: location.href, + }; + } + + if (!isStep5ProfileStillVisible()) { + return { + state: 'left_profile', + url: location.href, + }; + } + + return null; +} + +function installStep5NavigationCompletionReporter(completeOnce) { + if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') { + return () => {}; + } + + const onNavigationStarted = () => { + completeOnce({ + navigationStarted: true, + outcome: { + state: 'navigation_started', + url: location.href, + }, + }); + }; + + window.addEventListener('pagehide', onNavigationStarted, { once: true }); + window.addEventListener('beforeunload', onNavigationStarted, { once: true }); + + return () => { + window.removeEventListener('pagehide', onNavigationStarted); + window.removeEventListener('beforeunload', onNavigationStarted); + }; +} + +async function waitForStep5SubmitOutcome(options = {}) { + const { + timeoutMs = 45000, + maxAuthRetryRecoveries = 2, + maxSubmitClicks = 3, + retryClickIntervalMs = 3500, + } = options; + const start = Date.now(); + let authRetryRecoveryCount = 0; + let submitClickCount = 1; + let lastSubmitClickAt = Date.now(); + let lastStep5Error = ''; + + while (Date.now() - start < timeoutMs) { + throwIfStopped(); + + const retryState = getStep5AuthRetryPageState(); + if (retryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + if (retryState?.maxCheckAttemptsBlocked) { + throw createAuthMaxCheckAttemptsError(); + } + if (retryState) { + if (authRetryRecoveryCount >= maxAuthRetryRecoveries) { + throw new Error(`步骤 5:资料提交后连续进入认证重试页 ${maxAuthRetryRecoveries} 次,页面仍未恢复。URL: ${location.href}`); + } + authRetryRecoveryCount += 1; + log(`步骤 5:资料提交后进入认证重试页,正在自动恢复(${authRetryRecoveryCount}/${maxAuthRetryRecoveries})...`, 'warn'); + await recoverCurrentAuthRetryPage({ + flow: 'signup', + logLabel: '步骤 5:资料提交后检测到认证重试页,正在点击“重试”恢复', + maxClickAttempts: 2, + pathPatterns: getStep5AuthRetryPathPatterns(), + step: 5, + timeoutMs: 12000, + }); + lastSubmitClickAt = Date.now(); + continue; + } + + const successState = getStep5PostSubmitSuccessState(); + if (successState) { + return successState; + } + + const step5Error = typeof getStep5ErrorText === 'function' ? getStep5ErrorText() : ''; + if (step5Error) { + lastStep5Error = step5Error; + } + + if ( + isStep5ProfileStillVisible() + && submitClickCount < maxSubmitClicks + && Date.now() - lastSubmitClickAt >= retryClickIntervalMs + ) { + const submitButton = getStep5SubmitButton(); + if (isStep5SubmitButtonClickable(submitButton)) { + submitClickCount += 1; + log(`步骤 5:资料提交后仍停留在资料页,正在重新点击“完成帐户创建”(第 ${submitClickCount}/${maxSubmitClicks} 次)...`, 'warn'); + await humanPause(350, 900); + simulateClick(submitButton); + lastSubmitClickAt = Date.now(); + await sleep(1000); + continue; + } + } + + await sleep(250); + } + + const finalRetryState = getStep5AuthRetryPageState(); + if (finalRetryState?.userAlreadyExistsBlocked) { + throw createSignupUserAlreadyExistsError(); + } + if (finalRetryState?.maxCheckAttemptsBlocked) { + throw createAuthMaxCheckAttemptsError(); + } + if (finalRetryState) { + throw new Error(`步骤 5:资料提交后仍停留在认证重试页,自动恢复未完成。URL: ${location.href}`); + } + + const finalSuccessState = getStep5PostSubmitSuccessState(); + if (finalSuccessState) { + return finalSuccessState; + } + + const finalStep5Error = (typeof getStep5ErrorText === 'function' ? getStep5ErrorText() : '') || lastStep5Error; + if (finalStep5Error) { + throw new Error(`步骤 5:资料提交后页面返回错误:${finalStep5Error}。URL: ${location.href}`); + } + + throw new Error(`步骤 5:资料提交后未检测到页面跳转或恢复成功(已点击提交 ${submitClickCount}/${maxSubmitClicks} 次)。URL: ${location.href}`); +} + async function step5_fillNameBirthday(payload) { const { firstName, lastName, age, year, month, day, prefillOnly = false } = payload; if (!firstName || !lastName) throw new Error('未提供姓名数据。'); @@ -6291,7 +6547,7 @@ async function step5_fillNameBirthday(payload) { // Click "完成帐户创建" button await sleep(500); - const completeBtn = document.querySelector('button[type="submit"]') + const completeBtn = await waitForStep5SubmitButton(5000) || await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null); if (!completeBtn) { throw new Error('未找到“完成帐户创建”按钮。URL: ' + location.href); @@ -6299,20 +6555,40 @@ async function step5_fillNameBirthday(payload) { const isAgeMode = !birthdayMode && Boolean(ageInput); if (isAgeMode) { - log('步骤 5:当前为年龄输入模式,点击“完成帐户创建”后将直接完成当前步骤。', 'warn'); + log('步骤 5:当前为年龄输入模式,点击“完成帐户创建”后将等待页面结果。', 'info'); } - await humanPause(500, 1300); - simulateClick(completeBtn); + let reportedCompletionPayload = null; + function completeStep5Once(extra = {}) { + if (reportedCompletionPayload) { + return reportedCompletionPayload; + } - const completionPayload = getStep5DirectCompletionPayload({ isAgeMode }); - reportComplete(5, completionPayload); - - if (isAgeMode) { - log('步骤 5:年龄模式已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。', 'warn'); + const completionPayload = getStep5DirectCompletionPayload({ + isAgeMode, + navigationStarted: Boolean(extra.navigationStarted), + outcome: extra.outcome || null, + }); + reportedCompletionPayload = completionPayload; + reportComplete(5, completionPayload); return completionPayload; } - log('步骤 5:已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。'); - return completionPayload; + const cleanupNavigationReporter = installStep5NavigationCompletionReporter(completeStep5Once); + + await humanPause(500, 1300); + simulateClick(completeBtn); + log('步骤 5:已点击“完成帐户创建”,正在等待页面跳转、重试页或提交结果。'); + + try { + const outcome = await waitForStep5SubmitOutcome(); + cleanupNavigationReporter(); + + const completionPayload = completeStep5Once({ outcome }); + log(`步骤 5:资料提交结果已确认(${outcome.state || 'success'}),准备继续后续步骤。`, 'ok'); + return completionPayload; + } catch (error) { + cleanupNavigationReporter(); + throw error; + } } diff --git a/tests/step5-age-consent.test.js b/tests/step5-age-consent.test.js index 2199a00..2cffdfe 100644 --- a/tests/step5-age-consent.test.js +++ b/tests/step5-age-consent.test.js @@ -54,9 +54,21 @@ function extractFunction(name) { function getStep5Bundle() { return [ extractFunction('getStep5DirectCompletionPayload'), + extractFunction('isSignupProfilePageUrl'), extractFunction('isStep5AllConsentText'), extractFunction('findStep5AllConsentCheckbox'), extractFunction('isStep5CheckboxChecked'), + extractFunction('getStep5ProfilePathPatterns'), + extractFunction('getStep5AuthRetryPathPatterns'), + extractFunction('isStep5ProfilePageUrl'), + extractFunction('getStep5AuthRetryPageState'), + extractFunction('getStep5SubmitButton'), + extractFunction('waitForStep5SubmitButton'), + extractFunction('isStep5SubmitButtonClickable'), + extractFunction('isStep5ProfileStillVisible'), + extractFunction('getStep5PostSubmitSuccessState'), + extractFunction('installStep5NavigationCompletionReporter'), + extractFunction('waitForStep5SubmitOutcome'), extractFunction('step5_fillNameBirthday'), ].join('\n'); } @@ -73,6 +85,9 @@ const completeButton = { tagName: 'BUTTON', textContent: '\\u5b8c\\u6210\\u8d26\\u6237\\u521b\\u5efa', hidden: false, + getAttribute() { + return ''; + }, }; const allConsentLabel = { hidden: false, @@ -110,6 +125,7 @@ const document = { return null; case 'input[name="age"]': return ageInput; + case 'button[type="submit"], input[type="submit"]': case 'button[type="submit"]': return completeButton; default: @@ -136,6 +152,8 @@ function log(message, level = 'info') { logs.push({ message, level }); } +function throwIfStopped() {} + async function waitForElement() { return nameInput; } @@ -155,6 +173,14 @@ function isVisibleElement(el) { return Boolean(el) && !el.hidden; } +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return el.textContent || ''; +} + async function setReactAriaBirthdaySelect() { throw new Error('setReactAriaBirthdaySelect should not run in age-mode test'); } @@ -168,6 +194,9 @@ function simulateClick(el) { if (el === allConsentLabel || el === allConsentCheckbox) { allConsentCheckbox.checked = true; } + if (el === completeButton) { + location.href = 'https://chatgpt.com/'; + } } function reportComplete(step, payload) { @@ -178,6 +207,17 @@ function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); } +function getSignupAuthRetryPathPatterns() { return []; } +function getAuthTimeoutErrorPageState() { return null; } +async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); } +function createSignupUserAlreadyExistsError() { return new Error('user already exists'); } +function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); } +function getStep5ErrorText() { return ''; } +function isStep5Ready() { return /^https:\\/\\/auth\\.openai\\.com\\//.test(location.href); } +function isLikelyLoggedInChatgptHomeUrl() { return /^https:\\/\\/chatgpt\\.com\\//.test(location.href); } +function isOAuthConsentPage() { return false; } +function isAddPhonePageReady() { return false; } + ${getStep5Bundle()} return { @@ -205,9 +245,11 @@ return { const snapshot = api.snapshot(); assert.deepStrictEqual(result, { - skippedPostSubmitCheck: true, - directProceedToStep6: true, + profileSubmitted: true, + postSubmitChecked: true, ageMode: true, + outcome: 'logged_in_home', + url: 'https://chatgpt.com/', }); assert.equal(snapshot.nameValue, 'Mia Harris'); assert.equal(snapshot.ageValue, '19'); @@ -220,9 +262,11 @@ return { { step: 5, payload: { - skippedPostSubmitCheck: true, - directProceedToStep6: true, + profileSubmitted: true, + postSubmitChecked: true, ageMode: true, + outcome: 'logged_in_home', + url: 'https://chatgpt.com/', }, }, ]); diff --git a/tests/step5-direct-complete.test.js b/tests/step5-direct-complete.test.js index 1c11b6e..f485b9d 100644 --- a/tests/step5-direct-complete.test.js +++ b/tests/step5-direct-complete.test.js @@ -51,21 +51,43 @@ function extractFunction(name) { return source.slice(start, end); } +function getStep5OutcomeBundle() { + return [ + extractFunction('getStep5ProfilePathPatterns'), + extractFunction('getStep5AuthRetryPathPatterns'), + extractFunction('isStep5ProfilePageUrl'), + extractFunction('getStep5AuthRetryPageState'), + extractFunction('getStep5SubmitButton'), + extractFunction('waitForStep5SubmitButton'), + extractFunction('isStep5SubmitButtonClickable'), + extractFunction('isStep5ProfileStillVisible'), + extractFunction('getStep5PostSubmitSuccessState'), + extractFunction('installStep5NavigationCompletionReporter'), + extractFunction('waitForStep5SubmitOutcome'), + ].join('\n'); +} + function getStep5Bundle() { return [ extractFunction('getStep5DirectCompletionPayload'), + extractFunction('isSignupProfilePageUrl'), extractFunction('isStep5AllConsentText'), extractFunction('findStep5AllConsentCheckbox'), extractFunction('isStep5CheckboxChecked'), + getStep5OutcomeBundle(), extractFunction('step5_fillNameBirthday'), ].join('\n'); } -test('step 5 clicks submit and completes immediately on birthday page', async () => { +test('step 5 waits for post-submit outcome before completing on birthday page', async () => { const step5Source = extractFunction('step5_fillNameBirthday'); assert.ok( - !step5Source.includes('waitForStep5SubmitOutcome('), - 'Step 5 提交后不应再等待页面结果' + step5Source.includes('waitForStep5SubmitOutcome('), + 'Step 5 提交后必须等待页面结果' + ); + assert.ok( + !step5Source.includes('不再等待页面结果'), + 'Step 5 不应再保留直接完成的旧日志' ); const api = new Function(` @@ -84,6 +106,7 @@ const completeButton = { tagName: 'BUTTON', textContent: '完成帐户创建', hidden: false, + getAttribute() { return ''; }, }; const birthdaySelects = { @@ -92,6 +115,10 @@ const birthdaySelects = { '天': { label: '天', button: { hidden: false }, nativeSelect: {} }, }; +const location = { + href: 'https://auth.openai.com/u/signup/profile', +}; + const document = { querySelector(selector) { switch (selector) { @@ -102,6 +129,7 @@ const document = { return null; case 'input[name="birthday"]': return hiddenBirthday; + case 'button[type="submit"], input[type="submit"]': case 'button[type="submit"]': return completeButton; default: @@ -112,15 +140,14 @@ const document = { if (selector === 'input[name="allCheckboxes"][type="checkbox"]') { return []; } + if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') { + return [completeButton]; + } return []; }, execCommand() {}, }; -const location = { - href: 'https://auth.openai.com/u/signup/profile', -}; - function Event(type, init = {}) { this.type = type; this.bubbles = Boolean(init.bubbles); @@ -130,10 +157,8 @@ function log(message, level = 'info') { logs.push({ message, level }); } -async function waitForElement() { - return nameInput; -} - +function throwIfStopped() {} +async function waitForElement() { return nameInput; } async function humanPause() {} async function sleep() {} @@ -149,6 +174,14 @@ function isVisibleElement(el) { return Boolean(el) && !el.hidden; } +function isActionEnabled(el) { + return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; +} + +function getActionText(el) { + return el.textContent || ''; +} + async function setReactAriaBirthdaySelect(select, value) { selectedBirthday[select.label] = String(value).padStart(select.label === '年' ? 4 : 2, '0'); if (selectedBirthday['年'] && selectedBirthday['月'] && selectedBirthday['天']) { @@ -162,17 +195,30 @@ async function waitForElementByText() { function simulateClick(el) { clicks.push(el.textContent || el.tagName || 'element'); + if (el === completeButton) { + location.href = 'https://chatgpt.com/'; + } } function reportComplete(step, payload) { completions.push({ step, payload }); } - function normalizeInlineText(text) { - return String(text || '').replace(/\\s+/g, ' ').trim(); - } +function normalizeInlineText(text) { + return String(text || '').replace(/\\s+/g, ' ').trim(); +} +function getSignupAuthRetryPathPatterns() { return []; } +function getAuthTimeoutErrorPageState() { return null; } +async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); } +function createSignupUserAlreadyExistsError() { return new Error('user already exists'); } +function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); } +function getStep5ErrorText() { return ''; } +function isStep5Ready() { return /^https:\\/\\/auth\\.openai\\.com\\//.test(location.href); } +function isLikelyLoggedInChatgptHomeUrl() { return /^https:\\/\\/chatgpt\\.com\\//.test(location.href); } +function isOAuthConsentPage() { return false; } +function isAddPhonePageReady() { return false; } - ${getStep5Bundle()} +${getStep5Bundle()} return { async run(payload) { @@ -199,20 +245,20 @@ return { }); const snapshot = api.snapshot(); - assert.deepStrictEqual( - result, - { - skippedPostSubmitCheck: true, - directProceedToStep6: true, - }, - '生日模式点击提交后应直接返回完成载荷' - ); + assert.deepStrictEqual(result, { + profileSubmitted: true, + postSubmitChecked: true, + outcome: 'logged_in_home', + url: 'https://chatgpt.com/', + }); assert.deepStrictEqual(snapshot.completions, [ { step: 5, payload: { - skippedPostSubmitCheck: true, - directProceedToStep6: true, + profileSubmitted: true, + postSubmitChecked: true, + outcome: 'logged_in_home', + url: 'https://chatgpt.com/', }, }, ]); @@ -220,7 +266,185 @@ return { assert.equal(snapshot.nameValue, 'Test User'); assert.equal(snapshot.birthdayValue, '2003-06-19'); assert.ok( - snapshot.logs.some(({ message }) => /不再等待页面结果/.test(message)), - '日志应明确说明 Step 5 已直接完成' + snapshot.logs.some(({ message }) => /资料提交结果已确认/.test(message)), + '日志应明确说明 Step 5 已完成提交后检测' ); }); + +test('step 5 retries submit while profile page remains visible', async () => { + const api = new Function(` +const realDateNow = Date.now; +let now = 0; +const logs = []; +const completions = []; +const clicks = []; +const nameInput = { value: '', hidden: false }; +const ageInput = { value: '', hidden: false }; +const completeButton = { + tagName: 'BUTTON', + textContent: '完成帐户创建', + hidden: false, + getAttribute() { return ''; }, +}; +const location = { + href: 'https://auth.openai.com/u/signup/profile', +}; +const document = { + querySelector(selector) { + switch (selector) { + case '[role="spinbutton"][data-type="year"]': + case '[role="spinbutton"][data-type="month"]': + case '[role="spinbutton"][data-type="day"]': + case 'input[name="birthday"]': + return null; + case 'input[name="age"]': + return /^https:\\/\\/auth\\.openai\\.com\\//.test(location.href) ? ageInput : null; + case 'button[type="submit"], input[type="submit"]': + case 'button[type="submit"]': + return completeButton; + default: + return null; + } + }, + querySelectorAll(selector) { + if (selector === 'input[name="allCheckboxes"][type="checkbox"]') return []; + if (selector === 'input[type="checkbox"]') return []; + if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [completeButton]; + return []; + }, + execCommand() {}, +}; + +Date.now = () => now; +function log(message, level = 'info') { logs.push({ message, level }); } +function throwIfStopped() {} +async function waitForElement() { return nameInput; } +async function humanPause() {} +async function sleep(ms = 0) { now += ms || 250; } +function fillInput(input, value) { input.value = value; } +function findBirthdayReactAriaSelect() { return null; } +function isVisibleElement(el) { return Boolean(el) && !el.hidden; } +function isActionEnabled(el) { return Boolean(el) && !el.disabled && el.getAttribute?.('aria-disabled') !== 'true'; } +function getActionText(el) { return el.textContent || ''; } +async function setReactAriaBirthdaySelect() { throw new Error('setReactAriaBirthdaySelect should not run'); } +async function waitForElementByText() { return completeButton; } +function simulateClick(el) { + clicks.push(el.textContent || el.tagName || 'element'); + if (el === completeButton && clicks.filter((text) => text === '完成帐户创建').length >= 3) { + location.href = 'https://chatgpt.com/'; + } +} +function reportComplete(step, payload) { completions.push({ step, payload }); } +function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); } +function getSignupAuthRetryPathPatterns() { return []; } +function getAuthTimeoutErrorPageState() { return null; } +async function recoverCurrentAuthRetryPage() { throw new Error('should not recover retry page'); } +function createSignupUserAlreadyExistsError() { return new Error('user already exists'); } +function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); } +function getStep5ErrorText() { return ''; } +function isStep5Ready() { return /^https:\\/\\/auth\\.openai\\.com\\//.test(location.href); } +function isLikelyLoggedInChatgptHomeUrl() { return /^https:\\/\\/chatgpt\\.com\\//.test(location.href); } +function isOAuthConsentPage() { return false; } +function isAddPhonePageReady() { return false; } + +${getStep5Bundle()} + +return { + run() { + return step5_fillNameBirthday({ + firstName: 'Mia', + lastName: 'Harris', + age: 19, + }); + }, + snapshot() { + return { logs, completions, clicks, nameValue: nameInput.value, ageValue: ageInput.value }; + }, + restore() { + Date.now = realDateNow; + }, +}; +`)(); + + let result; + try { + result = await api.run(); + } finally { + api.restore(); + } + const snapshot = api.snapshot(); + + assert.deepStrictEqual(result, { + profileSubmitted: true, + postSubmitChecked: true, + ageMode: true, + outcome: 'logged_in_home', + url: 'https://chatgpt.com/', + }); + assert.equal(snapshot.nameValue, 'Mia Harris'); + assert.equal(snapshot.ageValue, '19'); + assert.equal(snapshot.clicks.filter((text) => text === '完成帐户创建').length, 3); + assert.equal(snapshot.logs.some(({ message }) => /仍停留在资料页,正在重新点击/.test(message)), true); +}); + +test('step 5 recovers auth retry page after profile submit', async () => { + const api = new Function(` +let retryVisible = true; +let recoverCalls = 0; +const location = { href: 'https://auth.openai.com/create-account/profile' }; +const document = { + querySelector() { return null; }, + querySelectorAll() { return []; }, +}; + +function throwIfStopped() {} +function log() {} +async function sleep() {} +async function humanPause() {} +function simulateClick() {} +function isVisibleElement() { return true; } +function isActionEnabled() { return true; } +function getActionText(el) { return el?.textContent || ''; } +function getSignupAuthRetryPathPatterns() { return []; } +function getAuthTimeoutErrorPageState() { + if (!retryVisible) return null; + return { + retryEnabled: true, + userAlreadyExistsBlocked: false, + maxCheckAttemptsBlocked: false, + }; +} +async function recoverCurrentAuthRetryPage() { + recoverCalls += 1; + retryVisible = false; + location.href = 'https://chatgpt.com/'; +} +function createSignupUserAlreadyExistsError() { return new Error('user already exists'); } +function createAuthMaxCheckAttemptsError() { return new Error('max_check_attempts'); } +function getStep5ErrorText() { return ''; } +function isStep5Ready() { return /^https:\\/\\/auth\\.openai\\.com\\//.test(location.href); } +function isLikelyLoggedInChatgptHomeUrl() { return /^https:\\/\\/chatgpt\\.com\\//.test(location.href); } +function isOAuthConsentPage() { return false; } +function isAddPhonePageReady() { return false; } + +${extractFunction('isSignupProfilePageUrl')} +${getStep5OutcomeBundle()} + +return { + run() { + return waitForStep5SubmitOutcome({ timeoutMs: 1000 }); + }, + snapshot() { + return { recoverCalls }; + }, +}; +`)(); + + const result = await api.run(); + + assert.deepStrictEqual(result, { + state: 'logged_in_home', + url: 'https://chatgpt.com/', + }); + assert.equal(api.snapshot().recoverCalls, 1); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 85a5ea8..3b024f8 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -448,7 +448,12 @@ test('verification flow keeps step 8 successful when code submit transport fails pollCloudflareTempEmailVerificationCode: async () => ({}), pollHotmailVerificationCode: async () => ({}), pollLuckmailVerificationCode: async () => ({}), - sendToContentScript: async () => ({}), + sendToContentScript: async (_source, message) => { + if (message.type === 'FILL_CODE') { + throw new Error('message channel is closed before a response was received'); + } + return {}; + }, sendToContentScriptResilient: async (_source, message) => { if (message.type === 'FILL_CODE') { throw new Error('message channel is closed before a response was received'); @@ -1282,6 +1287,151 @@ test('verification flow uses resilient signup-page transport when submitting ver assert.ok(resilientCalls[0].options.timeoutMs >= 30000); }); +test('verification flow does not replay step 8 code submit after transient auth-page transport failure', async () => { + const directMessages = []; + const resilientMessages = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isRetryableContentScriptTransportError: (error) => /did not respond/i.test(String(error?.message || error || '')), + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + directMessages.push(message.type); + if (message.type === 'FILL_CODE') { + throw new Error('步骤 8:页面通信异常 did not respond in 30s'); + } + return {}; + }, + sendToContentScriptResilient: async (_source, message) => { + resilientMessages.push(message.type); + if (message.type === 'GET_LOGIN_AUTH_STATE') { + return { + state: 'verification_page', + verificationErrorText: '代码不正确', + url: 'https://auth.openai.com/email-verification', + }; + } + throw new Error('FILL_CODE should not be replayed through resilient transport'); + }, + sendToMailContentScriptResilient: async () => ({}), + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + const result = await helpers.submitVerificationCode(8, '510725', { completionStep: 11 }); + + assert.deepStrictEqual(result, { + invalidCode: true, + errorText: '代码不正确', + url: 'https://auth.openai.com/email-verification', + }); + assert.deepStrictEqual(directMessages, ['FILL_CODE']); + assert.deepStrictEqual(resilientMessages, ['GET_LOGIN_AUTH_STATE']); +}); + +test('verification flow requests a new code immediately after Cloudflare Temp Email code is rejected', async () => { + const events = []; + const pollPayloads = []; + const completed = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async (_step, payload) => { + completed.push(payload); + }, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async (_step, _state, payload) => { + pollPayloads.push(payload); + const code = pollPayloads.length === 1 ? '111111' : '222222'; + events.push(['poll', code]); + return { code, emailTimestamp: pollPayloads.length }; + }, + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + if (message.type === 'RESEND_VERIFICATION_CODE') { + events.push(['resend', message.step]); + return {}; + } + if (message.type === 'FILL_CODE') { + events.push(['submit', message.payload.code]); + return message.payload.code === '111111' + ? { invalidCode: true, errorText: '代码不正确' } + : { success: true }; + } + return {}; + }, + sendToMailContentScriptResilient: async () => ({}), + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.resolveVerificationStep( + 8, + { + email: 'user@example.com', + loginVerificationRequestedAt: Date.now(), + verificationResendCount: 1, + }, + { provider: 'cloudflare-temp-email', label: 'Cloudflare Temp Email' }, + { + completionStep: 11, + lastResendAt: Date.now(), + resendIntervalMs: 25000, + } + ); + + assert.deepStrictEqual(events, [ + ['poll', '111111'], + ['submit', '111111'], + ['resend', 8], + ['poll', '222222'], + ['submit', '222222'], + ]); + assert.deepStrictEqual(pollPayloads[1].excludeCodes, ['111111']); + assert.equal(completed[0].code, '222222'); +}); + test('verification flow forwards optional signup profile payload when submitting signup verification code', async () => { const resilientCalls = []; From dfe5139250ab85a892a8e66969a8d9a0d50b7e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B4=E5=9C=A3=E4=BD=91?= Date: Sat, 9 May 2026 23:23:32 +0800 Subject: [PATCH 03/11] feat(gpc): add auto mode permission checks --- background.js | 43 ++- background/steps/create-plus-checkout.js | 99 ++++++- background/steps/fill-plus-checkout.js | 101 ++++++- data/step-definitions.js | 2 +- gopay-utils.js | 99 ++++++- sidepanel/sidepanel.html | 7 + sidepanel/sidepanel.js | 278 +++++++++++++++++- ...ackground-account-history-settings.test.js | 7 + ...ckground-message-router-step2-skip.test.js | 4 +- tests/gopay-utils.test.js | 19 +- ...us-checkout-billing-tab-resolution.test.js | 108 +++++++ tests/plus-checkout-create-wait.test.js | 251 ++++++++++++++-- ...sidepanel-auto-run-content-refresh.test.js | 3 + tests/sidepanel-plus-payment-method.test.js | 126 +++++++- tests/step-definitions-module.test.js | 5 +- 项目完整链路说明.md | 6 +- 16 files changed, 1086 insertions(+), 72 deletions(-) diff --git a/background.js b/background.js index 05e6e94..3b301de 100644 --- a/background.js +++ b/background.js @@ -456,6 +456,11 @@ function normalizePlusPaymentMethod(value = '') { return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL; } +function normalizeGpcHelperPhoneMode(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'auto' || normalized === 'builtin' ? 'auto' : 'manual'; +} + function normalizeContributionModeSource(value = '') { const normalized = String(value || '').trim().toLowerCase(); return normalized === CONTRIBUTION_SOURCE_SUB2API @@ -634,6 +639,7 @@ const PERSISTED_SETTING_DEFAULTS = { gopayHelperApiUrl: DEFAULT_GPC_HELPER_API_URL, gopayHelperApiKey: '', gopayHelperCardKey: '', + gopayHelperPhoneMode: 'manual', gopayHelperPhoneNumber: '', gopayHelperCountryCode: '+86', gopayHelperPin: '', @@ -665,6 +671,9 @@ const PERSISTED_SETTING_DEFAULTS = { gopayHelperBalancePayload: null, gopayHelperBalanceUpdatedAt: 0, gopayHelperBalanceError: '', + gopayHelperRemainingUses: 0, + gopayHelperAutoModeEnabled: false, + gopayHelperApiKeyStatus: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, oauthFlowTimeoutEnabled: true, @@ -2330,6 +2339,10 @@ function normalizePersistentSettingValue(key, value) { return self.GoPayUtils?.normalizeGoPayPin ? self.GoPayUtils.normalizeGoPayPin(value) : String(value || ''); + case 'gopayHelperPhoneMode': + return self.GoPayUtils?.normalizeGpcHelperPhoneMode + ? self.GoPayUtils.normalizeGpcHelperPhoneMode(value) + : (String(value || '').trim().toLowerCase() === 'auto' || String(value || '').trim().toLowerCase() === 'builtin' ? 'auto' : 'manual'); case 'gopayHelperPhoneNumber': return self.GoPayUtils?.normalizeGoPayPhone ? self.GoPayUtils.normalizeGoPayPhone(value) @@ -2404,6 +2417,7 @@ function normalizePersistentSettingValue(key, value) { case 'gopayHelperFailureDetail': case 'gopayHelperBalance': case 'gopayHelperBalanceError': + case 'gopayHelperApiKeyStatus': return String(value || '').trim(); case 'gopayHelperBalancePayload': case 'gopayHelperStartPayload': @@ -2412,10 +2426,12 @@ function normalizePersistentSettingValue(key, value) { case 'gopayHelperBalanceUpdatedAt': case 'gopayHelperApiInputWaitSeconds': case 'gopayHelperOtpInvalidCount': + case 'gopayHelperRemainingUses': return Math.max(0, Number(value) || 0); case 'autoRunSkipFailures': case 'oauthFlowTimeoutEnabled': case 'gopayHelperLocalSmsHelperEnabled': + case 'gopayHelperAutoModeEnabled': case 'autoRunDelayEnabled': case 'step6CookieCleanupEnabled': case 'phoneVerificationEnabled': @@ -9953,12 +9969,27 @@ async function refreshGpcApiKeyBalance(state = {}, options = {}) { const balancePayload = self.GoPayUtils?.unwrapGpcResponse ? self.GoPayUtils.unwrapGpcResponse(payload) : (payload?.data && typeof payload === 'object' ? payload.data : payload); + const balanceData = balancePayload && typeof balancePayload === 'object' && !Array.isArray(balancePayload) + ? balancePayload + : {}; + const remainingUses = self.GoPayUtils?.getGpcBalanceRemainingUses + ? self.GoPayUtils.getGpcBalanceRemainingUses(balanceData) + : Math.max(0, Number(balanceData.remaining_uses ?? balanceData.remainingUses ?? balanceData.balance ?? balanceData.remaining) || 0); + const autoModeEnabled = self.GoPayUtils?.isGpcAutoModeEnabled + ? self.GoPayUtils.isGpcAutoModeEnabled(balanceData) + : Boolean(balanceData.auto_mode_enabled ?? balanceData.autoModeEnabled); + const apiKeyStatus = self.GoPayUtils?.getGpcApiKeyStatus + ? self.GoPayUtils.getGpcApiKeyStatus(balanceData) + : String(balanceData.status || balanceData.card_status || balanceData.cardStatus || '').trim(); const balanceText = formatGpcApiKeyBalancePayload(payload) || rawText || '未知'; const updates = { gopayHelperBalance: balanceText, - gopayHelperBalancePayload: balancePayload && typeof balancePayload === 'object' && !Array.isArray(balancePayload) ? balancePayload : { raw: String(balancePayload || '') }, + gopayHelperBalancePayload: Object.keys(balanceData).length > 0 ? balanceData : { raw: String(balancePayload || '') }, gopayHelperBalanceUpdatedAt: Date.now(), gopayHelperBalanceError: '', + gopayHelperRemainingUses: Math.max(0, Number(remainingUses) || 0), + gopayHelperAutoModeEnabled: Boolean(autoModeEnabled), + gopayHelperApiKeyStatus: apiKeyStatus, }; const flowId = String(balancePayload?.flow_id || balancePayload?.flowId || '').trim(); if (flowId) { @@ -9987,7 +10018,15 @@ async function refreshGpcApiKeyBalance(state = {}, options = {}) { : `GPC 余额查询成功:${balanceText}`, 'info' ); - return { balance: balanceText, payload, updatedAt: updates.gopayHelperBalanceUpdatedAt }; + return { + balance: balanceText, + payload, + data: updates.gopayHelperBalancePayload, + remainingUses: updates.gopayHelperRemainingUses, + autoModeEnabled: updates.gopayHelperAutoModeEnabled, + apiKeyStatus: updates.gopayHelperApiKeyStatus, + updatedAt: updates.gopayHelperBalanceUpdatedAt, + }; } const refreshGpcCardBalance = refreshGpcApiKeyBalance; diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index c22cb30..0651049 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -8,6 +8,8 @@ const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; + const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; function createPlusCheckoutCreateExecutor(deps = {}) { const { @@ -86,6 +88,17 @@ return cleaned; } + function normalizeGpcHelperPhoneMode(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.normalizeGpcHelperPhoneMode) { + return rootScope.GoPayUtils.normalizeGpcHelperPhoneMode(value); + } + const normalized = String(value || '').trim().toLowerCase(); + return normalized === GPC_HELPER_PHONE_MODE_AUTO || normalized === 'builtin' + ? GPC_HELPER_PHONE_MODE_AUTO + : GPC_HELPER_PHONE_MODE_MANUAL; + } + function normalizeGpcOtpChannel(value = '') { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) { @@ -143,6 +156,17 @@ return buildGpcHelperApiUrl(apiUrl, '/api/gp/tasks'); } + function buildGpcBalanceUrl(apiUrl = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.buildGpcApiKeyBalanceUrl) { + return rootScope.GoPayUtils.buildGpcApiKeyBalanceUrl(apiUrl); + } + if (rootScope.GoPayUtils?.buildGpcCardBalanceUrl) { + return rootScope.GoPayUtils.buildGpcCardBalanceUrl(apiUrl); + } + return buildGpcHelperApiUrl(apiUrl, '/api/gp/balance'); + } + function unwrapGpcResponse(payload = {}) { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.GoPayUtils?.unwrapGpcResponse) { @@ -176,6 +200,55 @@ return payload?.data?.detail || payload?.detail || payload?.message || payload?.error || `HTTP ${status || 0}`; } + function getGpcRemainingUses(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.getGpcBalanceRemainingUses) { + return rootScope.GoPayUtils.getGpcBalanceRemainingUses(payload); + } + const data = unwrapGpcResponse(payload); + const numeric = Number(data?.remaining_uses ?? data?.remainingUses ?? data?.balance ?? data?.remaining); + return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : null; + } + + function isGpcAutoModeEnabled(payload = {}) { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.isGpcAutoModeEnabled) { + return rootScope.GoPayUtils.isGpcAutoModeEnabled(payload); + } + const data = unwrapGpcResponse(payload); + return data?.auto_mode_enabled === true || data?.autoModeEnabled === true; + } + + async function assertGpcApiKeyReadyForCreate(state = {}, phoneMode = GPC_HELPER_PHONE_MODE_MANUAL, apiKey = '') { + const apiUrl = buildGpcBalanceUrl(state?.gopayHelperApiUrl); + if (!apiUrl) { + throw new Error('创建 GPC 订单失败:缺少 API 地址。'); + } + const { response, data } = await fetchJsonWithTimeout(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-API-Key': apiKey, + }, + }, 30000); + if (!response?.ok || !isGpcUnifiedResponseOk(data)) { + const detail = getGpcResponseErrorDetail(data, response?.status || 0); + throw new Error(`创建 GPC 订单失败:API Key 校验失败:${detail}`); + } + const balanceData = unwrapGpcResponse(data); + const remainingUses = getGpcRemainingUses(balanceData); + const status = String(balanceData?.status || balanceData?.card_status || balanceData?.cardStatus || '').trim().toLowerCase(); + if (status && status !== 'active') { + throw new Error(`创建 GPC 订单失败:API Key 状态不可用(${status})。`); + } + if (remainingUses !== null && remainingUses <= 0) { + throw new Error('创建 GPC 订单失败:API Key 剩余次数不足。'); + } + if (phoneMode === GPC_HELPER_PHONE_MODE_AUTO && !isGpcAutoModeEnabled(balanceData)) { + throw new Error('创建 GPC 订单失败:当前 GPC API Key 未开通自动模式。'); + } + } + async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 30000) { const fetcher = typeof fetchImpl === 'function' ? fetchImpl @@ -226,25 +299,31 @@ if (!apiUrl) { throw new Error('创建 GPC 订单失败:缺少 API 地址。'); } + const phoneMode = normalizeGpcHelperPhoneMode(state?.gopayHelperPhoneMode || state?.phoneMode); + const isAutoMode = phoneMode === GPC_HELPER_PHONE_MODE_AUTO; const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim(); const countryCode = normalizeHelperCountryCode(state?.gopayHelperCountryCode || '86'); const pin = String(state?.gopayHelperPin || '').trim(); const apiKey = resolveGpcHelperApiKey(state); - if (!phoneNumber) { - throw new Error('创建 GPC 订单失败:缺少手机号。'); + if (!isAutoMode && !phoneNumber) { + throw new Error('创建 GPC 订单失败:手动模式缺少手机号。'); } - if (!pin) { - throw new Error('创建 GPC 订单失败:缺少 PIN。'); + if (!isAutoMode && !pin) { + throw new Error('创建 GPC 订单失败:手动模式缺少 PIN。'); } + throwIfStopped(); + await assertGpcApiKeyReadyForCreate(state, phoneMode, apiKey); throwIfStopped(); const payload = { access_token: token, - phone_mode: 'manual', - country_code: countryCode, - phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode), - otp_channel: normalizeGpcOtpChannel(state?.gopayHelperOtpChannel), + phone_mode: phoneMode, }; + if (!isAutoMode) { + payload.country_code = countryCode; + payload.phone_number = normalizeHelperPhoneNumber(phoneNumber, countryCode); + payload.otp_channel = normalizeGpcOtpChannel(state?.gopayHelperOtpChannel); + } const orderCreatedAt = Date.now(); const { response, data } = await fetchJsonWithTimeout(apiUrl, { @@ -273,6 +352,7 @@ remoteStage: String(taskData?.remote_stage || taskData?.remoteStage || '').trim(), orderCreatedAt, responsePayload: taskData && typeof taskData === 'object' && !Array.isArray(taskData) ? taskData : null, + phoneMode: normalizeGpcHelperPhoneMode(taskData?.phone_mode || taskData?.phoneMode || phoneMode), country: 'ID', currency: 'IDR', checkoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER, @@ -308,6 +388,7 @@ gopayHelperTaskStatus: result.taskStatus, gopayHelperStatusText: result.statusText, gopayHelperRemoteStage: result.remoteStage, + gopayHelperPhoneMode: result.phoneMode || normalizeGpcHelperPhoneMode(state?.gopayHelperPhoneMode || state?.phoneMode), gopayHelperTaskPayload: result.responsePayload, gopayHelperReferenceId: '', gopayHelperGoPayGuid: '', @@ -318,7 +399,7 @@ gopayHelperStartPayload: null, gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(), }); - await addLog(`步骤 6:GPC 任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info'); + await addLog(`步骤 6:GPC ${result.phoneMode === GPC_HELPER_PHONE_MODE_AUTO ? '自动' : '手动'}模式任务已创建(task_id: ${result.taskId}),准备继续下一步。`, 'info'); await completeStepFromBackground(6, { plusCheckoutCountry: result.country || 'ID', plusCheckoutCurrency: result.currency || 'IDR', diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index d294ca6..d14f137 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -11,7 +11,27 @@ const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; + const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const GPC_TASK_POLL_INTERVAL_MS = 3000; + const GPC_REMOTE_STAGE_LABELS = { + auto_otp_wait: '等待自动 OTP', + checkout_order_start: '创建订单', + checkout_start: '创建订单', + completed: '充值完成', + gopay_validate_pin: '校验 PIN', + otp_ready: '等待 PIN', + otp_submitted_local: 'OTP 已提交', + payment_processing: '支付处理中', + pin_submitted_local: 'PIN 已提交', + sms_otp_wait: '等待短信 OTP', + whatsapp_otp_wait: '等待 WhatsApp OTP', + }; + const GPC_WAITING_FOR_LABELS = { + auto_otp: '自动 OTP', + otp: 'OTP', + pin: 'PIN', + }; const PAYMENT_METHOD_CONFIGS = { [PLUS_PAYMENT_METHOD_PAYPAL]: { id: PLUS_PAYMENT_METHOD_PAYPAL, @@ -112,6 +132,56 @@ return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL; } + function normalizeGpcHelperPhoneMode(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.normalizeGpcHelperPhoneMode) { + return rootScope.GoPayUtils.normalizeGpcHelperPhoneMode(value); + } + const normalized = String(value || '').trim().toLowerCase(); + return normalized === GPC_HELPER_PHONE_MODE_AUTO || normalized === 'builtin' + ? GPC_HELPER_PHONE_MODE_AUTO + : GPC_HELPER_PHONE_MODE_MANUAL; + } + + function formatGpcRemoteStageLabel(stage = '') { + const normalized = String(stage || '').trim().toLowerCase(); + if (!normalized) { + return ''; + } + return GPC_REMOTE_STAGE_LABELS[normalized] || normalized; + } + + function formatGpcWaitingForLabel(waitingFor = '') { + const normalized = String(waitingFor || '').trim().toLowerCase(); + if (!normalized) { + return ''; + } + return GPC_WAITING_FOR_LABELS[normalized] || normalized.toUpperCase(); + } + + function formatGpcTaskStatusLog(task = {}) { + const statusText = String(task?.status_text || task?.statusText || '').trim(); + const status = String(task?.status || '').trim(); + const remoteStage = String(task?.remote_stage || task?.remoteStage || '').trim(); + const stageText = formatGpcRemoteStageLabel(remoteStage); + const waitingForText = formatGpcWaitingForLabel(task?.api_waiting_for || task?.apiWaitingFor || ''); + const mainText = stageText || statusText || status || '处理中'; + const parts = [`步骤 7:GPC 任务状态:${mainText}`]; + if (waitingForText && !mainText.includes(waitingForText)) { + parts.push(`,等待 ${waitingForText}`); + } + return parts.join(''); + } + + function getGpcHelperPhoneMode(state = {}, task = null) { + return normalizeGpcHelperPhoneMode( + task?.phone_mode + || task?.phoneMode + || state?.gopayHelperPhoneMode + || state?.phoneMode + ); + } + function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) { return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL]; } @@ -299,7 +369,7 @@ task.task_id = String(task.task_id || task.taskId || '').trim(); task.status = String(task.status || '').trim().toLowerCase(); task.status_text = String(task.status_text || task.statusText || '').trim(); - task.phone_mode = String(task.phone_mode || task.phoneMode || '').trim().toLowerCase(); + task.phone_mode = normalizeGpcHelperPhoneMode(task.phone_mode || task.phoneMode || ''); task.remote_stage = String(task.remote_stage || task.remoteStage || '').trim().toLowerCase(); task.api_waiting_for = String(task.api_waiting_for || task.apiWaitingFor || '').trim().toLowerCase(); task.api_input_deadline_at = String(task.api_input_deadline_at || task.apiInputDeadlineAt || '').trim(); @@ -318,6 +388,7 @@ gopayHelperTaskId: task.task_id, gopayHelperTaskStatus: task.status, gopayHelperStatusText: task.status_text, + gopayHelperPhoneMode: task.phone_mode, gopayHelperRemoteStage: task.remote_stage, gopayHelperApiWaitingFor: task.api_waiting_for, gopayHelperApiInputDeadlineAt: task.api_input_deadline_at, @@ -675,12 +746,16 @@ }); } - function isGpcTaskOtpWait(task = {}) { - return task?.phone_mode === 'manual' && task?.api_waiting_for === 'otp'; + function isGpcTaskManualMode(task = {}, state = {}) { + return getGpcHelperPhoneMode(state, task) === GPC_HELPER_PHONE_MODE_MANUAL; } - function isGpcTaskPinWait(task = {}) { - return task?.phone_mode === 'manual' + function isGpcTaskOtpWait(task = {}, state = {}) { + return isGpcTaskManualMode(task, state) && task?.api_waiting_for === 'otp'; + } + + function isGpcTaskPinWait(task = {}, state = {}) { + return isGpcTaskManualMode(task, state) && (task?.api_waiting_for === 'pin' || task?.status === 'otp_ready'); } @@ -840,27 +915,25 @@ throw new Error('步骤 7:GPC 模式缺少 API Key。'); } + const configuredPhoneMode = normalizeGpcHelperPhoneMode(state?.gopayHelperPhoneMode || state?.phoneMode || GPC_HELPER_PHONE_MODE_MANUAL); const rawPin = String(state?.gopayHelperPin || '').trim(); const pinDigits = rawPin.replace(/[^\d]/g, ''); const pin = normalizeSixDigitPin(rawPin); - if (!pin) { + if (configuredPhoneMode === GPC_HELPER_PHONE_MODE_MANUAL && !pin) { if (taskId && apiUrl && apiKey) { await stopGpcTaskBestEffort(apiUrl, taskId, apiKey, 'PIN 配置错误'); } throw new Error(pinDigits ? '步骤 7:GPC PIN 必须是 6 位数字,请检查侧边栏配置。' - : '步骤 7:GPC 模式缺少 PIN 配置。'); + : '步骤 7:GPC 手动模式缺少 PIN 配置。'); } - await addLog(`步骤 7:GPC 模式开始轮询任务(task_id: ${taskId})...`, 'info'); + await addLog(`步骤 7:GPC ${configuredPhoneMode === GPC_HELPER_PHONE_MODE_AUTO ? '自动' : '手动'}模式开始轮询任务(task_id: ${taskId})...`, 'info'); try { while (Date.now() <= deadline) { throwIfStopped(); const task = await fetchGpcTaskStatus(apiUrl, taskId, apiKey); - const statusText = task?.status_text || task?.status || '处理中'; - const remoteStage = task?.remote_stage || ''; - const waitingFor = task?.api_waiting_for || ''; - await addLog(`步骤 7:GPC 任务状态:${statusText}${remoteStage ? `(${remoteStage})` : ''}${waitingFor ? `,等待 ${waitingFor.toUpperCase()}` : ''}`, 'info'); + await addLog(formatGpcTaskStatusLog(task), 'info'); if (task.status === 'completed') { terminalReached = true; @@ -879,7 +952,7 @@ throw buildGpcTaskEndedError(task, 'GPC 任务已结束,请重新创建任务。'); } - if (isGpcTaskOtpWait(task)) { + if (isGpcTaskOtpWait(task, state)) { if (isGpcTaskInputDeadlineExpired(task)) { throw buildGpcInputDeadlineError(task, 'OTP'); } @@ -940,7 +1013,7 @@ otpLastSubmittedAt = Date.now(); lastSubmittedOtp = normalizedOtp; await addLog('步骤 7:OTP 已提交,继续等待 GPC 任务状态更新。', 'ok'); - } else if (isGpcTaskPinWait(task) && !pinSubmitted) { + } else if (isGpcTaskPinWait(task, state) && !pinSubmitted) { if (isGpcTaskInputDeadlineExpired(task)) { throw buildGpcInputDeadlineError(task, 'PIN'); } diff --git a/data/step-definitions.js b/data/step-definitions.js index 6cc1cae..dd87867 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -58,7 +58,7 @@ { id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' }, { id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' }, { id: 6, order: 60, key: 'plus-checkout-create', title: '创建 GPC 订单' }, - { id: 7, order: 70, key: 'plus-checkout-billing', title: 'GPC OTP/PIN 验证' }, + { id: 7, order: 70, key: 'plus-checkout-billing', title: '等待 GPC 任务完成' }, { id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' }, { id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' }, { id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' }, diff --git a/gopay-utils.js b/gopay-utils.js index b2c2eee..16d081f 100644 --- a/gopay-utils.js +++ b/gopay-utils.js @@ -5,6 +5,8 @@ const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; const DEFAULT_GPC_HELPER_API_URL = 'https://gpc.qlhazycoder.top'; + const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; + const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; const ALLOWED_GPC_HELPER_REMOTE_HOST = 'gpc.qlhazycoder.top'; function normalizePlusPaymentMethod(value = '') { @@ -47,6 +49,85 @@ return String(value || '').trim().replace(/[^\d]/g, ''); } + function normalizeGpcHelperPhoneMode(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === GPC_HELPER_PHONE_MODE_AUTO || normalized === 'builtin' + ? GPC_HELPER_PHONE_MODE_AUTO + : GPC_HELPER_PHONE_MODE_MANUAL; + } + + function normalizeGpcRemainingUses(value) { + if (value === undefined || value === null || String(value).trim() === '') { + return null; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : null; + } + + function unwrapGpcBalancePayload(payload = {}) { + const data = unwrapGpcResponse(payload); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return data; + } + const hasBalanceFields = [ + 'remaining_uses', + 'remainingUses', + 'balance', + 'remaining', + 'uses', + 'available_uses', + 'availableUses', + 'auto_mode_enabled', + 'autoModeEnabled', + 'auto_enabled', + 'autoEnabled', + 'status', + 'card_status', + 'cardStatus', + ].some((key) => Object.prototype.hasOwnProperty.call(data, key)); + if (!hasBalanceFields && data.data && typeof data.data === 'object' && !Array.isArray(data.data)) { + return data.data; + } + return data; + } + + function getGpcBalanceRemainingUses(payload = {}) { + const data = unwrapGpcBalancePayload(payload); + if (!data || typeof data !== 'object') { + return null; + } + return normalizeGpcRemainingUses( + data.remaining_uses + ?? data.remainingUses + ?? data.balance + ?? data.remaining + ?? data.uses + ?? data.available_uses + ?? data.availableUses + ); + } + + function isGpcAutoModeEnabled(payload = {}) { + const data = unwrapGpcBalancePayload(payload); + if (!data || typeof data !== 'object') { + return false; + } + const raw = data.auto_mode_enabled ?? data.autoModeEnabled ?? data.auto_enabled ?? data.autoEnabled; + if (typeof raw === 'boolean') { + return raw; + } + const normalized = String(raw ?? '').trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'enabled'; + } + + function getGpcApiKeyStatus(payload = {}) { + const data = unwrapGpcBalancePayload(payload); + if (!data || typeof data !== 'object') { + return ''; + } + return String(data.status || data.card_status || data.cardStatus || '').trim(); + } + function normalizeGpcOtpChannel(value = '') { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'sms') { @@ -291,7 +372,7 @@ } function formatGpcBalancePayload(payload = {}) { - const data = unwrapGpcResponse(payload); + const data = unwrapGpcBalancePayload(payload); if (!data || typeof data !== 'object') { return ''; } @@ -308,6 +389,11 @@ const totalUses = data.total_uses ?? data.totalUses; const usedUses = data.used_uses ?? data.usedUses; const status = String(data.status || data.card_status || data.cardStatus || '').trim(); + const hasAutoModeField = data.auto_mode_enabled !== undefined + || data.autoModeEnabled !== undefined + || data.auto_enabled !== undefined + || data.autoEnabled !== undefined; + const autoModeEnabled = isGpcAutoModeEnabled(data); const flowId = String(data.flow_id || data.flowId || '').trim(); const parts = []; if (firstValue !== undefined) { @@ -321,6 +407,9 @@ if (status) { parts.push(`状态 ${status}`); } + if (hasAutoModeField) { + parts.push(`自动模式 ${autoModeEnabled ? '已开通' : '未开通'}`); + } if (flowId) { parts.push(`flow_id ${flowId}`); } @@ -330,6 +419,8 @@ return { DEFAULT_GOPAY_COUNTRY_CODE, DEFAULT_GPC_HELPER_API_URL, + GPC_HELPER_PHONE_MODE_AUTO, + GPC_HELPER_PHONE_MODE_MANUAL, PLUS_PAYMENT_METHOD_GPC_HELPER, PLUS_PAYMENT_METHOD_GOPAY, PLUS_PAYMENT_METHOD_PAYPAL, @@ -348,8 +439,13 @@ buildGpcTaskQueryUrl, extractGpcResponseErrorDetail, formatGpcBalancePayload, + getGpcApiKeyStatus, + getGpcBalanceRemainingUses, isGpcUnifiedResponseOk, + isGpcAutoModeEnabled, normalizeGpcHelperBaseUrl, + normalizeGpcHelperPhoneMode, + normalizeGpcRemainingUses, normalizeGpcTaskId, normalizeGoPayCountryCode, normalizeGoPayPhone, @@ -358,6 +454,7 @@ normalizeGoPayPin, normalizeGpcOtpChannel, normalizePlusPaymentMethod, + unwrapGpcBalancePayload, unwrapGpcResponse, }; }); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 4a4e5bc..72b404a 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -305,6 +305,13 @@ 余额未获取 +