Files
FlowPilot/background/phone-verification-flow.js
T

1723 lines
66 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,
sendToContentScriptResilient,
setState,
sleepWithStop,
throwIfStopped,
DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php',
DEFAULT_HERO_SMS_REUSE_ENABLED = true,
HERO_SMS_COUNTRY_ID = 52,
HERO_SMS_COUNTRY_LABEL = 'Thailand',
HERO_SMS_SERVICE_CODE = 'dr',
HERO_SMS_SERVICE_LABEL = 'OpenAI',
DEFAULT_PHONE_CODE_WAIT_SECONDS = 60,
DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2,
DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5,
DEFAULT_PHONE_CODE_POLL_ROUNDS = 4,
} = deps;
const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation';
const PHONE_VERIFICATION_CODE_STATE_KEY = 'currentPhoneVerificationCode';
const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation';
const HERO_SMS_LAST_PRICE_TIERS_KEY = 'heroSmsLastPriceTiers';
const HERO_SMS_LAST_PRICE_COUNTRY_ID_KEY = 'heroSmsLastPriceCountryId';
const HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY = 'heroSmsLastPriceCountryLabel';
const HERO_SMS_LAST_PRICE_USER_LIMIT_KEY = 'heroSmsLastPriceUserLimit';
const HERO_SMS_LAST_PRICE_AT_KEY = 'heroSmsLastPriceAt';
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const PHONE_CODE_POLL_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_ROUNDS_MAX = 120;
const DEFAULT_PHONE_POLL_INTERVAL_MS = DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS * 1000;
const DEFAULT_PHONE_POLL_TIMEOUT_MS = 180000;
const DEFAULT_PHONE_REQUEST_TIMEOUT_MS = 20000;
const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3;
const DEFAULT_PHONE_NUMBER_MAX_USES = 3;
const DEFAULT_PHONE_NUMBER_REPLACEMENT_LIMIT = 3;
const DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS = 3;
const MAX_PHONE_PRICE_CANDIDATES = 8;
const DEFAULT_PHONE_ACTIVATION_RETRY_ROUNDS = 3;
const PHONE_ACTIVATION_RETRY_ROUNDS_MIN = 1;
const PHONE_ACTIVATION_RETRY_ROUNDS_MAX = 10;
const DEFAULT_PHONE_ACTIVATION_RETRY_DELAY_MS = 2000;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::';
const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::';
const PHONE_RESEND_THROTTLED_ERROR_PREFIX = 'PHONE_RESEND_THROTTLED::';
const PHONE_SMS_FAILURE_SKIP_THRESHOLD = 2;
function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) {
const trimmed = String(value || '').trim();
if (!trimmed) {
return fallback;
}
try {
return new URL(trimmed).toString();
} catch {
return fallback;
}
}
function normalizeApiKey(value) {
return String(value || '').trim();
}
function normalizeUseCount(value) {
return Math.max(0, Math.floor(Number(value) || 0));
}
function normalizePhoneReplacementLimit(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_PHONE_NUMBER_REPLACEMENT_LIMIT;
}
return Math.max(1, Math.min(20, parsed));
}
function normalizePhoneActivationRetryRounds(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_PHONE_ACTIVATION_RETRY_ROUNDS;
}
return Math.max(PHONE_ACTIVATION_RETRY_ROUNDS_MIN, Math.min(PHONE_ACTIVATION_RETRY_ROUNDS_MAX, parsed));
}
function normalizePhoneActivationRetryDelayMs(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_PHONE_ACTIVATION_RETRY_DELAY_MS;
}
return Math.max(500, Math.min(30000, parsed));
}
function normalizeHeroSmsPriceLimit(value) {
if (value === undefined || value === null || String(value).trim() === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.round(parsed * 10000) / 10000;
}
function isPhoneNumberUsedError(value) {
const text = String(value || '').trim();
if (!text) {
return false;
}
return /already\s+linked\s+to\s+the\s+maximum\s+number\s+of\s+accounts|phone\s+number\s+is\s+already\s+(?:in\s+use|linked|registered)|phone\s+number\s+has\s+already\s+been\s+used|already\s+associated\s+with\s+another\s+account|not\s+eligible\s+to\s+be\s+used|cannot\s+be\s+used\s+for\s+verification|号码.*(?:已|被).*(?:使用|占用|绑定|注册)|手机号.*(?:已|被).*(?:使用|占用|绑定|注册)|该手机号.*(?:已|被).*(?:使用|占用|绑定|注册)/i.test(text);
}
function normalizeCountryId(value, fallback = HERO_SMS_COUNTRY_ID) {
const parsed = Math.floor(Number(value));
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
const fallbackParsed = Math.floor(Number(fallback));
if (Number.isFinite(fallbackParsed) && fallbackParsed > 0) {
return fallbackParsed;
}
return 0;
}
function normalizeCountryLabel(value = '', fallback = HERO_SMS_COUNTRY_LABEL) {
return String(value || '').trim() || fallback;
}
function normalizePhoneCodeWaitSeconds(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_PHONE_CODE_WAIT_SECONDS;
}
return Math.max(PHONE_CODE_WAIT_SECONDS_MIN, Math.min(PHONE_CODE_WAIT_SECONDS_MAX, parsed));
}
function normalizePhoneCodeTimeoutWindows(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS;
}
return Math.max(PHONE_CODE_TIMEOUT_WINDOWS_MIN, Math.min(PHONE_CODE_TIMEOUT_WINDOWS_MAX, parsed));
}
function normalizePhoneCodePollIntervalSeconds(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS;
}
return Math.max(PHONE_CODE_POLL_INTERVAL_SECONDS_MIN, Math.min(PHONE_CODE_POLL_INTERVAL_SECONDS_MAX, parsed));
}
function normalizePhoneCodePollMaxRounds(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_PHONE_CODE_POLL_ROUNDS;
}
return Math.max(PHONE_CODE_POLL_ROUNDS_MIN, Math.min(PHONE_CODE_POLL_ROUNDS_MAX, parsed));
}
function normalizeHeroSmsReuseEnabled(value) {
if (value === undefined || value === null) {
return Boolean(DEFAULT_HERO_SMS_REUSE_ENABLED);
}
return Boolean(value);
}
function normalizeHeroSmsAcquirePriority(value = '') {
return String(value || '').trim().toLowerCase() === HERO_SMS_ACQUIRE_PRIORITY_PRICE
? HERO_SMS_ACQUIRE_PRIORITY_PRICE
: HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
}
function normalizeCountryFallbackList(value = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const seen = new Set();
const normalized = [];
for (const entry of source) {
let id = 0;
let label = '';
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
id = normalizeCountryId(entry.id ?? entry.countryId, 0);
label = String((entry.label ?? entry.countryLabel) || '').trim();
} else {
const text = String(entry || '').trim();
const structured = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/);
if (structured) {
id = normalizeCountryId(structured[1], 0);
label = String(structured[2] || '').trim();
} else {
id = normalizeCountryId(text, 0);
}
}
if (!Number.isFinite(id) || id <= 0 || seen.has(id)) {
continue;
}
seen.add(id);
normalized.push({
id,
label: label || `Country #${id}`,
});
}
return normalized;
}
function resolveCountryConfig(state = {}) {
return {
id: normalizeCountryId(state.heroSmsCountryId, HERO_SMS_COUNTRY_ID),
label: normalizeCountryLabel(state.heroSmsCountryLabel, HERO_SMS_COUNTRY_LABEL),
};
}
function resolveCountryCandidates(state = {}) {
const primary = resolveCountryConfig(state);
const fallbackList = normalizeCountryFallbackList(state.heroSmsCountryFallback);
const seen = new Set([primary.id]);
const candidates = [primary];
fallbackList.forEach((entry) => {
const nextId = normalizeCountryId(entry.id, 0);
if (!Number.isFinite(nextId) || nextId <= 0 || seen.has(nextId)) {
return;
}
seen.add(nextId);
candidates.push({
id: nextId,
label: normalizeCountryLabel(entry.label, `Country #${nextId}`),
});
});
return candidates;
}
function normalizeActivation(record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return null;
}
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();
return {
activationId,
phoneNumber,
provider: String(record.provider || 'hero-sms').trim() || 'hero-sms',
serviceCode: String(record.serviceCode || HERO_SMS_SERVICE_CODE).trim() || HERO_SMS_SERVICE_CODE,
countryId: normalizeCountryId(record.countryId, HERO_SMS_COUNTRY_ID),
...(countryLabel ? { countryLabel } : {}),
successfulUses: normalizeUseCount(record.successfulUses),
maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)),
...(statusAction ? { statusAction } : {}),
};
}
function normalizeActivationFallback(record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return null;
}
const fallback = {};
const provider = String(record.provider || '').trim();
const serviceCode = String(record.serviceCode || '').trim();
const countryId = Math.floor(Number(record.countryId));
const countryLabel = String(record.countryLabel || '').trim();
const statusAction = String(record.statusAction || '').trim();
if (provider) {
fallback.provider = provider;
}
if (serviceCode) {
fallback.serviceCode = serviceCode;
}
if (Number.isFinite(countryId) && countryId > 0) {
fallback.countryId = countryId;
}
if (countryLabel) {
fallback.countryLabel = countryLabel;
}
if (Object.prototype.hasOwnProperty.call(record, 'successfulUses')) {
fallback.successfulUses = normalizeUseCount(record.successfulUses);
}
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 ? ` Last HeroSMS status: ${lastResponse}` : '';
return new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}Timed out waiting for the phone verification code.${suffix}`);
}
function isPhoneCodeTimeoutError(error) {
return String(error?.message || '').startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX);
}
function isPhoneResendThrottledError(error) {
const message = String(error?.message || error || '').trim();
if (!message) {
return false;
}
if (message.startsWith(PHONE_RESEND_THROTTLED_ERROR_PREFIX)) {
return true;
}
return /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试/i.test(message);
}
function buildPhoneRestartStep7Error(phoneNumber = '') {
const suffix = phoneNumber ? ` Current number: ${phoneNumber}.` : '';
return new Error(
`${PHONE_RESTART_STEP7_ERROR_PREFIX}Phone verification could not receive an SMS after resend. Restart step 7 with a new number.${suffix}`
);
}
function sanitizePhoneCodeTimeoutError(error) {
const message = String(error?.message || '');
if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) {
return error;
}
return new Error(message.slice(PHONE_CODE_TIMEOUT_ERROR_PREFIX.length).trim() || 'Timed out waiting for the phone verification code.');
}
function sanitizePhoneRestartStep7Error(error) {
const message = String(error?.message || '');
if (!message.startsWith(PHONE_RESTART_STEP7_ERROR_PREFIX)) {
return error;
}
return new Error(
message.slice(PHONE_RESTART_STEP7_ERROR_PREFIX.length).trim()
|| 'Phone verification could not receive an SMS after resend. Restart step 7 with a new number.'
);
}
async function fetchHeroSmsPayload(config, query, actionLabel) {
const requestUrl = buildHeroSmsUrl(config.baseUrl, {
api_key: config.apiKey,
...query,
});
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS)
: null;
try {
const response = await fetchImpl(requestUrl, {
method: 'GET',
signal: controller?.signal,
});
const text = await response.text();
const payload = parseHeroSmsPayload(text);
if (!response.ok) {
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 resolvePhoneConfig(state = {}) {
const apiKey = normalizeApiKey(state.heroSmsApiKey);
if (!apiKey) {
throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.');
}
return {
apiKey,
baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL),
};
}
function parseActivationPayload(payload, fallback = null) {
const normalizedFallback = normalizeActivation(fallback) || 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 || 'hero-sms',
serviceCode: normalizedFallback?.serviceCode || HERO_SMS_SERVICE_CODE,
countryId: normalizedFallback?.countryId || HERO_SMS_COUNTRY_ID,
...(normalizedFallback?.countryLabel ? { countryLabel: normalizedFallback.countryLabel } : {}),
successfulUses: normalizedFallback?.successfulUses ?? 0,
maxUses: normalizedFallback?.maxUses ?? DEFAULT_PHONE_NUMBER_MAX_USES,
...(normalizedFallback?.statusAction ? { statusAction: normalizedFallback.statusAction } : {}),
};
}
if (/^ACCESS_READY$/i.test(text) && normalizedFallback) {
return normalizedFallback;
}
return null;
}
function resolveActivationStatusAction(activation) {
return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus';
}
function normalizeHeroSmsPrice(value) {
const price = Number(value);
if (!Number.isFinite(price) || price < 0) {
return null;
}
return price;
}
function collectHeroSmsPriceCandidates(payload, candidates = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectHeroSmsPriceCandidates(entry, candidates));
return candidates;
}
if (!payload || typeof payload !== 'object') {
return candidates;
}
const cost = normalizeHeroSmsPrice(payload.cost);
if (cost !== null) {
const count = Number(payload.count);
const physicalCount = Number(payload.physicalCount);
const hasCount = Number.isFinite(count);
const hasPhysicalCount = Number.isFinite(physicalCount);
if ((!hasCount && !hasPhysicalCount) || count > 0 || physicalCount > 0) {
candidates.push(cost);
}
}
Object.values(payload).forEach((value) => collectHeroSmsPriceCandidates(value, candidates));
return candidates;
}
function findLowestHeroSmsPrice(payload) {
const candidates = collectHeroSmsPriceCandidates(payload, []);
if (!candidates.length) {
return null;
}
return Math.min(...candidates);
}
function buildSortedUniquePriceCandidates(values = []) {
return Array.from(
new Set(
values
.map((value) => normalizeHeroSmsPrice(value))
.filter((value) => value !== null)
.map((value) => Math.round(value * 10000) / 10000)
)
)
.sort((left, right) => left - right)
.slice(0, MAX_PHONE_PRICE_CANDIDATES);
}
function isHeroSmsNoNumbersPayload(payload) {
return /\bNO_NUMBERS\b/i.test(describeHeroSmsPayload(payload));
}
function extractHeroSmsWrongMaxPrice(payload) {
if (payload && typeof payload === 'object') {
const title = String(payload.title || '').trim();
const minPrice = normalizeHeroSmsPrice(payload.info?.min);
if (/^WRONG_MAX_PRICE$/i.test(title) && minPrice !== null) {
return minPrice;
}
}
const text = describeHeroSmsPayload(payload);
const match = text.match(/\bWRONG_MAX_PRICE:(\d+(?:\.\d+)?)\b/i);
if (!match) {
return null;
}
return normalizeHeroSmsPrice(match[1]);
}
function isNetworkFetchFailure(error) {
const message = String(error?.message || '').trim();
return /failed to fetch|networkerror|load failed/i.test(message);
}
function isHeroSmsTerminalError(payloadOrMessage) {
const text = describeHeroSmsPayload(payloadOrMessage);
return /\bNO_BALANCE\b|\bNOT_ENOUGH_BALANCE\b|\bBAD_KEY\b|\bINVALID_KEY\b|\bBANNED\b|\bACCOUNT_BANNED\b|\bWRONG_KEY\b/i.test(text);
}
async function resolveCheapestPhoneActivationPrice(config, countryConfig) {
for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) {
try {
const payload = await fetchHeroSmsPayload(config, {
action: 'getPrices',
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
}, 'HeroSMS getPrices');
const price = findLowestHeroSmsPrice(payload);
if (price !== null) {
return price;
}
} catch (_) {
// Best-effort lookup only.
}
}
return null;
}
async function persistHeroSmsPricePlanSnapshot(countryConfig, pricePlan) {
if (typeof setState !== 'function') {
return;
}
const prices = Array.isArray(pricePlan?.prices)
? pricePlan.prices.filter((price) => Number.isFinite(Number(price)))
: [];
const userLimit = pricePlan?.userLimit === null || pricePlan?.userLimit === undefined
? ''
: String(pricePlan.userLimit);
await setState({
[HERO_SMS_LAST_PRICE_TIERS_KEY]: prices,
[HERO_SMS_LAST_PRICE_COUNTRY_ID_KEY]: normalizeCountryId(countryConfig?.id, 0),
[HERO_SMS_LAST_PRICE_COUNTRY_LABEL_KEY]: normalizeCountryLabel(countryConfig?.label, HERO_SMS_COUNTRY_LABEL),
[HERO_SMS_LAST_PRICE_USER_LIMIT_KEY]: userLimit,
[HERO_SMS_LAST_PRICE_AT_KEY]: Date.now(),
});
}
async function resolvePhoneActivationPricePlan(config, countryConfig, state = {}) {
const userLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice);
let priceCandidates = [];
for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) {
try {
const payload = await fetchHeroSmsPayload(config, {
action: 'getPrices',
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
}, 'HeroSMS getPrices');
priceCandidates = buildSortedUniquePriceCandidates(
collectHeroSmsPriceCandidates(payload, [])
);
if (priceCandidates.length > 0) {
break;
}
} catch (_) {
// best effort
}
}
const minCatalogPrice = priceCandidates.length > 0 ? priceCandidates[0] : null;
if (userLimit !== null) {
const bounded = priceCandidates.filter((price) => price <= userLimit);
if (bounded.length > 0) {
const boundedPlan = { prices: bounded, userLimit, minCatalogPrice };
await persistHeroSmsPricePlanSnapshot(countryConfig, boundedPlan);
return boundedPlan;
}
const userLimitedPlan = { prices: [userLimit], userLimit, minCatalogPrice };
await persistHeroSmsPricePlanSnapshot(countryConfig, userLimitedPlan);
return userLimitedPlan;
}
if (priceCandidates.length > 0) {
const plan = { prices: priceCandidates, userLimit: null, minCatalogPrice };
await persistHeroSmsPricePlanSnapshot(countryConfig, plan);
return plan;
}
const fallbackPlan = { prices: [null], userLimit: null, minCatalogPrice: null };
await persistHeroSmsPricePlanSnapshot(countryConfig, fallbackPlan);
return fallbackPlan;
}
async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) {
const query = {
action,
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
};
if (options.maxPrice !== null && options.maxPrice !== undefined) {
query.maxPrice = options.maxPrice;
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,
});
} 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;
}
}
}
async function requestPhoneActivation(state = {}, options = {}) {
const config = resolvePhoneConfig(state);
const allCountryCandidates = resolveCountryCandidates(state);
const blockedCountryIds = new Set(
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
.map((value) => normalizeCountryId(value, 0))
.filter((id) => id > 0)
);
let countryCandidates = allCountryCandidates.filter(
(entry) => !blockedCountryIds.has(normalizeCountryId(entry.id, 0))
);
if (!countryCandidates.length) {
countryCandidates = allCountryCandidates;
if (blockedCountryIds.size) {
await addLog(
'Step 9: all selected countries reached the temporary SMS-failure skip threshold, lifting skip for this acquire round.',
'warn'
);
}
}
const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
const requestActions = ['getNumber', 'getNumberV2'];
const configuredAcquireRounds = normalizePhoneActivationRetryRounds(
state?.heroSmsActivationRetryRounds
);
const maxAcquireRounds = Math.max(2, configuredAcquireRounds);
const retryDelayMs = normalizePhoneActivationRetryDelayMs(
state?.heroSmsActivationRetryDelayMs
);
let finalNoNumbersByCountry = [];
let finalLastError = null;
let finalLastFailureText = '';
for (let round = 1; round <= maxAcquireRounds; round += 1) {
if (maxAcquireRounds > 1) {
await addLog(
`Step 9: HeroSMS acquiring phone number (round ${round}/${maxAcquireRounds})...`,
'info'
);
}
const countryAttempts = countryCandidates.map((countryConfig, index) => ({
index,
countryConfig,
pricePlan: null,
orderingPrice: Number.POSITIVE_INFINITY,
}));
if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE) {
for (const attempt of countryAttempts) {
const pricePlan = await resolvePhoneActivationPricePlan(config, attempt.countryConfig, state);
const numericPrices = Array.isArray(pricePlan?.prices)
? pricePlan.prices
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value >= 0)
: [];
const minCandidatePrice = numericPrices.length ? Math.min(...numericPrices) : null;
const cappedByUserLimit = (
pricePlan?.userLimit !== null
&& pricePlan?.userLimit !== undefined
&& pricePlan?.minCatalogPrice !== null
&& pricePlan?.minCatalogPrice !== undefined
&& Number(pricePlan.minCatalogPrice) > Number(pricePlan.userLimit)
);
attempt.pricePlan = pricePlan;
attempt.orderingPrice = cappedByUserLimit
? Number.POSITIVE_INFINITY
: (minCandidatePrice !== null ? minCandidatePrice : Number.POSITIVE_INFINITY);
}
}
if (acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE && countryAttempts.length > 1) {
countryAttempts.sort((left, right) => {
if (left.orderingPrice !== right.orderingPrice) {
return left.orderingPrice - right.orderingPrice;
}
return left.index - right.index;
});
}
const noNumbersByCountry = [];
const retryableNoNumberCountries = [];
let lastError = null;
let lastFailureText = '';
for (const attempt of countryAttempts) {
const countryConfig = attempt.countryConfig;
const buildFallbackActivation = (requestAction) => ({
countryId: countryConfig.id,
...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}),
});
const pricePlan = attempt.pricePlan || await resolvePhoneActivationPricePlan(config, countryConfig, state);
let noNumbersObservedInCountry = false;
for (const maxPrice of pricePlan.prices) {
for (const requestAction of requestActions) {
try {
const payload = await requestPhoneActivationWithPrice(
config,
countryConfig,
requestAction,
maxPrice,
{ userLimit: pricePlan.userLimit }
);
const activation = parseActivationPayload(payload, buildFallbackActivation(requestAction));
if (activation) {
const { countryLabel: _ignoredCountryLabel, ...activationWithoutCountryLabel } = activation;
return {
...activationWithoutCountryLabel,
countryId: countryConfig.id,
};
}
const payloadText = describeHeroSmsPayload(payload);
if (isHeroSmsNoNumbersPayload(payload)) {
noNumbersObservedInCountry = true;
lastFailureText = payloadText || lastFailureText;
continue;
}
if (isHeroSmsTerminalError(payload)) {
throw new Error(`HeroSMS ${requestAction} failed: ${payloadText || 'empty response'}`);
}
lastFailureText = payloadText || lastFailureText;
lastError = new Error(`HeroSMS ${requestAction} failed: ${payloadText || 'empty response'}`);
} catch (error) {
const payloadOrMessage = error?.payload || error?.message;
if (isHeroSmsTerminalError(payloadOrMessage)) {
throw new Error(`HeroSMS ${requestAction} failed: ${describeHeroSmsPayload(payloadOrMessage) || 'empty response'}`);
}
if (isHeroSmsNoNumbersPayload(payloadOrMessage)) {
noNumbersObservedInCountry = true;
lastFailureText = describeHeroSmsPayload(payloadOrMessage) || lastFailureText;
continue;
}
lastFailureText = describeHeroSmsPayload(payloadOrMessage) || lastFailureText;
lastError = error;
}
}
}
if (noNumbersObservedInCountry) {
if (
pricePlan.userLimit !== null
&& pricePlan.minCatalogPrice !== null
&& pricePlan.minCatalogPrice > pricePlan.userLimit
) {
noNumbersByCountry.push(
`${countryConfig.label}: no numbers within maxPrice=${pricePlan.userLimit}; lowest listed=${pricePlan.minCatalogPrice}`
);
} else {
noNumbersByCountry.push(
`${countryConfig.label}: ${lastFailureText || 'NO_NUMBERS'}`
);
retryableNoNumberCountries.push(countryConfig.label);
}
continue;
}
}
finalNoNumbersByCountry = noNumbersByCountry;
finalLastError = lastError;
finalLastFailureText = lastFailureText;
if (
noNumbersByCountry.length
&& round < maxAcquireRounds
&& retryableNoNumberCountries.length > 0
) {
await addLog(
`Step 9: HeroSMS has no available numbers (round ${round}/${maxAcquireRounds}); retrying in ${Math.ceil(retryDelayMs / 1000)}s. Countries: ${retryableNoNumberCountries.join(', ')}.`,
'warn'
);
await sleepWithStop(retryDelayMs);
continue;
}
break;
}
if (finalNoNumbersByCountry.length) {
throw new Error(
`HeroSMS no numbers available across ${countryCandidates.length} country candidate(s): ${finalNoNumbersByCountry.join(' | ')}.`
);
}
if (finalLastError) {
throw finalLastError;
}
throw new Error(`HeroSMS failed to acquire a phone number. Last status: ${finalLastFailureText || 'unknown'}.`);
}
async function reactivatePhoneActivation(state = {}, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('Reusable phone activation is missing.');
}
const config = resolvePhoneConfig(state);
const payload = await fetchHeroSmsPayload(config, {
action: 'reactivate',
id: normalizedActivation.activationId,
}, 'HeroSMS reactivate');
const nextActivation = parseActivationPayload(payload, normalizedActivation);
if (!nextActivation) {
const text = describeHeroSmsPayload(payload);
throw new Error(`HeroSMS reactivate failed: ${text || 'empty response'}`);
}
return nextActivation;
}
async function setPhoneActivationStatus(state = {}, activation, status, actionLabel) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
return '';
}
const config = resolvePhoneConfig(state);
const payload = await fetchHeroSmsPayload(config, {
action: 'setStatus',
id: normalizedActivation.activationId,
status,
}, actionLabel);
return describeHeroSmsPayload(payload);
}
async function completePhoneActivation(state = {}, activation) {
await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)');
}
async function cancelPhoneActivation(state = {}, activation) {
try {
await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)');
} catch (_) {
// Best-effort cleanup.
}
}
async function requestAdditionalPhoneSms(state = {}, activation) {
try {
await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)');
} catch (_) {
// Best-effort request only.
}
}
async function pollPhoneActivationCode(state = {}, activation, options = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('Phone activation is missing.');
}
const 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;
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 extractVerificationCode = (rawCode) => {
const trimmed = String(rawCode || '').trim();
if (!trimmed) {
return '';
}
const digitMatch = trimmed.match(/\b(\d{4,8})\b/);
return digitMatch?.[1] || trimmed;
};
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 rawCode = String(okMatch[1] || '').trim();
const digitMatch = rawCode.match(/\b(\d{4,8})\b/);
return digitMatch?.[1] || rawCode;
}
if (/^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)$/i.test(text)) {
await sleepWithStop(intervalMs);
continue;
}
if (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: 'Step 9: waiting for auth page content script to recover before phone verification.',
});
const result = await sendToContentScriptResilient('signup-page', {
type: 'STEP8_GET_STATE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: auth page is switching, waiting to inspect phone verification state again...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
function resolveCountryConfigFromActivation(activation, fallbackState = {}) {
const candidates = resolveCountryCandidates(fallbackState);
if (activation && typeof activation === 'object') {
const countryId = normalizeCountryId(activation.countryId, 0);
if (countryId > 0) {
const matched = candidates.find((entry) => entry.id === countryId);
if (matched) {
return matched;
}
return {
id: countryId,
label: normalizeCountryLabel(activation.countryLabel, `Country #${countryId}`),
};
}
}
return candidates[0] || resolveCountryConfig(fallbackState);
}
async function submitPhoneNumber(tabId, phoneNumber, activation = null) {
const state = await getState();
const countryConfig = resolveCountryConfigFromActivation(activation, state);
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'submit add-phone number' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'SUBMIT_PHONE_NUMBER',
source: 'background',
payload: {
phoneNumber,
countryId: countryConfig.id,
countryLabel: countryConfig.label,
},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: waiting for add-phone page to become ready...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function submitPhoneVerificationCode(tabId, code) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(45000, { step: 9, actionLabel: 'submit phone verification code' })
: 45000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
source: 'background',
payload: { code },
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: waiting for phone verification page before filling the SMS code...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function resendPhoneVerificationCode(tabId) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'resend phone verification code' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'RESEND_PHONE_VERIFICATION_CODE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: waiting for the phone verification resend button...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function returnToAddPhone(tabId) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'return to add-phone page' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'RETURN_TO_ADD_PHONE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: returning to add-phone page to replace the phone number...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function persistCurrentActivation(activation) {
await setState({
[PHONE_ACTIVATION_STATE_KEY]: activation || null,
[PHONE_VERIFICATION_CODE_STATE_KEY]: '',
});
}
async function persistReusableActivation(activation) {
await setState({
[REUSABLE_PHONE_ACTIVATION_STATE_KEY]: activation || null,
});
}
async function clearCurrentActivation() {
await persistCurrentActivation(null);
}
async function clearReusableActivation() {
await persistReusableActivation(null);
}
async function acquirePhoneActivation(state = {}, options = {}) {
const countryCandidates = resolveCountryCandidates(state);
const blockedCountryIds = new Set(
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
.map((value) => normalizeCountryId(value, 0))
.filter((id) => id > 0)
);
const allowedCountryIds = new Set(
countryCandidates
.map((entry) => normalizeCountryId(entry.id, 0))
.filter((id) => id > 0 && !blockedCountryIds.has(id))
);
const preferredCountryLabel = countryCandidates[0]?.label || HERO_SMS_COUNTRY_LABEL;
const resolveCountryLabelById = (countryId) => (
countryCandidates.find((entry) => entry.id === normalizeCountryId(countryId, 0))?.label
|| preferredCountryLabel
);
const reuseEnabled = normalizeHeroSmsReuseEnabled(state.heroSmsReuseEnabled);
const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]);
if (
reuseEnabled
&&
reusableActivation
&& !blockedCountryIds.has(normalizeCountryId(reusableActivation.countryId, 0))
&& allowedCountryIds.has(reusableActivation.countryId)
&& reusableActivation.successfulUses < reusableActivation.maxUses
) {
try {
const reactivated = await reactivatePhoneActivation(state, reusableActivation);
await addLog(
`Step 9: reusing ${resolveCountryLabelById(reactivated.countryId)} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`,
'info'
);
return reactivated;
} catch (error) {
await addLog(`Step 9: failed to reuse phone number ${reusableActivation.phoneNumber}, falling back to a new number. ${error.message}`, 'warn');
await clearReusableActivation();
}
}
const activation = await requestPhoneActivation(state, { blockedCountryIds: Array.from(blockedCountryIds) });
await addLog(
`Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${resolveCountryLabelById(activation.countryId)} number ${activation.phoneNumber}.`,
'info'
);
return activation;
}
async function markActivationReusableAfterSuccess(state, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizeHeroSmsReuseEnabled(state?.heroSmsReuseEnabled)) {
await clearReusableActivation();
return;
}
if (!normalizedActivation) {
await clearReusableActivation();
return;
}
const successfulUses = normalizedActivation.successfulUses + 1;
if (successfulUses >= normalizedActivation.maxUses) {
await clearReusableActivation();
return;
}
await persistReusableActivation({
...normalizedActivation,
successfulUses,
});
}
async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('Phone activation is missing.');
}
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 addLog(
`Step 9: waiting up to ${waitSeconds} seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/${timeoutWindows}).`,
'info'
);
try {
const code = await pollPhoneActivationCode(state, normalizedActivation, {
actionLabel: windowIndex === 1
? 'poll phone verification code from HeroSMS'
: 'poll resent phone verification code from HeroSMS',
timeoutMs: 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(
`Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed, round ${pollCount}/${pollMaxRounds}).`,
'info'
);
},
});
return {
code,
replaceNumber: false,
};
} catch (error) {
if (!isPhoneCodeTimeoutError(error)) {
throw error;
}
if (windowIndex < timeoutWindows) {
await addLog(
`Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within ${waitSeconds} seconds, requesting another SMS.`,
'warn'
);
await requestAdditionalPhoneSms(state, normalizedActivation);
if (resendTriggeredForCurrentNumber) {
await addLog(
`Step 9: resend already used once for ${normalizedActivation.phoneNumber}; continue polling without another page resend to avoid rate limit.`,
'warn'
);
continue;
}
try {
await resendPhoneVerificationCode(tabId);
resendTriggeredForCurrentNumber = true;
await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info');
} catch (resendError) {
if (isPhoneResendThrottledError(resendError)) {
await addLog(
`Step 9: resend is throttled for ${normalizedActivation.phoneNumber}, replacing number immediately. ${resendError.message}`,
'warn'
);
return {
code: '',
replaceNumber: true,
reason: 'resend_throttled',
};
}
await addLog(`Step 9: failed to click resend on the phone verification page. ${resendError.message}`, 'warn');
}
continue;
}
await addLog(
`Step 9: no SMS for ${normalizedActivation.phoneNumber} after ${timeoutWindows} window(s), replacing the number inside step 9.`,
'warn'
);
return {
code: '',
replaceNumber: true,
reason: `sms_timeout_after_${timeoutWindows}_windows`,
};
}
}
throw new Error('Phone verification did not complete successfully.');
}
async function completePhoneVerificationFlow(tabId, initialPageState = null) {
let state = await getState();
let activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]);
let pageState = initialPageState || await readPhonePageState(tabId);
let shouldCancelActivation = false;
let remainingResendRequests = Math.max(0, Number(state.verificationResendCount) || 0);
const maxNumberReplacementAttempts = normalizePhoneReplacementLimit(
state.phoneVerificationReplacementLimit
);
let usedNumberReplacementAttempts = 0;
let preferReuseExistingActivationOnAddPhone = false;
let addPhoneReentryWithSameActivation = 0;
const countrySmsFailureCounts = new Map();
const getCountryFailureCount = (countryId) => {
const normalizedCountryId = normalizeCountryId(countryId, 0);
if (!normalizedCountryId) {
return 0;
}
return Math.max(0, Math.floor(Number(countrySmsFailureCounts.get(normalizedCountryId)) || 0));
};
const markCountrySmsFailure = async (countryId, reason = 'sms_timeout') => {
const normalizedCountryId = normalizeCountryId(countryId, 0);
if (!normalizedCountryId) {
return;
}
const nextCount = getCountryFailureCount(normalizedCountryId) + 1;
countrySmsFailureCounts.set(normalizedCountryId, nextCount);
if (nextCount >= PHONE_SMS_FAILURE_SKIP_THRESHOLD) {
const matched = resolveCountryCandidates(state)
.find((entry) => normalizeCountryId(entry.id, 0) === normalizedCountryId);
const countryLabel = matched?.label || `Country #${normalizedCountryId}`;
await addLog(
`Step 9: ${countryLabel} reached ${nextCount} SMS failures (${reason}); next acquisition will fallback to other selected country candidates first.`,
'warn'
);
}
};
const clearCountrySmsFailure = (countryId) => {
const normalizedCountryId = normalizeCountryId(countryId, 0);
if (!normalizedCountryId) {
return;
}
countrySmsFailureCounts.delete(normalizedCountryId);
};
const getBlockedCountryIds = () => Array.from(countrySmsFailureCounts.entries())
.filter(([, count]) => Number(count) >= PHONE_SMS_FAILURE_SKIP_THRESHOLD)
.map(([countryId]) => normalizeCountryId(countryId, 0))
.filter((countryId) => countryId > 0);
try {
while (true) {
state = await getState();
if (!activation) {
activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]);
}
if (pageState?.addPhonePage) {
if (!activation) {
activation = await acquirePhoneActivation(state, {
blockedCountryIds: getBlockedCountryIds(),
});
shouldCancelActivation = true;
await persistCurrentActivation(activation);
addPhoneReentryWithSameActivation = 0;
} else if (preferReuseExistingActivationOnAddPhone) {
addPhoneReentryWithSameActivation += 1;
if (addPhoneReentryWithSameActivation > 1) {
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw new Error(
`Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: returned_to_add_phone_loop.`
);
}
await addLog(
`Step 9: current number ${activation.phoneNumber} returned to add-phone repeatedly, replacing number (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`,
'warn'
);
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
preferReuseExistingActivationOnAddPhone = false;
addPhoneReentryWithSameActivation = 0;
pageState = {
...pageState,
addPhonePage: true,
phoneVerificationPage: false,
};
continue;
}
await addLog(
`Step 9: add-phone returned, re-submitting current number ${activation.phoneNumber} before requesting a new number.`,
'warn'
);
}
let submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation);
if (submitResult.addPhoneRejected) {
const addPhoneRejectText = String(submitResult.errorText || submitResult.url || 'unknown error');
if (isPhoneNumberUsedError(addPhoneRejectText)) {
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw new Error(
`Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: phone_number_used.`
);
}
await addLog(
`Step 9: add-phone rejected ${activation.phoneNumber} as already used (${addPhoneRejectText}), replacing number (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`,
'warn'
);
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
preferReuseExistingActivationOnAddPhone = false;
addPhoneReentryWithSameActivation = 0;
pageState = {
...pageState,
...submitResult,
addPhonePage: true,
phoneVerificationPage: false,
};
continue;
}
await addLog(
`Step 9: add-phone rejected current number but did not mark it as used (${addPhoneRejectText}), retrying once with the same number.`,
'warn'
);
submitResult = await submitPhoneNumber(tabId, activation.phoneNumber, activation);
if (submitResult.addPhoneRejected) {
throw new Error(
`Step 9: add-phone keeps rejecting current number without explicit "used" status: ${submitResult.errorText || submitResult.url || 'unknown error'}.`
);
}
}
await addLog('Step 9: submitted the phone number on add-phone page.', '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('The auth page is waiting for a phone verification code, but no HeroSMS activation is stored for this run.');
}
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) {
shouldReplaceNumber = true;
replaceReason = codeResult.reason || 'sms_not_received';
break;
}
await setState({
[PHONE_VERIFICATION_CODE_STATE_KEY]: String(codeResult.code || '').trim(),
});
await addLog(`Step 9: received phone verification code ${codeResult.code}.`, 'info');
const submitResult = await submitPhoneVerificationCode(tabId, codeResult.code);
if (submitResult.returnedToAddPhone) {
await addLog(
'Step 9: phone verification returned to add-phone after code submission, will try current number first.',
'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';
await addLog(
`Step 9: phone number was rejected as already used (${invalidErrorText}), replacing with a new number immediately.`,
'warn'
);
break;
}
if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) {
shouldReplaceNumber = true;
replaceReason = 'code_rejected';
await addLog(
`Step 9: phone verification code was rejected ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} times (${invalidErrorText}), replacing the number.`,
'warn'
);
break;
}
if (remainingResendRequests > 0) {
remainingResendRequests -= 1;
await requestAdditionalPhoneSms(state, activation);
try {
await resendPhoneVerificationCode(tabId);
await addLog('Step 9: clicked "Resend text message" after the phone code was rejected.', 'info');
} catch (resendError) {
await addLog(`Step 9: failed to click resend after code rejection. ${resendError.message}`, 'warn');
}
await addLog(
`Step 9: phone verification code was rejected, requested another SMS (${remainingResendRequests} resend attempts left).`,
'warn'
);
} else {
await addLog(
'Step 9: phone verification code was rejected and the configured resend budget is exhausted, retrying with the current activation window.',
'warn'
);
}
continue;
}
await completePhoneActivation(state, activation);
await markActivationReusableAfterSuccess(state, activation);
clearCountrySmsFailure(activation.countryId);
shouldCancelActivation = false;
await clearCurrentActivation();
addPhoneReentryWithSameActivation = 0;
await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok');
return submitResult;
}
if (!shouldReplaceNumber) {
if (pageState?.addPhonePage) {
continue;
}
throw new Error('Phone verification did not complete successfully.');
}
if (
activation
&& (replaceReason === 'resend_throttled' || /^sms_timeout_after_/i.test(String(replaceReason || '')))
) {
await markCountrySmsFailure(activation.countryId, replaceReason || 'sms_timeout');
}
usedNumberReplacementAttempts += 1;
if (usedNumberReplacementAttempts > maxNumberReplacementAttempts) {
throw new Error(
`Step 9: phone verification did not succeed after ${maxNumberReplacementAttempts} number replacements. Last reason: ${replaceReason || 'unknown'}.`
);
}
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
addPhoneReentryWithSameActivation = 0;
let returnResult = { addPhonePage: true, phoneVerificationPage: false };
try {
returnResult = await returnToAddPhone(tabId);
} catch (returnError) {
await addLog(`Step 9: failed to return to add-phone page before replacing number. ${returnError.message}`, 'warn');
}
await addLog(
`Step 9: replacing number and retrying inside step 9 (${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts}).`,
'warn'
);
pageState = {
...pageState,
...returnResult,
addPhonePage: true,
phoneVerificationPage: false,
};
}
} catch (error) {
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
throw sanitizePhoneRestartStep7Error(sanitizePhoneCodeTimeoutError(error));
}
}
return {
completePhoneVerificationFlow,
normalizeActivation,
pollPhoneActivationCode,
reactivatePhoneActivation,
requestPhoneActivation,
};
}
return {
createPhoneVerificationHelpers,
};
});