diff --git a/background.js b/background.js index 5755190..81c9048 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,7 @@ importScripts( 'managed-alias-utils.js', 'mail2925-utils.js', + 'background/phone-verification-flow.js', 'background/account-run-history.js', 'background/contribution-oauth.js', 'background/mail-2925-session.js', @@ -188,6 +189,11 @@ const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL; const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000; const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; +const DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php'; +const HERO_SMS_SERVICE_CODE = 'dr'; +const HERO_SMS_SERVICE_LABEL = 'OpenAI'; +const HERO_SMS_COUNTRY_ID = 52; +const HERO_SMS_COUNTRY_LABEL = 'Thailand'; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; @@ -295,6 +301,9 @@ const PERSISTED_SETTING_DEFAULTS = { cloudflareTempEmailDomains: [], hotmailAccounts: [], mail2925Accounts: [], + heroSmsApiKey: '', + heroSmsCountryId: HERO_SMS_COUNTRY_ID, + heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -354,6 +363,8 @@ const DEFAULT_STATE = { luckmailPreserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, currentLuckmailPurchase: null, currentLuckmailMailCursor: null, + currentPhoneActivation: null, + reusablePhoneActivation: null, autoRunning: false, // 当前是否处于自动运行中。 autoRunPhase: 'idle', // 当前自动运行阶段。 autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 @@ -1022,6 +1033,12 @@ function normalizePersistentSettingValue(key, value) { return normalizeHotmailAccounts(value); case 'mail2925Accounts': return normalizeMail2925Accounts(value); + case 'heroSmsApiKey': + return String(value || ''); + case 'heroSmsCountryId': + return Math.max(1, Math.floor(Number(value) || HERO_SMS_COUNTRY_ID)); + case 'heroSmsCountryLabel': + return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL; default: return value; } @@ -6429,7 +6446,7 @@ async function resumeAutoRun() { // ============================================================ const SIGNUP_ENTRY_URL = 'https://chatgpt.com/'; -const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/signup-page.js']; +const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/phone-auth.js', 'content/signup-page.js']; const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({ chrome, addLog, @@ -6502,6 +6519,21 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create throwIfStopped, VERIFICATION_POLL_MAX_ROUNDS, }); +const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.createPhoneVerificationHelpers({ + addLog, + DEFAULT_HERO_SMS_BASE_URL, + ensureStep8SignupPageReady, + getOAuthFlowStepTimeoutMs, + getState, + HERO_SMS_COUNTRY_ID, + HERO_SMS_COUNTRY_LABEL, + HERO_SMS_SERVICE_CODE, + HERO_SMS_SERVICE_LABEL, + sendToContentScriptResilient, + setState, + sleepWithStop, + throwIfStopped, +}); const step1Executor = self.MultiPageBackgroundStep1?.createStep1Executor({ addLog, completeStepFromBackground, @@ -7211,10 +7243,22 @@ function isAddPhoneAuthState(authState = {}) { async function getPostStep6AutoRestartDecision(step, error) { const normalizedStep = Number(step); const errorMessage = getErrorMessage(error); + const shouldForceRestartFromStep7 = /restart step 7 with a new number/i.test(errorMessage); if (!Number.isFinite(normalizedStep) || normalizedStep < 7 || normalizedStep > LAST_STEP_ID) { return { shouldRestart: false, blockedByAddPhone: false, + forcedByPhoneVerificationTimeout: false, + errorMessage, + authState: null, + }; + } + + if (shouldForceRestartFromStep7) { + return { + shouldRestart: true, + blockedByAddPhone: false, + forcedByPhoneVerificationTimeout: true, errorMessage, authState: null, }; @@ -7224,6 +7268,7 @@ async function getPostStep6AutoRestartDecision(step, error) { return { shouldRestart: false, blockedByAddPhone: true, + forcedByPhoneVerificationTimeout: false, errorMessage, authState: null, }; @@ -7246,6 +7291,7 @@ async function getPostStep6AutoRestartDecision(step, error) { return { shouldRestart: false, blockedByAddPhone: true, + forcedByPhoneVerificationTimeout: false, errorMessage, authState, }; @@ -7254,6 +7300,7 @@ async function getPostStep6AutoRestartDecision(step, error) { return { shouldRestart: true, blockedByAddPhone: false, + forcedByPhoneVerificationTimeout: false, errorMessage, authState, }; @@ -7375,7 +7422,7 @@ let step8TabUpdatedListener = null; let step8PendingReject = null; const STEP8_CLICK_EFFECT_TIMEOUT_MS = 15000; const STEP8_CLICK_RETRY_DELAY_MS = 500; -const STEP8_READY_WAIT_TIMEOUT_MS = 30000; +const STEP8_READY_WAIT_TIMEOUT_MS = 180000; const STEP8_MAX_ROUNDS = 5; const STEP8_STRATEGIES = [ { mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' }, @@ -7480,6 +7527,12 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS) if (pageState?.maxCheckAttemptsBlocked) { throw new Error(`${CLOUDFLARE_SECURITY_BLOCK_ERROR_PREFIX}${CLOUDFLARE_SECURITY_BLOCK_USER_MESSAGE}`); } + if (pageState?.addPhonePage || pageState?.phoneVerificationPage) { + await phoneVerificationHelpers.completePhoneVerificationFlow(tabId, pageState); + recovered = false; + await sleepWithStop(250); + continue; + } if (pageState?.addPhonePage) { throw new Error('步骤 9:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'); } diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js new file mode 100644 index 0000000..58a0509 --- /dev/null +++ b/background/phone-verification-flow.js @@ -0,0 +1,771 @@ +(function attachBackgroundPhoneVerification(root, factory) { + root.MultiPageBackgroundPhoneVerification = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPhoneVerificationModule() { + function createPhoneVerificationHelpers(deps = {}) { + const { + addLog, + ensureStep8SignupPageReady, + fetchImpl = (...args) => fetch(...args), + getOAuthFlowStepTimeoutMs, + getState, + sendToContentScriptResilient, + setState, + sleepWithStop, + throwIfStopped, + DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php', + HERO_SMS_COUNTRY_ID = 52, + HERO_SMS_COUNTRY_LABEL = 'Thailand', + HERO_SMS_SERVICE_CODE = 'dr', + HERO_SMS_SERVICE_LABEL = 'OpenAI', + } = deps; + + const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation'; + const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation'; + const DEFAULT_PHONE_POLL_INTERVAL_MS = 5000; + const DEFAULT_PHONE_POLL_TIMEOUT_MS = 180000; + const DEFAULT_PHONE_REQUEST_TIMEOUT_MS = 20000; + const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3; + const DEFAULT_PHONE_CODE_WAIT_WINDOW_MS = 60000; + const DEFAULT_PHONE_NUMBER_MAX_USES = 3; + const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::'; + const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::'; + + function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return fallback; + } + try { + return new URL(trimmed).toString(); + } catch { + return fallback; + } + } + + function normalizeApiKey(value) { + return String(value || '').trim(); + } + + function normalizeUseCount(value) { + return Math.max(0, Math.floor(Number(value) || 0)); + } + + function resolveCountryConfig(state = {}) { + return { + id: Math.max(1, Math.floor(Number(state.heroSmsCountryId) || HERO_SMS_COUNTRY_ID)), + label: String(state.heroSmsCountryLabel || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL, + }; + } + + function normalizeActivation(record) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + return null; + } + const activationId = String( + record.activationId ?? record.id ?? record.activation ?? '' + ).trim(); + const phoneNumber = String( + record.phoneNumber ?? record.number ?? record.phone ?? '' + ).trim(); + if (!activationId || !phoneNumber) { + return null; + } + return { + activationId, + phoneNumber, + provider: String(record.provider || 'hero-sms').trim() || 'hero-sms', + serviceCode: String(record.serviceCode || HERO_SMS_SERVICE_CODE).trim() || HERO_SMS_SERVICE_CODE, + countryId: Number(record.countryId) || HERO_SMS_COUNTRY_ID, + successfulUses: normalizeUseCount(record.successfulUses), + maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)), + }; + } + + function describeHeroSmsPayload(raw) { + if (typeof raw === 'string') { + return raw.trim(); + } + if (raw && typeof raw === 'object') { + if (raw.title || raw.details) { + const title = String(raw.title || '').trim(); + const details = String(raw.details || '').trim(); + return details ? `${title}: ${details}` : title; + } + if (raw.status === 'false' && raw.msg) { + return String(raw.msg).trim(); + } + try { + return JSON.stringify(raw); + } catch { + return String(raw); + } + } + return String(raw || '').trim(); + } + + function parseHeroSmsPayload(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) { + return ''; + } + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + return trimmed; + } + + function buildHeroSmsUrl(baseUrl, query = {}) { + const url = new URL(normalizeUrl(baseUrl)); + Object.entries(query).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + url.searchParams.set(key, String(value)); + }); + return url.toString(); + } + + function buildPhoneCodeTimeoutError(lastResponse = '') { + const suffix = lastResponse ? ` Last HeroSMS status: ${lastResponse}` : ''; + return new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}Timed out waiting for the phone verification code.${suffix}`); + } + + function isPhoneCodeTimeoutError(error) { + return String(error?.message || '').startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX); + } + + function buildPhoneRestartStep7Error(phoneNumber = '') { + const suffix = phoneNumber ? ` Current number: ${phoneNumber}.` : ''; + return new Error( + `${PHONE_RESTART_STEP7_ERROR_PREFIX}Phone verification could not receive an SMS after resend. Restart step 7 with a new number.${suffix}` + ); + } + + function sanitizePhoneCodeTimeoutError(error) { + const message = String(error?.message || ''); + if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) { + return error; + } + return new Error(message.slice(PHONE_CODE_TIMEOUT_ERROR_PREFIX.length).trim() || 'Timed out waiting for the phone verification code.'); + } + + function sanitizePhoneRestartStep7Error(error) { + const message = String(error?.message || ''); + if (!message.startsWith(PHONE_RESTART_STEP7_ERROR_PREFIX)) { + return error; + } + return new Error( + message.slice(PHONE_RESTART_STEP7_ERROR_PREFIX.length).trim() + || 'Phone verification could not receive an SMS after resend. Restart step 7 with a new number.' + ); + } + + async function fetchHeroSmsPayload(config, query, actionLabel) { + const requestUrl = buildHeroSmsUrl(config.baseUrl, { + api_key: config.apiKey, + ...query, + }); + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutId = controller + ? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS) + : null; + + try { + const response = await fetchImpl(requestUrl, { + method: 'GET', + signal: controller?.signal, + }); + const text = await response.text(); + const payload = parseHeroSmsPayload(text); + if (!response.ok) { + throw new Error(`${actionLabel} failed: ${describeHeroSmsPayload(payload) || response.status}`); + } + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`${actionLabel} timed out.`); + } + throw error; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + function resolvePhoneConfig(state = {}) { + const apiKey = normalizeApiKey(state.heroSmsApiKey); + if (!apiKey) { + throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.'); + } + return { + apiKey, + baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), + }; + } + + function parseActivationPayload(payload, fallback = null) { + const normalizedFallback = normalizeActivation(fallback); + const directActivation = normalizeActivation(payload); + if (directActivation) { + return { + ...directActivation, + successfulUses: normalizedFallback?.successfulUses || directActivation.successfulUses, + maxUses: normalizedFallback?.maxUses || directActivation.maxUses, + }; + } + + const text = describeHeroSmsPayload(payload); + const accessNumberMatch = text.match(/^ACCESS_NUMBER:([^:]+):(.+)$/i); + if (accessNumberMatch) { + return { + activationId: String(accessNumberMatch[1] || '').trim(), + phoneNumber: String(accessNumberMatch[2] || '').trim(), + provider: normalizedFallback?.provider || 'hero-sms', + serviceCode: normalizedFallback?.serviceCode || HERO_SMS_SERVICE_CODE, + countryId: normalizedFallback?.countryId || HERO_SMS_COUNTRY_ID, + successfulUses: normalizedFallback?.successfulUses || 0, + maxUses: normalizedFallback?.maxUses || DEFAULT_PHONE_NUMBER_MAX_USES, + }; + } + + if (/^ACCESS_READY$/i.test(text) && normalizedFallback) { + return normalizedFallback; + } + + return null; + } + + async function requestPhoneActivation(state = {}) { + const config = resolvePhoneConfig(state); + const countryConfig = resolveCountryConfig(state); + const payload = await fetchHeroSmsPayload(config, { + action: 'getNumber', + service: HERO_SMS_SERVICE_CODE, + country: countryConfig.id, + }, 'HeroSMS getNumber'); + + const activation = parseActivationPayload(payload, { + countryId: countryConfig.id, + }); + if (!activation) { + const text = describeHeroSmsPayload(payload); + throw new Error(`HeroSMS getNumber failed: ${text || 'empty response'}`); + } + + return activation; + } + + async function reactivatePhoneActivation(state = {}, activation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + throw new Error('Reusable phone activation is missing.'); + } + + const config = resolvePhoneConfig(state); + const payload = await fetchHeroSmsPayload(config, { + action: 'reactivate', + id: normalizedActivation.activationId, + }, 'HeroSMS reactivate'); + const nextActivation = parseActivationPayload(payload, normalizedActivation); + if (!nextActivation) { + const text = describeHeroSmsPayload(payload); + throw new Error(`HeroSMS reactivate failed: ${text || 'empty response'}`); + } + return nextActivation; + } + + async function setPhoneActivationStatus(state = {}, activation, status, actionLabel) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + return ''; + } + const config = resolvePhoneConfig(state); + const payload = await fetchHeroSmsPayload(config, { + action: 'setStatus', + id: normalizedActivation.activationId, + status, + }, actionLabel); + return describeHeroSmsPayload(payload); + } + + async function completePhoneActivation(state = {}, activation) { + await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)'); + } + + async function cancelPhoneActivation(state = {}, activation) { + try { + await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)'); + } catch (_) { + // Best-effort cleanup. + } + } + + async function requestAdditionalPhoneSms(state = {}, activation) { + try { + await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)'); + } catch (_) { + // Best-effort request only. + } + } + + async function pollPhoneActivationCode(state = {}, activation, options = {}) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + throw new Error('Phone activation is missing.'); + } + + const config = resolvePhoneConfig(state); + const configuredTimeoutMs = Math.max(1000, Number(options.timeoutMs) || 0); + const timeoutMs = configuredTimeoutMs || ( + typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs( + DEFAULT_PHONE_POLL_TIMEOUT_MS, + { step: 9, actionLabel: options.actionLabel || 'poll phone verification code' } + ) + : DEFAULT_PHONE_POLL_TIMEOUT_MS + ); + const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_PHONE_POLL_INTERVAL_MS); + const start = Date.now(); + let lastResponse = ''; + let pollCount = 0; + + while (Date.now() - start < timeoutMs) { + throwIfStopped(); + const payload = await fetchHeroSmsPayload(config, { + action: 'getStatus', + id: normalizedActivation.activationId, + }, 'HeroSMS getStatus'); + const text = describeHeroSmsPayload(payload); + lastResponse = text; + pollCount += 1; + + if (typeof options.onStatus === 'function') { + await options.onStatus({ + activation: normalizedActivation, + elapsedMs: Date.now() - start, + pollCount, + statusText: text, + timeoutMs, + }); + } + + const okMatch = text.match(/^STATUS_OK:(.+)$/i); + if (okMatch) { + const rawCode = String(okMatch[1] || '').trim(); + const digitMatch = rawCode.match(/\b(\d{4,8})\b/); + return digitMatch?.[1] || rawCode; + } + + if (/^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)$/i.test(text)) { + await sleepWithStop(intervalMs); + continue; + } + + if (/^STATUS_CANCEL$/i.test(text)) { + throw new Error('HeroSMS activation was cancelled before the SMS arrived.'); + } + + throw new Error(`HeroSMS getStatus failed: ${text || 'empty response'}`); + } + + throw buildPhoneCodeTimeoutError(lastResponse); + } + + async function readPhonePageState(tabId, timeoutMs = 10000) { + await ensureStep8SignupPageReady(tabId, { + timeoutMs, + logMessage: 'Step 9: waiting for auth page content script to recover before phone verification.', + }); + const result = await sendToContentScriptResilient('signup-page', { + type: 'STEP8_GET_STATE', + source: 'background', + payload: {}, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: 'Step 9: auth page is switching, waiting to inspect phone verification state again...', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function submitPhoneNumber(tabId, phoneNumber) { + const state = await getState(); + const countryConfig = resolveCountryConfig(state); + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'submit add-phone number' }) + : 30000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'SUBMIT_PHONE_NUMBER', + source: 'background', + payload: { + phoneNumber, + countryId: countryConfig.id, + countryLabel: countryConfig.label, + }, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: 'Step 9: waiting for add-phone page to become ready...', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function submitPhoneVerificationCode(tabId, code) { + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(45000, { step: 9, actionLabel: 'submit phone verification code' }) + : 45000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'SUBMIT_PHONE_VERIFICATION_CODE', + source: 'background', + payload: { code }, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: 'Step 9: waiting for phone verification page before filling the SMS code...', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function resendPhoneVerificationCode(tabId) { + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'resend phone verification code' }) + : 30000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'RESEND_PHONE_VERIFICATION_CODE', + source: 'background', + payload: {}, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: 'Step 9: waiting for the phone verification resend button...', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function returnToAddPhone(tabId) { + const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'return to add-phone page' }) + : 30000; + const result = await sendToContentScriptResilient('signup-page', { + type: 'RETURN_TO_ADD_PHONE', + source: 'background', + payload: {}, + }, { + timeoutMs, + responseTimeoutMs: timeoutMs, + retryDelayMs: 600, + logMessage: 'Step 9: returning to add-phone page to replace the phone number...', + }); + + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + async function persistCurrentActivation(activation) { + await setState({ + [PHONE_ACTIVATION_STATE_KEY]: activation || null, + }); + } + + async function persistReusableActivation(activation) { + await setState({ + [REUSABLE_PHONE_ACTIVATION_STATE_KEY]: activation || null, + }); + } + + async function clearCurrentActivation() { + await persistCurrentActivation(null); + } + + async function clearReusableActivation() { + await persistReusableActivation(null); + } + + async function acquirePhoneActivation(state = {}) { + const countryConfig = resolveCountryConfig(state); + const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); + if ( + reusableActivation + && reusableActivation.countryId === countryConfig.id + && reusableActivation.successfulUses < reusableActivation.maxUses + ) { + try { + const reactivated = await reactivatePhoneActivation(state, reusableActivation); + await addLog( + `Step 9: reusing ${countryConfig.label} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`, + 'info' + ); + return reactivated; + } catch (error) { + await addLog(`Step 9: failed to reuse phone number ${reusableActivation.phoneNumber}, falling back to a new number. ${error.message}`, 'warn'); + await clearReusableActivation(); + } + } else if (reusableActivation && reusableActivation.countryId !== countryConfig.id) { + await clearReusableActivation(); + } + + const activation = await requestPhoneActivation(state); + await addLog( + `Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${countryConfig.label} number ${activation.phoneNumber}.`, + 'info' + ); + return activation; + } + + async function markActivationReusableAfterSuccess(activation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + await clearReusableActivation(); + return; + } + + const successfulUses = normalizedActivation.successfulUses + 1; + if (successfulUses >= normalizedActivation.maxUses) { + await clearReusableActivation(); + return; + } + + await persistReusableActivation({ + ...normalizedActivation, + successfulUses, + }); + } + + async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + throw new Error('Phone activation is missing.'); + } + + let lastLoggedStatus = ''; + let lastLoggedPollCount = 0; + + for (let windowIndex = 1; windowIndex <= 2; windowIndex += 1) { + await addLog( + `Step 9: waiting up to 60 seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/2).`, + 'info' + ); + try { + const code = await pollPhoneActivationCode(state, normalizedActivation, { + actionLabel: windowIndex === 1 + ? 'poll phone verification code from HeroSMS' + : 'poll resent phone verification code from HeroSMS', + timeoutMs: DEFAULT_PHONE_CODE_WAIT_WINDOW_MS, + onStatus: async ({ elapsedMs, pollCount, statusText }) => { + const shouldLog = ( + pollCount === 1 + || statusText !== lastLoggedStatus + || pollCount - lastLoggedPollCount >= 3 + ); + if (!shouldLog) { + return; + } + lastLoggedStatus = statusText; + lastLoggedPollCount = pollCount; + await addLog( + `Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`, + 'info' + ); + }, + }); + return { + code, + replaceNumber: false, + }; + } catch (error) { + if (!isPhoneCodeTimeoutError(error)) { + throw error; + } + + if (windowIndex === 1) { + await addLog( + `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within 60 seconds, requesting another SMS.`, + 'warn' + ); + await requestAdditionalPhoneSms(state, normalizedActivation); + try { + await resendPhoneVerificationCode(tabId); + await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info'); + } catch (resendError) { + await addLog(`Step 9: failed to click resend on the phone verification page. ${resendError.message}`, 'warn'); + } + continue; + } + + await addLog( + `Step 9: still no SMS for ${normalizedActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`, + 'warn' + ); + throw buildPhoneRestartStep7Error(normalizedActivation.phoneNumber); + } + } + + throw new Error('Phone verification did not complete successfully.'); + } + + async function completePhoneVerificationFlow(tabId, initialPageState = null) { + let state = await getState(); + let activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]); + let pageState = initialPageState || await readPhonePageState(tabId); + let shouldCancelActivation = false; + let remainingResendRequests = Math.max(0, Number(state.verificationResendCount) || 0); + + try { + while (true) { + state = await getState(); + if (!activation) { + activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]); + } + + if (pageState?.addPhonePage) { + if (activation) { + await cancelPhoneActivation(state, activation); + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + } + + activation = await acquirePhoneActivation(state); + shouldCancelActivation = true; + await persistCurrentActivation(activation); + const submitResult = await submitPhoneNumber(tabId, activation.phoneNumber); + await addLog('Step 9: submitted the phone number on add-phone page.', 'info'); + pageState = { + ...pageState, + ...submitResult, + addPhonePage: false, + phoneVerificationPage: true, + }; + } + + if (!pageState?.phoneVerificationPage) { + pageState = await readPhonePageState(tabId); + } + + if (!pageState?.phoneVerificationPage) { + return pageState; + } + + if (!activation) { + throw new Error('The auth page is waiting for a phone verification code, but no HeroSMS activation is stored for this run.'); + } + + let shouldReplaceNumber = false; + + for (let attempt = 1; attempt <= DEFAULT_PHONE_SUBMIT_ATTEMPTS; attempt += 1) { + throwIfStopped(); + + const codeResult = await waitForPhoneCodeOrRotateNumber(tabId, state, activation); + if (codeResult.replaceNumber) { + shouldReplaceNumber = true; + break; + } + + await addLog(`Step 9: received phone verification code ${codeResult.code}.`, 'info'); + const submitResult = await submitPhoneVerificationCode(tabId, codeResult.code); + + if (submitResult.returnedToAddPhone) { + await addLog( + 'Step 9: phone verification returned to add-phone after code submission, replacing the current number.', + 'warn' + ); + shouldReplaceNumber = true; + pageState = { + ...pageState, + ...submitResult, + addPhonePage: true, + phoneVerificationPage: false, + }; + break; + } + + if (submitResult.invalidCode) { + if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) { + throw new Error( + `Phone verification code was rejected after ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} attempts: ${submitResult.errorText || submitResult.url || 'unknown error'}` + ); + } + + if (remainingResendRequests > 0) { + remainingResendRequests -= 1; + await requestAdditionalPhoneSms(state, activation); + try { + await resendPhoneVerificationCode(tabId); + await addLog('Step 9: clicked "Resend text message" after the phone code was rejected.', 'info'); + } catch (resendError) { + await addLog(`Step 9: failed to click resend after code rejection. ${resendError.message}`, 'warn'); + } + await addLog( + `Step 9: phone verification code was rejected, requested another SMS (${remainingResendRequests} resend attempts left).`, + 'warn' + ); + } else { + await addLog( + 'Step 9: phone verification code was rejected and the configured resend budget is exhausted, retrying with the current activation window.', + 'warn' + ); + } + continue; + } + + await completePhoneActivation(state, activation); + await markActivationReusableAfterSuccess(activation); + shouldCancelActivation = false; + await clearCurrentActivation(); + await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); + return submitResult; + } + + if (!shouldReplaceNumber) { + throw new Error('Phone verification did not complete successfully.'); + } + } + } catch (error) { + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation); + } + await clearCurrentActivation(); + throw sanitizePhoneRestartStep7Error(sanitizePhoneCodeTimeoutError(error)); + } + } + + return { + completePhoneVerificationFlow, + normalizeActivation, + pollPhoneActivationCode, + reactivatePhoneActivation, + requestPhoneActivation, + }; + } + + return { + createPhoneVerificationHelpers, + }; +}); diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index d61f8b2..59deab9 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -41,11 +41,11 @@ await addLog('步骤 9:正在监听 localhost 回调地址...'); const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' - ? await getOAuthFlowStepTimeoutMs(120000, { + ? await getOAuthFlowStepTimeoutMs(240000, { step: 9, actionLabel: 'OAuth localhost 回调', }) - : 120000; + : 240000; return new Promise((resolve, reject) => { let resolved = false; diff --git a/background/verification-flow.js b/background/verification-flow.js index 3b752eb..7e33c45 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -887,11 +887,6 @@ continue; } - if (submitResult.addPhonePage) { - const urlPart = submitResult.url ? ` URL: ${submitResult.url}` : ''; - throw new Error(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim()); - } - await setState({ lastEmailTimestamp: result.emailTimestamp, [stateKey]: result.code, @@ -900,10 +895,14 @@ await completeStepFromBackground(step, { emailTimestamp: result.emailTimestamp, code: result.code, + phoneVerificationRequired: Boolean(submitResult.addPhonePage), ...(step === 4 && submitResult?.skipProfileStep ? { skipProfileStep: true } : {}), }); triggerPostSuccessMailboxCleanup(step, mail); - return; + return { + phoneVerificationRequired: Boolean(submitResult.addPhonePage), + url: submitResult.url || '', + }; } } diff --git a/content/phone-auth.js b/content/phone-auth.js new file mode 100644 index 0000000..dc5faa7 --- /dev/null +++ b/content/phone-auth.js @@ -0,0 +1,453 @@ +(function attachPhoneAuthModule(root, factory) { + root.MultiPagePhoneAuth = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createPhoneAuthModule() { + function createPhoneAuthHelpers(deps = {}) { + const { + fillInput, + getActionText, + getPageTextSnapshot, + getVerificationErrorText, + humanPause, + isActionEnabled, + isAddPhonePageReady, + isConsentReady, + isPhoneVerificationPageReady, + isVisibleElement, + simulateClick, + sleep, + throwIfStopped, + waitForElement, + } = deps; + + function dispatchInputEvents(element) { + if (!element) return; + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + } + + function normalizePhoneDigits(value) { + let digits = String(value || '').replace(/\D+/g, ''); + if (digits.startsWith('00')) { + digits = digits.slice(2); + } + return digits; + } + + function normalizeCountryLabel(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/&/g, ' and ') + .replace(/[^\w\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + + function getOptionLabel(option) { + return String(option?.textContent || option?.label || '') + .replace(/\s+/g, ' ') + .trim(); + } + + function extractDialCodeFromText(value) { + const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/); + return String(match?.[1] || match?.[2] || '').trim(); + } + + function getCountryButtonText() { + const form = getAddPhoneForm(); + if (!form) return ''; + const button = form.querySelector('button[aria-haspopup="listbox"]'); + if (!button) return ''; + const valueNode = button.querySelector('.react-aria-SelectValue'); + return String(valueNode?.textContent || button.textContent || '') + .replace(/\s+/g, ' ') + .trim(); + } + + function getDisplayedDialCode() { + const buttonDialCode = extractDialCodeFromText(getCountryButtonText()); + if (buttonDialCode) { + return buttonDialCode; + } + + const phoneInput = getPhoneInput(); + const fieldRoot = phoneInput?.closest('fieldset') || phoneInput?.closest('form') || getAddPhoneForm(); + if (!fieldRoot) { + return ''; + } + + const visibleSpan = Array.from(fieldRoot.querySelectorAll('span')) + .find((element) => isVisibleElement(element) && /^\d{1,4}$/.test(String(element.textContent || '').trim())); + return String(visibleSpan?.textContent || '').trim(); + } + + function toNationalPhoneNumber(value, dialCode) { + const digits = normalizePhoneDigits(value); + const normalizedDialCode = normalizePhoneDigits(dialCode); + if (!digits) { + return ''; + } + if (normalizedDialCode && digits.startsWith(normalizedDialCode) && digits.length > normalizedDialCode.length) { + return digits.slice(normalizedDialCode.length); + } + return digits; + } + + function toE164PhoneNumber(value, dialCode) { + const digits = normalizePhoneDigits(value); + const normalizedDialCode = normalizePhoneDigits(dialCode); + if (!digits) { + return ''; + } + if (!normalizedDialCode) { + return digits.startsWith('+') ? digits : `+${digits}`; + } + if (digits.startsWith(normalizedDialCode)) { + return `+${digits}`; + } + if (digits.startsWith('0')) { + return `+${normalizedDialCode}${digits.slice(1)}`; + } + return `+${normalizedDialCode}${digits}`; + } + + function getAddPhoneForm() { + return document.querySelector('form[action*="/add-phone" i]'); + } + + function getPhoneVerificationForm() { + return document.querySelector('form[action*="/phone-verification" i]'); + } + + function getPhoneInput() { + const form = getAddPhoneForm(); + if (!form) return null; + const input = form.querySelector( + 'input[type="tel"], input[name="__reservedForPhoneNumberInput_tel"], input[autocomplete="tel"]' + ); + return input && isVisibleElement(input) ? input : null; + } + + function getHiddenPhoneNumberInput() { + const form = getAddPhoneForm(); + if (!form) return null; + return form.querySelector('input[name="phoneNumber"]'); + } + + function getCountrySelect() { + const form = getAddPhoneForm(); + if (!form) return null; + return form.querySelector('select'); + } + + function getSelectedCountryOption() { + const select = getCountrySelect(); + if (!select || select.selectedIndex < 0) { + return null; + } + return select.options[select.selectedIndex] || null; + } + + function findCountryOptionByLabel(countryLabel) { + const select = getCountrySelect(); + if (!select) { + return null; + } + const normalizedTarget = normalizeCountryLabel(countryLabel); + if (!normalizedTarget) { + return null; + } + + const options = Array.from(select.options); + return options.find((option) => normalizeCountryLabel(getOptionLabel(option)) === normalizedTarget) + || options.find((option) => { + const optionLabel = normalizeCountryLabel(getOptionLabel(option)); + return optionLabel && (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)); + }) + || null; + } + + async function ensureCountrySelected(countryLabel) { + const select = getCountrySelect(); + if (!select) { + return false; + } + + const targetOption = findCountryOptionByLabel(countryLabel); + if (!targetOption) { + throw new Error(`Add-phone page is missing the country option for "${countryLabel}".`); + } + + const selectedOption = getSelectedCountryOption(); + if (selectedOption && normalizeCountryLabel(getOptionLabel(selectedOption)) === normalizeCountryLabel(getOptionLabel(targetOption))) { + return true; + } + + select.value = String(targetOption.value || ''); + dispatchInputEvents(select); + await sleep(250); + + const nextSelectedOption = getSelectedCountryOption(); + return Boolean( + nextSelectedOption + && normalizeCountryLabel(getOptionLabel(nextSelectedOption)) === normalizeCountryLabel(getOptionLabel(targetOption)) + ); + } + + function getAddPhoneSubmitButton() { + const form = getAddPhoneForm(); + if (!form) return null; + const buttons = Array.from(form.querySelectorAll('button[type="submit"], input[type="submit"]')); + return buttons.find((button) => isVisibleElement(button) && isActionEnabled(button)) + || buttons.find((button) => isVisibleElement(button)) + || null; + } + + function getPhoneVerificationCodeInput() { + const form = getPhoneVerificationForm(); + if (!form) return null; + const input = form.querySelector( + 'input[name="code"], input[autocomplete="one-time-code"], input[inputmode="numeric"]' + ); + return input && isVisibleElement(input) ? input : null; + } + + function getPhoneVerificationSubmitButton() { + const form = getPhoneVerificationForm(); + if (!form) return null; + const buttons = Array.from(form.querySelectorAll('button[type="submit"], input[type="submit"]')); + return buttons.find((button) => { + if (!isVisibleElement(button) || !isActionEnabled(button)) return false; + const intent = String(button.getAttribute('value') || '').trim().toLowerCase(); + if (intent === 'resend') return false; + return true; + }) || buttons.find((button) => isVisibleElement(button)); + } + + function getPhoneVerificationResendButton(options = {}) { + const { allowDisabled = false } = options; + const form = getPhoneVerificationForm(); + if (!form) return null; + const buttons = Array.from(form.querySelectorAll('button, input[type="submit"], input[type="button"]')); + return buttons.find((button) => { + if (!isVisibleElement(button)) return false; + if (!allowDisabled && !isActionEnabled(button)) return false; + const intent = String(button.getAttribute('value') || '').trim().toLowerCase(); + if (intent === 'resend') return true; + return /resend/i.test(getActionText(button)); + }) || null; + } + + function getPhoneVerificationDisplayedPhone() { + const text = getPageTextSnapshot(); + const matches = text.match(/\+\d[\d\s-]{6,}\d/g); + return matches?.[0] ? matches[0].replace(/\s+/g, ' ').trim() : ''; + } + + async function waitForAddPhoneReady(timeout = 20000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + throwIfStopped(); + if (isAddPhonePageReady()) { + return true; + } + await sleep(150); + } + throw new Error('Timed out waiting for add-phone page.'); + } + + async function waitForPhoneVerificationReady(timeout = 20000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + throwIfStopped(); + if (isPhoneVerificationPageReady()) { + return { + phoneVerificationPage: true, + displayedPhone: getPhoneVerificationDisplayedPhone(), + url: location.href, + }; + } + await sleep(150); + } + throw new Error('Timed out waiting for phone verification page.'); + } + + async function submitPhoneNumber(payload = {}) { + const countryLabel = String(payload.countryLabel || '').trim(); + if (!countryLabel) { + throw new Error('Missing country label for add-phone submission.'); + } + + await waitForAddPhoneReady(); + const countrySelected = await ensureCountrySelected(countryLabel); + if (!countrySelected) { + throw new Error(`Failed to select "${countryLabel}" on the add-phone page.`); + } + + const dialCode = getDisplayedDialCode(); + if (!dialCode) { + throw new Error(`Could not determine the dial code for "${countryLabel}" on the add-phone page.`); + } + + const phoneNumber = toE164PhoneNumber(payload.phoneNumber, dialCode); + const nationalPhoneNumber = toNationalPhoneNumber(payload.phoneNumber, dialCode); + if (!phoneNumber || !nationalPhoneNumber) { + throw new Error('Missing phone number for add-phone submission.'); + } + + const phoneInput = getPhoneInput() || await waitForElement( + 'input[type="tel"], input[name="__reservedForPhoneNumberInput_tel"], input[autocomplete="tel"]', + 10000 + ); + const hiddenPhoneNumberInput = getHiddenPhoneNumberInput(); + const submitButton = getAddPhoneSubmitButton(); + + if (!phoneInput) { + throw new Error('Add-phone page is missing the phone number input.'); + } + if (!submitButton) { + throw new Error('Add-phone page is missing the submit button.'); + } + + await humanPause(250, 700); + fillInput(phoneInput, nationalPhoneNumber); + if (hiddenPhoneNumberInput) { + hiddenPhoneNumberInput.value = phoneNumber; + dispatchInputEvents(hiddenPhoneNumberInput); + } + await sleep(250); + simulateClick(submitButton); + return waitForPhoneVerificationReady(); + } + + async function waitForPhoneVerificationOutcome(timeout = 30000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + throwIfStopped(); + + const errorText = getVerificationErrorText(); + if (errorText) { + return { + invalidCode: true, + errorText, + url: location.href, + }; + } + + if (isConsentReady()) { + return { + success: true, + consentReady: true, + url: location.href, + }; + } + + if (isAddPhonePageReady()) { + return { + returnedToAddPhone: true, + url: location.href, + }; + } + + await sleep(150); + } + + if (isPhoneVerificationPageReady()) { + return { + invalidCode: true, + errorText: getVerificationErrorText() || 'Phone verification page stayed in place after code submission.', + url: location.href, + }; + } + + return { + success: true, + assumed: true, + url: location.href, + }; + } + + async function submitPhoneVerificationCode(payload = {}) { + const code = String(payload.code || '').trim(); + if (!code) { + throw new Error('Missing phone verification code.'); + } + + await waitForPhoneVerificationReady(); + const codeInput = getPhoneVerificationCodeInput() || await waitForElement( + 'input[name="code"], input[autocomplete="one-time-code"], input[inputmode="numeric"]', + 10000 + ); + const submitButton = getPhoneVerificationSubmitButton(); + + if (!codeInput) { + throw new Error('Phone verification page is missing the code input.'); + } + if (!submitButton) { + throw new Error('Phone verification page is missing the submit button.'); + } + + await humanPause(250, 700); + fillInput(codeInput, code); + await sleep(250); + simulateClick(submitButton); + return waitForPhoneVerificationOutcome(); + } + + async function resendPhoneVerificationCode(timeout = 45000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + throwIfStopped(); + const resendButton = getPhoneVerificationResendButton({ allowDisabled: true }); + if (resendButton && isActionEnabled(resendButton)) { + await humanPause(250, 700); + simulateClick(resendButton); + await sleep(1000); + return { + resent: true, + url: location.href, + }; + } + await sleep(250); + } + + throw new Error('Timed out waiting for the phone verification resend button.'); + } + + async function returnToAddPhone(timeout = 20000) { + if (isAddPhonePageReady()) { + return { + addPhonePage: true, + url: location.href, + }; + } + + if (!isPhoneVerificationPageReady()) { + throw new Error('The auth page is not currently on phone verification or add-phone page.'); + } + + location.assign('/add-phone'); + await waitForAddPhoneReady(timeout); + return { + addPhonePage: true, + url: location.href, + }; + } + + return { + getPhoneVerificationDisplayedPhone, + isPhoneVerificationPageReady, + resendPhoneVerificationCode, + returnToAddPhone, + submitPhoneNumber, + submitPhoneVerificationCode, + toE164PhoneNumber, + }; + } + + return { + createPhoneAuthHelpers, + }; +}); diff --git a/content/signup-page.js b/content/signup-page.js index 91c901d..fa867c7 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -21,6 +21,10 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1' || message.type === 'PREPARE_SIGNUP_VERIFICATION' || message.type === 'RECOVER_AUTH_RETRY_PAGE' || message.type === 'RESEND_VERIFICATION_CODE' + || message.type === 'SUBMIT_PHONE_NUMBER' + || message.type === 'SUBMIT_PHONE_VERIFICATION_CODE' + || message.type === 'RESEND_PHONE_VERIFICATION_CODE' + || message.type === 'RETURN_TO_ADD_PHONE' || message.type === 'ENSURE_SIGNUP_ENTRY_READY' || message.type === 'ENSURE_SIGNUP_PASSWORD_PAGE_READY' ) { @@ -76,6 +80,14 @@ async function handleCommand(message) { return await recoverCurrentAuthRetryPage(message.payload); case 'RESEND_VERIFICATION_CODE': return await resendVerificationCode(message.step); + case 'SUBMIT_PHONE_NUMBER': + return await phoneAuthHelpers.submitPhoneNumber(message.payload); + case 'SUBMIT_PHONE_VERIFICATION_CODE': + return await phoneAuthHelpers.submitPhoneVerificationCode(message.payload); + case 'RESEND_PHONE_VERIFICATION_CODE': + return await phoneAuthHelpers.resendPhoneVerificationCode(); + case 'RETURN_TO_ADD_PHONE': + return await phoneAuthHelpers.returnToAddPhone(); case 'ENSURE_SIGNUP_ENTRY_READY': return await ensureSignupEntryReady(); case 'ENSURE_SIGNUP_PASSWORD_PAGE_READY': @@ -959,6 +971,12 @@ function getLoginVerificationDisplayedEmail() { return matches[0] ? String(matches[0]).trim().toLowerCase() : ''; } +function getPhoneVerificationDisplayedPhone() { + const pageText = getPageTextSnapshot(); + const matches = pageText.match(/\+\d[\d\s-]{6,}\d/g) || []; + return matches[0] ? String(matches[0]).replace(/\s+/g, ' ').trim() : ''; +} + function getOAuthConsentForm() { return document.querySelector(OAUTH_CONSENT_FORM_SELECTOR); } @@ -1019,6 +1037,9 @@ function isVerificationPageStillVisible() { if (getCurrentAuthRetryPageState('signup_password') || getCurrentAuthRetryPageState('login')) { return false; } + if (isPhoneVerificationPageReady()) { + return false; + } if (getVerificationCodeTarget()) return true; if (findResendVerificationCodeTrigger({ allowDisabled: true })) return true; if (document.querySelector('form[action*="email-verification" i]')) return true; @@ -1044,15 +1065,68 @@ function isAddPhonePageReady() { return ADD_PHONE_PAGE_PATTERN.test(getPageTextSnapshot()); } +function isPhoneVerificationPageReady() { + const path = `${location.pathname || ''} ${location.href || ''}`; + if (/\/phone-verification(?:[/?#]|$)/i.test(path)) { + return true; + } + + const form = document.querySelector('form[action*="/phone-verification" i]'); + if (form && isVisibleElement(form)) { + return true; + } + + if (document.querySelector('button[name="intent"][value="resend"]') && getPhoneVerificationDisplayedPhone()) { + return true; + } + + const pageText = getPageTextSnapshot(); + const displayedPhone = getPhoneVerificationDisplayedPhone(); + return Boolean(getVerificationCodeTarget()) + && Boolean(displayedPhone) + && /check\s+your\s+phone|phone\s+verification|verify\s+your\s+phone|sms|text\s+message|code\s+to\s+\+/.test(pageText); +} + function isStep8Ready() { const continueBtn = getPrimaryContinueButton(); if (!continueBtn) return false; if (isVerificationPageStillVisible()) return false; + if (isPhoneVerificationPageReady()) return false; if (isAddPhonePageReady()) return false; return isOAuthConsentPage(); } +const phoneAuthHelpers = self.MultiPagePhoneAuth?.createPhoneAuthHelpers?.({ + fillInput, + getActionText, + getPageTextSnapshot, + getVerificationErrorText, + humanPause, + isActionEnabled, + isAddPhonePageReady, + isConsentReady: isStep8Ready, + isPhoneVerificationPageReady, + isVisibleElement, + simulateClick, + sleep, + throwIfStopped, + waitForElement, +}) || { + submitPhoneNumber: async () => { + throw new Error('Phone auth helpers are unavailable.'); + }, + submitPhoneVerificationCode: async () => { + throw new Error('Phone auth helpers are unavailable.'); + }, + resendPhoneVerificationCode: async () => { + throw new Error('Phone auth helpers are unavailable.'); + }, + returnToAddPhone: async () => { + throw new Error('Phone auth helpers are unavailable.'); + }, +}; + function normalizeInlineText(text) { return (text || '').replace(/\s+/g, ' ').trim(); } @@ -1453,6 +1527,7 @@ function inspectLoginAuthState() { const submitButton = getLoginSubmitButton({ allowDisabled: true }); const verificationVisible = isVerificationPageStillVisible(); const addPhonePage = isAddPhonePageReady(); + const phoneVerificationPage = isPhoneVerificationPageReady(); const consentReady = isStep8Ready(); const oauthConsentPage = isOAuthConsentPage(); const baseState = { @@ -1472,6 +1547,7 @@ function inspectLoginAuthState() { switchTrigger, verificationVisible, addPhonePage, + phoneVerificationPage, oauthConsentPage, consentReady, }; @@ -1483,6 +1559,14 @@ function inspectLoginAuthState() { }; } + if (phoneVerificationPage) { + return { + ...baseState, + state: 'phone_verification_page', + displayedPhone: getPhoneVerificationDisplayedPhone(), + }; + } + if (verificationTarget) { return { ...baseState, @@ -1538,6 +1622,7 @@ function serializeLoginAuthState(snapshot) { hasSwitchTrigger: Boolean(snapshot?.switchTrigger), verificationVisible: Boolean(snapshot?.verificationVisible), addPhonePage: Boolean(snapshot?.addPhonePage), + phoneVerificationPage: Boolean(snapshot?.phoneVerificationPage), oauthConsentPage: Boolean(snapshot?.oauthConsentPage), consentReady: Boolean(snapshot?.consentReady), }; @@ -2791,6 +2876,7 @@ function getStep8State() { consentReady: isStep8Ready(), verificationPage: isVerificationPageStillVisible(), addPhonePage: isAddPhonePageReady(), + phoneVerificationPage: isPhoneVerificationPageReady(), retryPage: Boolean(retryState), retryEnabled: Boolean(retryState?.retryEnabled), retryTitleMatched: Boolean(retryState?.titleMatched), diff --git a/manifest.json b/manifest.json index c40195c..bf2ab65 100644 --- a/manifest.json +++ b/manifest.json @@ -48,6 +48,7 @@ "content/activation-utils.js", "content/utils.js", "content/auth-page-recovery.js", + "content/phone-auth.js", "content/signup-page.js" ], "run_at": "document_idle" diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index f49f8df..27c068e 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -379,6 +379,20 @@ +
+ 接码平台 + HeroSMS / OpenAI / Thailand +
+
+ 接码国家 + +
+
+ 接码 API + +
OAuth 等待中... diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index c7f7b63..9f628bb 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -223,6 +223,9 @@ const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes' const inputAutoStepDelaySeconds = document.getElementById('input-auto-step-delay-seconds'); const inputVerificationResendCount = document.getElementById('input-verification-resend-count'); const rowAccountRunHistoryTextEnabled = document.getElementById('row-account-run-history-text-enabled'); +const inputHeroSmsApiKey = document.getElementById('input-hero-sms-api-key'); +const selectHeroSmsCountry = document.getElementById('select-hero-sms-country'); +const displayHeroSmsPlatform = document.getElementById('display-hero-sms-platform'); const inputAccountRunHistoryTextEnabled = document.getElementById('input-account-run-history-text-enabled'); const rowAccountRunHistoryHelperBaseUrl = document.getElementById('row-account-run-history-helper-base-url'); const inputAccountRunHistoryHelperBaseUrl = document.getElementById('input-account-run-history-helper-base-url'); @@ -276,6 +279,9 @@ const DEFAULT_LUCKMAIL_BASE_URL = 'https://mails.luckyous.com'; const DEFAULT_LUCKMAIL_EMAIL_TYPE = 'ms_graph'; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = 'http://127.0.0.1:17373'; +const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/'; +const DEFAULT_HERO_SMS_COUNTRY_ID = 52; +const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand'; function getManagedAliasUtils() { return window.MultiPageManagedAliasUtils || null; @@ -1654,6 +1660,15 @@ function collectSettingsPayload() { const mail2925UseAccountPool = typeof inputMail2925UseAccountPool !== 'undefined' ? Boolean(inputMail2925UseAccountPool?.checked) : Boolean(latestState?.mail2925UseAccountPool); + const heroSmsApiKeyValue = typeof inputHeroSmsApiKey !== 'undefined' && inputHeroSmsApiKey + ? (inputHeroSmsApiKey.value || '') + : ''; + const heroSmsCountry = typeof getSelectedHeroSmsCountryOption === 'function' + ? getSelectedHeroSmsCountryOption() + : { + id: typeof DEFAULT_HERO_SMS_COUNTRY_ID !== 'undefined' ? DEFAULT_HERO_SMS_COUNTRY_ID : 52, + label: typeof DEFAULT_HERO_SMS_COUNTRY_LABEL !== 'undefined' ? DEFAULT_HERO_SMS_COUNTRY_LABEL : 'Thailand', + }; return { panelMode: selectPanelMode.value, vpsUrl: inputVpsUrl.value.trim(), @@ -1717,6 +1732,9 @@ function collectSettingsPayload() { inputVerificationResendCount?.value, DEFAULT_VERIFICATION_RESEND_COUNT ), + heroSmsApiKey: heroSmsApiKeyValue, + heroSmsCountryId: heroSmsCountry.id, + heroSmsCountryLabel: heroSmsCountry.label, }; } @@ -1765,6 +1783,75 @@ function normalizeAccountRunHistoryHelperBaseUrlValue(value = '') { } } +function normalizeHeroSmsCountryId(value) { + return Math.max(1, Math.floor(Number(value) || DEFAULT_HERO_SMS_COUNTRY_ID)); +} + +function normalizeHeroSmsCountryLabel(value = '') { + return String(value || '').trim() || DEFAULT_HERO_SMS_COUNTRY_LABEL; +} + +function getSelectedHeroSmsCountryOption() { + if (!selectHeroSmsCountry) { + return { + id: DEFAULT_HERO_SMS_COUNTRY_ID, + label: DEFAULT_HERO_SMS_COUNTRY_LABEL, + }; + } + + const option = selectHeroSmsCountry.options[selectHeroSmsCountry.selectedIndex]; + return { + id: normalizeHeroSmsCountryId(selectHeroSmsCountry.value), + label: normalizeHeroSmsCountryLabel(option?.textContent), + }; +} + +function updateHeroSmsPlatformDisplay(label = '') { + if (!displayHeroSmsPlatform) { + return; + } + const countryLabel = normalizeHeroSmsCountryLabel(label); + displayHeroSmsPlatform.textContent = `HeroSMS / OpenAI / ${countryLabel}`; +} + +async function loadHeroSmsCountries() { + if (!selectHeroSmsCountry) { + return; + } + + const previousValue = selectHeroSmsCountry.value || String(DEFAULT_HERO_SMS_COUNTRY_ID); + try { + const response = await fetch('https://hero-sms.com/stubs/handler_api.php?action=getCountries'); + const payload = await response.json(); + const countries = Array.isArray(payload?.value) ? payload.value : (Array.isArray(payload) ? payload : []); + if (!countries.length) { + throw new Error('empty country list'); + } + + const optionsHtml = countries + .filter((item) => Number(item?.id) > 0 && String(item?.eng || '').trim()) + .sort((left, right) => String(left.eng || '').localeCompare(String(right.eng || ''))) + .map((item) => { + const id = normalizeHeroSmsCountryId(item.id); + const label = String(item.eng || '').trim(); + return ``; + }) + .join(''); + + if (optionsHtml) { + selectHeroSmsCountry.innerHTML = optionsHtml; + } + } catch (error) { + console.warn('Failed to load HeroSMS countries:', error); + selectHeroSmsCountry.innerHTML = ``; + } + + selectHeroSmsCountry.value = Array.from(selectHeroSmsCountry.options).some((option) => option.value === previousValue) + ? previousValue + : String(DEFAULT_HERO_SMS_COUNTRY_ID); + updateHeroSmsPlatformDisplay(getSelectedHeroSmsCountryOption().label); +} + function getSelectedLocalCpaStep9Mode() { const activeButton = localCpaStep9ModeButtons.find((button) => button.classList.contains('is-active')); return normalizeLocalCpaStep9Mode(activeButton?.dataset.localCpaStep9Mode); @@ -2150,6 +2237,16 @@ function applySettingsState(state) { normalizeVerificationResendCount(restoredVerificationResendCount, DEFAULT_VERIFICATION_RESEND_COUNT) ); } + if (inputHeroSmsApiKey) { + inputHeroSmsApiKey.value = state?.heroSmsApiKey || ''; + } + if (selectHeroSmsCountry) { + const restoredCountryId = String(normalizeHeroSmsCountryId(state?.heroSmsCountryId)); + if (Array.from(selectHeroSmsCountry.options).some((option) => option.value === restoredCountryId)) { + selectHeroSmsCountry.value = restoredCountryId; + } + updateHeroSmsPlatformDisplay(state?.heroSmsCountryLabel || getSelectedHeroSmsCountryOption().label); + } if (state?.autoRunTotalRuns) { inputRunCount.value = String(state.autoRunTotalRuns); } @@ -4975,6 +5072,20 @@ inputVerificationResendCount?.addEventListener('blur', () => { saveSettings({ silent: true }).catch(() => { }); }); +inputHeroSmsApiKey?.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputHeroSmsApiKey?.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); +}); + +selectHeroSmsCountry?.addEventListener('change', () => { + updateHeroSmsPlatformDisplay(getSelectedHeroSmsCountryOption().label); + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + // ============================================================ // Listen for Background broadcasts // ============================================================ @@ -5222,6 +5333,25 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ) ); } + if (message.payload.heroSmsApiKey !== undefined && inputHeroSmsApiKey) { + inputHeroSmsApiKey.value = message.payload.heroSmsApiKey || ''; + } + if ( + (message.payload.heroSmsCountryId !== undefined || message.payload.heroSmsCountryLabel !== undefined) + && selectHeroSmsCountry + ) { + const nextCountryId = String( + normalizeHeroSmsCountryId( + message.payload.heroSmsCountryId !== undefined + ? message.payload.heroSmsCountryId + : selectHeroSmsCountry.value + ) + ); + if (Array.from(selectHeroSmsCountry.options).some((option) => option.value === nextCountryId)) { + selectHeroSmsCountry.value = nextCountryId; + } + updateHeroSmsPlatformDisplay(message.payload.heroSmsCountryLabel || getSelectedHeroSmsCountryOption().label); + } renderContributionMode(); break; } @@ -5325,19 +5455,23 @@ setMail2925Mode(DEFAULT_MAIL_2925_MODE); initializeReleaseInfo().catch((err) => { console.error('Failed to initialize release info:', err); }); -restoreState().then(() => { - syncPasswordToggleLabel(); - syncVpsUrlToggleLabel(); - syncVpsPasswordToggleLabel(); - updatePanelModeUI(); - updateButtonStates(); - updateStatusDisplay(latestState); - return refreshContributionContentHint() - .catch((error) => { - console.warn('Failed to refresh contribution content hint during initialization:', error); - return null; - }) - .then(() => maybeShowNewUserGuidePrompt()); -}).catch((err) => { - console.error('Failed to initialize sidepanel state:', err); +loadHeroSmsCountries().catch((err) => { + console.error('Failed to load HeroSMS countries:', err); +}).finally(() => { + return restoreState().then(() => { + syncPasswordToggleLabel(); + syncVpsUrlToggleLabel(); + syncVpsPasswordToggleLabel(); + updatePanelModeUI(); + updateButtonStates(); + updateStatusDisplay(latestState); + return refreshContributionContentHint() + .catch((error) => { + console.warn('Failed to refresh contribution content hint during initialization:', error); + return null; + }) + .then(() => maybeShowNewUserGuidePrompt()); + }).catch((err) => { + console.error('Failed to initialize sidepanel state:', err); + }); }); diff --git a/tests/auto-run-step6-restart.test.js b/tests/auto-run-step6-restart.test.js index b643b0f..a487ea2 100644 --- a/tests/auto-run-step6-restart.test.js +++ b/tests/auto-run-step6-restart.test.js @@ -215,6 +215,20 @@ test('auto-run stops restarting on generic phone-page failure messages even with assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message))); }); +test('auto-run restarts from step 7 when phone verification cannot receive SMS after resend', async () => { + const harness = createHarness({ + failureStep: 9, + failureBudget: 1, + failureMessage: 'Phone verification could not receive an SMS after resend. Restart step 7 with a new number. Current number: 66959916439.', + authState: { state: 'add_phone_page', url: 'https://auth.openai.com/add-phone' }, + }); + + const events = await harness.run(); + + assert.equal(events.invalidations.length, 1); + assert.deepStrictEqual(events.steps, [7, 8, 9, 7, 8, 9, 10]); +}); + test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => { const harness = createHarness({ failureStep: 9, diff --git a/tests/phone-verification-flow.test.js b/tests/phone-verification-flow.test.js new file mode 100644 index 0000000..e6065b0 --- /dev/null +++ b/tests/phone-verification-flow.test.js @@ -0,0 +1,551 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background/phone-verification-flow.js', 'utf8'); +const globalScope = {}; +const api = new Function('self', `${source}; return self.MultiPageBackgroundPhoneVerification;`)(globalScope); + +test('phone verification helper requests HeroSMS numbers with fixed OpenAI and Thailand parameters', async () => { + const requests = []; + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + requests.push(new URL(url)); + return { + ok: true, + text: async () => 'ACCESS_NUMBER:123456:66959916439', + }; + }, + getState: async () => ({ heroSmsApiKey: 'demo-key' }), + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' }); + + assert.deepStrictEqual(activation, { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, + maxUses: 3, + }); + assert.equal(requests.length, 1); + assert.equal(requests[0].searchParams.get('action'), 'getNumber'); + assert.equal(requests[0].searchParams.get('service'), 'dr'); + assert.equal(requests[0].searchParams.get('country'), '52'); + assert.equal(requests[0].searchParams.get('api_key'), 'demo-key'); +}); + +test('phone verification helper completes add-phone flow, clears current activation, and stores reusable number state', async () => { + const requests = []; + const stateUpdates = []; + let currentState = { + heroSmsApiKey: 'demo-key', + verificationResendCount: 1, + currentPhoneActivation: null, + reusablePhoneActivation: null, + }; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getNumber') { + return { + ok: true, + text: async () => 'ACCESS_NUMBER:123456:66959916439', + }; + } + if (action === 'getStatus') { + return { + ok: true, + text: async () => 'STATUS_OK:654321', + }; + } + if (action === 'setStatus') { + return { + ok: true, + text: async () => 'ACCESS_ACTIVATION', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'SUBMIT_PHONE_NUMBER') { + return { + phoneVerificationPage: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + return { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + stateUpdates.push(updates); + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }); + + assert.deepStrictEqual(result, { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }); + assert.deepStrictEqual(stateUpdates, [ + { + currentPhoneActivation: { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, + maxUses: 3, + }, + }, + { + reusablePhoneActivation: { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 1, + maxUses: 3, + }, + }, + { + currentPhoneActivation: null, + }, + ]); + + const actions = requests.map((url) => url.searchParams.get('action')); + assert.deepStrictEqual(actions, ['getNumber', 'getStatus', 'setStatus']); +}); + +test('phone verification helper uses the configured HeroSMS country for both number acquisition and add-phone submission', async () => { + const requests = []; + const submittedPayloads = []; + let currentState = { + heroSmsApiKey: 'demo-key', + heroSmsCountryId: 16, + heroSmsCountryLabel: 'United Kingdom', + verificationResendCount: 0, + currentPhoneActivation: null, + reusablePhoneActivation: null, + }; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getNumber') { + return { + ok: true, + text: async () => 'ACCESS_NUMBER:654321:447911123456', + }; + } + if (action === 'getStatus') { + return { + ok: true, + text: async () => 'STATUS_OK:112233', + }; + } + if (action === 'setStatus') { + return { + ok: true, + text: async () => 'ACCESS_ACTIVATION', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'SUBMIT_PHONE_NUMBER') { + submittedPayloads.push(message.payload); + return { + phoneVerificationPage: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + return { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }); + + assert.deepStrictEqual(result, { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }); + assert.equal(requests[0].searchParams.get('action'), 'getNumber'); + assert.equal(requests[0].searchParams.get('country'), '16'); + assert.deepStrictEqual(submittedPayloads, [{ + phoneNumber: '447911123456', + countryId: 16, + countryLabel: 'United Kingdom', + }]); +}); + +test('phone verification helper throws a step-7 restart error after 60 seconds plus one resend window without SMS', async () => { + const requests = []; + const messages = []; + let currentState = { + heroSmsApiKey: 'demo-key', + verificationResendCount: 0, + currentPhoneActivation: null, + reusablePhoneActivation: null, + }; + const statusCallsById = {}; + const realDateNow = Date.now; + let fakeNow = 0; + Date.now = () => fakeNow; + + try { + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + const id = parsedUrl.searchParams.get('id'); + + if (action === 'getNumber') { + return { + ok: true, + text: async () => 'ACCESS_NUMBER:123456:66959916439', + }; + } + + if (action === 'getStatus') { + statusCallsById[id] = (statusCallsById[id] || 0) + 1; + return { + ok: true, + text: async () => 'STATUS_WAIT_CODE', + }; + } + + if (action === 'setStatus') { + return { + ok: true, + text: async () => 'ACCESS_ACTIVATION', + }; + } + + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + messages.push(message.type); + if (message.type === 'SUBMIT_PHONE_NUMBER') { + return { + phoneVerificationPage: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + if (message.type === 'RESEND_PHONE_VERIFICATION_CODE') { + return { + resent: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => { + fakeNow += 61000; + }, + throwIfStopped: () => {}, + }); + + await assert.rejects( + helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }), + /Restart step 7 with a new number/i + ); + assert.ok(statusCallsById['123456'] >= 2, 'first number should be polled twice before being replaced'); + assert.deepStrictEqual(messages, [ + 'SUBMIT_PHONE_NUMBER', + 'RESEND_PHONE_VERIFICATION_CODE', + ]); + + const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`); + assert.deepStrictEqual(actions, [ + 'getNumber:', + 'getStatus:123456', + 'setStatus:123456', + 'getStatus:123456', + 'setStatus:123456', + ]); + assert.equal(currentState.currentPhoneActivation, null); + } finally { + Date.now = realDateNow; + } +}); + +test('phone verification helper replaces the number when code submission returns to add-phone', async () => { + const requests = []; + const messages = []; + let currentState = { + heroSmsApiKey: 'demo-key', + verificationResendCount: 1, + currentPhoneActivation: null, + reusablePhoneActivation: null, + }; + + const numbers = [ + { activationId: '111111', phoneNumber: '66950000001' }, + { activationId: '222222', phoneNumber: '66950000002' }, + ]; + let numberIndex = 0; + let submitCodeCount = 0; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + const id = parsedUrl.searchParams.get('id'); + + if (action === 'getNumber') { + const nextNumber = numbers[numberIndex]; + numberIndex += 1; + return { + ok: true, + text: async () => `ACCESS_NUMBER:${nextNumber.activationId}:${nextNumber.phoneNumber}`, + }; + } + if (action === 'getStatus') { + return { + ok: true, + text: async () => 'STATUS_OK:654321', + }; + } + if (action === 'setStatus') { + return { + ok: true, + text: async () => `STATUS_UPDATED:${id}`, + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + messages.push(message.type); + if (message.type === 'SUBMIT_PHONE_NUMBER') { + return { + phoneVerificationPage: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + submitCodeCount += 1; + return submitCodeCount === 1 + ? { + returnedToAddPhone: true, + url: 'https://auth.openai.com/add-phone', + } + : { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }; + } + if (message.type === 'RESEND_PHONE_VERIFICATION_CODE') { + return { + resent: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }); + + assert.deepStrictEqual(result, { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }); + assert.deepStrictEqual(messages, [ + 'SUBMIT_PHONE_NUMBER', + 'SUBMIT_PHONE_VERIFICATION_CODE', + 'SUBMIT_PHONE_NUMBER', + 'SUBMIT_PHONE_VERIFICATION_CODE', + ]); + + const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`); + assert.deepStrictEqual(actions, [ + 'getNumber:', + 'getStatus:111111', + 'setStatus:111111', + 'getNumber:', + 'getStatus:222222', + 'setStatus:222222', + ]); + assert.deepStrictEqual(currentState.currentPhoneActivation, null); + assert.deepStrictEqual(currentState.reusablePhoneActivation, { + activationId: '222222', + phoneNumber: '66950000002', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 1, + maxUses: 3, + }); +}); + +test('phone verification helper reuses the same number up to three successful registrations', async () => { + const requests = []; + let currentState = { + heroSmsApiKey: 'demo-key', + verificationResendCount: 0, + currentPhoneActivation: null, + reusablePhoneActivation: { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 2, + maxUses: 3, + }, + }; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'reactivate') { + return { + ok: true, + text: async () => JSON.stringify({ + activationId: '222333', + phoneNumber: '66959916439', + }), + }; + } + if (action === 'getStatus') { + return { + ok: true, + text: async () => 'STATUS_OK:654321', + }; + } + if (action === 'setStatus') { + return { + ok: true, + text: async () => 'ACCESS_ACTIVATION', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'SUBMIT_PHONE_NUMBER') { + return { + phoneVerificationPage: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + return { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }); + + assert.deepStrictEqual(result, { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }); + assert.equal(requests[0].searchParams.get('action'), 'reactivate'); + assert.equal(requests[0].searchParams.get('id'), '123456'); + assert.deepStrictEqual(currentState.reusablePhoneActivation, null); +}); diff --git a/tests/sidepanel-contribution-button.test.js b/tests/sidepanel-contribution-button.test.js index d58a4dd..5e1cb7b 100644 --- a/tests/sidepanel-contribution-button.test.js +++ b/tests/sidepanel-contribution-button.test.js @@ -10,7 +10,7 @@ test('sidepanel html keeps a single contribution mode button in header', () => { assert.equal(matches.length, 1); assert.match(html, /id="btn-contribution-mode"[^>]*title="进入贡献模式并打开官网页"/); - assert.match(html, />贡献\/使用<\/button>/); + assert.match(html, />贡献\/使用教程<\/button>/); assert.match(html, /<\/header>\s*
{ + const api = new Function(` +let pollCount = 0; +const phoneVerificationCalls = []; + +function throwIfStopped() {} +async function sleepWithStop() {} +async function ensureStep8SignupPageReady() {} +const phoneVerificationHelpers = { + async completePhoneVerificationFlow(tabId, pageState) { + phoneVerificationCalls.push({ tabId, pageState }); + return { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }; + }, +}; +async function getStep8PageState() { + pollCount += 1; + if (pollCount === 1) { + return { + url: 'https://auth.openai.com/add-phone', + addPhonePage: true, + phoneVerificationPage: false, + consentReady: false, + }; + } + return { + url: 'https://auth.openai.com/authorize', + addPhonePage: false, + phoneVerificationPage: false, + consentReady: true, + }; +} + +${extractFunction('waitForStep8Ready')} + +return { + async run() { + return { + result: await waitForStep8Ready(88, 1000), + phoneVerificationCalls, + }; + }, +}; +`)(); + + const { result, phoneVerificationCalls } = await api.run(); + + assert.deepStrictEqual(phoneVerificationCalls, [ + { + tabId: 88, + pageState: { + url: 'https://auth.openai.com/add-phone', + addPhonePage: true, + phoneVerificationPage: false, + consentReady: false, + }, + }, + ]); + assert.deepStrictEqual(result, { + url: 'https://auth.openai.com/authorize', + addPhonePage: false, + phoneVerificationPage: false, + consentReady: true, + }); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 1234823..1d9fa1d 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -280,7 +280,7 @@ test('verification flow skips 2925 mailbox preclear when using a fixed signup ma assert.deepStrictEqual(mailMessages, ['POLL_EMAIL', 'DELETE_ALL_EMAILS']); }); -test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => { +test('verification flow completes step 8 and flags phone verification when add-phone appears after login code submit', async () => { const events = []; const helpers = api.createVerificationFlowHelpers({ @@ -330,18 +330,21 @@ test('verification flow treats add-phone after login code submit as fatal instea VERIFICATION_POLL_MAX_ROUNDS: 5, }); - await assert.rejects( - () => helpers.resolveVerificationStep( - 8, - { email: 'user@example.com', lastLoginCode: null }, - { provider: 'qq', label: 'QQ 邮箱' }, - {} - ), - /验证码提交后页面进入手机号页面/ + const result = await helpers.resolveVerificationStep( + 8, + { email: 'user@example.com', lastLoginCode: null }, + { provider: 'qq', label: 'QQ Mail' }, + {} ); + assert.deepStrictEqual(result, { + phoneVerificationRequired: true, + url: 'https://auth.openai.com/add-phone', + }); assert.deepStrictEqual(events, [ ['submit', '654321'], + ['state', '654321'], + ['complete', '654321'], ]); }); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 7fc4f45..23d20e7 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -43,6 +43,7 @@ - 在 sidepanel 初始化和点击“自动”按钮前刷新一次贡献站公开内容摘要;如果刷新失败,不阻塞主自动流程 - 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表 - 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新 +- 展示 HeroSMS 的接码国家与 API Key 设置,用于 OAuth 登录链路命中手机号验证页时直接续跑手机验证 - 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作 ### 2.2 Background Service Worker @@ -135,6 +136,8 @@ - OAuth 链接 - 当前邮箱 / 密码 - 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail` +- 当前手机号验证激活记录 `currentPhoneActivation` +- 可复用的手机号验证激活记录 `reusablePhoneActivation` - localhost 回调地址 - 自动运行轮次信息 - 当前自动运行 session 标识 `autoRunSessionId` @@ -159,6 +162,7 @@ - 2925 是否启用号池模式 `mail2925UseAccountPool` - 2925 当前选中的号池账号 ID `currentMail2925AccountId` - Cloudflare / Temp Email 设置 +- HeroSMS 的 API Key 与默认国家设置 - iCloud 相关偏好 - LuckMail API 配置 - 自动运行默认配置 @@ -412,7 +416,7 @@ 3. 打开邮箱页或 API 轮询入口 4. 轮询登录验证码 5. 回填登录验证码 -6. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则立即判为 fatal 错误,不再把步骤 8 视为成功 +6. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则步骤 8 会保留“登录验证码已提交成功”的结果,并把后续手机号验证需求继续交给步骤 9 处理 7. 如遇邮箱轮询类失败或显式的 `STEP8_RESTART_STEP7` 恢复错误,则按有限次数回到 Step 7 重试 8. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9 @@ -421,6 +425,7 @@ - 对 `2925 provide` 而言,Step 8 仍不依赖验证码页显示邮箱做收件匹配,而是直接测试所有命中 ChatGPT / OpenAI 过滤条件的邮件。 - 对 `2925 receive` 而言,Step 8 会把当前目标注册邮箱一并传给 2925 内容脚本;只有当邮件里显式写出了其他邮箱时才会跳过,从而在不破坏历史兼容性的前提下,尽量降低误收验证码的概率。 - 对 `custom` provider 而言,Step 8 仍使用手动验证码确认弹窗;弹窗当前额外提供“出现手机号验证”按钮,点击后会直接抛出与真实 `add-phone` 页面一致的 fatal 错误,供 Auto 按既有 add-phone 分支继续下一邮箱。 +- 当登录验证码提交后进入 `phone-verification` 页时,内容脚本会显式把该页面识别为“手机验证码页”,避免与邮箱验证码页混淆。 ### Step 9 @@ -431,9 +436,16 @@ 流程: 1. 监听 localhost callback -2. 准备 OAuth 同意页 -3. 尝试多轮点击“继续” -4. 一旦捕获 localhost callback,写入状态并完成步骤 +2. 准备 OAuth 同意页;如果页面已进入 `add-phone / phone-verification`,先切入手机号验证共享流程 +3. 手机号验证共享流程会按当前 sidepanel 中保存的 HeroSMS 国家与 API Key 申请或复用号码、提交号码、轮询短信验证码,并在验证码被拒绝或长时间收不到短信时决定重发、换号或把自动流拉回 Step 7 +4. 手机号验证完成后,继续等待 OAuth 同意页出现 +5. 尝试多轮点击“继续” +6. 一旦捕获 localhost callback,写入状态并完成步骤 + +补充: + +- HeroSMS 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。 +- 如果同一个号码在重发短信后 60 秒仍收不到验证码,后台会抛出“回到步骤 7 重新拿新号码”的恢复错误,而不是把当前号码无限重试下去。 ### Step 10 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 01607c9..c656600 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -49,10 +49,11 @@ - `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。 +- `background/phone-verification-flow.js`:手机号验证共享流程模块,负责在 OAuth 链路命中 `add-phone / phone-verification` 页面后向 HeroSMS 申请或复用号码、轮询短信验证码、提交手机号码与短信验证码,并在号码长期收不到短信时把后续自动流拉回步骤 7 重新拿号。 - `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 -- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。 +- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若登录验证码提交后页面转入 `add-phone / 手机号页`,则会把“后续需要手机号验证”的结果继续传给步骤 9,而不是误判为普通失败;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。 ## `background/steps/` @@ -78,8 +79,9 @@ - `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。 - `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。 - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。 +- `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 -- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击。 +- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前还会在登录链路中显式识别 `phone-verification` 页面,避免把手机验证码页误判成邮箱验证码页或普通未知页。 - `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。 - `content/utils.js`:内容脚本公共工具层,负责日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击。 - `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做步骤 10 判定。 @@ -122,8 +124,8 @@ - `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。 - `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。 - `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。 -- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时额外显示 `自定义号池` 文本框;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。 -- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。 +- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时额外显示 `自定义号池` 文本框;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;设置卡片中当前还新增 HeroSMS 的接码平台、国家与 API Key 行,用于 OAuth 链路的手机号验证;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。 +- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示。 - `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。 ## `tests/` @@ -163,6 +165,7 @@ - `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。 - `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成时的 Stop 中断行为。 - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 +- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。 - `tests/hotmail-api-mode.test.js`:测试 Hotmail API 模式相关文案和集成方式。 @@ -193,11 +196,11 @@ - `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证。 - `tests/step5-age-consent.test.js`:测试步骤 5 在年龄页会先勾选顶部“我同意以下所有各项”复选框,再点击提交。 - `tests/step5-direct-complete.test.js`:测试步骤 5 在资料页点击提交后立即完成当前步骤。 -- `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路。 +- `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路,并覆盖 `phone-verification` 页面不会被误判成邮箱验证码页。 - `tests/step6-timeout-recovery.test.js`:测试 OAuth 登录超时报错页的可恢复结果构造,当前对应步骤 7 链路。 - `tests/step8-callback-handling.test.js`:测试 localhost 回调地址捕获逻辑,当前对应步骤 9。 - `tests/step8-debugger-stop.test.js`:测试调试器点击在 Stop 场景下的中止行为,当前对应步骤 9。 -- `tests/step8-retry-page-recovery.test.js`:测试 OAuth 同意页点击后的重试页恢复分支,当前对应步骤 9。 +- `tests/step8-retry-page-recovery.test.js`:测试 OAuth 同意页点击后的重试页恢复分支,以及手机号验证完成后会继续回到 OAuth 同意页等待,当前对应步骤 9。 - `tests/step8-state-timeout-retry.test.js`:测试认证页通信错误是否可判定为可重试,当前对应步骤 9。 - `tests/step8-stop-cleanup.test.js`:测试 OAuth 确认流程在 Stop 后能正确清理监听器与挂起状态,当前对应步骤 9。 - `tests/step9-cpa-mode.test.js`:测试本地 CPA 平台回调跳过策略判断,当前作用于步骤 10。 @@ -205,4 +208,4 @@ - `tests/step9-status-diagnostics.test.js`:测试平台回调成功判定、红色错误态过滤,以及成功徽标与失败提示并存时的优先级,当前对应步骤 10。 - `tests/update-service.test.js`:测试侧边栏更新检查服务在 `Pro` / `v` 混合版本和旧缓存场景下都能按版本族正确排序,不会把 legacy `v` 版本误显示成最新可升级版本。 - `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。 -- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数、验证码提交流程中的 `beforeSubmit` 钩子执行顺序,以及步骤 8 提交验证码后进入手机号页时不会误判成功。 +- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数、验证码提交流程中的 `beforeSubmit` 钩子执行顺序,以及步骤 8 提交验证码后进入手机号页时会把“继续手机号验证”状态交给步骤 9 处理。