1723 lines
66 KiB
JavaScript
1723 lines
66 KiB
JavaScript
(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,
|
||
};
|
||
});
|