Files
FlowPilot/phone-sms/providers/nexsms.js
T

1008 lines
38 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.
// phone-sms/providers/nexsms.js — NexSMS 接码平台适配层
(function attachNexSmsProvider(root, factory) {
root.PhoneSmsNexSmsProvider = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createNexSmsProviderModule() {
const PROVIDER_ID = 'nexsms';
const DEFAULT_BASE_URL = 'https://api.nexsms.net';
const DEFAULT_SERVICE_CODE = 'ot';
const DEFAULT_SERVICE_LABEL = 'OpenAI';
const DEFAULT_COUNTRY_ID = 1;
const DEFAULT_COUNTRY_LABEL = 'Country #1';
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
const DEFAULT_ACQUIRE_RETRY_ROUNDS = 3;
const DEFAULT_ACQUIRE_RETRY_DELAY_MS = 2000;
const DEFAULT_POLL_TIMEOUT_MS = 180000;
const DEFAULT_POLL_INTERVAL_MS = 5000;
const MAX_PRICE_CANDIDATES = 8;
const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::';
const ACQUIRE_PRIORITY_COUNTRY = 'country';
const ACQUIRE_PRIORITY_PRICE = 'price';
const ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high';
function normalizeBaseUrl(value = '') {
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
try {
return new URL(trimmed).toString().replace(/\/+$/, '');
} catch {
return DEFAULT_BASE_URL;
}
}
function normalizeText(value = '', fallback = '') {
return String(value || '').trim() || fallback;
}
function normalizeNexSmsCountryId(value, fallback = DEFAULT_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 DEFAULT_COUNTRY_ID;
}
function normalizeNexSmsCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) {
return normalizeText(value, fallback);
}
function normalizeCountryKey(value) {
const countryId = normalizeNexSmsCountryId(value, -1);
return countryId >= 0 ? String(countryId) : '';
}
function normalizeNexSmsServiceCode(value = '', fallback = DEFAULT_SERVICE_CODE) {
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
if (normalized) {
return normalized;
}
const fallbackNormalized = String(fallback || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
return fallbackNormalized || DEFAULT_SERVICE_CODE;
}
function normalizePriceLimit(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 normalizeAcquirePriority(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === ACQUIRE_PRIORITY_PRICE) {
return ACQUIRE_PRIORITY_PRICE;
}
if (normalized === ACQUIRE_PRIORITY_PRICE_HIGH) {
return ACQUIRE_PRIORITY_PRICE_HIGH;
}
return ACQUIRE_PRIORITY_COUNTRY;
}
function normalizeActivationRetryRounds(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_ACQUIRE_RETRY_ROUNDS;
}
return Math.max(1, Math.min(10, parsed));
}
function normalizeActivationRetryDelayMs(value) {
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_ACQUIRE_RETRY_DELAY_MS;
}
return Math.max(500, Math.min(30000, parsed));
}
function normalizeNexSmsCountryOrder(value = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const normalized = [];
const seen = new Set();
source.forEach((entry) => {
let id = -1;
let label = '';
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
id = normalizeNexSmsCountryId(entry.id ?? entry.countryId, -1);
label = normalizeText(entry.label ?? entry.countryLabel, '');
} else {
const text = String(entry || '').trim();
const structured = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/);
id = normalizeNexSmsCountryId(structured?.[1] || text, -1);
label = normalizeText(structured?.[2], '');
}
if (id < 0 || seen.has(id)) {
return;
}
seen.add(id);
normalized.push({
id,
label: label || `Country #${id}`,
});
});
return normalized.slice(0, 20);
}
function resolveCountryCandidates(state = {}) {
const candidates = normalizeNexSmsCountryOrder(state?.nexSmsCountryOrder);
if (candidates.length) {
return candidates;
}
return [{
id: normalizeNexSmsCountryId(state?.nexSmsCountryId, DEFAULT_COUNTRY_ID),
label: normalizeNexSmsCountryLabel(state?.nexSmsCountryLabel, DEFAULT_COUNTRY_LABEL),
}];
}
function resolveCountryLabel(state = {}, countryId = DEFAULT_COUNTRY_ID) {
const countryKey = normalizeCountryKey(countryId);
const matched = resolveCountryCandidates(state)
.find((entry) => normalizeCountryKey(entry.id) === countryKey);
return matched?.label || (countryKey ? `Country #${countryKey}` : DEFAULT_COUNTRY_LABEL);
}
function parsePayload(text) {
const trimmed = String(text || '').trim();
if (!trimmed) {
return '';
}
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
function describePayload(raw) {
if (typeof raw === 'string') {
return raw.trim();
}
if (raw && typeof raw === 'object') {
const message = normalizeText(raw.message || raw.error || raw.msg || raw.statusText, '');
if (message) {
return message;
}
try {
return JSON.stringify(raw);
} catch {
return String(raw);
}
}
return String(raw || '').trim();
}
function isSuccessPayload(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return false;
}
return Number(payload.code) === 0;
}
function isNoNumbersError(payloadOrMessage) {
const text = describePayload(payloadOrMessage);
return /numbers?\s+not\s+found|暂无可用|no\s+numbers|no\s+stock|库存.*0|not\s+available/i.test(text);
}
function isPendingMessage(payloadOrMessage) {
const text = describePayload(payloadOrMessage);
return /no\s+sms|暂无短信|waiting|not\s+arrived|empty|未收到|短信为空|no\s+records/i.test(text);
}
function isTerminalError(payloadOrMessage, status = 0) {
if (Number(status) === 401 || Number(status) === 403) {
return true;
}
const text = describePayload(payloadOrMessage);
return /invalid\s*api\s*key|bad[_\s-]*key|wrong[_\s-]*key|unauthorized|forbidden|no\s*balance|insufficient\s*balance|余额不足|账号.*封禁|banned/i.test(text);
}
function resolveConfig(state = {}, deps = {}) {
return {
apiKey: normalizeText(state?.nexSmsApiKey),
baseUrl: normalizeBaseUrl(state?.nexSmsBaseUrl || DEFAULT_BASE_URL),
serviceCode: normalizeNexSmsServiceCode(state?.nexSmsServiceCode, DEFAULT_SERVICE_CODE),
fetchImpl: deps.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null),
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
}
async function fetchPayload(config, path, actionLabel, options = {}) {
if (!config.fetchImpl) {
throw new Error('NexSMS 网络请求实现不可用。');
}
if (!config.apiKey) {
throw new Error('NexSMS API Key 缺失,请先在侧边栏保存接码 API Key。');
}
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), Number(config.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS)
: null;
try {
const method = String(options.method || 'GET').trim().toUpperCase() || 'GET';
const requestUrl = new URL(path.replace(/^\/+/, ''), `${config.baseUrl.replace(/\/+$/, '')}/`);
requestUrl.searchParams.set('apiKey', config.apiKey);
Object.entries(options.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
requestUrl.searchParams.set(key, String(value));
});
const headers = {
Accept: 'application/json',
...(options.headers && typeof options.headers === 'object' ? options.headers : {}),
};
const requestInit = {
method,
headers,
signal: controller?.signal,
};
if (method !== 'GET' && method !== 'HEAD' && options.body !== undefined) {
requestInit.body = typeof options.body === 'string'
? options.body
: JSON.stringify(options.body);
if (!requestInit.headers['Content-Type']) {
requestInit.headers['Content-Type'] = 'application/json';
}
}
const response = await config.fetchImpl(requestUrl.toString(), requestInit);
const text = await response.text();
const payload = parsePayload(text);
if (!response.ok) {
const error = new Error(`${actionLabel}失败:${describePayload(payload) || response.status}`);
error.payload = payload;
error.status = response.status;
throw error;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`${actionLabel}超时。`);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
async function fetchBalance(state = {}, deps = {}) {
const config = resolveConfig(state, deps);
const payload = await fetchPayload(config, '/api/user/getBalance', 'NexSMS get balance');
if (!isSuccessPayload(payload)) {
throw new Error(`NexSMS get balance 失败:${describePayload(payload) || 'empty response'}`);
}
const balance = Number(payload?.data?.balance);
return {
balance: Number.isFinite(balance) ? balance : null,
raw: payload,
};
}
async function fetchPrices(state = {}, countryConfig = {}, deps = {}) {
const config = resolveConfig(state, deps);
const countryId = normalizeNexSmsCountryId(countryConfig?.id, DEFAULT_COUNTRY_ID);
return fetchPayload(config, '/api/getCountryByService', 'NexSMS getCountryByService', {
query: {
serviceCode: config.serviceCode,
countryId,
},
});
}
function buildSortedUniquePriceCandidates(values = []) {
return Array.from(
new Set(
values
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.round(value * 10000) / 10000)
)
)
.sort((left, right) => left - right)
.slice(0, MAX_PRICE_CANDIDATES);
}
function collectPriceCandidates(countryData = {}) {
const candidates = [];
const pushCandidate = (value) => {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
candidates.push(Math.round(numeric * 10000) / 10000);
}
};
pushCandidate(countryData.minPrice);
pushCandidate(countryData.medianPrice);
pushCandidate(countryData.maxPrice);
if (countryData.priceMap && typeof countryData.priceMap === 'object') {
Object.entries(countryData.priceMap).forEach(([priceKey, count]) => {
const availableCount = Number(count);
if (!Number.isFinite(availableCount) || availableCount <= 0) {
return;
}
pushCandidate(priceKey);
});
}
return buildSortedUniquePriceCandidates(candidates);
}
function resolvePriceRange(state = {}) {
const minPriceLimit = normalizePriceLimit(state?.nexSmsMinPrice ?? state?.heroSmsMinPrice);
const maxPriceLimit = normalizePriceLimit(state?.nexSmsMaxPrice ?? state?.heroSmsMaxPrice);
return {
minPriceLimit,
maxPriceLimit,
hasMinPriceLimit: minPriceLimit !== null,
hasMaxPriceLimit: maxPriceLimit !== null,
invalidRange: minPriceLimit !== null && maxPriceLimit !== null && minPriceLimit > maxPriceLimit,
};
}
function isPriceWithinRange(price, minPriceLimit = null, maxPriceLimit = null) {
const numeric = Number(price);
if (!Number.isFinite(numeric) || numeric <= 0) {
return false;
}
const normalized = Math.round(numeric * 10000) / 10000;
if (minPriceLimit !== null && normalized < minPriceLimit) {
return false;
}
if (maxPriceLimit !== null && normalized > maxPriceLimit) {
return false;
}
return true;
}
function filterPriceCandidatesWithinRange(prices = [], minPriceLimit = null, maxPriceLimit = null) {
return (Array.isArray(prices) ? prices : []).filter((price) => (
isPriceWithinRange(price, minPriceLimit, maxPriceLimit)
));
}
function filterPriceCandidatesAboveFloor(prices = [], minExclusivePrice = null) {
const floor = normalizePriceLimit(minExclusivePrice);
if (floor === null || floor <= 0) {
return Array.isArray(prices) ? [...prices] : [];
}
return (Array.isArray(prices) ? prices : []).filter((value) => {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > floor;
});
}
function reorderPriceCandidates(prices = [], acquirePriority = ACQUIRE_PRIORITY_COUNTRY, preferredPrice = null) {
const ordered = buildSortedUniquePriceCandidates(prices);
if (acquirePriority === ACQUIRE_PRIORITY_PRICE_HIGH) {
ordered.reverse();
}
const preferred = normalizePriceLimit(preferredPrice);
if (preferred === null) {
return ordered;
}
return [preferred, ...ordered.filter((price) => price !== preferred)];
}
function normalizePriceFloorMap(rawMap = {}, normalizeCountryKey) {
const normalizedMap = new Map();
if (!rawMap || typeof rawMap !== 'object') {
return normalizedMap;
}
Object.entries(rawMap).forEach(([rawCountryKey, rawPrice]) => {
const countryKey = String(
typeof normalizeCountryKey === 'function'
? normalizeCountryKey(rawCountryKey)
: rawCountryKey
).trim();
if (!countryKey) {
return;
}
const normalizedPrice = normalizePriceLimit(rawPrice);
if (normalizedPrice === null || normalizedPrice <= 0) {
return;
}
normalizedMap.set(countryKey, normalizedPrice);
});
return normalizedMap;
}
function formatPriceRangeText(minPriceLimit = null, maxPriceLimit = null) {
const minPrice = normalizePriceLimit(minPriceLimit);
const maxPrice = normalizePriceLimit(maxPriceLimit);
if (minPrice !== null && maxPrice !== null) {
return `${minPrice}~${maxPrice}`;
}
if (minPrice !== null) {
return `${minPrice}~`;
}
if (maxPrice !== null) {
return `~${maxPrice}`;
}
return 'unbounded';
}
async function resolveCountryPricePlan(state = {}, countryConfig = {}, deps = {}) {
const config = resolveConfig(state, deps);
const countryId = normalizeNexSmsCountryId(countryConfig?.id, -1);
if (countryId < 0) {
throw new Error(`NexSMS 国家 ID 无效:${countryConfig?.id}`);
}
const payload = await fetchPayload(config, '/api/getCountryByService', 'NexSMS getCountryByService', {
query: {
serviceCode: config.serviceCode,
countryId,
},
});
if (!isSuccessPayload(payload)) {
throw new Error(`NexSMS getCountryByService 失败:${describePayload(payload) || 'empty response'}`);
}
const countryData = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload.data || {})
: {};
const countryLabel = normalizeNexSmsCountryLabel(
countryData.countryName || countryConfig?.label,
`Country #${countryId}`
);
const prices = collectPriceCandidates(countryData);
const minCatalogPrice = prices.length
? prices[0]
: (() => {
const minPrice = Number(countryData.minPrice);
return Number.isFinite(minPrice) && minPrice > 0
? Math.round(minPrice * 10000) / 10000
: null;
})();
const priceRange = resolvePriceRange(state);
const filteredPrices = filterPriceCandidatesWithinRange(
prices,
priceRange.minPriceLimit,
priceRange.maxPriceLimit
);
return {
countryId,
countryLabel,
prices: filteredPrices,
userLimit: priceRange.maxPriceLimit,
minCatalogPrice,
rawPayload: payload,
};
}
function normalizeActivation(payload, fallback = {}) {
const source = payload && typeof payload === 'object' && !Array.isArray(payload)
? payload
: {};
const data = isSuccessPayload(source) ? (source.data || {}) : source;
const phoneCandidates = Array.isArray(data.phoneNumbers)
? data.phoneNumbers
: (Array.isArray(data.numbers) ? data.numbers : []);
const phoneNumber = normalizeText(
data.phoneNumber
|| data.phone
|| phoneCandidates[0]
|| source.phoneNumber
|| source.phone
|| fallback.phoneNumber
);
if (!phoneNumber) {
return null;
}
const countryId = normalizeNexSmsCountryId(
data.countryId ?? source.countryId ?? fallback.countryId,
DEFAULT_COUNTRY_ID
);
return {
activationId: normalizeText(data.activationId || source.activationId || fallback.activationId, phoneNumber),
phoneNumber,
provider: PROVIDER_ID,
serviceCode: normalizeNexSmsServiceCode(
data.serviceCode || source.serviceCode || fallback.serviceCode || DEFAULT_SERVICE_CODE,
DEFAULT_SERVICE_CODE
),
countryId,
countryLabel: normalizeNexSmsCountryLabel(
data.countryName || source.countryName || data.countryLabel || source.countryLabel || fallback.countryLabel,
`Country #${countryId}`
),
successfulUses: Math.max(0, Math.floor(Number(fallback.successfulUses) || 0)),
maxUses: 1,
...(fallback.selectedPrice !== undefined ? { selectedPrice: Number(fallback.selectedPrice) } : {}),
...(fallback.price !== undefined ? { price: Number(fallback.price) } : {}),
...(fallback.maxPrice !== undefined ? { maxPrice: Number(fallback.maxPrice) } : {}),
};
}
function resolveActivationCountry(activation = {}, state = {}) {
const normalizedActivation = normalizeActivation(activation)
|| (activation && typeof activation === 'object' ? activation : {});
const countryId = normalizeNexSmsCountryId(
normalizedActivation.countryId ?? normalizedActivation.country,
DEFAULT_COUNTRY_ID
);
const matched = resolveCountryCandidates(state)
.find((entry) => normalizeNexSmsCountryId(entry.id, -1) === countryId);
if (matched) {
return matched;
}
return {
id: countryId,
label: normalizeNexSmsCountryLabel(normalizedActivation.countryLabel, `Country #${countryId}`),
};
}
function getActivationCountryKey(activation = {}) {
return normalizeCountryKey(activation?.countryId ?? activation?.country);
}
function getActivationPrice(activation = {}) {
return Number.isFinite(Number(activation?.selectedPrice))
? Math.round(Number(activation.selectedPrice) * 10000) / 10000
: null;
}
async function requestActivation(state = {}, options = {}, deps = {}) {
const config = resolveConfig(state, deps);
const allCountryCandidates = resolveCountryCandidates(state);
if (!allCountryCandidates.length) {
throw new Error('步骤 9:NexSMS 未选择国家,请先在接码设置中至少选择 1 个国家。');
}
const blockedCountryIds = new Set(
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
.map((value) => normalizeNexSmsCountryId(value, -1))
.filter((id) => id >= 0)
);
let countryCandidates = allCountryCandidates.filter((entry) => {
const id = normalizeNexSmsCountryId(entry.id, -1);
return id >= 0 && !blockedCountryIds.has(id);
});
if (!countryCandidates.length) {
countryCandidates = allCountryCandidates;
if (blockedCountryIds.size && typeof deps.addLog === 'function') {
await deps.addLog('步骤 9:已选国家均达到临时收码失败跳过阈值,本轮解除跳过并重新尝试。', 'warn');
}
}
const acquirePriority = normalizeAcquirePriority(state?.heroSmsAcquirePriority);
const priceRange = resolvePriceRange(state);
if (priceRange.invalidRange) {
throw new Error(`NexSMS 价格区间无效:最低购买价 ${priceRange.minPriceLimit} 高于价格上限 ${priceRange.maxPriceLimit}`);
}
const hasPriceBounds = priceRange.hasMinPriceLimit || priceRange.hasMaxPriceLimit;
const preferredPriceTier = normalizePriceLimit(state?.heroSmsPreferredPrice);
const countryPriceFloorByCountryId = normalizePriceFloorMap(
options?.countryPriceFloorByCountryId,
(value) => String(normalizeNexSmsCountryId(value, -1))
);
const maxAcquireRounds = Math.max(2, normalizeActivationRetryRounds(state?.heroSmsActivationRetryRounds));
const retryDelayMs = normalizeActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs);
let finalNoNumbersByCountry = [];
let finalLastError = null;
for (let round = 1; round <= maxAcquireRounds; round += 1) {
if (maxAcquireRounds > 1 && typeof deps.addLog === 'function') {
await deps.addLog(`步骤 9:NexSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`, 'info');
}
const candidateAttempts = countryCandidates.map((countryConfig, index) => ({
index,
countryConfig,
pricePlan: null,
orderingPrice: Number.POSITIVE_INFINITY,
}));
if (
(acquirePriority === ACQUIRE_PRIORITY_PRICE || acquirePriority === ACQUIRE_PRIORITY_PRICE_HIGH)
&& candidateAttempts.length > 1
) {
for (const attempt of candidateAttempts) {
try {
const pricePlan = await resolveCountryPricePlan(state, attempt.countryConfig, deps);
attempt.pricePlan = pricePlan;
const orderedForRanking = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
const rangeFilteredForRanking = filterPriceCandidatesWithinRange(
orderedForRanking,
priceRange.minPriceLimit,
priceRange.maxPriceLimit
);
const rankingPrices = rangeFilteredForRanking.length
? rangeFilteredForRanking
: (hasPriceBounds ? [] : orderedForRanking);
attempt.orderingPrice = Array.isArray(rankingPrices) && rankingPrices.length
? Number(rankingPrices[0])
: Number.POSITIVE_INFINITY;
} catch (error) {
attempt.lookupError = error;
}
}
candidateAttempts.sort((left, right) => {
if (left.orderingPrice !== right.orderingPrice) {
return acquirePriority === ACQUIRE_PRIORITY_PRICE_HIGH
? (right.orderingPrice - left.orderingPrice)
: (left.orderingPrice - right.orderingPrice);
}
return left.index - right.index;
});
if (typeof deps.addLog === 'function') {
const rankingSummary = candidateAttempts.map((attempt) => {
const id = normalizeNexSmsCountryId(attempt.countryConfig.id, -1);
const label = normalizeText(attempt.countryConfig.label, `Country #${id}`);
return Number.isFinite(attempt.orderingPrice)
? `${label}:${attempt.orderingPrice}`
: `${label}:无`;
}).join(' | ');
await deps.addLog(`步骤 9NexSMS 价格优先排序:${rankingSummary}`, 'info');
}
}
const noNumbersByCountry = [];
const retryableNoNumberCountries = [];
let lastError = null;
for (const attempt of candidateAttempts) {
const countryId = normalizeNexSmsCountryId(attempt.countryConfig.id, -1);
const countryLabel = normalizeNexSmsCountryLabel(attempt.countryConfig.label, `Country #${countryId}`);
const countryPriceFloor = countryPriceFloorByCountryId.get(String(countryId)) ?? null;
let pricePlan = attempt.pricePlan;
if (!pricePlan) {
try {
pricePlan = await resolveCountryPricePlan(state, attempt.countryConfig, deps);
} catch (error) {
if (isTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`NexSMS price lookup 失败:${describePayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
lastError = error;
continue;
}
}
if (!Array.isArray(pricePlan.prices) || !pricePlan.prices.length) {
if (
pricePlan.userLimit !== null
&& pricePlan.minCatalogPrice !== null
&& pricePlan.minCatalogPrice > pricePlan.userLimit
) {
noNumbersByCountry.push(`${countryLabel}: 价格上限 ${pricePlan.userLimit} 内暂无可用号码;平台最低价=${pricePlan.minCatalogPrice}`);
} else {
const reason = describePayload(pricePlan.rawPayload) || '无可用价格档位';
noNumbersByCountry.push(`${countryLabel}: ${reason}`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
}
const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
const rangeFilteredPrices = filterPriceCandidatesWithinRange(
orderedPrices,
priceRange.minPriceLimit,
priceRange.maxPriceLimit
);
const candidatePrices = rangeFilteredPrices.length
? rangeFilteredPrices
: (hasPriceBounds ? [] : orderedPrices);
const floorFilteredPrices = filterPriceCandidatesAboveFloor(candidatePrices, countryPriceFloor);
const hasCountryPriceFloor = (
countryPriceFloor !== null
&& Number.isFinite(Number(countryPriceFloor))
&& Number(countryPriceFloor) > 0
);
const hasAlternativeCountries = candidateAttempts.some((entry) => (
normalizeNexSmsCountryId(entry?.countryConfig?.id, -1)
!== normalizeNexSmsCountryId(attempt?.countryConfig?.id, -1)
));
const pricesToTry = hasCountryPriceFloor
? (floorFilteredPrices.length ? floorFilteredPrices : (hasAlternativeCountries ? [] : candidatePrices.slice(0, 1)))
: (floorFilteredPrices.length ? floorFilteredPrices : candidatePrices);
if (!pricesToTry.length) {
if (priceRange.hasMinPriceLimit && !rangeFilteredPrices.length) {
noNumbersByCountry.push(`${countryLabel}: 价格区间 ${formatPriceRangeText(priceRange.minPriceLimit, priceRange.maxPriceLimit)} 内暂无可用号码`);
continue;
}
if (
countryPriceFloor !== null
&& Array.isArray(pricePlan.prices)
&& pricePlan.prices.length > 0
) {
noNumbersByCountry.push(`${countryLabel}: 当前回退尝试没有高于 ${countryPriceFloor} 的价格档位`);
} else {
noNumbersByCountry.push(`${countryLabel}: ${describePayload(pricePlan.rawPayload) || '暂无可用号码'}`);
retryableNoNumberCountries.push(countryLabel);
}
continue;
}
let boughtActivation = null;
for (const price of pricesToTry) {
try {
const payload = await fetchPayload(config, '/api/order/purchase', 'NexSMS purchase', {
method: 'POST',
body: {
serviceCode: config.serviceCode,
countryId,
quantity: 1,
price,
},
});
if (!isSuccessPayload(payload)) {
if (isNoNumbersError(payload)) {
continue;
}
if (isTerminalError(payload)) {
throw new Error(`NexSMS purchase 失败:${describePayload(payload) || 'empty response'}`);
}
lastError = new Error(`NexSMS purchase 失败:${describePayload(payload) || 'empty response'}`);
continue;
}
boughtActivation = normalizeActivation(payload, {
countryId,
countryLabel,
serviceCode: config.serviceCode,
selectedPrice: price,
});
if (!boughtActivation) {
lastError = new Error('NexSMS 购买成功,但未返回手机号。');
continue;
}
return boughtActivation;
} catch (error) {
if (isTerminalError(error?.payload || error?.message, error?.status)) {
throw new Error(`NexSMS purchase 失败:${describePayload(error?.payload || error?.message) || 'unknown terminal error'}`);
}
if (isNoNumbersError(error?.payload || error?.message)) {
continue;
}
lastError = error;
}
}
if (!boughtActivation) {
const fallbackReason = describePayload(pricePlan.rawPayload) || '暂无可用号码';
noNumbersByCountry.push(`${countryLabel}: ${fallbackReason}`);
retryableNoNumberCountries.push(countryLabel);
}
}
finalNoNumbersByCountry = noNumbersByCountry;
finalLastError = lastError;
if (
noNumbersByCountry.length
&& round < maxAcquireRounds
&& retryableNoNumberCountries.length > 0
) {
if (typeof deps.addLog === 'function') {
await deps.addLog(
`步骤 9:NexSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}`,
'warn'
);
}
await deps.sleepWithStop?.(retryDelayMs);
continue;
}
break;
}
if (finalNoNumbersByCountry.length) {
throw new Error(`NexSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}`);
}
if (finalLastError) {
throw finalLastError;
}
throw new Error('NexSMS 获取手机号失败。');
}
function extractVerificationCode(value = '') {
const text = String(value || '').trim();
if (!text) {
return '';
}
return text.match(/\b(\d{4,8})\b/)?.[1] || '';
}
async function pollActivationCode(state = {}, activation, options = {}, deps = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('缺少手机号接码订单。');
}
const config = resolveConfig(state, deps);
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || DEFAULT_POLL_TIMEOUT_MS);
const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_POLL_INTERVAL_MS);
const maxRoundsRaw = Math.floor(Number(options.maxRounds));
const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0;
const start = Date.now();
let pollCount = 0;
let lastResponse = '';
const emitWaitingForCode = async (statusText) => {
if (typeof options.onWaitingForCode === 'function') {
await options.onWaitingForCode({
activation: normalizedActivation,
elapsedMs: Date.now() - start,
pollCount,
statusText,
timeoutMs,
});
}
};
while (Date.now() - start < timeoutMs) {
if (maxRounds > 0 && pollCount >= maxRounds) {
break;
}
deps.throwIfStopped?.();
const payload = await fetchPayload(config, '/api/sms/messages', 'NexSMS get sms messages', {
query: {
phoneNumber: normalizedActivation.phoneNumber,
format: 'json_latest',
},
});
const text = describePayload(payload);
lastResponse = text;
pollCount += 1;
if (typeof options.onStatus === 'function') {
await options.onStatus({
activation: normalizedActivation,
elapsedMs: Date.now() - start,
pollCount,
statusText: text || 'PENDING',
timeoutMs,
});
}
if (isSuccessPayload(payload)) {
const code = extractVerificationCode(payload?.data?.code || payload?.data?.text || payload?.data?.message || '');
if (code) {
return code;
}
await emitWaitingForCode(text || 'PENDING');
await deps.sleepWithStop?.(intervalMs);
continue;
}
if (isPendingMessage(payload)) {
await emitWaitingForCode(text || 'PENDING');
await deps.sleepWithStop?.(intervalMs);
continue;
}
if (isTerminalError(payload)) {
throw new Error(`NexSMS get sms messages 失败:${text || 'unknown terminal error'}`);
}
await emitWaitingForCode(text || 'PENDING');
await deps.sleepWithStop?.(intervalMs);
}
throw new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}等待手机验证码超时。${lastResponse ? ` NexSMS 最后状态:${lastResponse}` : ''}`);
}
async function closeActivation(state = {}, activation, deps = {}, actionLabel = 'NexSMS close activation') {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
return '';
}
const config = resolveConfig(state, deps);
const payload = await fetchPayload(config, '/api/close/activation', actionLabel, {
method: 'POST',
body: {
phoneNumber: normalizedActivation.phoneNumber,
},
});
if (!isSuccessPayload(payload)) {
throw new Error(`NexSMS close activation 失败:${describePayload(payload) || 'empty response'}`);
}
return describePayload(payload);
}
async function reuseActivation() {
throw new Error('NexSMS 当前流程不支持复用手机号订单。');
}
async function finishActivation() {
return 'NexSMS complete skipped';
}
async function cancelActivation(state = {}, activation, deps = {}) {
return closeActivation(state, activation, deps, 'NexSMS close activation');
}
async function banActivation(state = {}, activation, deps = {}) {
return closeActivation(state, activation, deps, 'NexSMS close activation');
}
async function requestAdditionalSms() {
return '';
}
async function rotateActivation(state = {}, activation, options = {}, deps = {}) {
const releaseAction = String(options?.releaseAction || '').trim().toLowerCase() === 'ban'
? 'ban'
: 'cancel';
if (releaseAction === 'ban') {
await banActivation(state, activation, deps);
} else {
await cancelActivation(state, activation, deps);
}
return {
currentTicketId: String(activation?.activationId || activation?.phoneNumber || ''),
nextActivation: null,
};
}
function createProvider(deps = {}) {
const providerDeps = {
fetchImpl: deps.fetchImpl,
sleepWithStop: deps.sleepWithStop,
throwIfStopped: deps.throwIfStopped,
addLog: deps.addLog,
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
const capabilities = Object.freeze({
supportsReusableActivation: false,
supportsAutomaticFreeReuse: false,
supportsFreeReusePreservation: false,
supportsPageResend: true,
supportsPageResendProbe: true,
requiresCountrySelection: true,
});
return {
id: PROVIDER_ID,
label: 'NexSMS',
capabilities,
defaultCountryId: DEFAULT_COUNTRY_ID,
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
defaultProduct: DEFAULT_SERVICE_LABEL,
defaultServiceCode: DEFAULT_SERVICE_CODE,
normalizeCountryId: normalizeNexSmsCountryId,
normalizeCountryLabel: normalizeNexSmsCountryLabel,
normalizeCountryOrder: normalizeNexSmsCountryOrder,
normalizeCountryKey,
normalizeServiceCode: normalizeNexSmsServiceCode,
normalizeActivation,
resolveCountryCandidates,
resolveCountryLabel,
resolveActivationCountry,
getActivationCountryKey,
getActivationPrice,
requestActivation: (state, options) => requestActivation(state, options, providerDeps),
reuseActivation: (state, activation) => reuseActivation(state, activation, providerDeps),
finishActivation: (state, activation) => finishActivation(state, activation, providerDeps),
cancelActivation: (state, activation) => cancelActivation(state, activation, providerDeps),
banActivation: (state, activation) => banActivation(state, activation, providerDeps),
requestAdditionalSms: (state, activation) => requestAdditionalSms(state, activation, providerDeps),
rotateActivation: (state, activation, options) => rotateActivation(state, activation, options, providerDeps),
pollActivationCode: (state, activation, options) => pollActivationCode(state, activation, options, providerDeps),
prepareActivationForReuse: reuseActivation,
canPersistReusableActivation: () => false,
canPreserveActivationForFreeReuse: () => false,
shouldUsePageResend: () => true,
shouldProbePageResend: () => true,
fetchBalance: (state) => fetchBalance(state, providerDeps),
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
resolveCountryPricePlan: (state, countryConfig) => resolveCountryPricePlan(state, countryConfig, providerDeps),
resolvePriceRange,
formatPriceRangeText,
describePayload,
isSuccessPayload,
isNoNumbersError,
isPendingMessage,
isTerminalError,
};
}
return {
PROVIDER_ID,
DEFAULT_BASE_URL,
DEFAULT_COUNTRY_ID,
DEFAULT_COUNTRY_LABEL,
DEFAULT_SERVICE_CODE,
DEFAULT_SERVICE_LABEL,
createProvider,
describePayload,
isSuccessPayload,
normalizeNexSmsCountryId,
normalizeNexSmsCountryLabel,
normalizeNexSmsCountryOrder,
normalizeCountryKey,
normalizeNexSmsServiceCode,
normalizeActivation,
resolveActivationCountry,
};
});