Files
FlowPilot/background/phone-verification-flow.js
T
QLHazyCoder 16beb69db8 fix: reconcile GoPay steps and phone provider flows
- 吸收 PR #193/#194 后补齐 GoPay 订阅确认步骤注册,保持 GoPay Plus 为订阅确认后进入 OAuth 的 11 步流程。

- 修复手机号接码 provider 合并冲突,恢复 5sim/NexSMS/provider 顺序、复用池、价格阶梯和国家回退逻辑。

- 影响范围:共享步骤定义、background 步骤注册、手机号验证 Step 9、相关单元测试。
2026-05-04 04:27:49 +08:00

4768 lines
188 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function attachBackgroundPhoneVerification(root, factory) {
root.MultiPageBackgroundPhoneVerification = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPhoneVerificationModule() {
function createPhoneVerificationHelpers(deps = {}) {
const {
addLog,
ensureStep8SignupPageReady,
fetchImpl = (...args) => fetch(...args),
getOAuthFlowStepTimeoutMs,
getState,
sendToContentScript,
sendToContentScriptResilient,
setState,
broadcastDataUpdate = null,
sleepWithStop,
throwIfStopped,
DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php',
DEFAULT_FIVE_SIM_BASE_URL = 'https://5sim.net/v1',
DEFAULT_FIVE_SIM_PRODUCT = 'openai',
DEFAULT_FIVE_SIM_OPERATOR = 'any',
DEFAULT_FIVE_SIM_COUNTRY_ORDER = ['thailand'],
DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net',
DEFAULT_NEX_SMS_COUNTRY_ORDER = [1],
DEFAULT_NEX_SMS_SERVICE_CODE = 'ot',
DEFAULT_HERO_SMS_REUSE_ENABLED = true,
createFiveSimProvider = null,
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 REUSABLE_PHONE_ACTIVATION_POOL_STATE_KEY = 'phoneReusableActivationPool';
const PREFERRED_PHONE_ACTIVATION_STATE_KEY = 'phonePreferredActivation';
const PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY = 'currentPhoneVerificationCountdownEndsAt';
const PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY = 'currentPhoneVerificationCountdownWindowIndex';
const PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY = 'currentPhoneVerificationCountdownWindowTotal';
const PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY = 'phoneNoSupplyFailureStreak';
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 FIVE_SIM_RATE_LIMIT_ERROR_PREFIX = 'FIVE_SIM_RATE_LIMIT::';
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_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 HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high';
const PHONE_SMS_PROVIDER_HERO = 'hero-sms';
const PHONE_SMS_PROVIDER_5SIM = '5sim';
const PHONE_SMS_PROVIDER_HERO_SMS = PHONE_SMS_PROVIDER_HERO;
const PHONE_SMS_PROVIDER_FIVE_SIM = PHONE_SMS_PROVIDER_5SIM;
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO;
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = Object.freeze([
PHONE_SMS_PROVIDER_HERO,
PHONE_SMS_PROVIDER_5SIM,
PHONE_SMS_PROVIDER_NEXSMS,
]);
const MAX_PHONE_REUSABLE_POOL = 12;
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_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX = 'PHONE_ROUTE_405_RECOVERY_FAILED::';
const PHONE_SMS_FAILURE_SKIP_THRESHOLD = 2;
const MAX_ACTIVATION_PRICE_HINTS = 256;
const activationPriceHintsByKey = new Map();
function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) {
const trimmed = String(value || '').trim();
if (!trimmed) {
return fallback;
}
try {
return new URL(trimmed).toString();
} catch {
return fallback;
}
}
function normalizeApiKey(value) {
return String(value || '').trim();
}
function normalizePhoneSmsProvider(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === PHONE_SMS_PROVIDER_5SIM) {
return PHONE_SMS_PROVIDER_5SIM;
}
if (normalized === PHONE_SMS_PROVIDER_NEXSMS) {
return PHONE_SMS_PROVIDER_NEXSMS;
}
return PHONE_SMS_PROVIDER_HERO;
}
function isFiveSimProvider(state = {}) {
return normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER) === PHONE_SMS_PROVIDER_5SIM;
}
function normalizeNexSmsCountryId(value, fallback = 0) {
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 normalizeNexSmsCountryOrder(value = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const normalized = [];
const seen = new Set();
source.forEach((entry) => {
const id = normalizeNexSmsCountryId(
entry && typeof entry === 'object' && !Array.isArray(entry)
? (entry.id || entry.countryId || entry.country || '')
: entry,
-1
);
if (id < 0 || seen.has(id)) {
return;
}
seen.add(id);
normalized.push(id);
});
return normalized.slice(0, 10);
}
function resolveNexSmsCountryCandidates(state = {}) {
const ids = normalizeNexSmsCountryOrder(state?.nexSmsCountryOrder);
return ids.map((id) => ({
id,
label: `Country #${id}`,
}));
}
function normalizeNexSmsServiceCode(value = '', fallback = DEFAULT_NEX_SMS_SERVICE_CODE) {
const normalized = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '');
if (normalized) {
return normalized;
}
const fallbackNormalized = String(fallback || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '');
return fallbackNormalized || 'ot';
}
function normalizeFiveSimCountryCode(value = '', fallback = 'thailand') {
const normalized = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '');
return normalized || fallback;
}
function normalizeFiveSimCountryOrder(value = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const normalized = [];
const seen = new Set();
source.forEach((entry) => {
const code = normalizeFiveSimCountryCode(
entry && typeof entry === 'object' && !Array.isArray(entry)
? (entry.code || entry.country || entry.id || '')
: entry,
''
);
if (!code || seen.has(code)) {
return;
}
seen.add(code);
normalized.push(code);
});
return normalized.slice(0, 10);
}
function resolveFiveSimCountryCandidates(state = {}) {
let codes = normalizeFiveSimCountryOrder(state?.fiveSimCountryOrder);
if (!codes.length) {
const legacyPrimary = normalizeFiveSimCountryCode(state?.fiveSimCountryId, '');
const legacyFallback = normalizeFiveSimCountryOrder(state?.fiveSimCountryFallback);
codes = normalizeFiveSimCountryOrder([
...(legacyPrimary ? [legacyPrimary] : []),
...legacyFallback,
]);
}
return codes.map((code) => ({
code,
id: code,
label: (
code === normalizeFiveSimCountryCode(state?.fiveSimCountryId, '')
? normalizeCountryLabel(state?.fiveSimCountryLabel, code)
: code
),
}));
}
function normalizeUseCount(value) {
return Math.max(0, Math.floor(Number(value) || 0));
}
function normalizeTimestampMs(value) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
if (numeric >= 1000000000000) {
return Math.floor(numeric);
}
if (numeric >= 1000000000) {
return Math.floor(numeric * 1000);
}
}
const text = String(value || '').trim();
if (!text) {
return 0;
}
const parsed = Date.parse(text);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 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 assertFiveSimMaxPriceCompatibleWithOperator(operator, maxPriceLimit) {
const normalizedOperator = normalizeFiveSimCountryCode(operator, DEFAULT_FIVE_SIM_OPERATOR);
if (maxPriceLimit !== null && maxPriceLimit !== undefined && normalizedOperator !== DEFAULT_FIVE_SIM_OPERATOR) {
throw new Error('5sim maxPrice only works when operator is "any"; clear the price limit or switch operator to any before buying a number.');
}
}
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 /phone_max_usage_exceeded|phone_number_in_use|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 isPhoneNumberInvalidError(value) {
const text = String(value || '').trim();
if (!text) {
return false;
}
return /phone\s+number\s+is\s+not\s+valid|invalid\s+phone\s+number|invalid\s+phone|not\s+a\s+valid\s+phone|号码.*无效|手机号.*无效|电话号码.*无效/i.test(text);
}
function isRecoverableAddPhoneSubmitError(value) {
const text = String(value || '').trim();
if (!text) {
return false;
}
return (
isPhoneNumberInvalidError(text)
|| /failed\s+to\s+select\b.*add-phone\s+page|missing\s+the\s+country\s+option|could\s+not\s+determine\s+the\s+dial\s+code|add-phone\s+page\s+is\s+missing\s+the\s+phone\s+number\s+input|add-phone\s+page\s+is\s+missing\s+the\s+submit\s+button/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 = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE) {
return HERO_SMS_ACQUIRE_PRIORITY_PRICE;
}
if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH) {
return HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH;
}
return HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
}
function normalizePhoneSmsProviderOrder(value = [], fallbackOrder = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;|/]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const normalized = [];
const seen = new Set();
source.forEach((entry) => {
const provider = normalizePhoneSmsProvider(entry);
if (seen.has(provider)) {
return;
}
seen.add(provider);
normalized.push(provider);
});
if (normalized.length) {
return normalized.slice(0, 3);
}
const fallback = Array.isArray(fallbackOrder) ? fallbackOrder : [];
if (!fallback.length) {
return [];
}
const fallbackNormalized = [];
fallback.forEach((entry) => {
const provider = normalizePhoneSmsProvider(entry);
if (!provider || fallbackNormalized.includes(provider)) {
return;
}
fallbackNormalized.push(provider);
});
return fallbackNormalized.slice(0, 3);
}
function resolvePhoneProviderOrder(state = {}, preferredProvider = '') {
const currentProvider = normalizePhoneSmsProvider(
preferredProvider || state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER
);
const hasExplicitOrder = Array.isArray(state?.phoneSmsProviderOrder)
? state.phoneSmsProviderOrder.length > 0
: String(state?.phoneSmsProviderOrder || '').trim().length > 0;
if (hasExplicitOrder) {
const explicitOrder = normalizePhoneSmsProviderOrder(
state?.phoneSmsProviderOrder,
[]
);
if (explicitOrder.length) {
return explicitOrder;
}
return [currentProvider];
}
const fallbackOrder = normalizePhoneSmsProviderOrder(
[currentProvider],
DEFAULT_PHONE_SMS_PROVIDER_ORDER
);
if (fallbackOrder[0] === currentProvider) {
return fallbackOrder;
}
const withoutCurrent = fallbackOrder.filter((provider) => provider !== currentProvider);
return [currentProvider, ...withoutCurrent].slice(0, 3);
}
function reorderPriceCandidates(prices = [], acquirePriority = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY, preferredPrice = null) {
const hasNullTier = Array.isArray(prices)
&& prices.some((value) => value === null || value === undefined || String(value).trim() === '');
const normalized = Array.from(
new Set(
(Array.isArray(prices) ? prices : [])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.round(value * 10000) / 10000)
)
).sort((left, right) => left - right);
const ordered = acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
? normalized.reverse()
: normalized;
const preferred = Number(preferredPrice);
if (!Number.isFinite(preferred) || preferred <= 0) {
if (ordered.length) {
return ordered;
}
return hasNullTier ? [null] : [];
}
const normalizedPreferred = Math.round(preferred * 10000) / 10000;
const withoutPreferred = ordered.filter((value) => value !== normalizedPreferred);
return [normalizedPreferred, ...withoutPreferred];
}
function filterPriceCandidatesAboveFloor(prices = [], minExclusivePrice = null) {
const floor = normalizeHeroSmsPrice(minExclusivePrice);
if (floor === null || floor <= 0) {
return Array.isArray(prices) ? [...prices] : [];
}
return (Array.isArray(prices) ? prices : []).filter((value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return false;
}
const normalized = Math.round(numeric * 10000) / 10000;
return normalized > floor;
});
}
function shouldUseHeroSmsExpandedPriceLookup(state = {}) {
if (typeof state?.heroSmsUseExpandedPriceLookup === 'boolean') {
return state.heroSmsUseExpandedPriceLookup;
}
const runningInNode = (
typeof process !== 'undefined'
&& process
&& process.versions
&& process.versions.node
);
// Runtime default: enabled in extension/browser; tests in Node can opt-in explicitly.
return !runningInNode;
}
async function fetchHeroSmsPricePayloads(config, countryConfig, options = {}) {
const payloads = [];
const errors = [];
const actions = Array.isArray(options.actions) && options.actions.length
? options.actions
: (
shouldUseHeroSmsExpandedPriceLookup(options.state || {})
? ['getPricesExtended', 'getPrices']
: ['getPrices']
);
for (const action of actions) {
try {
const query = {
action,
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
};
if (action === 'getPricesExtended') {
query.freePrice = 'true';
}
const payload = await fetchHeroSmsPayload(config, query, `HeroSMS ${action}`);
payloads.push(payload);
} catch (error) {
errors.push({
action,
message: describeHeroSmsPayload(error?.payload || error?.message || ''),
});
}
}
return {
payloads,
errors,
};
}
function collectHeroSmsPriceCandidatesIncludingZeroStock(payload, candidates = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectHeroSmsPriceCandidatesIncludingZeroStock(entry, candidates));
return candidates;
}
if (!payload || typeof payload !== 'object') {
return candidates;
}
const cost = normalizeHeroSmsPrice(payload.cost);
if (cost !== null) {
candidates.push(cost);
}
Object.entries(payload).forEach(([key]) => {
const keyedPrice = normalizeHeroSmsPrice(key);
if (keyedPrice !== null) {
const value = payload[key];
if (value && typeof value === 'object') {
const stockState = resolveHeroSmsStockState(value);
if (stockState.hasStockField) {
candidates.push(keyedPrice);
}
return;
}
const numericCount = Number(value);
if (Number.isFinite(numericCount)) {
candidates.push(keyedPrice);
}
}
});
Object.values(payload).forEach((value) => collectHeroSmsPriceCandidatesIncludingZeroStock(value, candidates));
return candidates;
}
async function resolveHeroSmsPricePlanFromPricePayloads(config, countryConfig, state = {}, payloads = []) {
const userLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice);
const inStockCandidates = buildSortedUniquePriceCandidates(
(Array.isArray(payloads) ? payloads : [])
.flatMap((payload) => collectHeroSmsPriceCandidates(payload, []))
);
const allCatalogCandidates = buildSortedUniquePriceCandidates(
(Array.isArray(payloads) ? payloads : [])
.flatMap((payload) => collectHeroSmsPriceCandidatesIncludingZeroStock(payload, []))
);
const mergedCandidates = inStockCandidates.length
? buildSortedUniquePriceCandidates([
...inStockCandidates,
...allCatalogCandidates,
])
: [];
const minCatalogPrice = allCatalogCandidates.length
? allCatalogCandidates[0]
: (mergedCandidates.length ? mergedCandidates[0] : null);
if (userLimit !== null) {
const bounded = mergedCandidates.filter((price) => price <= userLimit);
if (bounded.length > 0) {
const boundedPlan = {
prices: bounded,
userLimit,
minCatalogPrice,
syntheticUserLimitProbe: false,
};
await persistHeroSmsPricePlanSnapshot(countryConfig, boundedPlan);
return boundedPlan;
}
const userLimitedPlan = {
prices: [userLimit],
userLimit,
minCatalogPrice,
syntheticUserLimitProbe: true,
};
await persistHeroSmsPricePlanSnapshot(countryConfig, userLimitedPlan);
return userLimitedPlan;
}
if (mergedCandidates.length > 0) {
const plan = {
prices: mergedCandidates,
userLimit: null,
minCatalogPrice,
syntheticUserLimitProbe: false,
};
await persistHeroSmsPricePlanSnapshot(countryConfig, plan);
return plan;
}
const fallbackPlan = {
prices: [null],
userLimit: null,
minCatalogPrice: null,
syntheticUserLimitProbe: false,
};
await persistHeroSmsPricePlanSnapshot(countryConfig, fallbackPlan);
return fallbackPlan;
}
function normalizeCountryPriceFloorMap(rawMap = {}, normalizeCountryKey) {
const normalizedMap = new Map();
if (!rawMap || typeof rawMap !== 'object') {
return normalizedMap;
}
Object.entries(rawMap).forEach(([rawCountryKey, rawPrice]) => {
const countryKey = String(
typeof normalizeCountryKey === 'function'
? normalizeCountryKey(rawCountryKey)
: rawCountryKey
).trim();
if (!countryKey) {
return;
}
const normalizedPrice = normalizeHeroSmsPrice(rawPrice);
if (normalizedPrice === null || normalizedPrice <= 0) {
return;
}
normalizedMap.set(countryKey, Math.round(normalizedPrice * 10000) / 10000);
});
return normalizedMap;
}
function getActivationProviderId(activation = {}, state = {}) {
return normalizePhoneSmsProvider(activation?.provider || state?.phoneSmsProvider);
}
function getPhoneSmsProviderLabel(providerId) {
const provider = normalizePhoneSmsProvider(providerId);
if (provider === PHONE_SMS_PROVIDER_FIVE_SIM) {
return '5sim';
}
if (provider === PHONE_SMS_PROVIDER_NEXSMS) {
return 'NexSMS';
}
return 'HeroSMS';
}
function formatStep9Reason(reason = '') {
const text = String(reason || '').trim();
if (!text) {
return '未知';
}
const normalized = text.toLowerCase();
const reasonMap = {
returned_to_add_phone_loop: '反复返回添加手机号页',
phone_number_used: '手机号已被使用',
sms_not_received: '未收到短信',
sms_timeout: '短信超时',
resend_throttled: '重发短信被限流',
code_rejected: '验证码被拒绝',
unknown: '未知',
};
if (reasonMap[normalized]) {
return reasonMap[normalized];
}
const timeoutWindowMatch = text.match(/^sms_timeout_after_(\d+)_windows$/i);
if (timeoutWindowMatch) {
return `连续 ${timeoutWindowMatch[1]} 轮等待后仍未收到短信`;
}
return text;
}
function isPhoneSmsReuseEnabled(state = {}) {
if (normalizePhoneSmsProvider(state?.phoneSmsProvider) === PHONE_SMS_PROVIDER_FIVE_SIM) {
return state?.fiveSimReuseEnabled !== false;
}
return normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled);
}
function createResolvedFiveSimProvider() {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
const factory = createFiveSimProvider || rootScope.PhoneSmsFiveSimProvider?.createProvider;
if (typeof factory !== 'function') {
return null;
}
return factory({
addLog,
fetchImpl,
requestTimeoutMs: DEFAULT_PHONE_REQUEST_TIMEOUT_MS,
sleepWithStop,
throwIfStopped,
});
}
function getFiveSimProviderForState(_state = {}) {
return createResolvedFiveSimProvider();
}
function normalizeFiveSimCountryId(value, fallback = 'england') {
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
return normalized || fallback;
}
function normalizeFiveSimCountryLabel(value = '', fallback = '英国 (England)') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryLabel) {
return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryLabel(value, fallback);
}
if (rootScope.PhoneSmsFiveSimProvider?.formatFiveSimCountryLabel) {
return rootScope.PhoneSmsFiveSimProvider.formatFiveSimCountryLabel('', value, fallback);
}
return String(value || '').trim() || fallback;
}
function normalizeFiveSimCountryFallbackList(value = []) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.PhoneSmsFiveSimProvider?.normalizeFiveSimCountryFallback) {
return rootScope.PhoneSmsFiveSimProvider.normalizeFiveSimCountryFallback(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 = '';
let label = '';
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
id = normalizeFiveSimCountryId(entry.id ?? entry.countryId ?? entry.slug, '');
label = String((entry.label ?? entry.countryLabel ?? entry.name ?? entry.text_en) || '').trim();
} else {
const text = String(entry || '').trim();
const structured = text.match(/^([a-z0-9_-]+)\s*(?:[:|/-]\s*(.+))?$/i);
id = normalizeFiveSimCountryId(structured?.[1] || text, '');
label = String(structured?.[2] || '').trim();
}
if (!id || seen.has(id)) continue;
seen.add(id);
normalized.push({ id, label: label || normalizeFiveSimCountryLabel('', id) });
}
return normalized;
}
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 = {}) {
const hasExplicitPrimaryCountry = Object.prototype.hasOwnProperty.call(state || {}, 'heroSmsCountryId');
const fallbackList = normalizeCountryFallbackList(state.heroSmsCountryFallback);
const primaryCountryId = normalizeCountryId(state.heroSmsCountryId, 0);
if (primaryCountryId > 0) {
return {
id: primaryCountryId,
label: normalizeCountryLabel(state.heroSmsCountryLabel, HERO_SMS_COUNTRY_LABEL),
};
}
if (hasExplicitPrimaryCountry) {
if (fallbackList.length) {
const firstFallback = fallbackList[0];
return {
id: normalizeCountryId(firstFallback.id, 0),
label: normalizeCountryLabel(firstFallback.label, `Country #${firstFallback.id}`),
};
}
return null;
}
return {
id: normalizeCountryId(HERO_SMS_COUNTRY_ID, HERO_SMS_COUNTRY_ID),
label: normalizeCountryLabel(state.heroSmsCountryLabel, HERO_SMS_COUNTRY_LABEL),
};
}
function resolveCountryCandidates(state = {}) {
const primary = resolveCountryConfig(state);
const fallbackList = normalizeCountryFallbackList(state.heroSmsCountryFallback);
if (!primary || !Number.isFinite(primary.id) || primary.id <= 0) {
return fallbackList
.map((entry) => ({
id: normalizeCountryId(entry.id, 0),
label: normalizeCountryLabel(entry.label, `Country #${entry.id}`),
}))
.filter((entry) => entry.id > 0);
}
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;
}
const activationId = String(
record.activationId ?? record.id ?? record.activation ?? ''
).trim();
const phoneNumber = String(
record.phoneNumber ?? record.number ?? record.phone ?? ''
).trim();
if (!activationId || !phoneNumber) {
return null;
}
const statusAction = String(record.statusAction || '').trim();
const countryLabel = String(record.countryLabel || '').trim();
const rawProvider = String(record.provider || '').trim();
const provider = normalizePhoneSmsProvider(rawProvider);
const rawCountryId = record.countryId ?? record.country;
const fallbackCountryId = provider === PHONE_SMS_PROVIDER_FIVE_SIM ? 'england' : HERO_SMS_COUNTRY_ID;
const expiresAt = normalizeTimestampMs(record.expiresAt);
const serviceCode = String(
record.serviceCode
|| (
provider === PHONE_SMS_PROVIDER_FIVE_SIM
? DEFAULT_FIVE_SIM_PRODUCT
: (provider === PHONE_SMS_PROVIDER_NEXSMS ? DEFAULT_NEX_SMS_SERVICE_CODE : HERO_SMS_SERVICE_CODE)
)
).trim();
const countryId = provider === PHONE_SMS_PROVIDER_FIVE_SIM
? normalizeFiveSimCountryId(record.countryCode ?? rawCountryId, fallbackCountryId)
: (
provider === PHONE_SMS_PROVIDER_NEXSMS
? normalizeNexSmsCountryId(rawCountryId, 0)
: normalizeCountryId(rawCountryId, fallbackCountryId)
);
return {
activationId,
phoneNumber,
provider,
serviceCode,
countryId,
...(provider === PHONE_SMS_PROVIDER_FIVE_SIM ? { countryCode: countryId } : {}),
...(countryLabel ? { countryLabel } : {}),
successfulUses: normalizeUseCount(record.successfulUses),
maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)),
...(expiresAt > 0 ? { expiresAt } : {}),
...(statusAction ? { statusAction } : {}),
};
}
function normalizeActivationPool(value = []) {
const source = Array.isArray(value) ? value : [];
const normalized = [];
const seen = new Set();
source.forEach((entry) => {
const activation = normalizeActivation(entry);
if (!activation) {
return;
}
const key = buildActivationIdentityKey(activation);
if (!key || seen.has(key)) {
return;
}
seen.add(key);
normalized.push(activation);
});
return normalized.slice(0, MAX_PHONE_REUSABLE_POOL);
}
function buildActivationIdentityKey(activation) {
const normalized = normalizeActivation(activation);
if (!normalized) {
return '';
}
return [
normalizePhoneSmsProvider(normalized.provider || ''),
String(normalized.activationId || '').trim(),
String(normalized.phoneNumber || '').trim(),
].join('::');
}
function isSameActivation(left, right) {
const leftKey = buildActivationIdentityKey(left);
const rightKey = buildActivationIdentityKey(right);
return Boolean(leftKey && rightKey && leftKey === rightKey);
}
function rememberActivationAcquiredPrice(activation, price) {
const key = buildActivationIdentityKey(activation);
const normalizedPrice = normalizeHeroSmsPrice(price);
if (!key || normalizedPrice === null || normalizedPrice <= 0) {
return;
}
const roundedPrice = Math.round(normalizedPrice * 10000) / 10000;
activationPriceHintsByKey.set(key, roundedPrice);
while (activationPriceHintsByKey.size > MAX_ACTIVATION_PRICE_HINTS) {
const oldest = activationPriceHintsByKey.keys().next();
if (oldest?.done) {
break;
}
activationPriceHintsByKey.delete(oldest.value);
}
}
function getActivationAcquiredPriceHint(activation) {
const key = buildActivationIdentityKey(activation);
if (!key) {
return null;
}
const raw = activationPriceHintsByKey.get(key);
const normalizedPrice = normalizeHeroSmsPrice(raw);
return normalizedPrice === null || normalizedPrice <= 0
? null
: Math.round(normalizedPrice * 10000) / 10000;
}
function forgetActivationAcquiredPriceHint(activation) {
const key = buildActivationIdentityKey(activation);
if (!key) {
return;
}
activationPriceHintsByKey.delete(key);
}
async function setPhoneRuntimeState(updates = {}) {
await setState(updates);
if (typeof broadcastDataUpdate === 'function') {
broadcastDataUpdate(updates);
}
}
function normalizeActivationFallback(record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return null;
}
const fallback = {};
const rawProvider = String(record.provider || '').trim();
const provider = rawProvider ? normalizePhoneSmsProvider(rawProvider) : '';
const serviceCode = String(record.serviceCode || '').trim();
const rawCountryId = record.countryId ?? record.country;
const countryId = provider === PHONE_SMS_PROVIDER_FIVE_SIM
? normalizeFiveSimCountryId(rawCountryId, '')
: Math.floor(Number(rawCountryId));
const countryLabel = String(record.countryLabel || '').trim();
const statusAction = String(record.statusAction || '').trim();
if (provider) {
fallback.provider = provider;
}
if (serviceCode) {
fallback.serviceCode = serviceCode;
}
if (provider === PHONE_SMS_PROVIDER_FIVE_SIM) {
if (countryId) {
fallback.countryId = countryId;
}
} else if (Number.isFinite(countryId) && countryId > 0) {
fallback.countryId = countryId;
if (provider === PHONE_SMS_PROVIDER_5SIM) {
fallback.countryCode = countryId;
}
}
if (countryLabel) {
fallback.countryLabel = countryLabel;
}
if (Object.prototype.hasOwnProperty.call(record, 'successfulUses')) {
fallback.successfulUses = normalizeUseCount(record.successfulUses);
}
if (Object.prototype.hasOwnProperty.call(record, 'maxUses')) {
fallback.maxUses = Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES));
}
if (statusAction) {
fallback.statusAction = statusAction;
}
return Object.keys(fallback).length ? fallback : null;
}
function describeHeroSmsPayload(raw) {
if (typeof raw === 'string') {
return raw.trim();
}
if (raw && typeof raw === 'object') {
if (raw.title || raw.details) {
const title = String(raw.title || '').trim();
const details = String(raw.details || '').trim();
return details ? `${title}: ${details}` : title;
}
if (raw.status === 'false' && raw.msg) {
return String(raw.msg).trim();
}
try {
return JSON.stringify(raw);
} catch {
return String(raw);
}
}
return String(raw || '').trim();
}
function parseHeroSmsPayload(text) {
const trimmed = String(text || '').trim();
if (!trimmed) {
return '';
}
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
function buildHeroSmsUrl(baseUrl, query = {}) {
const url = new URL(normalizeUrl(baseUrl));
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
url.searchParams.set(key, String(value));
});
return url.toString();
}
function buildPhoneCodeTimeoutError(lastResponse = '') {
const suffix = lastResponse ? ` HeroSMS 最后状态:${lastResponse}` : '';
return new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}等待手机验证码超时。${suffix}`);
}
function isPhoneCodeTimeoutError(error) {
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 isPhoneRoute405RecoveryError(error) {
const message = String(error?.message || error || '').trim();
if (!message) {
return false;
}
if (message.startsWith(PHONE_ROUTE_405_RECOVERY_FAILED_ERROR_PREFIX)) {
return true;
}
return /route\s+error.*405|405\s+method\s+not\s+allowed|post\s+request\s+to\s+["']?\/phone-verification|did\s+not\s+provide\s+an?\s+[`'"]?action/i.test(message);
}
function isPhoneActivationOrderMissingError(error, provider = '') {
const message = String(error?.message || error || '').trim();
if (!message) {
return false;
}
const normalizedProvider = normalizePhoneSmsProvider(provider);
if (normalizedProvider === PHONE_SMS_PROVIDER_5SIM) {
return /5sim\s+check\s+activation\s+failed.*order\s+not\s+found|order\s+not\s+found|activation\s+not\s+found|no\s+such\s+order/i.test(message);
}
return /activation\s+not\s+found|order\s+not\s+found|no\s+such\s+order/i.test(message);
}
function isStopRequestedError(error) {
const message = String(error?.message || error || '').trim();
if (!message) {
return false;
}
return message === '流程已被用户停止。'
|| /已被用户停止/.test(message)
|| /flow\s+was\s+stopped|stopped\s+by\s+user/i.test(message);
}
function buildPhoneRestartStep7Error(phoneNumber = '') {
const suffix = phoneNumber ? ` 当前号码:${phoneNumber}` : '';
return new Error(
`${PHONE_RESTART_STEP7_ERROR_PREFIX}手机验证重发后仍未收到短信,请从步骤 7 重新获取新号码。${suffix}`
);
}
function buildPhoneReplacementLimitError(maxNumberReplacementAttempts, reason = '') {
const safeMax = Math.max(0, Math.floor(Number(maxNumberReplacementAttempts) || 0));
const safeReason = String(reason || 'unknown').trim() || 'unknown';
return new Error(
`步骤 9:更换 ${safeMax} 次号码后手机号验证仍未成功。最后原因:${safeReason}. `
+ `Step 9: phone verification did not succeed after ${safeMax} number replacements. Last reason: ${safeReason}.`
);
}
function sanitizePhoneCodeTimeoutError(error) {
const message = String(error?.message || '');
if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) {
return error;
}
return new Error(message.slice(PHONE_CODE_TIMEOUT_ERROR_PREFIX.length).trim() || '等待手机验证码超时。');
}
function sanitizePhoneRestartStep7Error(error) {
const message = String(error?.message || '');
if (!message.startsWith(PHONE_RESTART_STEP7_ERROR_PREFIX)) {
return error;
}
return new Error(
message.slice(PHONE_RESTART_STEP7_ERROR_PREFIX.length).trim()
|| '手机验证重发后仍未收到短信,请从步骤 7 重新获取新号码。'
);
}
async function fetchHeroSmsPayload(config, query, actionLabel) {
const requestUrl = buildHeroSmsUrl(config.baseUrl, {
api_key: config.apiKey,
...query,
});
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS)
: null;
try {
const response = await fetchImpl(requestUrl, {
method: 'GET',
signal: controller?.signal,
});
const text = await response.text();
const payload = parseHeroSmsPayload(text);
if (!response.ok) {
const requestError = new Error(`${actionLabel} failed: ${describeHeroSmsPayload(payload) || response.status}`);
requestError.payload = payload;
requestError.status = response.status;
throw requestError;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`${actionLabel} timed out.`);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function parseFiveSimPayload(text) {
const trimmed = String(text || '').trim();
if (!trimmed) {
return '';
}
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
function describeFiveSimPayload(raw) {
if (typeof raw === 'string') {
return raw.trim();
}
if (raw && typeof raw === 'object') {
const message = String(raw.message || raw.error || raw.msg || raw.statusText || '').trim();
if (message) {
return message;
}
try {
return JSON.stringify(raw);
} catch {
return String(raw);
}
}
return String(raw || '').trim();
}
async function fetchFiveSimPayload(config, path, actionLabel, options = {}) {
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS)
: null;
try {
const requestUrl = new URL(path.replace(/^\/+/, ''), `${config.baseUrl.replace(/\/+$/, '')}/`);
const query = (options && options.query && typeof options.query === 'object') ? options.query : {};
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
requestUrl.searchParams.set(key, String(value));
});
const response = await fetchImpl(requestUrl.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
signal: controller?.signal,
});
const text = await response.text();
const payload = parseFiveSimPayload(text);
if (!response.ok) {
const requestError = new Error(`${actionLabel} failed: ${describeFiveSimPayload(payload) || response.status}`);
requestError.payload = payload;
requestError.status = response.status;
throw requestError;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`${actionLabel} timed out.`);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function parseNexSmsPayload(text) {
const trimmed = String(text || '').trim();
if (!trimmed) {
return '';
}
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
function describeNexSmsPayload(raw) {
if (typeof raw === 'string') {
return raw.trim();
}
if (raw && typeof raw === 'object') {
const message = String(raw.message || raw.error || raw.msg || raw.statusText || '').trim();
if (message) {
return message;
}
try {
return JSON.stringify(raw);
} catch {
return String(raw);
}
}
return String(raw || '').trim();
}
function isNexSmsSuccessPayload(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return false;
}
return Number(payload.code) === 0;
}
async function fetchNexSmsPayload(config, path, actionLabel, options = {}) {
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS)
: null;
try {
const method = String(options.method || 'GET').trim().toUpperCase() || 'GET';
const requestUrl = new URL(path.replace(/^\/+/, ''), `${config.baseUrl.replace(/\/+$/, '')}/`);
requestUrl.searchParams.set('apiKey', config.apiKey);
const query = (options && options.query && typeof options.query === 'object') ? options.query : {};
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
requestUrl.searchParams.set(key, String(value));
});
const headers = {
Accept: 'application/json',
...(options.headers && typeof options.headers === 'object' ? options.headers : {}),
};
const requestInit = {
method,
headers,
signal: controller?.signal,
};
if (method !== 'GET' && method !== 'HEAD' && options.body !== undefined) {
requestInit.body = typeof options.body === 'string'
? options.body
: JSON.stringify(options.body);
if (!requestInit.headers['Content-Type']) {
requestInit.headers['Content-Type'] = 'application/json';
}
}
const response = await fetchImpl(requestUrl.toString(), requestInit);
const text = await response.text();
const payload = parseNexSmsPayload(text);
if (!response.ok) {
const requestError = new Error(`${actionLabel} failed: ${describeNexSmsPayload(payload) || response.status}`);
requestError.payload = payload;
requestError.status = response.status;
throw requestError;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`${actionLabel} timed out.`);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function resolvePhoneConfig(state = {}) {
const provider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER);
if (provider === PHONE_SMS_PROVIDER_5SIM) {
const apiKey = normalizeApiKey(state.fiveSimApiKey || state.heroSmsApiKey);
if (!apiKey) {
throw new Error('5sim API key is missing. Save it in the side panel before running the phone flow.');
}
const configuredMaxPrice = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice);
const operator = normalizeFiveSimCountryCode(state.fiveSimOperator, DEFAULT_FIVE_SIM_OPERATOR);
const maxPriceLimit = configuredMaxPrice !== null
? configuredMaxPrice
: normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice);
assertFiveSimMaxPriceCompatibleWithOperator(operator, maxPriceLimit);
return {
provider,
apiKey,
baseUrl: normalizeUrl(state.fiveSimBaseUrl, DEFAULT_FIVE_SIM_BASE_URL).replace(/\/+$/, ''),
operator,
product: normalizeFiveSimCountryCode(state.fiveSimProduct, DEFAULT_FIVE_SIM_PRODUCT),
maxPriceLimit,
countryCandidates: resolveFiveSimCountryCandidates(state),
};
}
if (provider === PHONE_SMS_PROVIDER_NEXSMS) {
const apiKey = normalizeApiKey(state.nexSmsApiKey || state.heroSmsApiKey);
if (!apiKey) {
throw new Error('NexSMS API key is missing. Save it in the side panel before running the phone flow.');
}
return {
provider,
apiKey,
baseUrl: normalizeUrl(state.nexSmsBaseUrl, DEFAULT_NEX_SMS_BASE_URL).replace(/\/+$/, ''),
serviceCode: normalizeNexSmsServiceCode(state.nexSmsServiceCode, DEFAULT_NEX_SMS_SERVICE_CODE),
countryCandidates: resolveNexSmsCountryCandidates(state),
};
}
const apiKey = normalizeApiKey(state.heroSmsApiKey);
if (!apiKey) {
throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.');
}
return {
provider,
apiKey,
baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL),
countryCandidates: resolveCountryCandidates(state),
};
}
function parseActivationPayload(payload, fallback = null) {
const normalizedFallback = normalizeActivation(fallback) || normalizeActivationFallback(fallback);
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,
...(
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 || PHONE_SMS_PROVIDER_HERO,
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;
}
return null;
}
function resolveActivationStatusAction(activation) {
return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus';
}
function normalizeHeroSmsPrice(value) {
const direct = Number(value);
if (Number.isFinite(direct) && direct >= 0) {
return direct;
}
const text = String(value ?? '').trim();
if (!text) {
return null;
}
// HeroSMS occasionally returns formatted price strings such as "$0.1183".
// Extract the first decimal token so those tiers can still participate in
// fallback selection and pricing diagnostics.
const matched = text.match(/-?\d+(?:[.,]\d+)?/);
if (!matched) {
return null;
}
const normalizedText = String(matched[0] || '').replace(',', '.');
const parsed = Number(normalizedText);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
function resolveHeroSmsStockState(payload = {}) {
const physicalCount = Number(payload.physicalCount);
if (Number.isFinite(physicalCount)) {
return {
hasStockField: true,
stockCount: physicalCount,
};
}
const stockCandidates = [
payload.count,
payload.stock,
payload.available,
payload.quantity,
payload.qty,
payload.left,
payload.free,
]
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (!stockCandidates.length) {
return {
hasStockField: false,
stockCount: 0,
};
}
return {
hasStockField: true,
stockCount: Math.max(...stockCandidates),
};
}
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 stockState = resolveHeroSmsStockState(payload);
if (!stockState.hasStockField || stockState.stockCount > 0) {
candidates.push(cost);
}
}
// Some HeroSMS responses expose price tiers as object keys:
// { "0.05": { count: 0 }, "0.35": { count: 12 } }.
// Parse those keyed tiers so higher-price stock is not missed.
Object.entries(payload).forEach(([key, value]) => {
const keyedPrice = normalizeHeroSmsPrice(key);
if (keyedPrice === null) {
return;
}
if (value && typeof value === 'object') {
const stockState = resolveHeroSmsStockState(value);
// Ignore numeric keys that are actually country/service IDs.
// Keyed price tiers are considered valid only when stock fields exist.
if (stockState.hasStockField && stockState.stockCount > 0) {
candidates.push(keyedPrice);
}
return;
}
const numericCount = Number(value);
if (Number.isFinite(numericCount) && numericCount > 0) {
candidates.push(keyedPrice);
}
});
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);
}
function isProviderNoSupplyFailureMessage(message = '') {
const text = String(message || '').trim();
if (!text) {
return false;
}
return /no\s+numbers\s+available\s+across|no\s+free\s+phones|numbers?\s+not\s+found|no\s+numbers\s+within\s+maxprice|step\s*9:\s*(?:5sim|nexsms)\s+countries\s+are\s+empty|\bNO_NUMBERS\b/i.test(text);
}
function resolveNoSupplyDiagnosticsContext(state = {}, providerOrder = []) {
const order = Array.isArray(providerOrder) && providerOrder.length
? providerOrder
: resolvePhoneProviderOrder(state, state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER);
const heroCountryCount = resolveCountryCandidates(state).length;
const fiveSimCountryCount = resolveFiveSimCountryCandidates(state).length;
const nexSmsCountryCount = resolveNexSmsCountryCandidates(state).length;
const maxPrice = normalizeHeroSmsPriceLimit(state?.heroSmsMaxPrice);
const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
return {
order,
heroCountryCount,
fiveSimCountryCount,
nexSmsCountryCount,
maxPrice,
acquirePriority,
};
}
function formatNoSupplySuggestion(context = {}) {
const suggestions = [];
const maxPrice = Number(context?.maxPrice);
if (!Number.isFinite(maxPrice) || maxPrice <= 0) {
suggestions.push('先设置价格上限(建议 >= 0.12');
} else if (maxPrice < 0.12) {
suggestions.push('先提高价格上限(当前偏低)');
}
if ((context?.heroCountryCount || 0) <= 1) {
suggestions.push('HeroSMS 增加国家回退');
}
if ((context?.fiveSimCountryCount || 0) <= 0) {
suggestions.push('5sim 至少选择 1 个国家');
}
if ((context?.nexSmsCountryCount || 0) <= 0) {
suggestions.push('NexSMS 至少选择 1 个国家');
}
if (String(context?.acquirePriority || '') === HERO_SMS_ACQUIRE_PRIORITY_COUNTRY) {
suggestions.push('可尝试切到“价格优先”');
}
const unique = Array.from(new Set(suggestions));
if (!unique.length) {
return '优先提高价格上限,并调整服务商/国家优先级后重试';
}
return unique.slice(0, 3).join('');
}
async function resetPhoneNoSupplyFailureStreak(state = {}) {
const latestState = (typeof getState === 'function')
? (await getState().catch(() => state))
: state;
const current = Math.max(0, Math.floor(Number(latestState?.[PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]) || 0));
if (current > 0) {
await setPhoneRuntimeState({
[PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]: 0,
});
}
}
async function logNoSupplyDiagnostics(state = {}, providerOrder = [], providerErrors = []) {
const allNoSupply = Array.isArray(providerErrors)
&& providerErrors.length > 0
&& providerErrors.every((entry) => isProviderNoSupplyFailureMessage(entry));
if (!allNoSupply) {
await resetPhoneNoSupplyFailureStreak(state);
return false;
}
const latestState = (typeof getState === 'function')
? (await getState().catch(() => state))
: state;
const previousStreak = Math.max(
0,
Math.floor(Number(latestState?.[PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]) || 0)
);
const nextStreak = previousStreak + 1;
await setPhoneRuntimeState({
[PHONE_NO_SUPPLY_FAILURE_STREAK_STATE_KEY]: nextStreak,
});
const context = resolveNoSupplyDiagnosticsContext(
latestState && typeof latestState === 'object' ? latestState : state,
providerOrder
);
const maxPriceText = context.maxPrice === null ? '未设置' : String(context.maxPrice);
const providerOrderText = context.order.join(' > ');
const suggestion = formatNoSupplySuggestion(context);
await addLog(
`Step 9 diagnostics: 无号连续失败 ${nextStreak} 次;maxPrice=${maxPriceText}providerOrder=${providerOrderText};国家数 HeroSMS=${context.heroCountryCount}, 5sim=${context.fiveSimCountryCount}, NexSMS=${context.nexSmsCountryCount}。建议:${suggestion}`,
nextStreak >= 2 ? 'warn' : 'info'
);
return true;
}
async function resolveCheapestPhoneActivationPrice(config, countryConfig) {
for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) {
try {
const { payloads } = await fetchHeroSmsPricePayloads(config, countryConfig, { state });
const price = findLowestHeroSmsPrice(
payloads && payloads.length
? payloads
: []
);
const normalizedPrice = Number.isFinite(Number(price)) ? Number(price) : null;
if (normalizedPrice !== null) {
return normalizedPrice;
}
const fallbackCandidates = buildSortedUniquePriceCandidates(
(Array.isArray(payloads) ? payloads : [])
.flatMap((payload) => collectHeroSmsPriceCandidatesIncludingZeroStock(payload, []))
);
if (fallbackCandidates.length > 0) {
return fallbackCandidates[0];
}
} 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 = {}) {
for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) {
try {
const { payloads } = await fetchHeroSmsPricePayloads(config, countryConfig, { state });
const plan = await resolveHeroSmsPricePlanFromPricePayloads(
config,
countryConfig,
state,
payloads
);
if (
Array.isArray(plan?.prices)
&& plan.prices.length > 0
&& (
plan.prices.some((price) => Number.isFinite(Number(price)) && Number(price) > 0)
|| plan.syntheticUserLimitProbe
)
) {
return plan;
}
} catch (_) {
// best effort
}
}
const fallbackPlan = {
prices: [null],
userLimit: null,
minCatalogPrice: null,
syntheticUserLimitProbe: false,
};
await persistHeroSmsPricePlanSnapshot(countryConfig, fallbackPlan);
return fallbackPlan;
}
async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) {
const query = {
action,
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
};
if (options.maxPrice !== null && options.maxPrice !== undefined) {
query.maxPrice = options.maxPrice;
if (options.fixedPrice !== false) {
query.fixedPrice = 'true';
}
}
return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`);
}
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,
fixedPrice: options.fixedPrice,
});
} 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;
}
throw error;
}
}
}
function collectFiveSimPriceCandidates(payload, candidates = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectFiveSimPriceCandidates(entry, candidates));
return candidates;
}
if (!payload || typeof payload !== 'object') {
return candidates;
}
const cost = Number(payload.cost);
const count = Number(payload.count);
if (Number.isFinite(cost) && cost > 0) {
if (!Number.isFinite(count) || count > 0) {
candidates.push(Math.round(cost * 10000) / 10000);
}
}
Object.entries(payload).forEach(([key, value]) => {
const keyedPrice = Number(key);
if (!Number.isFinite(keyedPrice) || keyedPrice <= 0) {
return;
}
if (value && typeof value === 'object') {
const keyedCount = Number(value.count);
if (!Number.isFinite(keyedCount) || keyedCount > 0) {
candidates.push(Math.round(keyedPrice * 10000) / 10000);
}
return;
}
const numericCount = Number(value);
if (!Number.isFinite(numericCount) || numericCount > 0) {
candidates.push(Math.round(keyedPrice * 10000) / 10000);
}
});
Object.values(payload).forEach((entry) => collectFiveSimPriceCandidates(entry, candidates));
return candidates;
}
function collectFiveSimProductPriceCandidates(payload, product = DEFAULT_FIVE_SIM_PRODUCT, candidates = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates));
return candidates;
}
if (!payload || typeof payload !== 'object') {
return candidates;
}
const productPayload = payload[product] || payload[String(product || '').toLowerCase()];
if (productPayload && typeof productPayload === 'object') {
const price = Number(productPayload.Price ?? productPayload.price ?? productPayload.cost);
const qty = Number(productPayload.Qty ?? productPayload.qty ?? productPayload.count);
if (Number.isFinite(price) && price > 0 && (!Number.isFinite(qty) || qty > 0)) {
candidates.push(Math.round(price * 10000) / 10000);
}
}
Object.values(payload).forEach((entry) => collectFiveSimProductPriceCandidates(entry, product, candidates));
return candidates;
}
function findLowestFiveSimPrice(payload, product = DEFAULT_FIVE_SIM_PRODUCT, countryCode = '') {
const normalizedProduct = normalizeFiveSimCountryCode(product, DEFAULT_FIVE_SIM_PRODUCT);
const normalizedCountryCode = normalizeFiveSimCountryCode(countryCode, '');
const root = payload && typeof payload === 'object'
? (payload[normalizedProduct] || payload)
: {};
const countryPayload = (
normalizedCountryCode
? (root?.[normalizedCountryCode] || root)
: root
);
const candidates = collectFiveSimPriceCandidates(countryPayload, []);
if (!candidates.length) {
return null;
}
return Math.min(...candidates);
}
function isFiveSimNoNumbersError(payloadOrMessage) {
const text = describeFiveSimPayload(payloadOrMessage);
return /no\s+free\s+phones|no\s+phones\s+available|no\s+numbers\s+available/i.test(text);
}
function isFiveSimRateLimitError(payloadOrMessage, status = 0) {
if (Number(status) === 429) {
return true;
}
const text = describeFiveSimPayload(payloadOrMessage);
return /rate\s*limit|too\s*many\s*requests|request\s*limit|429/i.test(text);
}
function buildFiveSimRateLimitError(details = []) {
const suffix = Array.isArray(details) && details.length
? `${details.join(' | ')}`
: '。';
return new Error(`${FIVE_SIM_RATE_LIMIT_ERROR_PREFIX}5sim 购买接口触发限流,请稍后再试${suffix}`);
}
function isFiveSimTerminalError(payloadOrMessage, status = 0) {
if (Number(status) === 401 || Number(status) === 403) {
return true;
}
const text = describeFiveSimPayload(payloadOrMessage);
return /not\s+enough\s+balance|no\s+balance|unauthorized|invalid\s+token|forbidden|bad\s+key|wrong\s+key|banned/i.test(text);
}
async function resolveFiveSimLowestPrice(config, countryCode) {
try {
const payload = await fetchFiveSimPayload(
config,
'/guest/prices',
'5sim guest prices',
{
query: {
country: countryCode,
product: config.product,
},
}
);
return findLowestFiveSimPrice(payload, config.product, countryCode);
} catch {
return null;
}
}
function parseFiveSimActivationPayload(payload, fallback = {}) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return null;
}
const activationId = String(payload.id || payload.activationId || '').trim();
const phoneNumber = String(payload.phone || payload.number || '').trim();
if (!activationId || !phoneNumber) {
return null;
}
const fallbackCountryCode = normalizeFiveSimCountryCode(
fallback.countryCode || fallback.countryId || '',
'thailand'
);
const countryCode = normalizeFiveSimCountryCode(
payload.country || payload.country_name || payload.countryCode || payload.countryId || fallbackCountryCode,
fallbackCountryCode
);
const countryLabel = String(
payload.country_name
|| payload.countryName
|| payload.country
|| fallback.countryLabel
|| countryCode
).trim();
return {
activationId,
phoneNumber,
provider: PHONE_SMS_PROVIDER_5SIM,
serviceCode: normalizeFiveSimCountryCode(payload.product || fallback.serviceCode || DEFAULT_FIVE_SIM_PRODUCT, DEFAULT_FIVE_SIM_PRODUCT),
countryId: countryCode,
countryCode,
countryLabel: countryLabel || countryCode,
successfulUses: normalizeUseCount(payload.successfulUses ?? fallback.successfulUses ?? 0),
maxUses: Math.max(1, Math.floor(Number(payload.maxUses ?? fallback.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)),
...(() => {
const expiresAt = normalizeTimestampMs(
payload.expiresAt
?? payload.expires
?? payload.expireAt
?? payload.expired_at
?? payload.expiredAt
?? fallback.expiresAt
);
return expiresAt > 0 ? { expiresAt } : {};
})(),
};
}
async function requestFiveSimActivation(state = {}, options = {}) {
const config = resolvePhoneConfig(state);
const allCountryCandidates = Array.isArray(config.countryCandidates) && config.countryCandidates.length
? config.countryCandidates
: [];
if (!allCountryCandidates.length) {
throw new Error('Step 9: 5sim countries are empty. Please select at least one country in 接码设置。');
}
const blockedCountryIds = new Set(
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
.map((value) => normalizeFiveSimCountryCode(value, ''))
.filter(Boolean)
);
let countryCandidates = allCountryCandidates.filter(
(entry) => !blockedCountryIds.has(normalizeFiveSimCountryCode(entry.code || entry.id || '', ''))
);
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 maxPriceLimit = config.maxPriceLimit === undefined
? normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice)
: config.maxPriceLimit;
const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice);
const countryPriceFloorByCountryCode = normalizeCountryPriceFloorMap(
options?.countryPriceFloorByCountryId,
(value) => normalizeFiveSimCountryCode(value, '')
);
const configuredAcquireRounds = normalizePhoneActivationRetryRounds(state?.heroSmsActivationRetryRounds);
const maxAcquireRounds = Math.max(2, configuredAcquireRounds);
const retryDelayMs = normalizePhoneActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs);
let finalNoNumbersByCountry = [];
let finalRateLimitByCountry = [];
let finalLastError = null;
for (let round = 1; round <= maxAcquireRounds; round += 1) {
if (maxAcquireRounds > 1) {
await addLog(
`Step 9: 5sim acquiring phone number (round ${round}/${maxAcquireRounds})...`,
'info'
);
}
const noNumbersByCountry = [];
const rateLimitByCountry = [];
const retryableNoNumberCountries = [];
let lastError = null;
let orderedCountryCandidates = [...countryCandidates];
if (
(acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH)
&& countryCandidates.length > 1
) {
const rankedCandidates = [];
for (const [index, countryConfig] of countryCandidates.entries()) {
const countryCode = normalizeFiveSimCountryCode(countryConfig.code || countryConfig.id || '', 'thailand');
const lowestPrice = await resolveFiveSimLowestPrice(config, countryCode);
rankedCandidates.push({
index,
countryConfig,
lowestPrice: Number.isFinite(Number(lowestPrice)) ? Number(lowestPrice) : null,
});
}
rankedCandidates.sort((left, right) => {
const leftPrice = left.lowestPrice;
const rightPrice = right.lowestPrice;
const leftHasPrice = Number.isFinite(leftPrice);
const rightHasPrice = Number.isFinite(rightPrice);
if (leftHasPrice && rightHasPrice && leftPrice !== rightPrice) {
return acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
? (rightPrice - leftPrice)
: (leftPrice - rightPrice);
}
if (leftHasPrice !== rightHasPrice) {
return leftHasPrice ? -1 : 1;
}
return left.index - right.index;
});
orderedCountryCandidates = rankedCandidates.map((entry) => entry.countryConfig);
const rankedSummary = rankedCandidates
.map((entry) => {
const countryCode = normalizeFiveSimCountryCode(entry.countryConfig.code || entry.countryConfig.id || '', 'thailand');
const countryLabel = String(entry.countryConfig.label || countryCode).trim() || countryCode;
return Number.isFinite(entry.lowestPrice)
? `${countryLabel}:${entry.lowestPrice}`
: `${countryLabel}:n/a`;
})
.join(' | ');
await addLog(`Step 9: 5sim price-priority ranking: ${rankedSummary}`, 'info');
}
for (const countryConfig of orderedCountryCandidates) {
const countryCode = normalizeFiveSimCountryCode(countryConfig.code || countryConfig.id || '', 'thailand');
const countryLabel = String(countryConfig.label || countryCode).trim() || countryCode;
const countryPriceFloor = countryPriceFloorByCountryCode.get(countryCode) ?? null;
try {
const explicitFiveSimMaxPriceLimit = normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice);
let guestPricesPayload = null;
let productPricesPayload = null;
if (explicitFiveSimMaxPriceLimit !== null) {
try {
productPricesPayload = await fetchFiveSimPayload(
config,
`/guest/products/${countryCode}/${config.operator}`,
'5sim guest products'
);
} catch (_) {
productPricesPayload = null;
}
}
try {
guestPricesPayload = await fetchFiveSimPayload(
config,
'/guest/prices',
'5sim guest prices',
{
query: {
country: countryCode,
product: config.product,
},
}
);
} catch (_) {
guestPricesPayload = null;
}
const rawPriceCandidates = buildSortedUniquePriceCandidates(
[
...(
normalizeHeroSmsPriceLimit(state.fiveSimMaxPrice) !== null
? collectFiveSimProductPriceCandidates(productPricesPayload, config.product, [])
: []
),
...collectFiveSimPriceCandidates(
(
guestPricesPayload
&& typeof guestPricesPayload === 'object'
&& !Array.isArray(guestPricesPayload)
? (guestPricesPayload?.[config.product]?.[countryCode] || guestPricesPayload?.[countryCode] || guestPricesPayload)
: guestPricesPayload
),
[]
),
]
);
const boundedPriceCandidates = maxPriceLimit === null
? rawPriceCandidates
: rawPriceCandidates.filter((price) => Number(price) <= maxPriceLimit);
const orderedPricesFromCatalog = reorderPriceCandidates(
boundedPriceCandidates,
acquirePriority,
preferredPriceTier
);
const orderedPrices = orderedPricesFromCatalog.length
? (
explicitFiveSimMaxPriceLimit !== null
? [
explicitFiveSimMaxPriceLimit,
...orderedPricesFromCatalog.filter((price) => Number(price) !== Number(explicitFiveSimMaxPriceLimit)),
]
: orderedPricesFromCatalog
)
: (maxPriceLimit !== null ? [maxPriceLimit] : [null]);
const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor);
const hasCountryPriceFloor = (
countryPriceFloor !== null
&& Number.isFinite(Number(countryPriceFloor))
&& Number(countryPriceFloor) > 0
);
const hasAlternativeCountries = orderedCountryCandidates.some((entry) => (
normalizeFiveSimCountryCode(entry.code || entry.id || '', '')
!== normalizeFiveSimCountryCode(countryConfig.code || countryConfig.id || '', '')
));
// When a floor is set (e.g. timeout on current tier), do NOT fall back to the
// original lowest-tier list; that would retry the same tier forever and block
// country/provider fallback.
const pricesToTry = hasCountryPriceFloor
? (
floorFilteredPrices.length
? floorFilteredPrices
: (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1))
)
: (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices);
if (!pricesToTry.length) {
const lowestCatalog = rawPriceCandidates.length ? rawPriceCandidates[0] : null;
if (
maxPriceLimit !== null
&& lowestCatalog !== null
&& Number(lowestCatalog) > Number(maxPriceLimit)
) {
noNumbersByCountry.push(
`${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestCatalog}`
);
} else if (countryPriceFloor !== null && boundedPriceCandidates.length) {
noNumbersByCountry.push(
`${countryLabel}: no higher price tier above ${countryPriceFloor} for current fallback attempt`
);
} else if (rawPriceCandidates.length) {
const tierText = rawPriceCandidates.join(', ');
noNumbersByCountry.push(`${countryLabel}: all visible price tiers unavailable (${tierText})`);
retryableNoNumberCountries.push(countryLabel);
} else {
noNumbersByCountry.push(`${countryLabel}: no free phones`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
}
let acquiredActivation = null;
let countryNoNumbersText = '';
for (const candidatePrice of pricesToTry) {
try {
const payload = await fetchFiveSimPayload(
config,
`/user/buy/activation/${countryCode}/${config.operator}/${config.product}`,
'5sim buy activation',
{
query: {
...(candidatePrice !== null && candidatePrice !== undefined ? { maxPrice: candidatePrice } : {}),
...(normalizeHeroSmsReuseEnabled(state.heroSmsReuseEnabled) ? { reuse: 1 } : {}),
},
}
);
const activation = parseFiveSimActivationPayload(payload, {
countryCode,
countryLabel,
serviceCode: config.product,
});
if (activation) {
const priceValue = Number(candidatePrice);
rememberActivationAcquiredPrice(activation, priceValue);
acquiredActivation = activation;
break;
}
const payloadText = describeFiveSimPayload(payload);
if (isFiveSimRateLimitError(payload)) {
countryNoNumbersText = payloadText || countryNoNumbersText || 'rate limit';
continue;
}
if (isFiveSimNoNumbersError(payload)) {
countryNoNumbersText = payloadText || countryNoNumbersText || 'no free phones';
continue;
}
if (isFiveSimTerminalError(payload)) {
throw new Error(`5sim buy activation failed: ${payloadText || 'empty response'}`);
}
lastError = new Error(`5sim buy activation failed: ${payloadText || 'empty response'}`);
} catch (error) {
if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) {
countryNoNumbersText = describeFiveSimPayload(error?.payload || error?.message) || countryNoNumbersText || 'rate limit';
continue;
}
if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
if (isFiveSimNoNumbersError(error?.payload || error?.message)) {
countryNoNumbersText = describeFiveSimPayload(error?.payload || error?.message) || countryNoNumbersText || 'no free phones';
continue;
}
lastError = error;
}
}
if (acquiredActivation) {
return acquiredActivation;
}
const lowestPrice = rawPriceCandidates.length ? rawPriceCandidates[0] : await resolveFiveSimLowestPrice(config, countryCode);
if (maxPriceLimit !== null && lowestPrice !== null && Number(lowestPrice) > Number(maxPriceLimit)) {
noNumbersByCountry.push(
`${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}`
);
} else if (isFiveSimRateLimitError(countryNoNumbersText)) {
rateLimitByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'rate limit'}`);
} else {
noNumbersByCountry.push(`${countryLabel}: ${countryNoNumbersText || 'no free phones'}`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
} catch (error) {
if (isFiveSimRateLimitError(error?.payload || error?.message, error?.status)) {
rateLimitByCountry.push(`${countryLabel}: ${describeFiveSimPayload(error?.payload || error?.message) || 'rate limit'}`);
continue;
}
if (isFiveSimTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`5sim buy activation failed: ${describeFiveSimPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
if (isFiveSimNoNumbersError(error?.payload || error?.message)) {
const lowestPrice = await resolveFiveSimLowestPrice(config, countryCode);
if (maxPriceLimit !== null && lowestPrice !== null && lowestPrice > maxPriceLimit) {
noNumbersByCountry.push(
`${countryLabel}: no numbers within maxPrice=${maxPriceLimit}; lowest listed=${lowestPrice}`
);
} else {
noNumbersByCountry.push(`${countryLabel}: ${describeFiveSimPayload(error?.payload || error?.message) || 'no free phones'}`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
}
lastError = error;
}
}
finalNoNumbersByCountry = noNumbersByCountry;
finalRateLimitByCountry = rateLimitByCountry;
finalLastError = lastError;
if (rateLimitByCountry.length) {
throw buildFiveSimRateLimitError(rateLimitByCountry);
}
if (
noNumbersByCountry.length
&& round < maxAcquireRounds
&& retryableNoNumberCountries.length > 0
) {
await addLog(
`Step 9: 5sim has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`,
'warn'
);
await sleepWithStop(retryDelayMs);
continue;
}
break;
}
if (finalNoNumbersByCountry.length) {
throw new Error(
`5sim no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
);
}
if (finalRateLimitByCountry.length) {
throw buildFiveSimRateLimitError(finalRateLimitByCountry);
}
if (finalLastError) {
throw finalLastError;
}
throw new Error('5sim failed to acquire a phone number.');
}
function isNexSmsNoNumbersError(payloadOrMessage) {
const text = describeNexSmsPayload(payloadOrMessage);
return /numbers?\s+not\s+found|暂无可用|no\s+numbers|no\s+stock|库存.*0|not\s+available/i.test(text);
}
function isNexSmsPendingMessage(payloadOrMessage) {
const text = describeNexSmsPayload(payloadOrMessage);
return /no\s+sms|暂无短信|waiting|not\s+arrived|empty|未收到|短信为空|no\s+records/i.test(text);
}
function isNexSmsTerminalError(payloadOrMessage, status = 0) {
if (Number(status) === 401 || Number(status) === 403) {
return true;
}
const text = describeNexSmsPayload(payloadOrMessage);
return /invalid\s*api\s*key|bad[_\s-]*key|wrong[_\s-]*key|unauthorized|forbidden|no\s*balance|insufficient\s*balance|余额不足|账号.*封禁|banned/i.test(text);
}
function collectNexSmsPriceCandidates(countryData = {}) {
const candidates = [];
const pushCandidate = (value) => {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
candidates.push(Math.round(numeric * 10000) / 10000);
}
};
pushCandidate(countryData.minPrice);
pushCandidate(countryData.medianPrice);
pushCandidate(countryData.maxPrice);
if (countryData.priceMap && typeof countryData.priceMap === 'object') {
Object.entries(countryData.priceMap).forEach(([priceKey, count]) => {
const availableCount = Number(count);
if (!Number.isFinite(availableCount) || availableCount <= 0) {
return;
}
pushCandidate(priceKey);
});
}
return buildSortedUniquePriceCandidates(candidates);
}
async function resolveNexSmsCountryPricePlan(config, countryConfig, state = {}) {
const countryId = normalizeNexSmsCountryId(countryConfig?.id, -1);
if (countryId < 0) {
throw new Error(`NexSMS countryId is invalid: ${countryConfig?.id}`);
}
const payload = await fetchNexSmsPayload(
config,
'/api/getCountryByService',
'NexSMS getCountryByService',
{
query: {
serviceCode: config.serviceCode,
countryId,
},
}
);
if (!isNexSmsSuccessPayload(payload)) {
throw new Error(`NexSMS getCountryByService failed: ${describeNexSmsPayload(payload) || 'empty response'}`);
}
const countryData = (payload && typeof payload === 'object' && !Array.isArray(payload))
? (payload.data || {})
: {};
const countryLabel = normalizeCountryLabel(
countryData.countryName || countryConfig?.label,
`Country #${countryId}`
);
const prices = collectNexSmsPriceCandidates(countryData);
const minCatalogPrice = prices.length
? prices[0]
: (() => {
const minPrice = Number(countryData.minPrice);
return Number.isFinite(minPrice) && minPrice > 0
? Math.round(minPrice * 10000) / 10000
: null;
})();
const userLimit = normalizeHeroSmsPriceLimit(state?.heroSmsMaxPrice);
const filteredPrices = userLimit === null
? prices
: prices.filter((price) => price <= userLimit);
return {
countryId,
countryLabel,
prices: filteredPrices,
userLimit,
minCatalogPrice,
rawPayload: payload,
};
}
function parseNexSmsActivationPayload(payload, fallback = {}) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return null;
}
if (!isNexSmsSuccessPayload(payload)) {
return null;
}
const data = payload.data || {};
const phoneCandidates = Array.isArray(data.phoneNumbers)
? data.phoneNumbers
: (Array.isArray(data.numbers) ? data.numbers : []);
const phoneNumber = String(
data.phoneNumber
|| data.phone
|| phoneCandidates[0]
|| fallback.phoneNumber
|| ''
).trim();
if (!phoneNumber) {
return null;
}
const countryId = normalizeNexSmsCountryId(
data.countryId ?? fallback.countryId,
0
);
const countryLabel = normalizeCountryLabel(
data.countryName || fallback.countryLabel,
`Country #${countryId}`
);
const serviceCode = normalizeNexSmsServiceCode(
data.serviceCode || fallback.serviceCode || DEFAULT_NEX_SMS_SERVICE_CODE,
DEFAULT_NEX_SMS_SERVICE_CODE
);
return {
activationId: phoneNumber,
phoneNumber,
provider: PHONE_SMS_PROVIDER_NEXSMS,
serviceCode,
countryId,
countryLabel,
successfulUses: normalizeUseCount(fallback.successfulUses ?? 0),
maxUses: 1,
};
}
async function requestNexSmsActivation(state = {}, options = {}) {
const config = resolvePhoneConfig(state);
const allCountryCandidates = Array.isArray(config.countryCandidates) && config.countryCandidates.length
? config.countryCandidates
: resolveNexSmsCountryCandidates(state);
if (!allCountryCandidates.length) {
throw new Error('Step 9: NexSMS countries are empty. Please select at least one country in 接码设置。');
}
const blockedCountryIds = new Set(
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
.map((value) => normalizeNexSmsCountryId(value, -1))
.filter((id) => id >= 0)
);
let countryCandidates = allCountryCandidates.filter((entry) => {
const id = normalizeNexSmsCountryId(entry.id, -1);
return id >= 0 && !blockedCountryIds.has(id);
});
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 preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice);
const countryPriceFloorByCountryId = normalizeCountryPriceFloorMap(
options?.countryPriceFloorByCountryId,
(value) => String(normalizeNexSmsCountryId(value, -1))
);
const configuredAcquireRounds = normalizePhoneActivationRetryRounds(state?.heroSmsActivationRetryRounds);
const maxAcquireRounds = Math.max(2, configuredAcquireRounds);
const retryDelayMs = normalizePhoneActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs);
let finalNoNumbersByCountry = [];
let finalLastError = null;
for (let round = 1; round <= maxAcquireRounds; round += 1) {
if (maxAcquireRounds > 1) {
await addLog(
`Step 9: NexSMS acquiring phone number (round ${round}/${maxAcquireRounds})...`,
'info'
);
}
const candidateAttempts = [];
for (const [index, countryConfig] of countryCandidates.entries()) {
candidateAttempts.push({
index,
countryConfig,
pricePlan: null,
orderingPrice: Number.POSITIVE_INFINITY,
});
}
if (
(acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH)
&& candidateAttempts.length > 1
) {
for (const attempt of candidateAttempts) {
try {
const pricePlan = await resolveNexSmsCountryPricePlan(config, attempt.countryConfig, state);
attempt.pricePlan = pricePlan;
const orderedForRanking = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
attempt.orderingPrice = Array.isArray(orderedForRanking) && orderedForRanking.length
? Number(orderedForRanking[0])
: Number.POSITIVE_INFINITY;
} catch (error) {
attempt.pricePlan = null;
attempt.orderingPrice = Number.POSITIVE_INFINITY;
attempt.lookupError = error;
}
}
candidateAttempts.sort((left, right) => {
if (left.orderingPrice !== right.orderingPrice) {
return acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
? (right.orderingPrice - left.orderingPrice)
: (left.orderingPrice - right.orderingPrice);
}
return left.index - right.index;
});
const rankingSummary = candidateAttempts.map((attempt) => {
const id = normalizeNexSmsCountryId(attempt.countryConfig.id, -1);
const label = String(attempt.countryConfig.label || `Country #${id}`).trim() || `Country #${id}`;
return Number.isFinite(attempt.orderingPrice)
? `${label}:${attempt.orderingPrice}`
: `${label}:n/a`;
}).join(' | ');
await addLog(`Step 9: NexSMS price-priority ranking: ${rankingSummary}`, 'info');
}
const noNumbersByCountry = [];
const retryableNoNumberCountries = [];
let lastError = null;
for (const attempt of candidateAttempts) {
const countryId = normalizeNexSmsCountryId(attempt.countryConfig.id, -1);
const countryLabel = normalizeCountryLabel(attempt.countryConfig.label, `Country #${countryId}`);
const countryPriceFloor = countryPriceFloorByCountryId.get(String(countryId)) ?? null;
let pricePlan = attempt.pricePlan;
if (!pricePlan) {
try {
pricePlan = await resolveNexSmsCountryPricePlan(config, attempt.countryConfig, state);
} catch (error) {
if (isNexSmsTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`NexSMS price lookup failed: ${describeNexSmsPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
lastError = error;
continue;
}
}
if (!Array.isArray(pricePlan.prices) || !pricePlan.prices.length) {
if (
pricePlan.userLimit !== null
&& pricePlan.minCatalogPrice !== null
&& pricePlan.minCatalogPrice > pricePlan.userLimit
) {
noNumbersByCountry.push(
`${countryLabel}: no numbers within maxPrice=${pricePlan.userLimit}; lowest listed=${pricePlan.minCatalogPrice}`
);
} else {
const reason = describeNexSmsPayload(pricePlan.rawPayload) || 'no price candidates';
noNumbersByCountry.push(`${countryLabel}: ${reason}`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
}
const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor);
const hasCountryPriceFloor = (
countryPriceFloor !== null
&& Number.isFinite(Number(countryPriceFloor))
&& Number(countryPriceFloor) > 0
);
const hasAlternativeCountries = candidateAttempts.some((entry) => (
normalizeNexSmsCountryId(entry?.countryConfig?.id, -1)
!== normalizeNexSmsCountryId(attempt?.countryConfig?.id, -1)
));
// With an explicit floor, only try higher tiers. If none exist, we should
// move to country/provider fallback instead of retrying the same tier again.
const pricesToTry = hasCountryPriceFloor
? (
floorFilteredPrices.length
? floorFilteredPrices
: (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1))
)
: (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices);
if (!pricesToTry.length) {
if (
countryPriceFloor !== null
&& Array.isArray(pricePlan.prices)
&& pricePlan.prices.length > 0
) {
noNumbersByCountry.push(
`${countryLabel}: no higher price tier above ${countryPriceFloor} for current fallback attempt`
);
} else {
noNumbersByCountry.push(`${countryLabel}: ${describeNexSmsPayload(pricePlan.rawPayload) || 'no numbers found'}`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
}
for (const price of pricesToTry) {
try {
const payload = await fetchNexSmsPayload(
config,
'/api/order/purchase',
'NexSMS purchase',
{
method: 'POST',
body: {
serviceCode: config.serviceCode,
countryId,
quantity: 1,
price,
},
}
);
if (!isNexSmsSuccessPayload(payload)) {
if (isNexSmsNoNumbersError(payload)) {
continue;
}
if (isNexSmsTerminalError(payload)) {
throw new Error(`NexSMS purchase failed: ${describeNexSmsPayload(payload) || 'empty response'}`);
}
lastError = new Error(`NexSMS purchase failed: ${describeNexSmsPayload(payload) || 'empty response'}`);
continue;
}
const activation = parseNexSmsActivationPayload(payload, {
countryId,
countryLabel,
serviceCode: config.serviceCode,
});
if (!activation) {
lastError = new Error('NexSMS purchase succeeded but did not return a phone number.');
continue;
}
const numericPrice = Number(price);
rememberActivationAcquiredPrice(activation, numericPrice);
return activation;
} catch (error) {
if (isNexSmsTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`NexSMS purchase failed: ${describeNexSmsPayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
if (isNexSmsNoNumbersError(error?.payload || error?.message)) {
continue;
}
lastError = error;
}
}
const fallbackReason = describeNexSmsPayload(pricePlan.rawPayload) || 'no numbers found';
noNumbersByCountry.push(`${countryLabel}: ${fallbackReason}`);
retryableNoNumberCountries.push(countryLabel);
}
finalNoNumbersByCountry = noNumbersByCountry;
finalLastError = lastError;
if (
noNumbersByCountry.length
&& round < maxAcquireRounds
&& retryableNoNumberCountries.length > 0
) {
await addLog(
`Step 9: NexSMS has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`,
'warn'
);
await sleepWithStop(retryDelayMs);
continue;
}
break;
}
if (finalNoNumbersByCountry.length) {
throw new Error(
`NexSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
);
}
if (finalLastError) {
throw finalLastError;
}
throw new Error('NexSMS failed to acquire a phone number.');
}
async function requestPhoneActivation(state = {}, options = {}) {
if (normalizePhoneSmsProvider(state?.phoneSmsProvider) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
return provider.requestActivation(state, options);
}
}
const config = resolvePhoneConfig(state);
if (config.provider === PHONE_SMS_PROVIDER_5SIM) {
return requestFiveSimActivation(state, options);
}
if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) {
return requestNexSmsActivation(state, options);
}
const allCountryCandidates = Array.isArray(config.countryCandidates) && config.countryCandidates.length
? config.countryCandidates
: resolveCountryCandidates(state);
if (!allCountryCandidates.length) {
throw new Error('Step 9: HeroSMS countries are empty. Please select at least one country in 接码设置。');
}
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(
'步骤 9:已选国家均达到临时收码失败跳过阈值,本轮解除跳过并重新尝试。',
'warn'
);
}
}
const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice);
const countryPriceFloorByCountryId = normalizeCountryPriceFloorMap(
options?.countryPriceFloorByCountryId,
(value) => String(normalizeCountryId(value, 0))
);
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(
`步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`,
'info'
);
await addLog(
`步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`,
'info'
);
}
const countryAttempts = countryCandidates.map((countryConfig, index) => ({
index,
countryConfig,
pricePlan: null,
orderingPrice: Number.POSITIVE_INFINITY,
}));
if (
acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE
|| acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
) {
for (const attempt of countryAttempts) {
const pricePlan = await resolvePhoneActivationPricePlan(config, attempt.countryConfig, state);
const orderedPrices = reorderPriceCandidates(pricePlan?.prices, acquirePriority, preferredPriceTier);
const numericPrices = Array.isArray(orderedPrices)
? orderedPrices
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
: [];
const candidateOrderingPrice = numericPrices.length ? numericPrices[0] : 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
: (candidateOrderingPrice !== null ? candidateOrderingPrice : Number.POSITIVE_INFINITY);
}
}
if (
(acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH)
&& countryAttempts.length > 1
) {
countryAttempts.sort((left, right) => {
if (left.orderingPrice !== right.orderingPrice) {
return acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
? (right.orderingPrice - left.orderingPrice)
: (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 countryIdKey = String(normalizeCountryId(countryConfig?.id, 0));
const countryPriceFloor = countryPriceFloorByCountryId.get(countryIdKey) ?? null;
const buildFallbackActivation = (requestAction) => ({
countryId: countryConfig.id,
...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}),
});
const pricePlan = attempt.pricePlan || await resolvePhoneActivationPricePlan(config, countryConfig, state);
let noNumbersObservedInCountry = false;
const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
const floorFilteredPrices = filterPriceCandidatesAboveFloor(orderedPrices, countryPriceFloor);
const hasCountryPriceFloor = (
countryPriceFloor !== null
&& Number.isFinite(Number(countryPriceFloor))
&& Number(countryPriceFloor) > 0
);
const hasAlternativeCountries = countryAttempts.some((entry) => (
String(normalizeCountryId(entry?.countryConfig?.id, 0))
!== String(normalizeCountryId(countryConfig?.id, 0))
));
// Same rule as 5sim/NexSMS: once floor is set, never re-try lower/equal tiers.
// Keep a probe fallback only for HeroSMS (single-country/no-tier environments),
// so replacement-limit behavior remains stable while still allowing country fallback.
const pricesToTry = hasCountryPriceFloor
? (
floorFilteredPrices.length
? floorFilteredPrices
: (hasAlternativeCountries ? [] : orderedPrices.slice(0, 1))
)
: (floorFilteredPrices.length ? floorFilteredPrices : orderedPrices);
const rawTierText = Array.isArray(pricePlan?.prices) && pricePlan.prices.length
? pricePlan.prices
.map((value) => (value === null || value === undefined ? 'auto' : String(value)))
.join(', ')
: 'none';
await addLog(
`Step 9: HeroSMS ${countryConfig.label} price plan resolved -> tiers=[${rawTierText}], userLimit=${pricePlan?.userLimit ?? 'none'}, minCatalog=${pricePlan?.minCatalogPrice ?? 'n/a'}.`,
'info'
);
if (pricesToTry.length > 1 || countryPriceFloor !== null) {
const tierText = pricesToTry
.map((value) => (value === null || value === undefined ? 'auto' : String(value)))
.join(', ');
await addLog(
`Step 9: HeroSMS ${countryConfig.label} price candidates: ${tierText}${countryPriceFloor !== null ? ` (floor>${countryPriceFloor})` : ''}.`,
'info'
);
}
if (!pricesToTry.length) {
if (
countryPriceFloor !== null
&& Array.isArray(pricePlan.prices)
&& pricePlan.prices.length > 0
) {
noNumbersByCountry.push(
`${countryConfig.label}: no higher price tier above ${countryPriceFloor} for current fallback attempt`
);
continue;
}
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;
}
for (const maxPrice of pricesToTry) {
for (const requestAction of requestActions) {
try {
const fixedPrice = !Boolean(pricePlan.syntheticUserLimitProbe);
await addLog(
`Step 9: HeroSMS ${countryConfig.label} trying ${requestAction} at tier ${maxPrice === null || maxPrice === undefined ? 'auto' : maxPrice}.`,
'info'
);
const payload = await requestPhoneActivationWithPrice(
config,
countryConfig,
requestAction,
maxPrice,
{
userLimit: pricePlan.userLimit,
fixedPrice,
}
);
const activation = parseActivationPayload(payload, buildFallbackActivation(requestAction));
if (activation) {
const numericPrice = Number(maxPrice);
rememberActivationAcquiredPrice(activation, numericPrice);
return {
...activation,
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) {
const tiersTriedText = pricesToTry
.map((value) => (value === null || value === undefined ? 'auto' : String(value)))
.join(', ');
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'}${tiersTriedText ? ` (tiers tried: ${tiersTriedText})` : ''}`
);
retryableNoNumberCountries.push(countryConfig.label);
}
continue;
}
}
finalNoNumbersByCountry = noNumbersByCountry;
finalLastError = lastError;
finalLastFailureText = lastFailureText;
if (
noNumbersByCountry.length
&& round < maxAcquireRounds
&& retryableNoNumberCountries.length > 0
) {
await addLog(
`步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}`,
'warn'
);
await addLog(
`步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮),${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}`,
'warn'
);
await sleepWithStop(retryDelayMs);
continue;
}
break;
}
if (finalNoNumbersByCountry.length) {
throw new Error(
`HeroSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}`
+ ` HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
);
}
if (finalLastError) {
throw finalLastError;
}
throw new Error(`HeroSMS 获取手机号失败,最后状态:${finalLastFailureText || '未知'}`);
}
async function reactivatePhoneActivation(state = {}, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('缺少可复用的手机号接码订单。');
}
if (getActivationProviderId(normalizedActivation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
return provider.reuseActivation(state, normalizedActivation);
}
}
const config = resolvePhoneConfig(state);
if (config.provider === PHONE_SMS_PROVIDER_5SIM) {
const reuseProduct = normalizeFiveSimCountryCode(
normalizedActivation.serviceCode || config.product || DEFAULT_FIVE_SIM_PRODUCT,
DEFAULT_FIVE_SIM_PRODUCT
);
const reuseNumber = String(normalizedActivation.phoneNumber || '').replace(/[^\d]/g, '');
if (!reuseNumber) {
throw new Error('5sim reuse activation failed: phone number is missing.');
}
const payload = await fetchFiveSimPayload(
config,
`/user/reuse/${reuseProduct}/${reuseNumber}`,
'5sim reuse activation'
);
const nextActivation = parseFiveSimActivationPayload(payload, normalizedActivation);
if (!nextActivation) {
const text = describeFiveSimPayload(payload);
throw new Error(`5sim reuse activation failed: ${text || 'empty response'}`);
}
return nextActivation;
}
if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) {
throw new Error('NexSMS does not support activation reuse for this flow.');
}
const payload = await fetchHeroSmsPayload(config, {
action: 'reactivate',
id: normalizedActivation.activationId,
}, 'HeroSMS reactivate');
const nextActivation = parseActivationPayload(payload, normalizedActivation);
if (!nextActivation) {
const text = describeHeroSmsPayload(payload);
throw new Error(`HeroSMS 复用手机号失败:${text || '空响应'}`);
}
return nextActivation;
}
async function setPhoneActivationStatus(state = {}, activation, status, actionLabel) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
return '';
}
const config = resolvePhoneConfig(state);
if (config.provider === PHONE_SMS_PROVIDER_5SIM) {
const endpoint = status === 6
? `/user/finish/${normalizedActivation.activationId}`
: `/user/cancel/${normalizedActivation.activationId}`;
const payload = await fetchFiveSimPayload(config, endpoint, actionLabel || '5sim set status');
return describeFiveSimPayload(payload);
}
if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) {
if (status === 6) {
return 'NexSMS complete skipped';
}
const payload = await fetchNexSmsPayload(
config,
'/api/close/activation',
actionLabel || 'NexSMS close activation',
{
method: 'POST',
body: {
phoneNumber: normalizedActivation.phoneNumber,
},
}
);
if (!isNexSmsSuccessPayload(payload)) {
throw new Error(`NexSMS close activation failed: ${describeNexSmsPayload(payload) || 'empty response'}`);
}
return describeNexSmsPayload(payload);
}
const payload = await fetchHeroSmsPayload(config, {
action: 'setStatus',
id: normalizedActivation.activationId,
status,
}, actionLabel);
return describeHeroSmsPayload(payload);
}
async function completePhoneActivation(state = {}, activation) {
if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
await provider.finishActivation(state, activation);
return;
}
}
await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)');
}
async function cancelPhoneActivation(state = {}, activation) {
try {
if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
await provider.cancelActivation(state, activation);
return;
}
}
await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)');
} catch (_) {
// Best-effort cleanup.
}
}
async function banPhoneActivation(state = {}, activation) {
try {
if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
await provider.banActivation(state, activation);
return;
}
}
await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)');
} catch (_) {
// Best-effort cleanup.
}
}
async function requestAdditionalPhoneSms(state = {}, activation) {
const config = resolvePhoneConfig(state);
if (config.provider !== PHONE_SMS_PROVIDER_HERO) {
return;
}
try {
if (getActivationProviderId(activation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) {
// 5sim does not expose a HeroSMS-style setStatus(3) resend primitive.
return;
}
await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)');
} catch (_) {
// Best-effort request only.
}
}
async function pollPhoneActivationCode(state = {}, activation, options = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('缺少手机号接码订单。');
}
if (getActivationProviderId(normalizedActivation, state) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
return provider.pollActivationCode(state, normalizedActivation, options);
}
}
const statusAction = resolveActivationStatusAction(normalizedActivation);
const config = resolvePhoneConfig(state);
const configuredTimeoutMs = Math.max(1000, Number(options.timeoutMs) || 0);
const timeoutMs = configuredTimeoutMs || (
typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(
DEFAULT_PHONE_POLL_TIMEOUT_MS,
{ step: 9, actionLabel: options.actionLabel || 'poll phone verification code' }
)
: DEFAULT_PHONE_POLL_TIMEOUT_MS
);
const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_PHONE_POLL_INTERVAL_MS);
const maxRoundsRaw = Math.floor(Number(options.maxRounds));
const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0;
const start = Date.now();
let lastResponse = '';
let pollCount = 0;
const extractVerificationCode = (rawCode) => {
const trimmed = String(rawCode || '').trim();
if (!trimmed) {
return '';
}
const digitMatch = trimmed.match(/\b(\d{4,8})\b/);
return digitMatch?.[1] || '';
};
if (config.provider === PHONE_SMS_PROVIDER_5SIM) {
while (Date.now() - start < timeoutMs) {
if (maxRounds > 0 && pollCount >= maxRounds) {
break;
}
throwIfStopped();
const payload = await fetchFiveSimPayload(
config,
`/user/check/${normalizedActivation.activationId}`,
'5sim check activation'
);
const text = describeFiveSimPayload(payload);
lastResponse = text;
pollCount += 1;
const smsList = Array.isArray(payload?.sms) ? payload.sms : [];
const directCode = extractVerificationCode(payload?.code || payload?.sms_code);
const smsCode = directCode || smsList
.map((smsItem) => extractVerificationCode(smsItem?.code || smsItem?.text || smsItem?.message || ''))
.find(Boolean);
if (smsCode) {
return smsCode;
}
const statusText = String(payload?.status || '').trim().toUpperCase();
if (/^(RECEIVED|PENDING|RETRY|PREPARE|WAITING)$/i.test(statusText) || !statusText) {
if (typeof options.onStatus === 'function') {
await options.onStatus({
activation: normalizedActivation,
elapsedMs: Date.now() - start,
pollCount,
statusText: statusText || text || 'PENDING',
timeoutMs,
});
}
await sleepWithStop(intervalMs);
continue;
}
if (/^(CANCELED|CANCELLED|BANNED|FINISHED|EXPIRED|TIMEOUT)$/i.test(statusText)) {
throw new Error(`5sim activation ended before receiving SMS: ${statusText}`);
}
throw new Error(`5sim check activation failed: ${text || statusText || 'empty response'}`);
}
throw buildPhoneCodeTimeoutError(lastResponse);
}
if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) {
while (Date.now() - start < timeoutMs) {
if (maxRounds > 0 && pollCount >= maxRounds) {
break;
}
throwIfStopped();
const payload = await fetchNexSmsPayload(
config,
'/api/sms/messages',
'NexSMS get sms messages',
{
query: {
phoneNumber: normalizedActivation.phoneNumber,
format: 'json_latest',
},
}
);
const text = describeNexSmsPayload(payload);
lastResponse = text;
pollCount += 1;
if (typeof options.onStatus === 'function') {
await options.onStatus({
activation: normalizedActivation,
elapsedMs: Date.now() - start,
pollCount,
statusText: text || 'PENDING',
timeoutMs,
});
}
if (isNexSmsSuccessPayload(payload)) {
const directCode = extractVerificationCode(payload?.data?.code || payload?.data?.text || '');
if (directCode) {
return directCode;
}
await sleepWithStop(intervalMs);
continue;
}
if (isNexSmsPendingMessage(payload)) {
await sleepWithStop(intervalMs);
continue;
}
if (isNexSmsTerminalError(payload)) {
throw new Error(`NexSMS get sms messages failed: ${text || 'unknown terminal error'}`);
}
await sleepWithStop(intervalMs);
}
throw buildPhoneCodeTimeoutError(lastResponse);
}
while (Date.now() - start < timeoutMs) {
if (maxRounds > 0 && pollCount >= maxRounds) {
break;
}
throwIfStopped();
const payload = await fetchHeroSmsPayload(config, {
action: statusAction,
id: normalizedActivation.activationId,
}, `HeroSMS ${statusAction}`);
const text = describeHeroSmsPayload(payload);
lastResponse = text;
pollCount += 1;
if (typeof options.onStatus === 'function') {
await options.onStatus({
activation: normalizedActivation,
elapsedMs: Date.now() - start,
pollCount,
statusText: text,
timeoutMs,
});
}
const v2Code = (
payload
&& typeof payload === 'object'
&& !Array.isArray(payload)
&& (
extractVerificationCode(payload.sms?.code)
|| extractVerificationCode(payload.call?.code)
)
);
if (v2Code) {
return v2Code;
}
const okMatch = text.match(/^STATUS_OK:(.+)$/i);
if (okMatch) {
const extractedCode = extractVerificationCode(okMatch[1] || '');
if (extractedCode) {
return extractedCode;
}
await sleepWithStop(intervalMs);
continue;
}
if (/^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)(?::.+)?$/i.test(text)) {
await sleepWithStop(intervalMs);
continue;
}
if (statusAction === 'getStatusV2' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
await sleepWithStop(intervalMs);
continue;
}
if (/^STATUS_CANCEL$/i.test(text)) {
throw new Error('HeroSMS activation was cancelled before the SMS arrived.');
}
throw new Error(`HeroSMS ${statusAction} failed: ${text || 'empty response'}`);
}
throw buildPhoneCodeTimeoutError(lastResponse);
}
async function readPhonePageState(tabId, timeoutMs = 10000) {
await ensureStep8SignupPageReady(tabId, {
timeoutMs,
logMessage: '步骤 9:等待认证页脚本恢复后继续手机号验证。',
});
const result = await sendToContentScriptResilient('signup-page', {
type: 'STEP8_GET_STATE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: '步骤 9:认证页正在切换,等待后重新检查手机号验证状态...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
function resolveCountryCandidatesForProvider(state = {}, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) {
if (normalizePhoneSmsProvider(providerId) === PHONE_SMS_PROVIDER_FIVE_SIM) {
const provider = getFiveSimProviderForState(state);
if (provider) {
return provider.resolveCountryCandidates(state);
}
return resolveFiveSimCountryCandidates(state);
}
if (normalizePhoneSmsProvider(providerId) === PHONE_SMS_PROVIDER_NEXSMS) {
return resolveNexSmsCountryCandidates(state);
}
return resolveCountryCandidates(state);
}
function resolveCountryConfigFromActivation(activation, fallbackState = {}) {
const providerId = getActivationProviderId(activation, fallbackState);
const candidates = resolveCountryCandidatesForProvider(fallbackState, providerId);
if (activation && typeof activation === 'object') {
if (providerId === PHONE_SMS_PROVIDER_FIVE_SIM) {
const countryId = normalizeFiveSimCountryId(activation.countryId, '');
if (countryId) {
const matched = candidates.find((entry) => String(entry.id) === countryId);
if (matched) return matched;
return {
id: countryId,
label: normalizeFiveSimCountryLabel(activation.countryLabel, countryId),
};
}
} else {
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] || (providerId === PHONE_SMS_PROVIDER_FIVE_SIM
? { id: 'england', label: 'England' }
: resolveCountryConfig(fallbackState));
}
async function submitPhoneNumber(tabId, phoneNumber, activation = null) {
const state = await getState();
const countryConfig = resolveCountryConfigFromActivation(activation, state);
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: '提交添加手机号' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'SUBMIT_PHONE_NUMBER',
source: 'background',
payload: {
phoneNumber,
countryId: countryConfig.id,
countryLabel: countryConfig.label,
},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: '步骤 9:等待添加手机号页面就绪...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function submitPhoneVerificationCode(tabId, code) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(45000, { step: 9, actionLabel: '提交手机验证码' })
: 45000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
source: 'background',
payload: { code },
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: '步骤 9:等待手机验证码页面就绪后填写短信验证码...',
});
if (result?.error) {
if (isPhoneNumberUsedError(result.error)) {
return {
invalidCode: true,
errorText: String(result.error || ''),
};
}
throw new Error(result.error);
}
return result || {};
}
async function resendPhoneVerificationCode(tabId) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(65000, { step: 9, actionLabel: 'resend phone verification code' })
: 65000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'RESEND_PHONE_VERIFICATION_CODE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: '步骤 9:等待手机验证码重发按钮出现...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function returnToAddPhone(tabId) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'return to add-phone page' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'RETURN_TO_ADD_PHONE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: '步骤 9:返回添加手机号页面以更换号码...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function persistCurrentActivation(activation) {
const normalizedActivation = normalizeActivation(activation);
const updates = {
[PHONE_ACTIVATION_STATE_KEY]: normalizedActivation || null,
[PHONE_VERIFICATION_CODE_STATE_KEY]: '',
};
if (!normalizedActivation) {
updates[PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY] = 0;
updates[PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY] = 0;
updates[PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY] = 0;
}
await setPhoneRuntimeState(updates);
}
async function persistReusableActivation(activation) {
await setPhoneRuntimeState({
[REUSABLE_PHONE_ACTIVATION_STATE_KEY]: normalizeActivation(activation) || null,
});
}
function readReusableActivationPoolFromState(state = {}) {
return normalizeActivationPool(state?.[REUSABLE_PHONE_ACTIVATION_POOL_STATE_KEY]);
}
async function persistReusableActivationPool(pool = []) {
await setPhoneRuntimeState({
[REUSABLE_PHONE_ACTIVATION_POOL_STATE_KEY]: normalizeActivationPool(pool),
});
}
async function upsertReusableActivationPool(activation, options = {}) {
const normalized = normalizeActivation(activation);
if (!normalized) {
return [];
}
const state = options?.state || await getState();
const existingPool = readReusableActivationPoolFromState(state);
const filtered = existingPool.filter((entry) => !isSameActivation(entry, normalized));
const nextPool = [normalized, ...filtered].slice(0, MAX_PHONE_REUSABLE_POOL);
await persistReusableActivationPool(nextPool);
return nextPool;
}
async function removeReusableActivationFromPool(activation, options = {}) {
const normalized = normalizeActivation(activation);
if (!normalized) {
return [];
}
const state = options?.state || await getState();
const existingPool = readReusableActivationPoolFromState(state);
const nextPool = existingPool.filter((entry) => !isSameActivation(entry, normalized));
if (nextPool.length === existingPool.length) {
return existingPool;
}
await persistReusableActivationPool(nextPool);
return nextPool;
}
async function clearCurrentActivation() {
await persistCurrentActivation(null);
}
async function clearReusableActivation() {
await persistReusableActivation(null);
}
async function setPhoneRuntimeCountdown(activation, waitSeconds, windowIndex, windowTotal) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
return;
}
const safeWaitSeconds = Math.max(0, Math.floor(Number(waitSeconds) || 0));
await setPhoneRuntimeState({
[PHONE_ACTIVATION_STATE_KEY]: normalizedActivation,
[PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY]: Date.now() + safeWaitSeconds * 1000,
[PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY]: Math.max(0, Math.floor(Number(windowIndex) || 0)),
[PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY]: Math.max(0, Math.floor(Number(windowTotal) || 0)),
});
}
async function clearPhoneRuntimeCountdown() {
await setPhoneRuntimeState({
[PHONE_RUNTIME_COUNTDOWN_ENDS_AT_KEY]: 0,
[PHONE_RUNTIME_COUNTDOWN_WINDOW_INDEX_KEY]: 0,
[PHONE_RUNTIME_COUNTDOWN_WINDOW_TOTAL_KEY]: 0,
});
}
async function acquirePhoneActivation(state = {}, options = {}) {
const provider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER);
const providerOrder = resolvePhoneProviderOrder(state, provider);
const countryCandidates = resolveCountryCandidatesForProvider(state, provider);
if (
(provider === PHONE_SMS_PROVIDER_5SIM || provider === PHONE_SMS_PROVIDER_NEXSMS)
&& !countryCandidates.length
) {
throw new Error(`Step 9: ${provider === PHONE_SMS_PROVIDER_5SIM ? '5sim' : 'NexSMS'} countries are empty. Please select at least one country in 接码设置。`);
}
const normalizeCountryKey = (value) => (
provider === PHONE_SMS_PROVIDER_5SIM
? normalizeFiveSimCountryCode(value, '')
: (
provider === PHONE_SMS_PROVIDER_NEXSMS
? String(normalizeNexSmsCountryId(value, -1))
: String(normalizeCountryId(value, 0))
)
);
const blockedCountryIds = new Set(
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
.map((value) => normalizeCountryKey(value))
.filter((id) => (
provider === PHONE_SMS_PROVIDER_NEXSMS
? (id !== '' && id !== null && id !== undefined)
: Boolean(id && id !== '0')
))
);
const allowedCountryIds = new Set(
countryCandidates
.map((entry) => normalizeCountryKey(entry.id || entry.code))
.filter((id) => (
provider === PHONE_SMS_PROVIDER_NEXSMS
? (id !== '' && id !== null && id !== undefined && !blockedCountryIds.has(id))
: Boolean(id && id !== '0' && !blockedCountryIds.has(id))
))
);
const preferredCountryLabel = countryCandidates[0]?.label || (
provider === PHONE_SMS_PROVIDER_5SIM
? ''
: (
provider === PHONE_SMS_PROVIDER_NEXSMS
? ''
: HERO_SMS_COUNTRY_LABEL
)
);
const resolveCountryLabelById = (countryId) => {
const normalizedCountryKey = normalizeCountryKey(countryId);
return countryCandidates.find((entry) => normalizeCountryKey(entry.id || entry.code) === normalizedCountryKey)?.label
|| preferredCountryLabel;
};
const scopedStateForProvider = (providerName) => ({
...state,
phoneSmsProvider: normalizePhoneSmsProvider(providerName),
});
const preferredActivation = normalizeActivation(state[PREFERRED_PHONE_ACTIVATION_STATE_KEY]);
let failedPreferredActivation = null;
const canTryPreferredActivation = (
!Boolean(options?.skipPreferredActivation)
&& preferredActivation
&& (provider === PHONE_SMS_PROVIDER_HERO || provider === PHONE_SMS_PROVIDER_5SIM)
&& preferredActivation.provider === provider
&& !blockedCountryIds.has(normalizeCountryKey(preferredActivation.countryId))
&& allowedCountryIds.has(normalizeCountryKey(preferredActivation.countryId))
&& preferredActivation.successfulUses < preferredActivation.maxUses
);
if (canTryPreferredActivation) {
try {
const reactivated = await reactivatePhoneActivation(state, preferredActivation);
await addLog(
`步骤 9:优先复用手动选择号码 ${reactivated.phoneNumber}${reactivated.countryId ? `${resolveCountryLabelById(reactivated.countryId)}` : ''}`,
'info'
);
await resetPhoneNoSupplyFailureStreak(state);
return reactivated;
} catch (error) {
failedPreferredActivation = preferredActivation;
await removeReusableActivationFromPool(preferredActivation, { state }).catch(() => {});
await addLog(
`步骤 9:手动选择号码 ${preferredActivation.phoneNumber} 不可用,将改为获取新号码。${error.message}`,
'warn'
);
}
}
const reuseEnabled = isPhoneSmsReuseEnabled(state);
const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]);
const reusableActivationPool = readReusableActivationPoolFromState(state);
const reusableCandidates = [];
const seenReusableKeys = new Set();
const pushReusableCandidate = (candidate) => {
const normalizedCandidate = normalizeActivation(candidate);
if (!normalizedCandidate) {
return;
}
const candidateKey = buildActivationIdentityKey(normalizedCandidate);
if (!candidateKey || seenReusableKeys.has(candidateKey)) {
return;
}
seenReusableKeys.add(candidateKey);
reusableCandidates.push(normalizedCandidate);
};
pushReusableCandidate(reusableActivation);
reusableActivationPool.forEach((candidate) => pushReusableCandidate(candidate));
if (reuseEnabled && (provider === PHONE_SMS_PROVIDER_HERO || provider === PHONE_SMS_PROVIDER_5SIM)) {
for (const candidateActivation of reusableCandidates) {
if (candidateActivation.provider !== provider) {
continue;
}
if (isSameActivation(candidateActivation, failedPreferredActivation)) {
continue;
}
if (candidateActivation.successfulUses >= candidateActivation.maxUses) {
continue;
}
if (blockedCountryIds.has(normalizeCountryKey(candidateActivation.countryId))) {
continue;
}
if (!allowedCountryIds.has(normalizeCountryKey(candidateActivation.countryId))) {
continue;
}
try {
const reactivated = await reactivatePhoneActivation(state, candidateActivation);
await addLog(
`步骤 9:复用 ${resolveCountryLabelById(reactivated.countryId)} 号码 ${reactivated.phoneNumber}(第 ${reactivated.successfulUses + 1}/${reactivated.maxUses} 次)。`,
'info'
);
await resetPhoneNoSupplyFailureStreak(state);
return reactivated;
} catch (error) {
await addLog(`步骤 9:复用号码 ${candidateActivation.phoneNumber} 失败,将改为获取新号码。${error.message}`, 'warn');
await removeReusableActivationFromPool(candidateActivation, { state }).catch(() => {});
if (isSameActivation(reusableActivation, candidateActivation)) {
await clearReusableActivation();
}
}
}
}
let lastProviderError = null;
const providerErrors = [];
const skippedFallbackProviders = [];
for (const providerCandidate of providerOrder) {
const useBlockedCountryIds = providerCandidate === provider
? Array.from(blockedCountryIds)
: [];
const useCountryPriceFloorByCountryId = (
providerCandidate === provider
&& options?.countryPriceFloorByCountryId
&& typeof options.countryPriceFloorByCountryId === 'object'
)
? options.countryPriceFloorByCountryId
: {};
try {
const activation = await requestPhoneActivation(
scopedStateForProvider(providerCandidate),
{
blockedCountryIds: useBlockedCountryIds,
countryPriceFloorByCountryId: useCountryPriceFloorByCountryId,
}
);
const providerLabel = getPhoneSmsProviderLabel(providerCandidate);
const providerCountryLabel = providerCandidate === provider
? resolveCountryLabelById(activation.countryId)
: String(activation?.countryLabel || activation?.countryId || '').trim();
if (providerCandidate !== provider) {
await addLog(
`步骤 9:主接码平台 ${getPhoneSmsProviderLabel(provider)} 暂无可用号码,已回退到 ${providerLabel}${providerCountryLabel ? ` / ${providerCountryLabel}` : ''}`,
'warn'
);
}
await addLog(
`步骤 9:已从 ${providerLabel}${providerCountryLabel ? ` / ${providerCountryLabel}` : ''} 获取号码 ${activation.phoneNumber}`,
'info'
);
await resetPhoneNoSupplyFailureStreak(state);
return activation;
} catch (error) {
if (isStopRequestedError(error)) {
throw error;
}
const providerErrorMessage = String(error?.message || error || 'unknown error');
const providerLabel = getPhoneSmsProviderLabel(providerCandidate);
if (
providerCandidate !== provider
&& /step\s*9:\s*(?:5sim|nexsms)\s+countries\s+are\s+empty/i.test(providerErrorMessage)
) {
skippedFallbackProviders.push(`${providerLabel}: countries are empty`);
await addLog(
`步骤 9:跳过回退接码平台 ${providerLabel},因为接码设置中未选择国家。`,
'warn'
);
continue;
}
lastProviderError = error;
providerErrors.push(`${providerCandidate}: ${providerErrorMessage}`);
}
}
if (providerErrors.length) {
await logNoSupplyDiagnostics(state, providerOrder, providerErrors);
const skippedSuffix = skippedFallbackProviders.length
? ` | skipped fallback providers: ${skippedFallbackProviders.join('; ')}`
: '';
throw new Error(`Step 9: all provider candidates failed to acquire number. ${providerErrors.join(' | ')}${skippedSuffix}`);
}
throw lastProviderError || new Error('Step 9: failed to acquire phone activation.');
}
async function markActivationReusableAfterSuccess(state, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!isPhoneSmsReuseEnabled(state)) {
await clearReusableActivation();
return;
}
if (!normalizedActivation) {
await clearReusableActivation();
return;
}
const reusableProvider = normalizedActivation.provider;
const canPersistReusableActivation = reusableProvider === PHONE_SMS_PROVIDER_HERO
|| reusableProvider === PHONE_SMS_PROVIDER_5SIM;
if (!canPersistReusableActivation) {
await clearReusableActivation();
return;
}
const successfulUses = normalizedActivation.successfulUses + 1;
const nextReusableActivation = {
...normalizedActivation,
successfulUses,
};
await upsertReusableActivationPool(nextReusableActivation, { state });
if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) {
await clearReusableActivation();
return;
}
if (successfulUses >= normalizedActivation.maxUses) {
await clearReusableActivation();
await removeReusableActivationFromPool(nextReusableActivation, { state });
return;
}
await persistReusableActivation(nextReusableActivation);
}
async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('缺少手机号接码订单。');
}
const providerLabel = normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM
? '5sim'
: (normalizedActivation.provider === PHONE_SMS_PROVIDER_NEXSMS ? 'NexSMS' : 'HeroSMS');
const usePageResend = normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM;
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 <= timeoutWindows; windowIndex += 1) {
await setPhoneRuntimeCountdown(normalizedActivation, waitSeconds, windowIndex, timeoutWindows);
await addLog(
`步骤 9:等待号码 ${normalizedActivation.phoneNumber} 接收短信,最长 ${waitSeconds} 秒(第 ${windowIndex}/${timeoutWindows} 轮)。`,
'info'
);
try {
const code = await pollPhoneActivationCode(state, normalizedActivation, {
actionLabel: windowIndex === 1
? '从接码平台轮询手机验证码'
: '从接码平台轮询重发后的手机验证码',
timeoutMs: waitSeconds * 1000,
intervalMs: pollIntervalSeconds * 1000,
maxRounds: pollMaxRounds,
onStatus: async ({ elapsedMs, pollCount, statusText }) => {
const shouldLog = (
pollCount === 1
|| statusText !== lastLoggedStatus
|| pollCount - lastLoggedPollCount >= 3
);
if (!shouldLog) {
return;
}
lastLoggedStatus = statusText;
lastLoggedPollCount = pollCount;
await addLog(
`步骤 9${getPhoneSmsProviderLabel(normalizedActivation.provider)} 号码 ${normalizedActivation.phoneNumber} 状态:${statusText}(已等待 ${Math.ceil(elapsedMs / 1000)} 秒,第 ${pollCount}/${pollMaxRounds} 次轮询)。`,
'info'
);
},
});
await clearPhoneRuntimeCountdown();
return {
code,
replaceNumber: false,
};
} catch (error) {
if (!isPhoneCodeTimeoutError(error)) {
if (isPhoneActivationOrderMissingError(error, normalizedActivation.provider)) {
await addLog(
`Step 9: ${providerLabel} activation for ${normalizedActivation.phoneNumber} became invalid (${error.message || error}), replacing number immediately.`,
'warn'
);
await clearPhoneRuntimeCountdown();
return {
code: '',
replaceNumber: true,
reason: 'activation_not_found',
};
}
throw error;
}
if (windowIndex < timeoutWindows) {
await addLog(
`步骤 9:号码 ${normalizedActivation.phoneNumber}${waitSeconds} 秒内未收到短信,正在请求再次发送。`,
'warn'
);
if (!usePageResend) {
await addLog(
`Step 9: ${providerLabel} keeps the same verification page session and skips page resend to avoid route 405 / resend throttling; continue polling this number.`,
'warn'
);
continue;
}
await requestAdditionalPhoneSms(state, normalizedActivation);
if (resendTriggeredForCurrentNumber) {
await addLog(
`步骤 9:号码 ${normalizedActivation.phoneNumber} 已触发过一次页面重发;为避免限流,将继续轮询不再点击重发。`,
'warn'
);
continue;
}
try {
await resendPhoneVerificationCode(tabId);
resendTriggeredForCurrentNumber = true;
await addLog('步骤 9:已点击手机验证码页面的“重新发送短信”。', 'info');
} catch (resendError) {
if (isStopRequestedError(resendError)) {
throw resendError;
}
if (isPhoneResendThrottledError(resendError)) {
await addLog(
`步骤 9:号码 ${normalizedActivation.phoneNumber} 重发短信被限流,立即更换号码。${resendError.message}`,
'warn'
);
await clearPhoneRuntimeCountdown();
return {
code: '',
replaceNumber: true,
reason: 'resend_throttled',
};
}
await addLog(`步骤 9:点击手机验证码页面重发按钮失败。${resendError.message}`, 'warn');
}
continue;
}
await addLog(
`步骤 9:号码 ${normalizedActivation.phoneNumber} 连续 ${timeoutWindows} 轮未收到短信,将在步骤 9 内更换号码。`,
'warn'
);
await clearPhoneRuntimeCountdown();
return {
code: '',
replaceNumber: true,
reason: `sms_timeout_after_${timeoutWindows}_windows`,
};
}
}
throw new Error('手机号验证未完成。');
}
async function completePhoneVerificationFlow(tabId, initialPageState = null) {
let state = await getState();
let activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]);
let pageState = initialPageState || await readPhonePageState(tabId);
let shouldCancelActivation = false;
let remainingResendRequests = Math.max(0, Number(state.verificationResendCount) || 0);
const maxNumberReplacementAttempts = normalizePhoneReplacementLimit(
state.phoneVerificationReplacementLimit
);
let usedNumberReplacementAttempts = 0;
let preferredActivationExhausted = false;
let preferReuseExistingActivationOnAddPhone = false;
let addPhoneReentryWithSameActivation = 0;
const countrySmsFailureCounts = new Map();
const countryPriceFloorByKey = new Map();
const normalizeCountryFailureKey = (countryId, provider = activation?.provider || state?.phoneSmsProvider || '') => {
const normalizedProvider = normalizePhoneSmsProvider(provider || state?.phoneSmsProvider || '');
if (normalizedProvider === PHONE_SMS_PROVIDER_5SIM) {
const normalizedCountryCode = normalizeFiveSimCountryCode(countryId, '');
return normalizedCountryCode ? `${normalizedProvider}:${normalizedCountryCode}` : '';
}
if (normalizedProvider === PHONE_SMS_PROVIDER_NEXSMS) {
const normalizedCountryId = normalizeNexSmsCountryId(countryId, -1);
return normalizedCountryId >= 0 ? `${normalizedProvider}:${normalizedCountryId}` : '';
}
const normalizedCountryId = normalizeCountryId(countryId, 0);
return normalizedCountryId > 0 ? `${normalizedProvider}:${normalizedCountryId}` : '';
};
const splitCountryFailureKey = (countryKey, providerHint = '') => {
const fallbackProvider = normalizePhoneSmsProvider(
providerHint || activation?.provider || state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER
);
const raw = String(countryKey || '').trim();
if (!raw) {
return { provider: fallbackProvider, countryKey: '' };
}
const idx = raw.indexOf(':');
if (idx <= 0) {
return { provider: fallbackProvider, countryKey: raw };
}
const providerPrefix = normalizePhoneSmsProvider(raw.slice(0, idx));
const keyPart = raw.slice(idx + 1).trim();
return {
provider: providerPrefix || fallbackProvider,
countryKey: keyPart,
};
};
const resolveCountryLabelByFailureKey = (countryKey, provider = activation?.provider || state?.phoneSmsProvider || '') => {
const parsed = splitCountryFailureKey(countryKey, provider);
const normalizedProvider = normalizePhoneSmsProvider(parsed.provider || provider || state?.phoneSmsProvider || '');
const normalizedCountryKey = String(parsed.countryKey || '').trim();
if (!normalizedCountryKey) {
return 'Unknown country';
}
if (normalizedProvider === PHONE_SMS_PROVIDER_5SIM) {
const matched = resolveFiveSimCountryCandidates(state)
.find((entry) => String(entry.id || entry.code || '') === normalizedCountryKey);
return matched?.label || normalizedCountryKey || 'Unknown country';
}
if (normalizedProvider === PHONE_SMS_PROVIDER_NEXSMS) {
const normalizedCountryId = normalizeNexSmsCountryId(normalizedCountryKey, -1);
const matched = resolveNexSmsCountryCandidates(state)
.find((entry) => normalizeNexSmsCountryId(entry.id, -1) === normalizedCountryId);
return matched?.label || `Country #${normalizedCountryId}`;
}
const normalizedCountryId = normalizeCountryId(normalizedCountryKey, 0);
const matched = resolveCountryCandidates(state)
.find((entry) => normalizeCountryId(entry.id, 0) === normalizedCountryId);
return matched?.label || `Country #${normalizedCountryId}`;
};
const ensureAddPhonePageBeforeSubmit = async (attemptLabel = 'before submit') => {
let snapshot = null;
try {
snapshot = await readPhonePageState(tabId, 12000);
} catch (error) {
await addLog(
`Step 9: failed to inspect auth page ${attemptLabel}. ${error.message}`,
'warn'
);
snapshot = null;
}
if (snapshot?.addPhonePage) {
return snapshot;
}
try {
const returned = await returnToAddPhone(tabId);
const merged = {
...(snapshot || {}),
...(returned || {}),
};
if (merged?.addPhonePage) {
return merged;
}
} catch (error) {
await addLog(
`Step 9: failed to return to add-phone page ${attemptLabel}. ${error.message}`,
'warn'
);
}
const latest = await readPhonePageState(tabId, 15000);
if (!latest?.addPhonePage) {
throw new Error(
`Step 9: auth page is not on add-phone before phone submit (${attemptLabel}). URL: ${latest?.url || 'unknown'}`
);
}
return latest;
};
const getCountryFailureKey = (countryId, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => (
normalizePhoneSmsProvider(providerId) === PHONE_SMS_PROVIDER_FIVE_SIM
? normalizeFiveSimCountryId(countryId, '')
: String(normalizeCountryId(countryId, 0) || '')
);
const getCountryFailureCount = (countryId, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => {
const countryKey = normalizeCountryFailureKey(countryId, providerId);
if (!countryKey) {
return 0;
}
return Math.max(0, Math.floor(Number(countrySmsFailureCounts.get(countryKey)) || 0));
};
const markCountrySmsFailure = async (countryId, reason = 'sms_timeout', providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => {
const countryKey = normalizeCountryFailureKey(countryId, providerId);
if (!countryKey) {
return;
}
const parsed = splitCountryFailureKey(countryKey, providerId);
const nextCount = getCountryFailureCount(parsed.countryKey, parsed.provider) + 1;
countrySmsFailureCounts.set(countryKey, nextCount);
if (nextCount >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) {
const countryLabel = resolveCountryLabelByFailureKey(countryKey, providerId);
await addLog(
`步骤 9${countryLabel} 已累计 ${nextCount} 次短信失败(${formatStep9Reason(reason)});下次获取号码会优先尝试其它已选国家。`,
'warn'
);
}
};
const clearCountrySmsFailure = (countryId, providerId = normalizePhoneSmsProvider(state?.phoneSmsProvider)) => {
const countryKey = normalizeCountryFailureKey(countryId, providerId);
if (!countryKey) {
return;
}
countrySmsFailureCounts.delete(countryKey);
countryPriceFloorByKey.delete(countryKey);
};
const getBlockedCountryIds = () => {
const activeProvider = normalizePhoneSmsProvider(
state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER
);
return Array.from(countrySmsFailureCounts.entries())
.filter(([countryKey, count]) => (
Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD
|| !countryPriceFloorByKey.has(countryKey)
))
.map(([countryKey]) => splitCountryFailureKey(countryKey, activeProvider))
.filter((entry) => entry.provider === activeProvider)
.map((entry) => String(entry.countryKey || '').trim())
.filter(Boolean);
};
const getCountryPriceFloorById = () => {
const activeProvider = normalizePhoneSmsProvider(
state?.phoneSmsProvider || activation?.provider || DEFAULT_PHONE_SMS_PROVIDER
);
const floorById = {};
countryPriceFloorByKey.forEach((price, compoundCountryKey) => {
const numeric = normalizeHeroSmsPrice(price);
if (numeric === null || numeric <= 0) {
return;
}
const parsed = splitCountryFailureKey(compoundCountryKey, activeProvider);
if (parsed.provider !== activeProvider) {
return;
}
const keyPart = String(parsed.countryKey || '').trim();
if (!keyPart) {
return;
}
floorById[keyPart] = Math.round(numeric * 10000) / 10000;
});
return floorById;
};
const setCountryPriceFloorFromActivation = async (activationCandidate, reason = '') => {
const normalizedActivation = normalizeActivation(activationCandidate);
if (!normalizedActivation) {
return;
}
const countryKey = normalizeCountryFailureKey(
normalizedActivation.countryId,
normalizedActivation.provider
);
if (!countryKey) {
return;
}
const floorPrice = normalizeHeroSmsPrice(
normalizedActivation.price
?? normalizedActivation.maxPrice
?? normalizedActivation.selectedPrice
?? getActivationAcquiredPriceHint(normalizedActivation)
);
if (floorPrice === null || floorPrice <= 0) {
return;
}
const currentFloor = normalizeHeroSmsPrice(countryPriceFloorByKey.get(countryKey));
if (currentFloor !== null && currentFloor >= floorPrice) {
return;
}
const normalizedFloor = Math.round(floorPrice * 10000) / 10000;
countryPriceFloorByKey.set(countryKey, normalizedFloor);
const countryLabel = resolveCountryLabelByFailureKey(countryKey, normalizedActivation.provider);
await addLog(
`Step 9: ${countryLabel} will try a higher price tier (> ${normalizedFloor}) due to ${reason || 'sms timeout'}.`,
'warn'
);
};
const isPreferredActivation = (activationCandidate, stateSnapshot = {}) => (
isSameActivation(
stateSnapshot?.[PREFERRED_PHONE_ACTIVATION_STATE_KEY],
activationCandidate
)
);
const markPreferredActivationExhausted = async (reason = '') => {
if (preferredActivationExhausted || !activation || !isPreferredActivation(activation, state)) {
return;
}
preferredActivationExhausted = true;
await addLog(
`Step 9: preferred number ${activation.phoneNumber} failed (${reason || 'unknown reason'}), falling back to a new number.`,
'warn'
);
};
const rotateActivationAfterAddPhoneFailure = async (failureReason, failureCode, submitState = {}) => {
await markPreferredActivationExhausted(failureCode || failureReason);
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, failureCode || 'add_phone_rejected');
}
await addLog(
`Step 9: replacing number after add-phone failure (${failureReason}) (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`,
'warn'
);
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
preferReuseExistingActivationOnAddPhone = false;
addPhoneReentryWithSameActivation = 0;
let addPhoneSnapshot = {
...pageState,
...submitState,
addPhonePage: true,
phoneVerificationPage: false,
};
try {
const returned = await returnToAddPhone(tabId);
addPhoneSnapshot = {
...addPhoneSnapshot,
...returned,
addPhonePage: true,
phoneVerificationPage: false,
};
} catch (returnError) {
await addLog(
`Step 9: failed to return to add-phone page after rejection, will continue with best-effort state. ${returnError.message}`,
'warn'
);
}
try {
const verified = await ensureAddPhonePageBeforeSubmit('after add-phone rejection');
addPhoneSnapshot = {
...addPhoneSnapshot,
...verified,
addPhonePage: true,
phoneVerificationPage: false,
};
} catch (verifyError) {
await addLog(
`Step 9: failed to verify add-phone state after rejection. ${verifyError.message}`,
'warn'
);
}
pageState = addPhoneSnapshot;
};
try {
while (true) {
state = await getState();
if (!activation) {
activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]);
}
if (pageState?.addPhonePage) {
const addPhoneUrlText = String(pageState?.url || '').trim().toLowerCase();
const looksLikeAddPhoneUrl = /\/add-phone(?:[/?#]|$)/i.test(addPhoneUrlText);
if (!looksLikeAddPhoneUrl) {
pageState = await ensureAddPhonePageBeforeSubmit(
activation ? 'with current activation' : 'with new activation'
);
}
if (!activation) {
activation = await acquirePhoneActivation(state, {
blockedCountryIds: getBlockedCountryIds(),
countryPriceFloorByCountryId: getCountryPriceFloorById(),
skipPreferredActivation: preferredActivationExhausted,
});
shouldCancelActivation = true;
await persistCurrentActivation(activation);
addPhoneReentryWithSameActivation = 0;
} else if (preferReuseExistingActivationOnAddPhone) {
addPhoneReentryWithSameActivation += 1;
if (addPhoneReentryWithSameActivation > 1) {
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, 'returned_to_add_phone_loop');
}
await addLog(
`步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${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(
`步骤 9:页面返回添加手机号,将先重新提交当前号码 ${activation.phoneNumber},暂不获取新号码。`,
'warn'
);
}
let submitResult = null;
try {
submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation);
} catch (submitError) {
const submitErrorText = String(submitError?.message || submitError || 'unknown error');
if (isRecoverableAddPhoneSubmitError(submitErrorText)) {
await rotateActivationAfterAddPhoneFailure(
submitErrorText,
'add_phone_submit_failed',
{ url: pageState?.url || '' }
);
continue;
}
throw submitError;
}
if (submitResult.addPhoneRejected) {
const addPhoneRejectText = String(submitResult.errorText || submitResult.url || 'unknown error');
if (isPhoneNumberUsedError(addPhoneRejectText)) {
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw new Error(
`步骤 9:更换 ${maxNumberReplacementAttempts} 次号码后手机号验证仍未成功。最后原因:${formatStep9Reason('phone_number_used')}`
);
}
await addLog(
`步骤 9:添加手机号页面提示 ${activation.phoneNumber} 已被使用(${addPhoneRejectText}),正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`,
'warn'
);
if (shouldCancelActivation && activation) {
await banPhoneActivation(state, activation);
}
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
preferReuseExistingActivationOnAddPhone = false;
addPhoneReentryWithSameActivation = 0;
pageState = {
...pageState,
...submitResult,
addPhonePage: true,
phoneVerificationPage: false,
};
continue;
}
await addLog(
`步骤 9:添加手机号页面拒绝当前号码,但未明确提示已使用(${addPhoneRejectText}),将用同一号码再试一次。`,
'warn'
);
let retrySubmitError = null;
try {
submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation);
} catch (submitError) {
retrySubmitError = submitError;
}
if (retrySubmitError || submitResult.addPhoneRejected) {
const retryRejectText = String(
retrySubmitError?.message
|| submitResult?.errorText
|| submitResult?.url
|| 'unknown error'
);
if (isPhoneNumberUsedError(retryRejectText) || isRecoverableAddPhoneSubmitError(retryRejectText)) {
await rotateActivationAfterAddPhoneFailure(
`add-phone keeps rejecting ${activation.phoneNumber} (${retryRejectText})`,
isPhoneNumberUsedError(retryRejectText) ? 'phone_number_used' : 'add_phone_rejected',
submitResult || {}
);
continue;
}
throw new Error(
`步骤 9:添加手机号页面持续拒绝当前号码,但没有明确“已使用”状态:${submitResult.errorText || submitResult.url || '未知错误'}`
);
}
}
await addLog('步骤 9:已在添加手机号页面提交号码。', 'info');
pageState = {
...pageState,
...submitResult,
addPhonePage: false,
phoneVerificationPage: true,
};
preferReuseExistingActivationOnAddPhone = false;
addPhoneReentryWithSameActivation = 0;
}
if (!pageState?.phoneVerificationPage) {
pageState = await readPhonePageState(tabId);
}
if (!pageState?.phoneVerificationPage) {
return pageState;
}
if (!activation) {
throw new Error('认证页面正在等待手机验证码,但当前运行没有保存手机号接码订单。');
}
let shouldReplaceNumber = false;
let replaceReason = '';
for (let attempt = 1; attempt <= DEFAULT_PHONE_SUBMIT_ATTEMPTS; attempt += 1) {
throwIfStopped();
const codeResult = await waitForPhoneCodeOrRotateNumber(tabId, state, activation);
if (codeResult.replaceNumber) {
await markPreferredActivationExhausted(codeResult.reason || 'sms_timeout');
shouldReplaceNumber = true;
replaceReason = codeResult.reason || 'sms_not_received';
break;
}
await setPhoneRuntimeState({
[PHONE_VERIFICATION_CODE_STATE_KEY]: String(codeResult.code || '').trim(),
});
await addLog(`步骤 9:已收到手机验证码 ${codeResult.code}`, 'info');
const submitResult = await submitPhoneVerificationCode(tabId, codeResult.code);
if (submitResult.returnedToAddPhone) {
await addLog(
'步骤 9:提交验证码后返回添加手机号页面,将先重试当前号码。',
'warn'
);
preferReuseExistingActivationOnAddPhone = true;
pageState = {
...pageState,
...submitResult,
addPhonePage: true,
phoneVerificationPage: false,
};
break;
}
if (submitResult.invalidCode) {
const invalidErrorText = String(submitResult.errorText || submitResult.url || 'unknown error');
if (isPhoneNumberUsedError(invalidErrorText)) {
shouldReplaceNumber = true;
replaceReason = 'phone_number_used';
if (shouldCancelActivation && activation) {
await banPhoneActivation(state, activation);
shouldCancelActivation = false;
}
await addLog(
`步骤 9:手机号被提示已使用(${invalidErrorText}),立即更换新号码。`,
'warn'
);
break;
}
if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) {
shouldReplaceNumber = true;
replaceReason = 'code_rejected';
if (shouldCancelActivation && activation) {
await banPhoneActivation(state, activation);
shouldCancelActivation = false;
}
await addLog(
`步骤 9:手机验证码连续 ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} 次被拒(${invalidErrorText}),将更换号码。`,
'warn'
);
break;
}
if (remainingResendRequests > 0) {
remainingResendRequests -= 1;
await requestAdditionalPhoneSms(state, activation);
try {
await resendPhoneVerificationCode(tabId);
await addLog('步骤 9:手机验证码被拒后已点击“重新发送短信”。', 'info');
} catch (resendError) {
await addLog(`步骤 9:验证码被拒后点击重发失败。${resendError.message}`, 'warn');
}
if (shouldReplaceNumber) {
break;
}
await addLog(
`步骤 9:手机验证码被拒,已请求再次发送短信(剩余 ${remainingResendRequests} 次重发)。`,
'warn'
);
} else {
await addLog(
'步骤 9:手机验证码被拒,配置的重发次数已用完,将在当前接码窗口内继续重试。',
'warn'
);
}
continue;
}
await completePhoneActivation(state, activation);
await markActivationReusableAfterSuccess(state, activation);
clearCountrySmsFailure(activation.countryId, activation.provider);
shouldCancelActivation = false;
await clearCurrentActivation();
addPhoneReentryWithSameActivation = 0;
await addLog('步骤 9:手机号验证已完成,等待 OAuth 授权页。', 'ok');
return submitResult;
}
if (!shouldReplaceNumber) {
if (pageState?.addPhonePage) {
continue;
}
throw new Error('手机号验证未完成。');
}
if (
activation
&& (
replaceReason === 'resend_throttled'
|| replaceReason === 'route_405_retry_loop'
|| /^sms_timeout_after_/i.test(String(replaceReason || ''))
)
) {
await setCountryPriceFloorFromActivation(activation, replaceReason || 'sms_timeout');
await markCountrySmsFailure(activation.countryId, replaceReason || 'sms_timeout', activation.provider);
}
await markPreferredActivationExhausted(replaceReason || 'replace_number');
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw buildPhoneReplacementLimitError(maxNumberReplacementAttempts, replaceReason || 'unknown');
}
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
addPhoneReentryWithSameActivation = 0;
let returnResult = {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
};
try {
returnResult = await returnToAddPhone(tabId);
} catch (returnError) {
await addLog(`步骤 9:更换号码前返回添加手机号页面失败。${returnError.message}`, 'warn');
}
if (!returnResult?.addPhonePage) {
try {
const stateSnapshot = await readPhonePageState(tabId, 12000);
if (stateSnapshot?.addPhonePage) {
returnResult = {
...returnResult,
...stateSnapshot,
addPhonePage: true,
phoneVerificationPage: false,
};
}
} catch (_) {
// Best effort: keep fallback state for compatibility with tests and older flows.
}
}
try {
const verifiedAddPhoneState = await ensureAddPhonePageBeforeSubmit('after replace-number rotation');
returnResult = {
...returnResult,
...verifiedAddPhoneState,
addPhonePage: true,
phoneVerificationPage: false,
};
} catch (verifyError) {
await addLog(
`Step 9: failed to verify add-phone page after number replacement. ${verifyError.message}`,
'warn'
);
}
await addLog(
`步骤 9:正在更换号码并在步骤 9 内重试(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`,
'warn'
);
pageState = {
...pageState,
...returnResult,
addPhonePage: true,
phoneVerificationPage: false,
};
}
} catch (error) {
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
throw sanitizePhoneRestartStep7Error(sanitizePhoneCodeTimeoutError(error));
}
}
return {
completePhoneVerificationFlow,
normalizeActivation,
pollPhoneActivationCode,
reactivatePhoneActivation,
requestPhoneActivation,
};
}
return {
createPhoneVerificationHelpers,
};
});