From 4c091a3d32af17cf62f63fd3972b522af5e95ab5 Mon Sep 17 00:00:00 2001 From: daniellee2015 Date: Wed, 29 Apr 2026 00:51:05 +0800 Subject: [PATCH] feat(flow): stabilize step7-9, expand HeroSMS, and update usage tutorial --- background.js | 347 +++- background/phone-verification-flow.js | 1116 ++++++++++-- background/steps/confirm-oauth.js | 41 +- background/verification-flow.js | 28 +- content/phone-auth.js | 119 ++ docs/使用教程.md | 82 +- sidepanel/ip-proxy-panel.js | 36 +- sidepanel/sidepanel.css | 401 ++++- sidepanel/sidepanel.html | 467 +++-- sidepanel/sidepanel.js | 1583 +++++++++++++++-- tests/auto-run-step6-restart.test.js | 12 +- ...ackground-account-history-settings.test.js | 36 + tests/background-luckmail.test.js | 13 + tests/phone-verification-flow.test.js | 1502 ++++++++++++---- ...dflare-temp-email-random-subdomain.test.js | 13 - tests/sidepanel-contribution-mode.test.js | 1 + tests/sidepanel-hotmail-manager.test.js | 16 - tests/sidepanel-icloud-provider.test.js | 69 +- tests/sidepanel-mail2925-base-email.test.js | 1 + ...epanel-phone-verification-settings.test.js | 172 +- tests/step9-timeout-recovery.test.js | 231 +++ 21 files changed, 5265 insertions(+), 1021 deletions(-) create mode 100644 tests/step9-timeout-recovery.test.js diff --git a/background.js b/background.js index de43b22..9ddb88f 100644 --- a/background.js +++ b/background.js @@ -265,6 +265,21 @@ const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600; const VERIFICATION_RESEND_COUNT_MIN = 0; const VERIFICATION_RESEND_COUNT_MAX = 20; const DEFAULT_VERIFICATION_RESEND_COUNT = 4; +const PHONE_REPLACEMENT_LIMIT_MIN = 1; +const PHONE_REPLACEMENT_LIMIT_MAX = 20; +const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3; +const PHONE_CODE_WAIT_SECONDS_MIN = 15; +const PHONE_CODE_WAIT_SECONDS_MAX = 300; +const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60; +const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1; +const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10; +const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2; +const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1; +const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30; +const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5; +const PHONE_CODE_POLL_ROUNDS_MIN = 1; +const PHONE_CODE_POLL_ROUNDS_MAX = 120; +const DEFAULT_PHONE_CODE_POLL_ROUNDS = 4; const LEGACY_AUTO_STEP_DELAY_KEYS = ['autoStepRandomDelayMinSeconds', 'autoStepRandomDelayMaxSeconds']; const LEGACY_VERIFICATION_RESEND_COUNT_KEYS = ['signupVerificationResendCount', 'loginVerificationResendCount']; const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; @@ -283,6 +298,10 @@ 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 DEFAULT_HERO_SMS_REUSE_ENABLED = true; +const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; +const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; +const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; const PERSISTENT_ALIAS_STATE_KEYS = [ @@ -446,6 +465,11 @@ const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null, phoneVerificationEnabled: false, verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT, + phoneVerificationReplacementLimit: DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT, + phoneCodeWaitSeconds: DEFAULT_PHONE_CODE_WAIT_SECONDS, + phoneCodeTimeoutWindows: DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS, + phoneCodePollIntervalSeconds: DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS, + phoneCodePollMaxRounds: DEFAULT_PHONE_CODE_POLL_ROUNDS, mailProvider: '163', mail2925Mode: DEFAULT_MAIL_2925_MODE, mail2925UseAccountPool: false, @@ -481,9 +505,12 @@ const PERSISTED_SETTING_DEFAULTS = { mail2925Accounts: [], paypalAccounts: [], heroSmsApiKey: '', + heroSmsReuseEnabled: DEFAULT_HERO_SMS_REUSE_ENABLED, + heroSmsAcquirePriority: DEFAULT_HERO_SMS_ACQUIRE_PRIORITY, heroSmsMaxPrice: '', heroSmsCountryId: HERO_SMS_COUNTRY_ID, heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, + heroSmsCountryFallback: [], }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -563,7 +590,13 @@ const DEFAULT_STATE = { currentLuckmailPurchase: null, currentLuckmailMailCursor: null, currentPhoneActivation: null, + currentPhoneVerificationCode: '', reusablePhoneActivation: null, + heroSmsLastPriceTiers: [], + heroSmsLastPriceCountryId: 0, + heroSmsLastPriceCountryLabel: '', + heroSmsLastPriceUserLimit: '', + heroSmsLastPriceAt: 0, pendingPhoneActivationConfirmation: null, autoRunning: false, // 当前是否处于自动运行中。 autoRunPhase: 'idle', // 当前自动运行阶段。 @@ -664,6 +697,143 @@ function normalizeVerificationResendCount(value, fallback) { ); } +function normalizePhoneVerificationReplacementLimit(value, fallback = DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_REPLACEMENT_LIMIT_MAX, + Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT)) + ); + } + return Math.min( + PHONE_REPLACEMENT_LIMIT_MAX, + Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodeWaitSeconds(value, fallback = DEFAULT_PHONE_CODE_WAIT_SECONDS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_WAIT_SECONDS_MAX, + Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_WAIT_SECONDS)) + ); + } + return Math.min( + PHONE_CODE_WAIT_SECONDS_MAX, + Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodeTimeoutWindows(value, fallback = DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_TIMEOUT_WINDOWS_MAX, + Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS)) + ); + } + return Math.min( + PHONE_CODE_TIMEOUT_WINDOWS_MAX, + Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodePollIntervalSeconds(value, fallback = DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_POLL_INTERVAL_SECONDS_MAX, + Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS)) + ); + } + return Math.min( + PHONE_CODE_POLL_INTERVAL_SECONDS_MAX, + Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.floor(numeric)) + ); +} + +function normalizePhoneCodePollMaxRounds(value, fallback = DEFAULT_PHONE_CODE_POLL_ROUNDS) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + if (!rawValue || !Number.isFinite(numeric)) { + return Math.min( + PHONE_CODE_POLL_ROUNDS_MAX, + Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.floor(Number(fallback) || DEFAULT_PHONE_CODE_POLL_ROUNDS)) + ); + } + return Math.min( + PHONE_CODE_POLL_ROUNDS_MAX, + Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.floor(numeric)) + ); +} + +function normalizeHeroSmsMaxPrice(value = '') { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return ''; + } + const numeric = Number(rawValue); + if (!Number.isFinite(numeric) || numeric <= 0) { + return ''; + } + return String(Math.round(numeric * 10000) / 10000); +} + +function normalizeHeroSmsAcquirePriority(value = '') { + return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE + ? HERO_SMS_ACQUIRE_PRIORITY_PRICE + : HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; +} + +function normalizeHeroSmsCountryFallback(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const seenIds = new Set(); + const normalized = []; + + for (const entry of source) { + let countryId = 0; + let countryLabel = ''; + + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + countryId = Math.floor(Number(entry.countryId ?? entry.id) || 0); + countryLabel = String((entry.countryLabel ?? entry.label) || '').trim(); + } else { + const text = String(entry || '').trim(); + const structuredMatch = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/); + if (structuredMatch) { + countryId = Math.floor(Number(structuredMatch[1]) || 0); + countryLabel = String(structuredMatch[2] || '').trim(); + } else { + countryId = Math.floor(Number(text) || 0); + } + } + + if (!Number.isFinite(countryId) || countryId <= 0 || seenIds.has(countryId)) { + continue; + } + seenIds.add(countryId); + normalized.push({ + id: countryId, + label: countryLabel || `Country #${countryId}`, + }); + if (normalized.length >= 20) { + break; + } + } + + return normalized; +} + function resolveLegacyAutoStepDelaySeconds(input = {}) { const hasLegacyMin = input.autoStepRandomDelayMinSeconds !== undefined; const hasLegacyMax = input.autoStepRandomDelayMaxSeconds !== undefined; @@ -1264,6 +1434,16 @@ function normalizePersistentSettingValue(key, value) { return normalizeAutoStepDelaySeconds(value, PERSISTED_SETTING_DEFAULTS.autoStepDelaySeconds); case 'verificationResendCount': return normalizeVerificationResendCount(value, DEFAULT_VERIFICATION_RESEND_COUNT); + case 'phoneVerificationReplacementLimit': + return normalizePhoneVerificationReplacementLimit(value, DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT); + case 'phoneCodeWaitSeconds': + return normalizePhoneCodeWaitSeconds(value, DEFAULT_PHONE_CODE_WAIT_SECONDS); + case 'phoneCodeTimeoutWindows': + return normalizePhoneCodeTimeoutWindows(value, DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS); + case 'phoneCodePollIntervalSeconds': + return normalizePhoneCodePollIntervalSeconds(value, DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS); + case 'phoneCodePollMaxRounds': + return normalizePhoneCodePollMaxRounds(value, DEFAULT_PHONE_CODE_POLL_ROUNDS); case 'mailProvider': return normalizeMailProvider(value); case 'mail2925Mode': @@ -1327,12 +1507,18 @@ function normalizePersistentSettingValue(key, value) { return normalizePayPalAccounts(value); case 'heroSmsApiKey': return String(value || ''); + case 'heroSmsReuseEnabled': + return Boolean(value); + case 'heroSmsAcquirePriority': + return normalizeHeroSmsAcquirePriority(value); case 'heroSmsMaxPrice': - return String(value || '').trim(); + return normalizeHeroSmsMaxPrice(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; + case 'heroSmsCountryFallback': + return normalizeHeroSmsCountryFallback(value); default: return value; } @@ -1878,6 +2064,7 @@ async function resetState() { 'accounts', 'tabRegistry', 'sourceLastUrls', + 'reusablePhoneActivation', 'luckmailApiKey', 'luckmailBaseUrl', 'luckmailEmailType', @@ -1892,6 +2079,25 @@ async function resetState() { getPersistedAliasState(), ]); const contributionModeState = buildContributionModeState(Boolean(prev.contributionMode), persistedSettings, prev); + const reusablePhoneActivation = ( + prev.reusablePhoneActivation + && typeof prev.reusablePhoneActivation === 'object' + && !Array.isArray(prev.reusablePhoneActivation) + && String( + prev.reusablePhoneActivation.activationId + ?? prev.reusablePhoneActivation.id + ?? prev.reusablePhoneActivation.activation + ?? '' + ).trim() + && String( + prev.reusablePhoneActivation.phoneNumber + ?? prev.reusablePhoneActivation.number + ?? prev.reusablePhoneActivation.phone + ?? '' + ).trim() + ) + ? prev.reusablePhoneActivation + : null; await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, @@ -1912,6 +2118,8 @@ async function resetState() { luckmailPreserveTagName: String(prev.luckmailPreserveTagName || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, currentLuckmailPurchase: null, currentLuckmailMailCursor: null, + // Keep reusable phone activation across round resets so the same number can be reactivated up to maxUses. + reusablePhoneActivation, preferredIcloudHost: prev.preferredIcloudHost || '', }); } @@ -6042,6 +6250,7 @@ function getDownstreamStateResets(step, state = {}) { lastSignupCode: null, lastLoginCode: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 2) { @@ -6057,6 +6266,7 @@ function getDownstreamStateResets(step, state = {}) { lastSignupCode: null, lastLoginCode: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 3 || step === 4) { @@ -6071,6 +6281,7 @@ function getDownstreamStateResets(step, state = {}) { lastSignupCode: null, lastLoginCode: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 5 || step === 6 || step === 7 || step === 8) { @@ -6092,6 +6303,7 @@ function getDownstreamStateResets(step, state = {}) { oauthFlowDeadlineSourceUrl: null, pendingPhoneActivationConfirmation: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (step === 9) { @@ -6099,6 +6311,7 @@ function getDownstreamStateResets(step, state = {}) { pendingPhoneActivationConfirmation: null, plusReturnUrl: '', localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (stepKey === 'oauth-login' || stepKey === 'fetch-login-code') { @@ -6109,6 +6322,7 @@ function getDownstreamStateResets(step, state = {}) { oauthFlowDeadlineSourceUrl: null, pendingPhoneActivationConfirmation: null, localhostUrl: null, + currentPhoneVerificationCode: '', }; } if (stepKey === 'confirm-oauth') { @@ -8598,6 +8812,11 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.createPhoneVerificationHelpers({ addLog, DEFAULT_HERO_SMS_BASE_URL, + DEFAULT_HERO_SMS_REUSE_ENABLED, + DEFAULT_PHONE_CODE_WAIT_SECONDS, + DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS, + DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS, + DEFAULT_PHONE_CODE_POLL_ROUNDS, ensureStep8SignupPageReady, getOAuthFlowStepTimeoutMs, getState, @@ -9448,6 +9667,10 @@ async function getPostStep6AutoRestartDecision(step, error) { const hasTransientNetworkSignal = /connect:\s*connection refused|failed to fetch|i\/o timeout|context deadline exceeded|eof|connection reset by peer/i.test(normalizedMessage); return mentionsTokenExchange && hasTransientNetworkSignal; }; + const isPhoneVerificationLocalFailure = (errorMessage = '') => { + const normalizedMessage = String(errorMessage || ''); + return /HeroSMS|phone verification did not succeed|number replacements|sms_timeout_after_resend|phone number is already linked|add-phone keeps rejecting current number|接码|手机号|手机验证码|步骤\s*9.*(?:手机号|验证码)|Step\s*9.*phone verification/i.test(normalizedMessage); + }; const normalizedStep = Number(step); const errorMessage = getErrorMessage(error); @@ -9478,6 +9701,17 @@ async function getPostStep6AutoRestartDecision(step, error) { }; } + if (isPhoneVerificationLocalFailure(errorMessage)) { + return { + shouldRestart: false, + blockedByAddPhone: true, + forcedByPhoneVerificationTimeout: false, + restartStep: authChainStartStep, + errorMessage, + authState: null, + }; + } + if (shouldForceRestartFromStep7) { return { shouldRestart: true, @@ -10080,6 +10314,116 @@ function getStep8EffectLabel(effect) { } } +function isStep9OAuthLocalhostTimeoutError(error, visibleStep = 9) { + const message = getErrorMessage(error); + if (!message) { + return false; + } + if (!/从拿到 OAuth 登录地址开始/.test(message)) { + return false; + } + if (!/localhost 回调|OAuth localhost 回调/i.test(message)) { + return false; + } + const normalizedStep = Number(visibleStep); + if (Number.isFinite(normalizedStep) && normalizedStep > 0) { + const stepPrefix = new RegExp(`步骤\\s*${normalizedStep}\\s*:`); + if (!stepPrefix.test(message)) { + return false; + } + } + return true; +} + +async function recoverOAuthLocalhostTimeout(details = {}) { + const { + error, + state, + visibleStep = 9, + } = details; + + if (!isStep9OAuthLocalhostTimeoutError(error, visibleStep)) { + return null; + } + + const authLoginStep = typeof getAuthChainStartStepId === 'function' + ? getAuthChainStartStepId(state || {}) + : FINAL_OAUTH_CHAIN_START_STEP; + const loginCodeStep = Number(visibleStep) >= 12 ? 11 : 8; + + await addLog( + `步骤 ${visibleStep}:检测到 OAuth localhost 回调等待窗口已过期,正在复核认证页并回到步骤 ${authLoginStep} 重拉授权链路。`, + 'warn' + ); + + let authState = null; + try { + authState = await getLoginAuthStateFromContent({ + timeoutMs: 10000, + responseTimeoutMs: 10000, + logMessage: `步骤 ${visibleStep}:正在复核认证页状态,确认是否可自动恢复 localhost 回调链路...`, + }); + } catch (inspectError) { + await addLog( + `步骤 ${visibleStep}:复核认证页状态失败(${getErrorMessage(inspectError)}),将先尝试按步骤 ${loginCodeStep} 收尾恢复。`, + 'warn' + ); + } + + if (isAddPhoneAuthState(authState)) { + const stateLabel = getLoginAuthStateLabel(authState.state); + await addLog( + `步骤 ${visibleStep}:当前认证页为 ${stateLabel},将直接回到步骤 ${authLoginStep} 重新拉起授权链路,避免步骤 8/9 恢复冲突。`, + 'warn' + ); + } else if (authState && authState.state && !['verification_page', 'oauth_consent_page'].includes(authState.state)) { + const stateLabel = getLoginAuthStateLabel(authState.state); + await addLog( + `步骤 ${visibleStep}:当前认证页为 ${stateLabel},不满足快速恢复条件,将回到步骤 ${authLoginStep} 重开授权链路。`, + 'warn' + ); + } + + const latestState = await getState(); + if (!step7Executor?.executeStep7 || !step8Executor?.executeStep8) { + return null; + } + + await addLog( + `步骤 ${visibleStep}:正在自动重开步骤 ${authLoginStep} -> ${loginCodeStep},恢复到可继续捕获 localhost 回调的状态。`, + 'warn' + ); + await step7Executor.executeStep7({ + ...latestState, + visibleStep: authLoginStep, + }); + + const stateAfterStep7 = await getState(); + await step8Executor.executeStep8({ + ...stateAfterStep7, + visibleStep: loginCodeStep, + }); + + const recoveredState = await getState(); + const oauthUrl = String(recoveredState?.oauthUrl || state?.oauthUrl || '').trim(); + if (oauthUrl && typeof startOAuthFlowTimeoutWindow === 'function') { + await startOAuthFlowTimeoutWindow({ + step: Number(visibleStep) || 9, + oauthUrl, + }); + } + + await setState({ + localhostUrl: null, + }); + + await addLog( + `步骤 ${visibleStep}:已恢复到步骤 ${authLoginStep} -> ${loginCodeStep} 收尾状态,并刷新 OAuth localhost 回调等待窗口,准备重试当前步骤。`, + 'warn' + ); + return await getState(); +} + const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ addLog, chrome, @@ -10097,6 +10441,7 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ getStep8TabUpdatedListener, isTabAlive, prepareStep8DebuggerClick, + recoverOAuthLocalhostTimeout, reloadStep8ConsentPage, reuseOrCreateTab, setStep8PendingReject, diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index e8b7eb8..4f5e4f0 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -13,23 +13,51 @@ sleepWithStop, throwIfStopped, DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php', + DEFAULT_HERO_SMS_REUSE_ENABLED = true, HERO_SMS_COUNTRY_ID = 52, HERO_SMS_COUNTRY_LABEL = 'Thailand', HERO_SMS_SERVICE_CODE = 'dr', HERO_SMS_SERVICE_LABEL = 'OpenAI', + DEFAULT_PHONE_CODE_WAIT_SECONDS = 60, + DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2, + DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5, + DEFAULT_PHONE_CODE_POLL_ROUNDS = 4, } = deps; const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation'; + const PHONE_VERIFICATION_CODE_STATE_KEY = 'currentPhoneVerificationCode'; const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation'; - const PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY = 'pendingPhoneActivationConfirmation'; - const DEFAULT_PHONE_POLL_INTERVAL_MS = 5000; + const HERO_SMS_LAST_PRICE_TIERS_KEY = 'heroSmsLastPriceTiers'; + const HERO_SMS_LAST_PRICE_COUNTRY_ID_KEY = 'heroSmsLastPriceCountryId'; + const HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY = 'heroSmsLastPriceCountryLabel'; + const HERO_SMS_LAST_PRICE_USER_LIMIT_KEY = 'heroSmsLastPriceUserLimit'; + const HERO_SMS_LAST_PRICE_AT_KEY = 'heroSmsLastPriceAt'; + const PHONE_CODE_WAIT_SECONDS_MIN = 15; + const PHONE_CODE_WAIT_SECONDS_MAX = 300; + const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1; + const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10; + const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1; + const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30; + const PHONE_CODE_POLL_ROUNDS_MIN = 1; + const PHONE_CODE_POLL_ROUNDS_MAX = 120; + const DEFAULT_PHONE_POLL_INTERVAL_MS = DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS * 1000; 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 DEFAULT_PHONE_NUMBER_REPLACEMENT_LIMIT = 3; + const DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS = 3; + const MAX_PHONE_PRICE_CANDIDATES = 8; + const DEFAULT_PHONE_ACTIVATION_RETRY_ROUNDS = 3; + const PHONE_ACTIVATION_RETRY_ROUNDS_MIN = 1; + const PHONE_ACTIVATION_RETRY_ROUNDS_MAX = 10; + const DEFAULT_PHONE_ACTIVATION_RETRY_DELAY_MS = 2000; + const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; + const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::'; const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::'; + const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::'; + const PHONE_SMS_FAILURE_SKIP_THRESHOLD = 2; function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) { const trimmed = String(value || '').trim(); @@ -47,29 +75,183 @@ return String(value || '').trim(); } - function normalizeManualHeroSmsMaxPrice(value) { - const trimmed = String(value ?? '').trim(); - if (!trimmed) { - return null; - } - const price = Number(trimmed); - if (!Number.isFinite(price) || price <= 0) { - return null; - } - return String(price); - } - function normalizeUseCount(value) { return Math.max(0, Math.floor(Number(value) || 0)); } + function normalizePhoneReplacementLimit(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_NUMBER_REPLACEMENT_LIMIT; + } + return Math.max(1, Math.min(20, parsed)); + } + + function normalizePhoneActivationRetryRounds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_ACTIVATION_RETRY_ROUNDS; + } + return Math.max(PHONE_ACTIVATION_RETRY_ROUNDS_MIN, Math.min(PHONE_ACTIVATION_RETRY_ROUNDS_MAX, parsed)); + } + + function normalizePhoneActivationRetryDelayMs(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_ACTIVATION_RETRY_DELAY_MS; + } + return Math.max(500, Math.min(30000, parsed)); + } + + function normalizeHeroSmsPriceLimit(value) { + if (value === undefined || value === null || String(value).trim() === '') { + return null; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.round(parsed * 10000) / 10000; + } + + function isPhoneNumberUsedError(value) { + const text = String(value || '').trim(); + if (!text) { + return false; + } + return /already\s+linked\s+to\s+the\s+maximum\s+number\s+of\s+accounts|phone\s+number\s+is\s+already\s+(?:in\s+use|linked|registered)|phone\s+number\s+has\s+already\s+been\s+used|already\s+associated\s+with\s+another\s+account|not\s+eligible\s+to\s+be\s+used|cannot\s+be\s+used\s+for\s+verification|号码.*(?:已|被).*(?:使用|占用|绑定|注册)|手机号.*(?:已|被).*(?:使用|占用|绑定|注册)|该手机号.*(?:已|被).*(?:使用|占用|绑定|注册)/i.test(text); + } + + function normalizeCountryId(value, fallback = HERO_SMS_COUNTRY_ID) { + const parsed = Math.floor(Number(value)); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + const fallbackParsed = Math.floor(Number(fallback)); + if (Number.isFinite(fallbackParsed) && fallbackParsed > 0) { + return fallbackParsed; + } + return 0; + } + + function normalizeCountryLabel(value = '', fallback = HERO_SMS_COUNTRY_LABEL) { + return String(value || '').trim() || fallback; + } + + function normalizePhoneCodeWaitSeconds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_WAIT_SECONDS; + } + return Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.min(PHONE_CODE_WAIT_SECONDS_MAX, parsed)); + } + + function normalizePhoneCodeTimeoutWindows(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS; + } + return Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.min(PHONE_CODE_TIMEOUT_WINDOWS_MAX, parsed)); + } + + function normalizePhoneCodePollIntervalSeconds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS; + } + return Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.min(PHONE_CODE_POLL_INTERVAL_SECONDS_MAX, parsed)); + } + + function normalizePhoneCodePollMaxRounds(value) { + const parsed = Math.floor(Number(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_PHONE_CODE_POLL_ROUNDS; + } + return Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.min(PHONE_CODE_POLL_ROUNDS_MAX, parsed)); + } + + function normalizeHeroSmsReuseEnabled(value) { + if (value === undefined || value === null) { + return Boolean(DEFAULT_HERO_SMS_REUSE_ENABLED); + } + return Boolean(value); + } + + function normalizeHeroSmsAcquirePriority(value = '') { + return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE + ? HERO_SMS_ACQUIRE_PRIORITY_PRICE + : HERO_SMS_ACQUIRE_PRIORITY_COUNTRY; + } + + function normalizeCountryFallbackList(value = []) { + const source = Array.isArray(value) + ? value + : String(value || '') + .split(/[\r\n,,;;]+/) + .map((entry) => String(entry || '').trim()) + .filter(Boolean); + const seen = new Set(); + const normalized = []; + + for (const entry of source) { + let id = 0; + let label = ''; + + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + id = normalizeCountryId(entry.id ?? entry.countryId, 0); + label = String((entry.label ?? entry.countryLabel) || '').trim(); + } else { + const text = String(entry || '').trim(); + const structured = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/); + if (structured) { + id = normalizeCountryId(structured[1], 0); + label = String(structured[2] || '').trim(); + } else { + id = normalizeCountryId(text, 0); + } + } + + if (!Number.isFinite(id) || id <= 0 || seen.has(id)) { + continue; + } + seen.add(id); + normalized.push({ + id, + label: label || `Country #${id}`, + }); + } + + return normalized; + } + 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, + id: normalizeCountryId(state.heroSmsCountryId, HERO_SMS_COUNTRY_ID), + label: normalizeCountryLabel(state.heroSmsCountryLabel, HERO_SMS_COUNTRY_LABEL), }; } + function resolveCountryCandidates(state = {}) { + const primary = resolveCountryConfig(state); + const fallbackList = normalizeCountryFallbackList(state.heroSmsCountryFallback); + const seen = new Set([primary.id]); + const candidates = [primary]; + + fallbackList.forEach((entry) => { + const nextId = normalizeCountryId(entry.id, 0); + if (!Number.isFinite(nextId) || nextId <= 0 || seen.has(nextId)) { + return; + } + seen.add(nextId); + candidates.push({ + id: nextId, + label: normalizeCountryLabel(entry.label, `Country #${nextId}`), + }); + }); + + return candidates; + } + function normalizeActivation(record) { if (!record || typeof record !== 'object' || Array.isArray(record)) { return null; @@ -84,12 +266,14 @@ return null; } const statusAction = String(record.statusAction || '').trim(); + const countryLabel = String(record.countryLabel || '').trim(); 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, + countryId: normalizeCountryId(record.countryId, HERO_SMS_COUNTRY_ID), + ...(countryLabel ? { countryLabel } : {}), successfulUses: normalizeUseCount(record.successfulUses), maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)), ...(statusAction ? { statusAction } : {}), @@ -105,6 +289,7 @@ const provider = String(record.provider || '').trim(); const serviceCode = String(record.serviceCode || '').trim(); const countryId = Math.floor(Number(record.countryId)); + const countryLabel = String(record.countryLabel || '').trim(); const statusAction = String(record.statusAction || '').trim(); if (provider) { @@ -116,6 +301,9 @@ if (Number.isFinite(countryId) && countryId > 0) { fallback.countryId = countryId; } + if (countryLabel) { + fallback.countryLabel = countryLabel; + } if (Object.prototype.hasOwnProperty.call(record, 'successfulUses')) { fallback.successfulUses = normalizeUseCount(record.successfulUses); } @@ -186,6 +374,17 @@ return String(error?.message || '').startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX); } + function isPhoneResendThrottledError(error) { + const message = String(error?.message || error || '').trim(); + if (!message) { + return false; + } + if (message.startsWith(PHONE_RESEND_THROTTLED_ERROR_PREFIX)) { + return true; + } + return /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试/i.test(message); + } + function buildPhoneRestartStep7Error(phoneNumber = '') { const suffix = phoneNumber ? ` Current number: ${phoneNumber}.` : ''; return new Error( @@ -248,19 +447,13 @@ } } - function resolvePhoneConfig(state = {}, options = {}) { + 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.'); } - const requireMaxPrice = Boolean(options.requireMaxPrice); - const maxPrice = normalizeManualHeroSmsMaxPrice(state.heroSmsMaxPrice); - if (requireMaxPrice && !maxPrice) { - throw new Error('HeroSMS maxPrice is missing. Fill it in below the country selector before running the phone flow.'); - } return { apiKey, - ...(maxPrice ? { maxPrice } : {}), baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), }; } @@ -270,31 +463,37 @@ const directActivation = normalizeActivation(payload); if (directActivation) { const statusAction = normalizedFallback?.statusAction || directActivation.statusAction; - return { - ...directActivation, - provider: normalizedFallback?.provider || directActivation.provider, - serviceCode: normalizedFallback?.serviceCode || directActivation.serviceCode, - countryId: normalizedFallback?.countryId || directActivation.countryId, - successfulUses: normalizedFallback?.successfulUses ?? directActivation.successfulUses, - maxUses: normalizedFallback?.maxUses ?? directActivation.maxUses, - ...(statusAction ? { statusAction } : {}), - }; - } + return { + ...directActivation, + provider: normalizedFallback?.provider || directActivation.provider, + serviceCode: normalizedFallback?.serviceCode || directActivation.serviceCode, + countryId: normalizedFallback?.countryId || directActivation.countryId, + ...( + normalizedFallback?.countryLabel || directActivation.countryLabel + ? { countryLabel: normalizedFallback?.countryLabel || directActivation.countryLabel } + : {} + ), + successfulUses: normalizedFallback?.successfulUses ?? directActivation.successfulUses, + maxUses: normalizedFallback?.maxUses ?? directActivation.maxUses, + ...(statusAction ? { statusAction } : {}), + }; + } 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, - ...(normalizedFallback?.statusAction ? { statusAction: normalizedFallback.statusAction } : {}), - }; - } + 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, + ...(normalizedFallback?.countryLabel ? { countryLabel: normalizedFallback.countryLabel } : {}), + successfulUses: normalizedFallback?.successfulUses ?? 0, + maxUses: normalizedFallback?.maxUses ?? DEFAULT_PHONE_NUMBER_MAX_USES, + ...(normalizedFallback?.statusAction ? { statusAction: normalizedFallback.statusAction } : {}), + }; + } if (/^ACCESS_READY$/i.test(text) && normalizedFallback) { return normalizedFallback; @@ -307,10 +506,173 @@ return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus'; } + function normalizeHeroSmsPrice(value) { + const price = Number(value); + if (!Number.isFinite(price) || price < 0) { + return null; + } + return price; + } + + function collectHeroSmsPriceCandidates(payload, candidates = []) { + if (Array.isArray(payload)) { + payload.forEach((entry) => collectHeroSmsPriceCandidates(entry, candidates)); + return candidates; + } + if (!payload || typeof payload !== 'object') { + return candidates; + } + + const cost = normalizeHeroSmsPrice(payload.cost); + if (cost !== null) { + const count = Number(payload.count); + const physicalCount = Number(payload.physicalCount); + const hasCount = Number.isFinite(count); + const hasPhysicalCount = Number.isFinite(physicalCount); + if ((!hasCount && !hasPhysicalCount) || count > 0 || physicalCount > 0) { + candidates.push(cost); + } + } + + Object.values(payload).forEach((value) => collectHeroSmsPriceCandidates(value, candidates)); + return candidates; + } + + function findLowestHeroSmsPrice(payload) { + const candidates = collectHeroSmsPriceCandidates(payload, []); + if (!candidates.length) { + return null; + } + return Math.min(...candidates); + } + + function buildSortedUniquePriceCandidates(values = []) { + return Array.from( + new Set( + values + .map((value) => normalizeHeroSmsPrice(value)) + .filter((value) => value !== null) + .map((value) => Math.round(value * 10000) / 10000) + ) + ) + .sort((left, right) => left - right) + .slice(0, MAX_PHONE_PRICE_CANDIDATES); + } + function isHeroSmsNoNumbersPayload(payload) { return /\bNO_NUMBERS\b/i.test(describeHeroSmsPayload(payload)); } + function extractHeroSmsWrongMaxPrice(payload) { + if (payload && typeof payload === 'object') { + const title = String(payload.title || '').trim(); + const minPrice = normalizeHeroSmsPrice(payload.info?.min); + if (/^WRONG_MAX_PRICE$/i.test(title) && minPrice !== null) { + return minPrice; + } + } + + const text = describeHeroSmsPayload(payload); + const match = text.match(/\bWRONG_MAX_PRICE:(\d+(?:\.\d+)?)\b/i); + if (!match) { + return null; + } + return normalizeHeroSmsPrice(match[1]); + } + + function isNetworkFetchFailure(error) { + const message = String(error?.message || '').trim(); + return /failed to fetch|networkerror|load failed/i.test(message); + } + + function isHeroSmsTerminalError(payloadOrMessage) { + const text = describeHeroSmsPayload(payloadOrMessage); + return /\bNO_BALANCE\b|\bNOT_ENOUGH_BALANCE\b|\bBAD_KEY\b|\bINVALID_KEY\b|\bBANNED\b|\bACCOUNT_BANNED\b|\bWRONG_KEY\b/i.test(text); + } + + async function resolveCheapestPhoneActivationPrice(config, countryConfig) { + for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { + try { + const payload = await fetchHeroSmsPayload(config, { + action: 'getPrices', + service: HERO_SMS_SERVICE_CODE, + country: countryConfig.id, + }, 'HeroSMS getPrices'); + const price = findLowestHeroSmsPrice(payload); + if (price !== null) { + return price; + } + } catch (_) { + // Best-effort lookup only. + } + } + return null; + } + + async function persistHeroSmsPricePlanSnapshot(countryConfig, pricePlan) { + if (typeof setState !== 'function') { + return; + } + const prices = Array.isArray(pricePlan?.prices) + ? pricePlan.prices.filter((price) => Number.isFinite(Number(price))) + : []; + const userLimit = pricePlan?.userLimit === null || pricePlan?.userLimit === undefined + ? '' + : String(pricePlan.userLimit); + await setState({ + [HERO_SMS_LAST_PRICE_TIERS_KEY]: prices, + [HERO_SMS_LAST_PRICE_COUNTRY_ID_KEY]: normalizeCountryId(countryConfig?.id, 0), + [HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY]: normalizeCountryLabel(countryConfig?.label, HERO_SMS_COUNTRY_LABEL), + [HERO_SMS_LAST_PRICE_USER_LIMIT_KEY]: userLimit, + [HERO_SMS_LAST_PRICE_AT_KEY]: Date.now(), + }); + } + + async function resolvePhoneActivationPricePlan(config, countryConfig, state = {}) { + const userLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice); + let priceCandidates = []; + + for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { + try { + const payload = await fetchHeroSmsPayload(config, { + action: 'getPrices', + service: HERO_SMS_SERVICE_CODE, + country: countryConfig.id, + }, 'HeroSMS getPrices'); + priceCandidates = buildSortedUniquePriceCandidates( + collectHeroSmsPriceCandidates(payload, []) + ); + if (priceCandidates.length > 0) { + break; + } + } catch (_) { + // best effort + } + } + + const minCatalogPrice = priceCandidates.length > 0 ? priceCandidates[0] : null; + if (userLimit !== null) { + const bounded = priceCandidates.filter((price) => price <= userLimit); + if (bounded.length > 0) { + const boundedPlan = { prices: bounded, userLimit, minCatalogPrice }; + await persistHeroSmsPricePlanSnapshot(countryConfig, boundedPlan); + return boundedPlan; + } + const userLimitedPlan = { prices: [userLimit], userLimit, minCatalogPrice }; + await persistHeroSmsPricePlanSnapshot(countryConfig, userLimitedPlan); + return userLimitedPlan; + } + + if (priceCandidates.length > 0) { + const plan = { prices: priceCandidates, userLimit: null, minCatalogPrice }; + await persistHeroSmsPricePlanSnapshot(countryConfig, plan); + return plan; + } + const fallbackPlan = { prices: [null], userLimit: null, minCatalogPrice: null }; + await persistHeroSmsPricePlanSnapshot(countryConfig, fallbackPlan); + return fallbackPlan; + } + async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) { const query = { action, @@ -324,40 +686,239 @@ return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`); } - async function requestPhoneActivation(state = {}) { - const config = resolvePhoneConfig(state, { requireMaxPrice: true }); - const countryConfig = resolveCountryConfig(state); - const maxPrice = config.maxPrice; - const buildFallbackActivation = (requestAction) => ({ - countryId: countryConfig.id, - ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), - }); - let requestAction = 'getNumber'; - let payload; + async function requestPhoneActivationWithPrice(config, countryConfig, action, maxPrice, options = {}) { + let nextMaxPrice = maxPrice; + let retriedWithUpdatedPrice = false; + let retriedWithoutPrice = false; + const userLimit = normalizeHeroSmsPriceLimit(options.userLimit); + + while (true) { + try { + return await fetchPhoneActivationPayload(config, countryConfig, action, { + maxPrice: nextMaxPrice, + }); + } catch (error) { + const updatedMaxPrice = extractHeroSmsWrongMaxPrice(error?.payload || error?.message); + if ( + nextMaxPrice !== null + && nextMaxPrice !== undefined + && !retriedWithUpdatedPrice + && updatedMaxPrice !== null + ) { + if (userLimit !== null && updatedMaxPrice > userLimit) { + throw new Error( + `HeroSMS ${action} failed: WRONG_MAX_PRICE requires ${updatedMaxPrice}, which exceeds configured maxPrice=${userLimit}.` + ); + } + nextMaxPrice = updatedMaxPrice; + retriedWithUpdatedPrice = true; + continue; + } + + if ( + nextMaxPrice !== null + && nextMaxPrice !== undefined + && !retriedWithoutPrice + && isNetworkFetchFailure(error) + ) { + nextMaxPrice = null; + retriedWithoutPrice = true; + continue; + } - try { - payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); - } catch (error) { - if (!isHeroSmsNoNumbersPayload(error?.payload || error?.message)) { throw error; } - requestAction = 'getNumberV2'; - payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); + } + } + + async function requestPhoneActivation(state = {}, options = {}) { + const config = resolvePhoneConfig(state); + const allCountryCandidates = resolveCountryCandidates(state); + const blockedCountryIds = new Set( + (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) + .map((value) => normalizeCountryId(value, 0)) + .filter((id) => id > 0) + ); + let countryCandidates = allCountryCandidates.filter( + (entry) => !blockedCountryIds.has(normalizeCountryId(entry.id, 0)) + ); + if (!countryCandidates.length) { + countryCandidates = allCountryCandidates; + if (blockedCountryIds.size) { + await addLog( + 'Step 9: all selected countries reached the temporary SMS-failure skip threshold, lifting skip for this acquire round.', + 'warn' + ); + } + } + const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority); + const requestActions = ['getNumber', 'getNumberV2']; + const configuredAcquireRounds = normalizePhoneActivationRetryRounds( + state?.heroSmsActivationRetryRounds + ); + const maxAcquireRounds = Math.max(2, configuredAcquireRounds); + const retryDelayMs = normalizePhoneActivationRetryDelayMs( + state?.heroSmsActivationRetryDelayMs + ); + + let finalNoNumbersByCountry = []; + let finalLastError = null; + let finalLastFailureText = ''; + + for (let round = 1; round <= maxAcquireRounds; round += 1) { + if (maxAcquireRounds > 1) { + await addLog( + `Step 9: HeroSMS acquiring phone number (round ${round}/${maxAcquireRounds})...`, + 'info' + ); + } + + const countryAttempts = countryCandidates.map((countryConfig, index) => ({ + index, + countryConfig, + pricePlan: null, + orderingPrice: Number.POSITIVE_INFINITY, + })); + + if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE) { + for (const attempt of countryAttempts) { + const pricePlan = await resolvePhoneActivationPricePlan(config, attempt.countryConfig, state); + const numericPrices = Array.isArray(pricePlan?.prices) + ? pricePlan.prices + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value >= 0) + : []; + const minCandidatePrice = numericPrices.length ? Math.min(...numericPrices) : null; + const cappedByUserLimit = ( + pricePlan?.userLimit !== null + && pricePlan?.userLimit !== undefined + && pricePlan?.minCatalogPrice !== null + && pricePlan?.minCatalogPrice !== undefined + && Number(pricePlan.minCatalogPrice) > Number(pricePlan.userLimit) + ); + attempt.pricePlan = pricePlan; + attempt.orderingPrice = cappedByUserLimit + ? Number.POSITIVE_INFINITY + : (minCandidatePrice !== null ? minCandidatePrice : Number.POSITIVE_INFINITY); + } + } + + if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE && countryAttempts.length > 1) { + countryAttempts.sort((left, right) => { + if (left.orderingPrice !== right.orderingPrice) { + return left.orderingPrice - right.orderingPrice; + } + return left.index - right.index; + }); + } + + const noNumbersByCountry = []; + const retryableNoNumberCountries = []; + let lastError = null; + let lastFailureText = ''; + + for (const attempt of countryAttempts) { + const countryConfig = attempt.countryConfig; + const buildFallbackActivation = (requestAction) => ({ + countryId: countryConfig.id, + ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), + }); + const pricePlan = attempt.pricePlan || await resolvePhoneActivationPricePlan(config, countryConfig, state); + let noNumbersObservedInCountry = false; + + for (const maxPrice of pricePlan.prices) { + for (const requestAction of requestActions) { + try { + const payload = await requestPhoneActivationWithPrice( + config, + countryConfig, + requestAction, + maxPrice, + { userLimit: pricePlan.userLimit } + ); + const activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); + if (activation) { + const { countryLabel: _ignoredCountryLabel, ...activationWithoutCountryLabel } = activation; + return { + ...activationWithoutCountryLabel, + countryId: countryConfig.id, + }; + } + const payloadText = describeHeroSmsPayload(payload); + if (isHeroSmsNoNumbersPayload(payload)) { + noNumbersObservedInCountry = true; + lastFailureText = payloadText || lastFailureText; + continue; + } + if (isHeroSmsTerminalError(payload)) { + throw new Error(`HeroSMS ${requestAction} failed: ${payloadText || 'empty response'}`); + } + lastFailureText = payloadText || lastFailureText; + lastError = new Error(`HeroSMS ${requestAction} failed: ${payloadText || 'empty response'}`); + } catch (error) { + const payloadOrMessage = error?.payload || error?.message; + if (isHeroSmsTerminalError(payloadOrMessage)) { + throw new Error(`HeroSMS ${requestAction} failed: ${describeHeroSmsPayload(payloadOrMessage) || 'empty response'}`); + } + if (isHeroSmsNoNumbersPayload(payloadOrMessage)) { + noNumbersObservedInCountry = true; + lastFailureText = describeHeroSmsPayload(payloadOrMessage) || lastFailureText; + continue; + } + lastFailureText = describeHeroSmsPayload(payloadOrMessage) || lastFailureText; + lastError = error; + } + } + } + + if (noNumbersObservedInCountry) { + if ( + pricePlan.userLimit !== null + && pricePlan.minCatalogPrice !== null + && pricePlan.minCatalogPrice > pricePlan.userLimit + ) { + noNumbersByCountry.push( + `${countryConfig.label}: no numbers within maxPrice=${pricePlan.userLimit}; lowest listed=${pricePlan.minCatalogPrice}` + ); + } else { + noNumbersByCountry.push( + `${countryConfig.label}: ${lastFailureText || 'NO_NUMBERS'}` + ); + retryableNoNumberCountries.push(countryConfig.label); + } + continue; + } + } + + finalNoNumbersByCountry = noNumbersByCountry; + finalLastError = lastError; + finalLastFailureText = lastFailureText; + + if ( + noNumbersByCountry.length + && round < maxAcquireRounds + && retryableNoNumberCountries.length > 0 + ) { + await addLog( + `Step 9: HeroSMS has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`, + 'warn' + ); + await sleepWithStop(retryDelayMs); + continue; + } + + break; } - let activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); - if (!activation && requestAction === 'getNumber' && isHeroSmsNoNumbersPayload(payload)) { - requestAction = 'getNumberV2'; - payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); - activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); + if (finalNoNumbersByCountry.length) { + throw new Error( + `HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.` + ); } - - if (!activation) { - const text = describeHeroSmsPayload(payload); - throw new Error(`HeroSMS ${requestAction} failed: ${text || 'empty response'}`); + if (finalLastError) { + throw finalLastError; } - - return activation; + throw new Error(`HeroSMS failed to acquire a phone number. Last status: ${finalLastFailureText || 'unknown'}.`); } async function reactivatePhoneActivation(state = {}, activation) { @@ -394,7 +955,7 @@ } async function completePhoneActivation(state = {}, activation) { - await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)'); + await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)'); } async function cancelPhoneActivation(state = {}, activation) { @@ -431,11 +992,16 @@ : DEFAULT_PHONE_POLL_TIMEOUT_MS ); const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_PHONE_POLL_INTERVAL_MS); + const maxRoundsRaw = Math.floor(Number(options.maxRounds)); + const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0; const start = Date.now(); let lastResponse = ''; let pollCount = 0; while (Date.now() - start < timeoutMs) { + if (maxRounds > 0 && pollCount >= maxRounds) { + break; + } throwIfStopped(); const payload = await fetchHeroSmsPayload(config, { action: statusAction, @@ -526,9 +1092,27 @@ return result || {}; } - async function submitPhoneNumber(tabId, phoneNumber) { + function resolveCountryConfigFromActivation(activation, fallbackState = {}) { + const candidates = resolveCountryCandidates(fallbackState); + if (activation && typeof activation === 'object') { + const countryId = normalizeCountryId(activation.countryId, 0); + if (countryId > 0) { + const matched = candidates.find((entry) => entry.id === countryId); + if (matched) { + return matched; + } + return { + id: countryId, + label: normalizeCountryLabel(activation.countryLabel, `Country #${countryId}`), + }; + } + } + return candidates[0] || resolveCountryConfig(fallbackState); + } + + async function submitPhoneNumber(tabId, phoneNumber, activation = null) { const state = await getState(); - const countryConfig = resolveCountryConfig(state); + const countryConfig = resolveCountryConfigFromActivation(activation, state); const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' ? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'submit add-phone number' }) : 30000; @@ -619,6 +1203,7 @@ async function persistCurrentActivation(activation) { await setState({ [PHONE_ACTIVATION_STATE_KEY]: activation || null, + [PHONE_VERIFICATION_CODE_STATE_KEY]: '', }); } @@ -636,30 +1221,37 @@ await persistReusableActivation(null); } - function incrementActivationUseCount(activation) { - const normalizedActivation = normalizeActivation(activation); - if (!normalizedActivation) { - return null; - } - - return { - ...normalizedActivation, - successfulUses: Math.min(normalizedActivation.successfulUses + 1, normalizedActivation.maxUses), - }; - } - - async function acquirePhoneActivation(state = {}) { - const countryConfig = resolveCountryConfig(state); + async function acquirePhoneActivation(state = {}, options = {}) { + const countryCandidates = resolveCountryCandidates(state); + const blockedCountryIds = new Set( + (Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : []) + .map((value) => normalizeCountryId(value, 0)) + .filter((id) => id > 0) + ); + const allowedCountryIds = new Set( + countryCandidates + .map((entry) => normalizeCountryId(entry.id, 0)) + .filter((id) => id > 0 && !blockedCountryIds.has(id)) + ); + const preferredCountryLabel = countryCandidates[0]?.label || HERO_SMS_COUNTRY_LABEL; + const resolveCountryLabelById = (countryId) => ( + countryCandidates.find((entry) => entry.id === normalizeCountryId(countryId, 0))?.label + || preferredCountryLabel + ); + const reuseEnabled = normalizeHeroSmsReuseEnabled(state.heroSmsReuseEnabled); const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); if ( + reuseEnabled + && reusableActivation - && reusableActivation.countryId === countryConfig.id + && !blockedCountryIds.has(normalizeCountryId(reusableActivation.countryId, 0)) + && allowedCountryIds.has(reusableActivation.countryId) && 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}).`, + `Step 9: reusing ${resolveCountryLabelById(reactivated.countryId)} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`, 'info' ); return reactivated; @@ -667,82 +1259,66 @@ 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); + const activation = await requestPhoneActivation(state, { blockedCountryIds: Array.from(blockedCountryIds) }); await addLog( - `Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${countryConfig.label} number ${activation.phoneNumber}.`, + `Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${resolveCountryLabelById(activation.countryId)} number ${activation.phoneNumber}.`, 'info' ); return activation; } - async function syncReusableActivationAfterUse(activation) { + async function markActivationReusableAfterSuccess(state, activation) { const normalizedActivation = normalizeActivation(activation); + if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) { + await clearReusableActivation(); + return; + } if (!normalizedActivation) { await clearReusableActivation(); return; } - if (normalizedActivation.successfulUses >= normalizedActivation.maxUses) { + const successfulUses = normalizedActivation.successfulUses + 1; + if (successfulUses >= normalizedActivation.maxUses) { await clearReusableActivation(); return; } - await persistReusableActivation(normalizedActivation); - } - - async function persistPendingPhoneActivationConfirmation(activation) { - await setState({ - [PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]: activation || null, + await persistReusableActivation({ + ...normalizedActivation, + successfulUses, }); } - async function clearPendingPhoneActivationConfirmation() { - await persistPendingPhoneActivationConfirmation(null); - } - - async function finalizePendingPhoneActivationConfirmation(stateOverride = null) { - const state = stateOverride || await getState(); - const pendingActivation = normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]); - if (!pendingActivation) { - return null; - } - - const committedActivation = incrementActivationUseCount(pendingActivation); - if (!committedActivation) { - await clearPendingPhoneActivationConfirmation(); - await clearReusableActivation(); - return null; - } - - await syncReusableActivationAfterUse(committedActivation); - await clearPendingPhoneActivationConfirmation(); - return committedActivation; - } - async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) { - let currentActivation = normalizeActivation(activation); - if (!currentActivation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { throw new Error('Phone activation is missing.'); } + const waitSeconds = normalizePhoneCodeWaitSeconds(state?.phoneCodeWaitSeconds); + const timeoutWindows = normalizePhoneCodeTimeoutWindows(state?.phoneCodeTimeoutWindows); + const pollIntervalSeconds = normalizePhoneCodePollIntervalSeconds(state?.phoneCodePollIntervalSeconds); + const pollMaxRounds = normalizePhoneCodePollMaxRounds(state?.phoneCodePollMaxRounds); let lastLoggedStatus = ''; let lastLoggedPollCount = 0; + let resendTriggeredForCurrentNumber = false; - for (let windowIndex = 1; windowIndex <= 2; windowIndex += 1) { + for (let windowIndex = 1; windowIndex <= timeoutWindows; windowIndex += 1) { await addLog( - `Step 9: waiting up to 60 seconds for SMS on ${currentActivation.phoneNumber} (${windowIndex}/2).`, + `Step 9: waiting up to ${waitSeconds} seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/${timeoutWindows}).`, 'info' ); try { - const code = await pollPhoneActivationCode(state, currentActivation, { + 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, + timeoutMs: waitSeconds * 1000, + intervalMs: pollIntervalSeconds * 1000, + maxRounds: pollMaxRounds, onStatus: async ({ elapsedMs, pollCount, statusText }) => { const shouldLog = ( pollCount === 1 @@ -755,7 +1331,7 @@ lastLoggedStatus = statusText; lastLoggedPollCount = pollCount; await addLog( - `Step 9: HeroSMS status for ${currentActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`, + `Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed, round ${pollCount}/${pollMaxRounds}).`, 'info' ); }, @@ -769,26 +1345,49 @@ throw error; } - if (windowIndex === 1) { + if (windowIndex < timeoutWindows) { await addLog( - `Step 9: no SMS arrived for ${currentActivation.phoneNumber} within 60 seconds, requesting another SMS.`, + `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within ${waitSeconds} seconds, requesting another SMS.`, 'warn' ); - await requestAdditionalPhoneSms(state, currentActivation); + await requestAdditionalPhoneSms(state, normalizedActivation); + if (resendTriggeredForCurrentNumber) { + await addLog( + `Step 9: resend already used once for ${normalizedActivation.phoneNumber}; continue polling without another page resend to avoid rate limit.`, + 'warn' + ); + continue; + } try { await resendPhoneVerificationCode(tabId); + resendTriggeredForCurrentNumber = true; await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info'); } catch (resendError) { + if (isPhoneResendThrottledError(resendError)) { + await addLog( + `Step 9: resend is throttled for ${normalizedActivation.phoneNumber}, replacing number immediately. ${resendError.message}`, + 'warn' + ); + return { + code: '', + replaceNumber: true, + reason: 'resend_throttled', + }; + } 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 ${currentActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`, + `Step 9: no SMS for ${normalizedActivation.phoneNumber} after ${timeoutWindows} window(s), replacing the number inside step 9.`, 'warn' ); - throw buildPhoneRestartStep7Error(currentActivation.phoneNumber); + return { + code: '', + replaceNumber: true, + reason: `sms_timeout_after_${timeoutWindows}_windows`, + }; } } @@ -801,6 +1400,52 @@ let pageState = initialPageState || await readPhonePageState(tabId); let shouldCancelActivation = false; let remainingResendRequests = Math.max(0, Number(state.verificationResendCount) || 0); + const maxNumberReplacementAttempts = normalizePhoneReplacementLimit( + state.phoneVerificationReplacementLimit + ); + let usedNumberReplacementAttempts = 0; + let preferReuseExistingActivationOnAddPhone = false; + let addPhoneReentryWithSameActivation = 0; + const countrySmsFailureCounts = new Map(); + + const getCountryFailureCount = (countryId) => { + const normalizedCountryId = normalizeCountryId(countryId, 0); + if (!normalizedCountryId) { + return 0; + } + return Math.max(0, Math.floor(Number(countrySmsFailureCounts.get(normalizedCountryId)) || 0)); + }; + + const markCountrySmsFailure = async (countryId, reason = 'sms_timeout') => { + const normalizedCountryId = normalizeCountryId(countryId, 0); + if (!normalizedCountryId) { + return; + } + const nextCount = getCountryFailureCount(normalizedCountryId) + 1; + countrySmsFailureCounts.set(normalizedCountryId, nextCount); + if (nextCount >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) { + const matched = resolveCountryCandidates(state) + .find((entry) => normalizeCountryId(entry.id, 0) === normalizedCountryId); + const countryLabel = matched?.label || `Country #${normalizedCountryId}`; + await addLog( + `Step 9: ${countryLabel} reached ${nextCount} SMS failures (${reason}); next acquisition will fallback to other selected country candidates first.`, + 'warn' + ); + } + }; + + const clearCountrySmsFailure = (countryId) => { + const normalizedCountryId = normalizeCountryId(countryId, 0); + if (!normalizedCountryId) { + return; + } + countrySmsFailureCounts.delete(normalizedCountryId); + }; + + const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries()) + .filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) + .map(([countryId]) => normalizeCountryId(countryId, 0)) + .filter((countryId) => countryId > 0); try { while (true) { @@ -810,20 +1455,91 @@ } if (pageState?.addPhonePage) { - if (normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY])) { - await clearPendingPhoneActivationConfirmation(); - } - if (activation) { - await cancelPhoneActivation(state, activation); - await clearCurrentActivation(); - activation = null; - shouldCancelActivation = false; + if (!activation) { + activation = await acquirePhoneActivation(state, { + blockedCountryIds: getBlockedCountryIds(), + }); + shouldCancelActivation = true; + await persistCurrentActivation(activation); + addPhoneReentryWithSameActivation = 0; + } else if (preferReuseExistingActivationOnAddPhone) { + addPhoneReentryWithSameActivation += 1; + if (addPhoneReentryWithSameActivation > 1) { + usedNumberReplacementAttempts += 1; + if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { + throw new Error( + `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: returned_to_add_phone_loop.` + ); + } + await addLog( + `Step 9: current number ${activation.phoneNumber} returned to add-phone repeatedly, replacing number (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + 'warn' + ); + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation); + } + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + preferReuseExistingActivationOnAddPhone = false; + addPhoneReentryWithSameActivation = 0; + pageState = { + ...pageState, + addPhonePage: true, + phoneVerificationPage: false, + }; + continue; + } + await addLog( + `Step 9: add-phone returned, re-submitting current number ${activation.phoneNumber} before requesting a new number.`, + 'warn' + ); + } + + let submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation); + if (submitResult.addPhoneRejected) { + const addPhoneRejectText = String(submitResult.errorText || submitResult.url || 'unknown error'); + if (isPhoneNumberUsedError(addPhoneRejectText)) { + usedNumberReplacementAttempts += 1; + if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { + throw new Error( + `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: phone_number_used.` + ); + } + + await addLog( + `Step 9: add-phone rejected ${activation.phoneNumber} as already used (${addPhoneRejectText}), replacing number (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + 'warn' + ); + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation); + } + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + preferReuseExistingActivationOnAddPhone = false; + addPhoneReentryWithSameActivation = 0; + pageState = { + ...pageState, + ...submitResult, + addPhonePage: true, + phoneVerificationPage: false, + }; + continue; + } + + await addLog( + `Step 9: add-phone rejected current number but did not mark it as used (${addPhoneRejectText}), retrying once with the same number.`, + 'warn' + ); + submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation); + if (submitResult.addPhoneRejected) { + throw new Error( + `Step 9: add-phone keeps rejecting current number without explicit "used" status: ${submitResult.errorText || submitResult.url || 'unknown error'}.` + ); + } } - 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, @@ -831,6 +1547,8 @@ addPhonePage: false, phoneVerificationPage: true, }; + preferReuseExistingActivationOnAddPhone = false; + addPhoneReentryWithSameActivation = 0; } if (!pageState?.phoneVerificationPage) { @@ -846,6 +1564,7 @@ } let shouldReplaceNumber = false; + let replaceReason = ''; for (let attempt = 1; attempt <= DEFAULT_PHONE_SUBMIT_ATTEMPTS; attempt += 1) { throwIfStopped(); @@ -853,18 +1572,22 @@ const codeResult = await waitForPhoneCodeOrRotateNumber(tabId, state, activation); if (codeResult.replaceNumber) { shouldReplaceNumber = true; + replaceReason = codeResult.reason || 'sms_not_received'; break; } + await setState({ + [PHONE_VERIFICATION_CODE_STATE_KEY]: String(codeResult.code || '').trim(), + }); 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.', + 'Step 9: phone verification returned to add-phone after code submission, will try current number first.', 'warn' ); - shouldReplaceNumber = true; + preferReuseExistingActivationOnAddPhone = true; pageState = { ...pageState, ...submitResult, @@ -875,10 +1598,25 @@ } 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'}` + const invalidErrorText = String(submitResult.errorText || submitResult.url || 'unknown error'); + if (isPhoneNumberUsedError(invalidErrorText)) { + shouldReplaceNumber = true; + replaceReason = 'phone_number_used'; + await addLog( + `Step 9: phone number was rejected as already used (${invalidErrorText}), replacing with a new number immediately.`, + 'warn' ); + break; + } + + if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) { + shouldReplaceNumber = true; + replaceReason = 'code_rejected'; + await addLog( + `Step 9: phone verification code was rejected ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} times (${invalidErrorText}), replacing the number.`, + 'warn' + ); + break; } if (remainingResendRequests > 0) { @@ -903,27 +1641,62 @@ continue; } - try { - await completePhoneActivation(state, activation); - await persistReusableActivation(activation); - await persistPendingPhoneActivationConfirmation(activation); - } catch (activationStatusError) { - await clearReusableActivation(); - await clearPendingPhoneActivationConfirmation(); - await addLog( - `Step 9: phone verification succeeded, but HeroSMS setStatus(3) failed. The next flow will request a new number. ${activationStatusError.message}`, - 'warn' - ); - } + await completePhoneActivation(state, activation); + await markActivationReusableAfterSuccess(state, activation); + clearCountrySmsFailure(activation.countryId); shouldCancelActivation = false; await clearCurrentActivation(); - await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); - return submitResult; - } + addPhoneReentryWithSameActivation = 0; + await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); + return submitResult; + } if (!shouldReplaceNumber) { + if (pageState?.addPhonePage) { + continue; + } throw new Error('Phone verification did not complete successfully.'); } + + if ( + activation + && (replaceReason === 'resend_throttled' || /^sms_timeout_after_/i.test(String(replaceReason || ''))) + ) { + await markCountrySmsFailure(activation.countryId, replaceReason || 'sms_timeout'); + } + + usedNumberReplacementAttempts += 1; + if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) { + throw new Error( + `Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: ${replaceReason || 'unknown'}.` + ); + } + + if (shouldCancelActivation && activation) { + await cancelPhoneActivation(state, activation); + } + await clearCurrentActivation(); + activation = null; + shouldCancelActivation = false; + addPhoneReentryWithSameActivation = 0; + + let returnResult = { addPhonePage: true, phoneVerificationPage: false }; + try { + returnResult = await returnToAddPhone(tabId); + } catch (returnError) { + await addLog(`Step 9: failed to return to add-phone page before replacing number. ${returnError.message}`, 'warn'); + } + + await addLog( + `Step 9: replacing number and retrying inside step 9 (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`, + 'warn' + ); + pageState = { + ...pageState, + ...returnResult, + addPhonePage: true, + phoneVerificationPage: false, + }; } } catch (error) { if (shouldCancelActivation && activation) { @@ -936,7 +1709,6 @@ return { completePhoneVerificationFlow, - finalizePendingPhoneActivationConfirmation, normalizeActivation, pollPhoneActivationCode, reactivatePhoneActivation, diff --git a/background/steps/confirm-oauth.js b/background/steps/confirm-oauth.js index 88607c9..e172945 100644 --- a/background/steps/confirm-oauth.js +++ b/background/steps/confirm-oauth.js @@ -16,6 +16,7 @@ getTabId, isTabAlive, prepareStep8DebuggerClick, + recoverOAuthLocalhostTimeout, reloadStep8ConsentPage, reuseOrCreateTab, sleepWithStop, @@ -44,19 +45,43 @@ async function executeStep9(state) { const visibleStep = getVisibleStep(state, 9); - if (!state.oauthUrl) { + let activeState = state; + + if (!activeState.oauthUrl) { const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep); throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}。`); } await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`); - const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' - ? await getOAuthFlowStepTimeoutMs(240000, { - step: visibleStep, - actionLabel: 'OAuth localhost 回调', - }) - : 240000; + let callbackTimeoutMs = 240000; + let timeoutRecoveryAttempted = false; + while (true) { + try { + callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function' + ? await getOAuthFlowStepTimeoutMs(240000, { + step: visibleStep, + actionLabel: 'OAuth localhost 回调', + oauthUrl: activeState?.oauthUrl || '', + }) + : 240000; + break; + } catch (error) { + if (timeoutRecoveryAttempted || typeof recoverOAuthLocalhostTimeout !== 'function') { + throw error; + } + const recoveredState = await recoverOAuthLocalhostTimeout({ + error, + state: activeState, + visibleStep, + }); + if (!recoveredState) { + throw error; + } + activeState = recoveredState; + timeoutRecoveryAttempted = true; + } + } return new Promise((resolve, reject) => { let resolved = false; @@ -124,7 +149,7 @@ await chrome.tabs.update(signupTabId, { active: true }); await addLog(`步骤 ${visibleStep}:已切回认证页,正在准备调试器点击...`); } else { - signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl); + signupTabId = await reuseOrCreateTab('signup-page', activeState.oauthUrl); await addLog(`步骤 ${visibleStep}:已重新打开认证页,正在准备调试器点击...`); } diff --git a/background/verification-flow.js b/background/verification-flow.js index f41d9fa..b184a32 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -222,6 +222,28 @@ return Math.min(20, Math.max(0, Math.floor(numeric))); } + function getVerificationRequestedAtStateKey(step) { + if (Number(step) === 4) return 'signupVerificationRequestedAt'; + if (Number(step) === 8) return 'loginVerificationRequestedAt'; + return ''; + } + + function resolveInitialVerificationRequestedAt(step, state = {}, fallback = 0) { + const stateKey = getVerificationRequestedAtStateKey(step); + const candidateValues = [ + fallback, + stateKey ? state?.[stateKey] : 0, + ]; + + for (const value of candidateValues) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.floor(numeric); + } + } + return 0; + } + function getLegacyVerificationResendCountDefault(step, options = {}) { const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst); const legacyMaxRounds = Math.max(1, Math.floor(Number(VERIFICATION_POLL_MAX_ROUNDS) || 1)); @@ -919,10 +941,14 @@ : getConfiguredVerificationResendCount(step, state, { requestFreshCodeFirst }); const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15; const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); - let lastResendAt = Number(options.lastResendAt) || 0; const externalOnResendRequestedAt = typeof options.onResendRequestedAt === 'function' ? options.onResendRequestedAt : null; + let lastResendAt = resolveInitialVerificationRequestedAt( + step, + state, + Number(options.lastResendAt) || 0 + ); const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => { if (externalOnResendRequestedAt) { diff --git a/content/phone-auth.js b/content/phone-auth.js index 595f32f..e9701f5 100644 --- a/content/phone-auth.js +++ b/content/phone-auth.js @@ -18,6 +18,8 @@ throwIfStopped, waitForElement, } = deps; + const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::'; + const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i; function dispatchInputEvents(element) { if (!element) return; @@ -312,6 +314,90 @@ return matches?.[0] ? matches[0].replace(/\s+/g, ' ').trim() : ''; } + function getAddPhoneErrorText() { + const form = getAddPhoneForm(); + if (!form) { + return ''; + } + + const messages = []; + const selectors = [ + '.react-aria-FieldError', + '[slot="errorMessage"]', + '[id$="-error"]', + '[data-invalid="true"] + *', + '[aria-invalid="true"] + *', + '[class*="error"]', + ]; + for (const selector of selectors) { + form.querySelectorAll(selector).forEach((el) => { + const text = String(el?.textContent || '').replace(/\s+/g, ' ').trim(); + if (text) { + messages.push(text); + } + }); + } + + const invalidInput = form.querySelector('input[aria-invalid="true"], input[data-invalid="true"]'); + if (invalidInput) { + const wrapper = invalidInput.closest('form, [data-rac], div'); + const text = String(wrapper?.textContent || '').replace(/\s+/g, ' ').trim(); + if (text) { + messages.push(text); + } + } + + const preferred = messages.find((text) => ( + /already|used|linked|eligible|invalid|phone|号码|手机号|错误|失败|try\s+again/i.test(text) + )); + return preferred || messages[0] || ''; + } + + function getPhoneVerificationInlineMessages() { + const form = getPhoneVerificationForm(); + if (!form) { + return []; + } + const messages = []; + const selectors = [ + '.react-aria-FieldError', + '[slot="errorMessage"]', + '[id$="-error"]', + '[data-invalid="true"] + *', + '[aria-invalid="true"] + *', + '[class*="error"]', + ]; + for (const selector of selectors) { + form.querySelectorAll(selector).forEach((element) => { + const text = String(element?.textContent || '').replace(/\s+/g, ' ').trim(); + if (text) { + messages.push(text); + } + }); + } + const verificationError = String(getVerificationErrorText?.() || '').trim(); + if (verificationError) { + messages.push(verificationError); + } + return messages; + } + + function getPhoneResendThrottleText() { + const inlineMatch = getPhoneVerificationInlineMessages() + .find((text) => PHONE_RESEND_THROTTLED_PATTERN.test(text)); + if (inlineMatch) { + return inlineMatch; + } + const pageSnapshot = String(getPageTextSnapshot?.() || '').replace(/\s+/g, ' ').trim(); + if (pageSnapshot && PHONE_RESEND_THROTTLED_PATTERN.test(pageSnapshot)) { + const concise = pageSnapshot.match( + /tried\s+to\s+resend\s+too\s+many\s+times[^.。!?]*[.。!?]?|please\s+try\s+again\s+later[^.。!?]*[.。!?]?|发送.*过于频繁[^。!?]*[。!?]?|稍后再试[^。!?]*[。!?]?/i + ); + return String(concise?.[0] || pageSnapshot).trim(); + } + return ''; + } + async function waitForAddPhoneReady(timeout = 20000) { const start = Date.now(); while (Date.now() - start < timeout) { @@ -335,8 +421,28 @@ url: location.href, }; } + if (isAddPhonePageReady()) { + const errorText = getAddPhoneErrorText(); + if (errorText) { + return { + addPhoneRejected: true, + errorText, + url: location.href, + }; + } + } await sleep(150); } + if (isAddPhonePageReady()) { + const errorText = getAddPhoneErrorText(); + if (errorText) { + return { + addPhoneRejected: true, + errorText, + url: location.href, + }; + } + } throw new Error('Timed out waiting for phone verification page.'); } @@ -466,11 +572,19 @@ const start = Date.now(); while (Date.now() - start < timeout) { throwIfStopped(); + const throttledText = getPhoneResendThrottleText(); + if (throttledText) { + throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${throttledText}`); + } const resendButton = getPhoneVerificationResendButton({ allowDisabled: true }); if (resendButton && isActionEnabled(resendButton)) { await humanPause(250, 700); simulateClick(resendButton); await sleep(1000); + const afterClickThrottleText = getPhoneResendThrottleText(); + if (afterClickThrottleText) { + throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${afterClickThrottleText}`); + } return { resent: true, url: location.href, @@ -479,6 +593,11 @@ await sleep(250); } + const timeoutThrottleText = getPhoneResendThrottleText(); + if (timeoutThrottleText) { + throw new Error(`${PHONE_RESEND_THROTTLED_ERROR_PREFIX}${timeoutThrottleText}`); + } + throw new Error('Timed out waiting for the phone verification resend button.'); } diff --git a/docs/使用教程.md b/docs/使用教程.md index f9721a6..a9a4d84 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,6 +1,6 @@ # Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程、扩展内置动态 IP 代理、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`HeroSMS` 手机接码扩展能力、`PayPal` 注册绑卡流程、扩展内置动态 IP 代理、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -10,6 +10,7 @@ - 需要使用 `iCloud+` 的 `隐藏邮件地址` 作为隐私邮箱 - 需要临时切换 `QQ 邮箱` 地址继续使用 - 需要使用 `网易邮箱`、`网易邮箱大师` 注册多个邮箱或替身邮箱 +- 需要在 `Step 9` 的手机号验证阶段使用 `HeroSMS` 接码(含多国家回退与价格策略) - 需要注册并使用 `PayPal` 个人账户 - 需要在扩展内使用 `711Proxy` 动态 IP,并在自动流程中按轮次切换出口 - 需要检查当前节点的出口 IP、纯净度、泄露情况和访问速度 @@ -26,6 +27,7 @@ - 一个可正常登录的 `QQ 邮箱` - 手机端已安装 `网易邮箱` 或 `网易邮箱大师` - 一个可正常接收短信的手机号 +- 一个可用的 `HeroSMS API Key`(用于手机号接码) - 一张可在线支付的借记卡或信用卡 - 如需使用扩展内置动态 IP,需准备 `711Proxy` 的 Host、Port、代理账号和代理密码 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` @@ -221,7 +223,71 @@ 只要网络环境不要太差,并且不要连续高频注册,一般不容易马上触发额外手机号验证。 建议注册一个后换节奏再继续,不要一口气连续创建。 -### 第七部分:`PayPal` 注册与绑卡使用教程 +### 第七部分:`HeroSMS` 手机接码扩展使用教程(新增) + +本部分用于说明扩展中 `Step 9` 手机号验证链路的最新接码能力与推荐用法。 +目标是减少“重复重发导致封禁”与“同国家持续拿不到码”的失败率,并让失败可以在 `Step 9` 内部自愈。 + +#### 一、入口与启用 + +1. 打开扩展侧栏,找到 `接码设置`。 +2. 打开右侧接码开关(`IP 代理`下方同风格开关)。 +3. 展开设置后,先确认 `接码平台` 显示为 `HeroSMS / OpenAI`。 + +#### 二、国家与优先级(新增能力) + +1. 在 `接码国家` 中多选国家(最多 `3` 个),按你的业务顺序排列。 +2. 在 `国家优先级` 中选择策略: + - `国家优先`:严格按你选择的顺序先后尝试。 + - `价格优先`:在候选国家里先选可用价格更低的国家,再尝试拿号。 +3. 当同一国家连续失败达到阈值后,流程会自动优先尝试下一个候选国家。 + +#### 三、价格控制与价格预览(新增能力) + +1. 填写 `接码 API`(支持右侧眼睛按钮显示/隐藏,避免粘贴错误)。 +2. 在 `价格` 一行点击 `查询价格`,查看候选国家当前档位。 +3. 设置 `价格上限`(`maxPrice`)限制成本,避免异常高价拿号。 +4. 如果日志提示 `no numbers within maxPrice`,说明上限太低,按当前价格结果适度上调即可。 + +#### 四、号码复用与参数建议 + +1. `号码复用` 开关: + - 开启:同号码在可复用范围内优先复用,减少频繁拿号。 + - 关闭:每次优先新号,适合风控更严格的场景。 +2. 建议参数(稳妥起步): + - `验证码重发`:`0` 或 `1` + - `换号上限`:`3` + - `验证码限时`:`60` 秒 + - `超时次数`:`2` + - `轮询间隔`:`5` 秒 + - `轮询次数`:`3-4` 次(不要设置过高) + +#### 五、失败自愈策略(新增能力) + +当前版本已内置以下保护逻辑: + +1. 当页面出现 `Tried to resend too many times. Please try again later.` 时,流程会停止继续狂点 `Resend`,改为换号/换国家路径。 +2. 单个号码不会无限重发,避免被目标站点判定重发过频。 +3. 收不到短信时优先在 `Step 9` 内局部恢复,不直接回卷到 `Step 1`。 + +#### 六、运行状态怎么看 + +在 `运行状态` 中重点看: + +1. `当前分配`:当前拿到的手机号与国家。 +2. `验证码`:是否已收到可提交验证码。 +3. `价格预览`:当前国家候选的可用价格信息(查询后刷新)。 + +#### 七、常见问题与处理 + +1. `no numbers available across ...` + 表示候选国家当前无号或价格上限过低。先提高价格上限,再调整国家顺序。 +2. `NO_NUMBERS` 频繁出现 + 增加候选国家数量(最多 3 个),并优先启用 `价格优先`。 +3. 页面提示重发过多 + 不要手工持续点重发,让流程自动换号/换国家即可。 + +### 第八部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 @@ -273,14 +339,14 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第八部分:0元试用 ChatGPT Plus 教程 +### 第九部分:0元试用 ChatGPT Plus 教程 本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 #### 准备工作 1. 已有一个登录状态的 ChatGPT 账户。 -2. 一个可用的 PayPal 账户(参考第七部分进行注册和绑卡)。 +2. 一个可用的 PayPal 账户(参考第八部分进行注册和绑卡)。 3. 能够接收生成的账单地址的真实地址或虚拟地址。 4. Chrome 浏览器(推荐使用地址补全功能)。 @@ -428,7 +494,7 @@ - **PayPal 登录后页面无法继续跳转** 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 -### 第九部分:扩展内置动态 IP 代理使用教程 +### 第十部分:扩展内置动态 IP 代理使用教程 本部分说明扩展侧边栏里的 `IP代理` 功能。 它和 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 这类系统代理不一样:扩展会通过浏览器代理 API 给当前浏览器设置 PAC 代理,并自动回填代理鉴权。当前首版只开放 `711Proxy` 的账号密码模式,`API 拉取` 和多账号列表入口暂未开放。 @@ -534,7 +600,7 @@ 如果后续要换出口,优先尝试 `Change`。 如果 `Change` 不可用,再调整 session 或代理账号后点击 `同步`。 -### 第十部分:节点检测与纯净度检查网站 +### 第十一部分:节点检测与纯净度检查网站 本部分用于检查当前代理节点的出口 IP、归属地、风险标签、泄露情况和网站访问速度。 建议每次切换节点后都重新打开这些网站检查一次。 @@ -568,7 +634,7 @@ 接着打开 [IPPure](https://ippure.com/) 检查纯净度、黑名单、代理识别和泄露情况。 最后用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 测试目标网站的访问速度和连通性。 -### 第十一部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第十二部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -677,7 +743,7 @@ function main(config, profileName) { 5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。 ![2026 04 25 003703](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163745--2026-04-25-003703--21e892504b82.png) -### 第十二部分:订阅节点与自建推荐 +### 第十三部分:订阅节点与自建推荐 如果你还没有订阅节点或想要寻找稳定、便宜的科学上网方式,可以参考以下三种方案获取。 diff --git a/sidepanel/ip-proxy-panel.js b/sidepanel/ip-proxy-panel.js index 947a9de..bb8c2d5 100644 --- a/sidepanel/ip-proxy-panel.js +++ b/sidepanel/ip-proxy-panel.js @@ -907,14 +907,18 @@ function buildIpProxyActionHintText(options = {}) { const mode = normalizeIpProxyModeForCurrentRelease(options?.mode || DEFAULT_IP_PROXY_MODE); const poolCount = Math.max(0, Number(options?.poolCount) || 0); const changeAvailable = Boolean(options?.changeAvailable); + const dynamicPoolCount = poolCount > 0 ? poolCount : 1; if (mode === 'api') { - return '下一条:切到已拉取代理池的下一条。Change:仅账号模式可用。'; + const nextPart = poolCount > 1 + ? `下一条:当前共 ${dynamicPoolCount} 条节点,切到已拉取代理池的下一条节点。` + : `下一条:当前仅 ${dynamicPoolCount} 条节点,执行重绑复测(不保证更换出口)。`; + return `${nextPart} Change:仅账号模式可用。`; } const nextPart = poolCount > 1 - ? '下一条:切到代理池的下一条节点。' - : '下一条:当前仅 1 条节点,执行重绑复测(不保证更换出口)。'; + ? `下一条:当前共 ${dynamicPoolCount} 条节点,切到代理池的下一条节点。` + : `下一条:当前仅 ${dynamicPoolCount} 条节点,执行重绑复测(不保证更换出口)。`; const changePart = changeAvailable ? 'Change:保持当前 session 重绑链路并复测出口。' : 'Change:需 711 账号模式且用户名包含 session。'; @@ -931,7 +935,6 @@ function setIpProxyCurrentDisplay(text = '', hasValue = false) { function formatIpProxyCurrentDisplay(state = latestState) { const mode = normalizeIpProxyModeForCurrentRelease(state?.ipProxyMode); if (mode === 'account') { - const runtime = getIpProxyRuntimeSnapshot(state, mode); const current = getIpProxyCurrentEntry(state); if (!current) { return { @@ -939,10 +942,8 @@ function formatIpProxyCurrentDisplay(state = latestState) { hasValue: false, }; } - const count = runtime.pool.length > 0 ? runtime.pool.length : 1; - const index = runtime.index; return { - text: `${current.host}:${current.port}${current.region ? ` [${current.region}]` : ''} (${Math.min(index + 1, count)}/${count})`, + text: `${current.host}:${current.port}${current.region ? ` [${current.region}]` : ''}`, hasValue: true, }; } @@ -960,7 +961,7 @@ function formatIpProxyCurrentDisplay(state = latestState) { const region = String(current.region || '').trim(); const label = region ? `${current.host}:${current.port} [${region}]` : `${current.host}:${current.port}`; return { - text: `${label}${count ? ` (${Math.min(index + 1, count)}/${count})` : ''}`, + text: label, hasValue: true, }; } @@ -986,19 +987,7 @@ function buildIpProxyCurrentDisplayText(display = {}, runtimeStatus = {}) { if (!hasValue || !rawText) { return rawText; } - const runtimeText = String(runtimeStatus?.text || '').trim().toLowerCase(); - if (!runtimeText) { - return rawText; - } - const endpointToken = extractIpProxyEndpointToken(rawText); - if (!endpointToken || !runtimeText.includes(endpointToken)) { - return rawText; - } - const indexToken = extractIpProxyIndexToken(rawText); - if (indexToken) { - return `节点 ${indexToken}`; - } - return '当前节点'; + return rawText; } function formatIpProxyRuntimeStatus(state = latestState) { @@ -1353,11 +1342,12 @@ function updateIpProxyUI(state = latestState) { setIpProxyCurrentDisplay(currentDisplayText, currentDisplay.hasValue); const runtimeSnapshot = getIpProxyRuntimeSnapshot(runtimeState, mode, service); const runtimePoolCount = Array.isArray(runtimeSnapshot?.pool) ? runtimeSnapshot.pool.length : 0; + const runtimePoolCountForDisplay = runtimePoolCount > 0 ? runtimePoolCount : 1; const hasCurrentEntry = Boolean(getIpProxyCurrentEntry(runtimeState)); const changeAvailable = canChangeIpProxyExitWithCurrentSession(runtimeState); const nextActionTitle = runtimePoolCount > 1 - ? '切换到代理池下一条节点并应用' - : '当前仅 1 条节点:重绑当前节点并复测连通性(不保证更换出口)'; + ? `切换到代理池下一条节点并应用(当前共 ${runtimePoolCountForDisplay} 条)` + : `当前仅 ${runtimePoolCountForDisplay} 条节点:重绑当前节点并复测连通性(不保证更换出口)`; if (btnIpProxyRefresh) { btnIpProxyRefresh.disabled = actionBusy || !enabled || !canOperate; diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 1c8bd16..cf8a5af 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -607,7 +607,12 @@ header { Data Card ============================================================ */ -#data-section { margin-bottom: 14px; } +#data-section { + margin-bottom: 14px; + display: flex; + flex-direction: column; + gap: 12px; +} .data-card { background: var(--bg-surface); @@ -769,6 +774,21 @@ header { gap: 8px; } +#settings-card .data-row.module-divider-start { + position: relative; + margin-top: 10px; + padding-top: 12px; +} + +#settings-card .data-row.module-divider-start::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + border-top: 1px solid color-mix(in srgb, var(--border) 76%, transparent); +} + .data-check-row { align-items: flex-start; } @@ -798,24 +818,6 @@ header { white-space: nowrap; } -.section-collapse-body { - display: flex; - flex-direction: column; - gap: 9px; -} - -.section-collapse-body[hidden] { - display: none; -} - -#btn-toggle-hotmail-section { - white-space: nowrap; -} - -#btn-toggle-cloudflare-temp-email-section { - white-space: nowrap; -} - .ip-proxy-fold { width: 100%; border: none; @@ -832,6 +834,39 @@ header { padding-top: 0; } +.phone-verification-card { + margin-top: 10px; +} + +.phone-verification-header-actions { + flex: 0 0 auto; + align-items: center; +} + +#btn-toggle-phone-verification-section { + white-space: nowrap; +} + +.phone-verification-fold-row { + display: block; +} + +.phone-verification-fold { + width: 100%; + border: none; + border-radius: 0; + background: transparent; + padding: 0; +} + +.phone-verification-fold-body { + display: flex; + flex-direction: column; + gap: 8px; + border-top: none; + padding-top: 0; +} + .ip-proxy-layout-row { display: block; } @@ -891,10 +926,18 @@ header { .ip-proxy-actions-inline { flex-wrap: wrap; - align-items: center; + align-items: flex-start; row-gap: 6px; } +#row-ip-proxy-actions { + align-items: flex-start; +} + +#row-ip-proxy-actions > .data-label { + padding-top: 9px; +} + .ip-proxy-action-grid { width: 100%; display: flex; @@ -936,19 +979,25 @@ header { .ip-proxy-runtime-main { min-width: 0; + font-size: 12px; + line-height: 1.45; } .ip-proxy-runtime-meta { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; } .ip-proxy-check-ip-btn { - min-width: 64px; - padding-inline: 10px; + min-width: 0; + padding-inline: 8px; flex-shrink: 0; + margin-left: 0; + position: absolute; + top: 0; + right: 0; } .ip-proxy-runtime-current { @@ -962,6 +1011,14 @@ header { color: var(--text-primary); } +#row-ip-proxy-runtime-status { + align-items: flex-start; +} + +#row-ip-proxy-runtime-status > .data-label { + padding-top: 9px; +} + .ip-proxy-runtime-dot { width: 8px; height: 8px; @@ -990,20 +1047,53 @@ header { .ip-proxy-runtime-details { margin: 0; padding: 0; + min-width: 0; + width: 100%; + padding-right: 84px; +} + +.ip-proxy-runtime-details-row { + position: relative; + min-width: 0; + width: 100%; + min-height: 24px; } .ip-proxy-runtime-details summary { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 24px; cursor: pointer; user-select: none; color: var(--text-secondary); font-size: 11px; line-height: 1.4; + list-style: none; +} + +.ip-proxy-runtime-details summary::-webkit-details-marker { + display: none; +} + +.ip-proxy-runtime-details summary::after { + content: '▾'; + font-size: 10px; + line-height: 1; + color: inherit; + transform: rotate(-90deg); + transform-origin: center; + transition: transform var(--transition); } .ip-proxy-runtime-details[open] summary { color: var(--text-primary); } +.ip-proxy-runtime-details[open] summary::after { + transform: rotate(0deg); +} + .ip-proxy-runtime-details-text { margin-top: 4px; font-size: 11px; @@ -1859,6 +1949,271 @@ header { text-align: center; } +.hero-sms-country-stack { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; +} + +.hero-sms-country-mainline { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.hero-sms-country-note { + font-size: 12px; + color: var(--text-muted); +} + +.hero-sms-reuse-max-inline { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: nowrap; +} + +.hero-sms-reuse-max-left { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; +} + +.hero-sms-reuse-max-right { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.hero-sms-max-price-input { + width: 72px; + text-align: center; +} + +.hero-sms-country-menu { + position: relative; + flex: 1; + min-width: 260px; +} + +.hero-sms-country-menu-btn { + width: 100%; + height: 33px; + min-height: 33px; + padding-top: 0; + padding-bottom: 0; + justify-content: flex-start; + overflow: hidden; + text-overflow: ellipsis; +} + +.hero-sms-country-menu-btn[aria-expanded="true"] { + border-color: var(--blue); + color: var(--blue); + background: var(--blue-soft); +} + +.hero-sms-country-menu-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 1200; + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + max-height: 180px; + overflow-y: auto; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); +} + +.hero-sms-country-menu-search { + padding-bottom: 6px; + border-bottom: 1px solid var(--border-subtle); +} + +.hero-sms-country-menu-search-input { + width: 100%; +} + +.hero-sms-country-menu-dropdown[hidden] { + display: none !important; +} + +.hero-sms-country-menu-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + text-align: left; +} + +.hero-sms-country-menu-item-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hero-sms-country-menu-item-badge { + flex: 0 0 auto; + min-width: 42px; + text-align: right; + color: var(--brand); + font-weight: 700; +} + +.hero-sms-runtime-grid { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 12px; +} + +.hero-sms-runtime-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.hero-sms-runtime-cell-span2 { + grid-column: 1 / -1; +} + +.hero-sms-runtime-key { + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; +} + +.hero-sms-runtime-value { + flex: 1 1 auto; + min-width: 0; +} + +.hero-sms-price-preview-stack { + width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +} + +.hero-sms-price-preview-head { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: nowrap; + gap: 8px; +} + +#btn-hero-sms-price-preview { + height: 33px; + min-height: 33px; + padding-top: 0; + padding-bottom: 0; + align-self: flex-start; +} + +.hero-sms-price-controls-grid { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 12px; +} + +.hero-sms-price-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.hero-sms-price-control .setting-controls { + margin-left: auto; + width: 104px; + justify-content: flex-start; +} + +.hero-sms-price-control-reuse { + justify-content: space-between; +} + +#row-hero-sms-max-price, +#row-phone-code-settings-group { + align-items: flex-start; +} + +#row-hero-sms-max-price > .data-label, +#row-phone-code-settings-group > .data-label { + padding-top: 9px; +} + +.hero-sms-toggle-controls { + width: 104px; + justify-content: flex-start; +} + +.hero-sms-price-preview-result { + width: 100%; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-surface); + padding: 6px 8px; +} + +.hero-sms-price-preview-text { + display: block; + white-space: pre-line; + line-height: 1.45; +} + +.hero-sms-settings-grid { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 12px; +} + +.hero-sms-settings-cell { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + min-width: 0; +} + +.hero-sms-settings-cell .setting-controls { + margin-left: auto; +} + +.hero-sms-settings-caption { + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; +} + .data-unit { font-size: 12px; font-weight: 600; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 6fa6a12..c693382 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -216,7 +216,7 @@ title="显示 Codex2API 管理密钥"> -
+
账户密码
添加
-
+
邮箱服务
-
+
注册邮箱
-
+
延迟
@@ -389,58 +389,7 @@
-
- 接码 -
-
- -
-
- 验证码重发 -
- - -
-
-
-
- - - - - -
+
OAuth 等待中...
@@ -452,6 +401,171 @@
+
+
+
+ + 手机号验证与 HeroSMS 获取策略 +
+
+ + +
+
+ +
@@ -589,6 +703,25 @@
+
@@ -600,64 +733,60 @@ 用于生成邮箱或接收转发邮件
-
-