1509 lines
58 KiB
JavaScript
1509 lines
58 KiB
JavaScript
// phone-sms/providers/hero-sms.js — HeroSMS 接码平台适配层
|
||
(function attachHeroSmsProvider(root, factory) {
|
||
root.PhoneSmsHeroSmsProvider = factory();
|
||
})(typeof self !== 'undefined' ? self : globalThis, function createHeroSmsProviderModule() {
|
||
const PROVIDER_ID = 'hero-sms';
|
||
const DEFAULT_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php';
|
||
const DEFAULT_SERVICE_CODE = 'dr';
|
||
const DEFAULT_SERVICE_LABEL = 'OpenAI';
|
||
const DEFAULT_COUNTRY_ID = 52;
|
||
const DEFAULT_COUNTRY_LABEL = 'Thailand';
|
||
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
|
||
const DEFAULT_OPERATOR = 'any';
|
||
const DEFAULT_MAX_USES = 3;
|
||
const DEFAULT_PRICE_LOOKUP_ATTEMPTS = 3;
|
||
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 HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
|
||
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
|
||
const HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high';
|
||
const COUNTRY_BY_PHONE_PREFIX = Object.freeze([
|
||
{ prefix: '84', id: 10, iso: 'VN', label: 'Vietnam' },
|
||
{ prefix: '66', id: 52, iso: 'TH', label: 'Thailand' },
|
||
{ prefix: '62', id: 6, iso: 'ID', label: 'Indonesia' },
|
||
{ prefix: '44', id: 16, iso: 'GB', label: 'United Kingdom' },
|
||
{ prefix: '81', id: 151, iso: 'JP', label: 'Japan' },
|
||
{ prefix: '49', id: 43, iso: 'DE', label: 'Germany' },
|
||
{ prefix: '33', id: 73, iso: 'FR', label: 'France' },
|
||
{ prefix: '1', id: 187, iso: 'US', label: 'USA' },
|
||
]);
|
||
|
||
function normalizeHeroSmsCountryId(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));
|
||
return Number.isFinite(fallbackParsed) && fallbackParsed > 0 ? fallbackParsed : DEFAULT_COUNTRY_ID;
|
||
}
|
||
|
||
function normalizeOptionalHeroSmsCountryId(value) {
|
||
const parsed = Math.floor(Number(value));
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||
}
|
||
|
||
function normalizeHeroSmsCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) {
|
||
return String(value || '').trim() || fallback;
|
||
}
|
||
|
||
function normalizeCountryKey(value) {
|
||
const countryId = normalizeHeroSmsCountryId(value, 0);
|
||
return countryId > 0 ? String(countryId) : '';
|
||
}
|
||
|
||
function inferCountryFromPhoneNumber(phoneNumber = '') {
|
||
const digits = String(phoneNumber || '').replace(/\D+/g, '');
|
||
if (!digits) {
|
||
return null;
|
||
}
|
||
const match = COUNTRY_BY_PHONE_PREFIX.find((entry) => digits.startsWith(entry.prefix));
|
||
if (!match) {
|
||
return null;
|
||
}
|
||
return {
|
||
id: normalizeHeroSmsCountryId(match.id, 0),
|
||
iso: match.iso,
|
||
label: normalizeHeroSmsCountryLabel(match.label, `Country #${match.id}`),
|
||
};
|
||
}
|
||
|
||
function normalizeHeroSmsMaxPrice(value = '') {
|
||
const rawValue = String(value ?? '').trim();
|
||
if (!rawValue) return '';
|
||
const numeric = Number(rawValue);
|
||
if (!Number.isFinite(numeric) || numeric <= 0) return '';
|
||
return String(Math.round(numeric * 10000) / 10000);
|
||
}
|
||
|
||
function normalizeHeroSmsPrice(value) {
|
||
const direct = Number(value);
|
||
if (Number.isFinite(direct) && direct >= 0) {
|
||
return direct;
|
||
}
|
||
const text = String(value ?? '').trim();
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
const matched = text.match(/-?\d+(?:[.,]\d+)?/);
|
||
if (!matched) {
|
||
return null;
|
||
}
|
||
const parsed = Number(String(matched[0] || '').replace(',', '.'));
|
||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||
}
|
||
|
||
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 normalizeHeroSmsOperator(value = '', fallback = DEFAULT_OPERATOR) {
|
||
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_OPERATOR;
|
||
}
|
||
|
||
function normalizeHeroSmsAcquirePriority(value = '') {
|
||
const normalized = String(value || '').trim().toLowerCase();
|
||
if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE) {
|
||
return HERO_SMS_ACQUIRE_PRIORITY_PRICE;
|
||
}
|
||
if (normalized === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH) {
|
||
return HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH;
|
||
}
|
||
return HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
|
||
}
|
||
|
||
function 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 normalizeHeroSmsCountryFallback(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 = normalizeHeroSmsCountryId(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*(.+))?$/);
|
||
id = normalizeHeroSmsCountryId(structured?.[1] || text, 0);
|
||
label = String(structured?.[2] || '').trim();
|
||
}
|
||
if (!id || seen.has(id)) continue;
|
||
seen.add(id);
|
||
normalized.push({ id, label: label || `Country #${id}` });
|
||
if (normalized.length >= 20) break;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function normalizeBaseUrl(value = '') {
|
||
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
|
||
try {
|
||
return new URL(trimmed).toString();
|
||
} catch {
|
||
return DEFAULT_BASE_URL;
|
||
}
|
||
}
|
||
|
||
function buildUrl(config = {}, query = {}) {
|
||
const url = new URL(normalizeBaseUrl(config.baseUrl));
|
||
Object.entries(query || {}).forEach(([key, value]) => {
|
||
if (value === undefined || value === null || value === '') return;
|
||
url.searchParams.set(key, String(value));
|
||
});
|
||
return url.toString();
|
||
}
|
||
|
||
function parsePayload(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 describePayload(raw) {
|
||
if (typeof raw === 'string') return raw.trim();
|
||
if (raw && typeof raw === 'object') {
|
||
const direct = String(raw.message || raw.msg || raw.error || raw.title || raw.status || '').trim();
|
||
if (direct) return direct;
|
||
try { return JSON.stringify(raw); } catch { return String(raw); }
|
||
}
|
||
return String(raw || '').trim();
|
||
}
|
||
|
||
function resolveConfig(state = {}, deps = {}) {
|
||
return {
|
||
apiKey: String(state.heroSmsApiKey || '').trim(),
|
||
baseUrl: state.heroSmsBaseUrl || DEFAULT_BASE_URL,
|
||
operator: normalizeHeroSmsOperator(state.heroSmsOperator, DEFAULT_OPERATOR),
|
||
fetchImpl: deps.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null),
|
||
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
||
};
|
||
}
|
||
|
||
function assertApiKey(config) {
|
||
if (!config.apiKey) {
|
||
throw new Error('HeroSMS API Key 缺失,请先在侧边栏保存接码 API Key。');
|
||
}
|
||
}
|
||
|
||
async function fetchPayload(config, query, actionLabel = 'HeroSMS request') {
|
||
assertApiKey(config);
|
||
if (query.api_key === undefined && config.apiKey) {
|
||
query = { api_key: config.apiKey, ...query };
|
||
}
|
||
if (!config.fetchImpl) {
|
||
throw new Error('HeroSMS 网络请求实现不可用。');
|
||
}
|
||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||
const timeoutId = controller
|
||
? setTimeout(() => controller.abort(), Number(config.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS)
|
||
: null;
|
||
try {
|
||
const response = await config.fetchImpl(buildUrl(config, query), {
|
||
method: 'GET',
|
||
signal: controller?.signal,
|
||
});
|
||
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);
|
||
}
|
||
}
|
||
|
||
function resolveCountryConfig(state = {}) {
|
||
const hasExplicitPrimaryCountry = Object.prototype.hasOwnProperty.call(state || {}, 'heroSmsCountryId');
|
||
const fallbackList = normalizeHeroSmsCountryFallback(state.heroSmsCountryFallback);
|
||
const primaryCountryId = normalizeOptionalHeroSmsCountryId(state.heroSmsCountryId);
|
||
if (primaryCountryId > 0) {
|
||
return {
|
||
id: primaryCountryId,
|
||
label: normalizeHeroSmsCountryLabel(state.heroSmsCountryLabel),
|
||
};
|
||
}
|
||
if (hasExplicitPrimaryCountry) {
|
||
if (fallbackList.length) {
|
||
const firstFallback = fallbackList[0];
|
||
return {
|
||
id: normalizeOptionalHeroSmsCountryId(firstFallback.id),
|
||
label: normalizeHeroSmsCountryLabel(firstFallback.label, `Country #${firstFallback.id}`),
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
return {
|
||
id: normalizeHeroSmsCountryId(DEFAULT_COUNTRY_ID, DEFAULT_COUNTRY_ID),
|
||
label: normalizeHeroSmsCountryLabel(state.heroSmsCountryLabel),
|
||
};
|
||
}
|
||
|
||
function resolveCountryCandidates(state = {}) {
|
||
const primary = resolveCountryConfig(state);
|
||
const fallbackList = normalizeHeroSmsCountryFallback(state.heroSmsCountryFallback);
|
||
if (!primary || !Number.isFinite(primary.id) || primary.id <= 0) {
|
||
return fallbackList
|
||
.map((entry) => ({
|
||
id: normalizeOptionalHeroSmsCountryId(entry.id),
|
||
label: normalizeHeroSmsCountryLabel(entry.label, `Country #${entry.id}`),
|
||
}))
|
||
.filter((entry) => entry.id > 0);
|
||
}
|
||
const seen = new Set([primary.id]);
|
||
const candidates = [primary];
|
||
fallbackList.forEach((entry) => {
|
||
const id = normalizeOptionalHeroSmsCountryId(entry.id);
|
||
if (!id || seen.has(id)) return;
|
||
seen.add(id);
|
||
candidates.push({ id, label: normalizeHeroSmsCountryLabel(entry.label, `Country #${id}`) });
|
||
});
|
||
return candidates;
|
||
}
|
||
|
||
async function fetchBalance(state = {}, deps = {}) {
|
||
const config = resolveConfig(state, deps);
|
||
if (!config.apiKey) {
|
||
throw new Error('HeroSMS API Key 缺失,请先在侧边栏保存接码 API Key。');
|
||
}
|
||
const payload = await fetchPayload(config, { action: 'getBalance' }, 'HeroSMS getBalance');
|
||
const balance = Number(String(describePayload(payload)).replace(/^ACCESS_BALANCE:/i, '').trim());
|
||
return { balance, raw: payload };
|
||
}
|
||
|
||
async function fetchPrices(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) {
|
||
const config = resolveConfig(state, deps);
|
||
return fetchPayload(config, {
|
||
action: 'getPrices',
|
||
service: DEFAULT_SERVICE_CODE,
|
||
country: normalizeHeroSmsCountryId(countryConfig?.id),
|
||
}, 'HeroSMS getPrices');
|
||
}
|
||
|
||
function shouldUseExpandedPriceLookup(state = {}) {
|
||
if (typeof state?.heroSmsUseExpandedPriceLookup === 'boolean') {
|
||
return state.heroSmsUseExpandedPriceLookup;
|
||
}
|
||
const runningInNode = (
|
||
typeof process !== 'undefined'
|
||
&& process
|
||
&& process.versions
|
||
&& process.versions.node
|
||
);
|
||
return !runningInNode;
|
||
}
|
||
|
||
function resolveStockState(payload = {}) {
|
||
const physicalCount = Number(payload.physicalCount);
|
||
if (Number.isFinite(physicalCount)) {
|
||
return { hasStockField: true, stockCount: physicalCount };
|
||
}
|
||
const stockCandidates = [
|
||
payload.count,
|
||
payload.stock,
|
||
payload.available,
|
||
payload.quantity,
|
||
payload.qty,
|
||
payload.left,
|
||
payload.free,
|
||
]
|
||
.map((value) => Number(value))
|
||
.filter((value) => Number.isFinite(value));
|
||
if (!stockCandidates.length) {
|
||
return { hasStockField: false, stockCount: 0 };
|
||
}
|
||
return { hasStockField: true, stockCount: Math.max(...stockCandidates) };
|
||
}
|
||
|
||
function collectPriceCandidates(payload, candidates = []) {
|
||
if (Array.isArray(payload)) {
|
||
payload.forEach((entry) => collectPriceCandidates(entry, candidates));
|
||
return candidates;
|
||
}
|
||
if (!payload || typeof payload !== 'object') {
|
||
return candidates;
|
||
}
|
||
const cost = normalizeHeroSmsPrice(payload.cost);
|
||
if (cost !== null) {
|
||
const stockState = resolveStockState(payload);
|
||
if (!stockState.hasStockField || stockState.stockCount > 0) {
|
||
candidates.push(cost);
|
||
}
|
||
}
|
||
Object.entries(payload).forEach(([key, value]) => {
|
||
const keyedPrice = normalizeHeroSmsPrice(key);
|
||
if (keyedPrice === null) {
|
||
return;
|
||
}
|
||
if (value && typeof value === 'object') {
|
||
const stockState = resolveStockState(value);
|
||
if (stockState.hasStockField && stockState.stockCount > 0) {
|
||
candidates.push(keyedPrice);
|
||
}
|
||
return;
|
||
}
|
||
const numericCount = Number(value);
|
||
if (Number.isFinite(numericCount) && numericCount > 0) {
|
||
candidates.push(keyedPrice);
|
||
}
|
||
});
|
||
Object.values(payload).forEach((value) => collectPriceCandidates(value, candidates));
|
||
return candidates;
|
||
}
|
||
|
||
function collectPriceCandidatesIncludingZeroStock(payload, candidates = []) {
|
||
if (Array.isArray(payload)) {
|
||
payload.forEach((entry) => collectPriceCandidatesIncludingZeroStock(entry, candidates));
|
||
return candidates;
|
||
}
|
||
if (!payload || typeof payload !== 'object') {
|
||
return candidates;
|
||
}
|
||
const cost = normalizeHeroSmsPrice(payload.cost);
|
||
if (cost !== null) {
|
||
candidates.push(cost);
|
||
}
|
||
Object.entries(payload).forEach(([key, value]) => {
|
||
const keyedPrice = normalizeHeroSmsPrice(key);
|
||
if (keyedPrice === null) {
|
||
return;
|
||
}
|
||
if (value && typeof value === 'object') {
|
||
if (resolveStockState(value).hasStockField) {
|
||
candidates.push(keyedPrice);
|
||
}
|
||
return;
|
||
}
|
||
if (Number.isFinite(Number(value))) {
|
||
candidates.push(keyedPrice);
|
||
}
|
||
});
|
||
Object.values(payload).forEach((value) => collectPriceCandidatesIncludingZeroStock(value, candidates));
|
||
return 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_PRICE_CANDIDATES);
|
||
}
|
||
|
||
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 = normalizeHeroSmsPrice(minExclusivePrice);
|
||
if (floor === null || floor <= 0) {
|
||
return Array.isArray(prices) ? [...prices] : [];
|
||
}
|
||
return (Array.isArray(prices) ? prices : []).filter((value) => {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||
return false;
|
||
}
|
||
return Math.round(numeric * 10000) / 10000 > floor;
|
||
});
|
||
}
|
||
|
||
function reorderPriceCandidates(prices = [], acquirePriority = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY, preferredPrice = null) {
|
||
const hasNullTier = Array.isArray(prices)
|
||
&& prices.some((value) => value === null || value === undefined || String(value).trim() === '');
|
||
const normalized = buildSortedUniquePriceCandidates(
|
||
(Array.isArray(prices) ? prices : []).filter((value) => (
|
||
!(value === null || value === undefined || String(value).trim() === '')
|
||
))
|
||
);
|
||
const ordered = acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
|
||
? normalized.reverse()
|
||
: normalized;
|
||
const preferred = Number(preferredPrice);
|
||
if (!Number.isFinite(preferred) || preferred <= 0) {
|
||
if (ordered.length) {
|
||
return ordered;
|
||
}
|
||
return hasNullTier ? [null] : [];
|
||
}
|
||
const normalizedPreferred = Math.round(preferred * 10000) / 10000;
|
||
const withoutPreferred = ordered.filter((value) => value !== normalizedPreferred);
|
||
return [normalizedPreferred, ...withoutPreferred];
|
||
}
|
||
|
||
function 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 = normalizeHeroSmsPrice(rawPrice);
|
||
if (normalizedPrice === null || normalizedPrice <= 0) {
|
||
return;
|
||
}
|
||
normalizedMap.set(countryKey, Math.round(normalizedPrice * 10000) / 10000);
|
||
});
|
||
return normalizedMap;
|
||
}
|
||
|
||
function formatPriceRangeText(minPriceLimit = null, maxPriceLimit = null) {
|
||
const minPrice = normalizeHeroSmsPriceLimit(minPriceLimit);
|
||
const maxPrice = normalizeHeroSmsPriceLimit(maxPriceLimit);
|
||
if (minPrice !== null && maxPrice !== null) {
|
||
return `${minPrice}~${maxPrice}`;
|
||
}
|
||
if (minPrice !== null) {
|
||
return `${minPrice}~`;
|
||
}
|
||
if (maxPrice !== null) {
|
||
return `~${maxPrice}`;
|
||
}
|
||
return 'unbounded';
|
||
}
|
||
|
||
function resolvePriceRange(state = {}) {
|
||
const minPriceLimit = normalizeHeroSmsPriceLimit(state?.heroSmsMinPrice);
|
||
const maxPriceLimit = normalizeHeroSmsPriceLimit(state?.heroSmsMaxPrice);
|
||
return {
|
||
minPriceLimit,
|
||
maxPriceLimit,
|
||
hasMinPriceLimit: minPriceLimit !== null,
|
||
hasMaxPriceLimit: maxPriceLimit !== null,
|
||
invalidRange: minPriceLimit !== null && maxPriceLimit !== null && minPriceLimit > maxPriceLimit,
|
||
};
|
||
}
|
||
|
||
async function fetchPricePayloads(config, countryConfig, state = {}) {
|
||
const payloads = [];
|
||
const actions = shouldUseExpandedPriceLookup(state)
|
||
? ['getPricesExtended', 'getPrices']
|
||
: ['getPrices'];
|
||
for (const action of actions) {
|
||
try {
|
||
const query = {
|
||
action,
|
||
service: DEFAULT_SERVICE_CODE,
|
||
country: normalizeHeroSmsCountryId(countryConfig?.id),
|
||
};
|
||
if (action === 'getPricesExtended') {
|
||
query.freePrice = 'true';
|
||
}
|
||
payloads.push(await fetchPayload(config, query, `HeroSMS ${action}`));
|
||
} catch (_) {
|
||
// Price lookup is best-effort; acquisition may still probe a tier.
|
||
}
|
||
}
|
||
return payloads;
|
||
}
|
||
|
||
async function persistPricePlanSnapshot(countryConfig, pricePlan, deps = {}) {
|
||
if (typeof deps.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 deps.setState({
|
||
heroSmsLastPriceTiers: prices,
|
||
heroSmsLastPriceCountryId: normalizeHeroSmsCountryId(countryConfig?.id, 0),
|
||
heroSmsLastPriceCountryLabel: normalizeHeroSmsCountryLabel(countryConfig?.label, DEFAULT_COUNTRY_LABEL),
|
||
heroSmsLastPriceUserLimit: userLimit,
|
||
heroSmsLastPriceAt: Date.now(),
|
||
});
|
||
}
|
||
|
||
async function resolvePricePlan(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) {
|
||
const config = resolveConfig(state, deps);
|
||
for (let attempt = 1; attempt <= DEFAULT_PRICE_LOOKUP_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
const payloads = await fetchPricePayloads(config, countryConfig, state);
|
||
const userLimit = normalizeHeroSmsPriceLimit(state.heroSmsMaxPrice);
|
||
const inStockCandidates = buildSortedUniquePriceCandidates(
|
||
payloads.flatMap((payload) => collectPriceCandidates(payload, []))
|
||
);
|
||
const allCatalogCandidates = buildSortedUniquePriceCandidates(
|
||
payloads.flatMap((payload) => collectPriceCandidatesIncludingZeroStock(payload, []))
|
||
);
|
||
const mergedCandidates = inStockCandidates.length
|
||
? buildSortedUniquePriceCandidates([...inStockCandidates, ...allCatalogCandidates])
|
||
: [];
|
||
const minCatalogPrice = allCatalogCandidates.length
|
||
? allCatalogCandidates[0]
|
||
: (mergedCandidates.length ? mergedCandidates[0] : null);
|
||
let plan = null;
|
||
if (userLimit !== null) {
|
||
const bounded = mergedCandidates.filter((price) => price <= userLimit);
|
||
plan = {
|
||
prices: bounded.length > 0 ? bounded : [userLimit],
|
||
userLimit,
|
||
minCatalogPrice,
|
||
syntheticUserLimitProbe: bounded.length <= 0,
|
||
};
|
||
} else if (mergedCandidates.length > 0) {
|
||
plan = {
|
||
prices: mergedCandidates,
|
||
userLimit: null,
|
||
minCatalogPrice,
|
||
syntheticUserLimitProbe: false,
|
||
};
|
||
} else {
|
||
plan = {
|
||
prices: [null],
|
||
userLimit: null,
|
||
minCatalogPrice: null,
|
||
syntheticUserLimitProbe: false,
|
||
};
|
||
}
|
||
await persistPricePlanSnapshot(countryConfig, plan, deps);
|
||
if (
|
||
Array.isArray(plan.prices)
|
||
&& plan.prices.length > 0
|
||
&& (
|
||
plan.prices.some((price) => Number.isFinite(Number(price)) && Number(price) > 0)
|
||
|| plan.syntheticUserLimitProbe
|
||
)
|
||
) {
|
||
return plan;
|
||
}
|
||
} catch (_) {
|
||
// Retry price lookup before falling back to automatic tier.
|
||
}
|
||
}
|
||
const fallbackPlan = {
|
||
prices: [null],
|
||
userLimit: null,
|
||
minCatalogPrice: null,
|
||
syntheticUserLimitProbe: false,
|
||
};
|
||
await persistPricePlanSnapshot(countryConfig, fallbackPlan, deps);
|
||
return fallbackPlan;
|
||
}
|
||
|
||
function extractWrongMaxPrice(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 = describePayload(payload);
|
||
const match = text.match(/\bWRONG_MAX_PRICE:(\d+(?:\.\d+)?)\b/i);
|
||
return match ? normalizeHeroSmsPrice(match[1]) : null;
|
||
}
|
||
|
||
function isNetworkFetchFailure(error) {
|
||
return /failed to fetch|networkerror|load failed/i.test(String(error?.message || '').trim());
|
||
}
|
||
|
||
function isNoNumbersPayload(payload) {
|
||
return /\bNO_NUMBERS\b/i.test(describePayload(payload));
|
||
}
|
||
|
||
function isTerminalError(payloadOrMessage) {
|
||
const text = describePayload(payloadOrMessage);
|
||
return /\bNO_BALANCE\b|\bNOT_ENOUGH_BALANCE\b|\bBAD_KEY\b|\bINVALID_KEY\b|\bBANNED\b|\bACCOUNT_BANNED\b|\bWRONG_KEY\b/i.test(text);
|
||
}
|
||
|
||
function formatActionName(action = '') {
|
||
const normalized = String(action || '').trim().toLowerCase();
|
||
if (normalized === 'getnumber' || normalized === 'getnumberv2') {
|
||
return '获取手机号';
|
||
}
|
||
if (normalized === 'getstatus' || normalized === 'getstatusv2') {
|
||
return '查询短信状态';
|
||
}
|
||
if (normalized === 'setstatus') {
|
||
return '更新订单状态';
|
||
}
|
||
if (normalized === 'getprices' || normalized === 'getpricesextended') {
|
||
return '查询价格';
|
||
}
|
||
return action ? `${action} 请求` : '请求';
|
||
}
|
||
|
||
function createActionFailureError(action, reason = '') {
|
||
const rawReason = describePayload(reason) || '未知错误';
|
||
let normalizedReason = rawReason;
|
||
if (/\bBAD_KEY\b|\bWRONG_KEY\b|\bINVALID_KEY\b/i.test(rawReason)) {
|
||
normalizedReason = 'API Key 无效(BAD_KEY)';
|
||
} else if (/\bNO_BALANCE\b|\bNOT_ENOUGH_BALANCE\b/i.test(rawReason)) {
|
||
normalizedReason = '余额不足';
|
||
} else if (/\bBANNED\b|\bACCOUNT_BANNED\b/i.test(rawReason)) {
|
||
normalizedReason = '账号已被封禁';
|
||
}
|
||
const error = new Error(`HeroSMS ${formatActionName(action)}失败:${normalizedReason}`);
|
||
error.localizedPhoneSmsFailure = true;
|
||
return error;
|
||
}
|
||
|
||
async function fetchActivationPayload(config, countryConfig, action, options = {}) {
|
||
const query = {
|
||
action,
|
||
service: DEFAULT_SERVICE_CODE,
|
||
country: normalizeHeroSmsCountryId(countryConfig?.id),
|
||
};
|
||
const operator = normalizeHeroSmsOperator(config?.operator, DEFAULT_OPERATOR);
|
||
if (operator && operator !== DEFAULT_OPERATOR) {
|
||
query.operator = operator;
|
||
}
|
||
if (options.maxPrice !== null && options.maxPrice !== undefined) {
|
||
query.maxPrice = options.maxPrice;
|
||
if (options.fixedPrice !== false) {
|
||
query.fixedPrice = 'true';
|
||
}
|
||
}
|
||
return fetchPayload(config, query, `HeroSMS ${action}`);
|
||
}
|
||
|
||
async function requestActivationWithPrice(config, countryConfig, action, maxPrice, options = {}) {
|
||
let nextMaxPrice = maxPrice;
|
||
let retriedWithUpdatedPrice = false;
|
||
let retriedWithoutPrice = false;
|
||
const userLimit = normalizeHeroSmsPriceLimit(options.userLimit);
|
||
const userMinLimit = normalizeHeroSmsPriceLimit(options.userMinLimit);
|
||
while (true) {
|
||
try {
|
||
return await fetchActivationPayload(config, countryConfig, action, {
|
||
maxPrice: nextMaxPrice,
|
||
fixedPrice: options.fixedPrice,
|
||
});
|
||
} catch (error) {
|
||
const updatedMaxPrice = extractWrongMaxPrice(error?.payload || error?.message);
|
||
if (
|
||
nextMaxPrice !== null
|
||
&& nextMaxPrice !== undefined
|
||
&& !retriedWithUpdatedPrice
|
||
&& updatedMaxPrice !== null
|
||
) {
|
||
if (userLimit !== null && updatedMaxPrice > userLimit) {
|
||
throw new Error(
|
||
`HeroSMS ${formatActionName(action)}失败:价格上限过低,平台要求至少 ${updatedMaxPrice},已超过当前配置的价格上限 ${userLimit}。`
|
||
);
|
||
}
|
||
if (userMinLimit !== null && updatedMaxPrice < userMinLimit) {
|
||
throw new Error(
|
||
`HeroSMS ${formatActionName(action)}失败:平台要求价格 ${updatedMaxPrice} 低于当前配置的最低购买价 ${userMinLimit}。`
|
||
);
|
||
}
|
||
nextMaxPrice = updatedMaxPrice;
|
||
retriedWithUpdatedPrice = true;
|
||
continue;
|
||
}
|
||
if (
|
||
nextMaxPrice !== null
|
||
&& nextMaxPrice !== undefined
|
||
&& !retriedWithoutPrice
|
||
&& isNetworkFetchFailure(error)
|
||
) {
|
||
nextMaxPrice = null;
|
||
retriedWithoutPrice = true;
|
||
continue;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeActivation(record, fallback = {}) {
|
||
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 || fallback.statusAction || '').trim();
|
||
return {
|
||
activationId,
|
||
phoneNumber,
|
||
provider: PROVIDER_ID,
|
||
serviceCode: String(record.serviceCode || fallback.serviceCode || DEFAULT_SERVICE_CODE).trim() || DEFAULT_SERVICE_CODE,
|
||
countryId: normalizeHeroSmsCountryId(record.countryId ?? record.country ?? fallback.countryId),
|
||
...(record.countryLabel
|
||
? { countryLabel: normalizeHeroSmsCountryLabel(record.countryLabel, fallback.countryLabel) }
|
||
: {}),
|
||
successfulUses: Math.max(0, Math.floor(Number(record.successfulUses ?? fallback.successfulUses) || 0)),
|
||
maxUses: Math.max(1, Math.floor(Number(record.maxUses ?? fallback.maxUses) || DEFAULT_MAX_USES)),
|
||
...(statusAction ? { statusAction } : {}),
|
||
...(record.source ? { source: String(record.source || '').trim() } : {}),
|
||
};
|
||
}
|
||
|
||
function resolveCountryLabel(state = {}, countryId = DEFAULT_COUNTRY_ID) {
|
||
const countryKey = normalizeCountryKey(countryId);
|
||
const matched = resolveCountryCandidates(state)
|
||
.find((entry) => normalizeCountryKey(entry.id) === countryKey);
|
||
return matched?.label || normalizeHeroSmsCountryLabel('', countryKey ? `Country #${countryKey}` : DEFAULT_COUNTRY_LABEL);
|
||
}
|
||
|
||
function resolveActivationCountry(activation = {}, state = {}) {
|
||
const normalizedActivation = normalizeActivation(activation)
|
||
|| (activation && typeof activation === 'object' ? activation : {});
|
||
const inferred = inferCountryFromPhoneNumber(normalizedActivation.phoneNumber);
|
||
const rawCountryId = normalizeHeroSmsCountryId(normalizedActivation.countryId, 0);
|
||
const hasExplicitCountry = (
|
||
rawCountryId > 0
|
||
&& !(
|
||
normalizedActivation.manualOnly
|
||
&& rawCountryId === DEFAULT_COUNTRY_ID
|
||
&& inferred?.id
|
||
&& inferred.id !== rawCountryId
|
||
)
|
||
);
|
||
const countryId = hasExplicitCountry
|
||
? rawCountryId
|
||
: normalizeHeroSmsCountryId(inferred?.id, rawCountryId || DEFAULT_COUNTRY_ID);
|
||
const matched = resolveCountryCandidates(state)
|
||
.find((entry) => normalizeHeroSmsCountryId(entry.id, 0) === countryId);
|
||
if (matched) {
|
||
return matched;
|
||
}
|
||
return {
|
||
id: countryId,
|
||
label: normalizeHeroSmsCountryLabel(
|
||
hasExplicitCountry ? normalizedActivation.countryLabel : (inferred?.label || normalizedActivation.countryLabel),
|
||
`Country #${countryId}`
|
||
),
|
||
};
|
||
}
|
||
|
||
function getActivationCountryKey(activation = {}) {
|
||
return normalizeCountryKey(activation?.countryId ?? activation?.country);
|
||
}
|
||
|
||
function getActivationPrice(activation = {}) {
|
||
return normalizeHeroSmsPrice(
|
||
activation?.selectedPrice
|
||
?? activation?.price
|
||
?? activation?.maxPrice
|
||
);
|
||
}
|
||
|
||
function parseActivationPayload(payload, fallback = {}) {
|
||
const normalizedFallback = normalizeActivation(fallback) || (fallback && typeof fallback === 'object' ? fallback : {});
|
||
const direct = normalizeActivation(payload, normalizedFallback);
|
||
if (direct) {
|
||
return direct;
|
||
}
|
||
const text = describePayload(payload);
|
||
const accessNumberMatch = text.match(/^ACCESS_NUMBER:([^:]+):(.+)$/i);
|
||
if (accessNumberMatch) {
|
||
return normalizeActivation({
|
||
activationId: String(accessNumberMatch[1] || '').trim(),
|
||
phoneNumber: String(accessNumberMatch[2] || '').trim(),
|
||
provider: PROVIDER_ID,
|
||
serviceCode: normalizedFallback.serviceCode || DEFAULT_SERVICE_CODE,
|
||
countryId: normalizedFallback.countryId || DEFAULT_COUNTRY_ID,
|
||
successfulUses: normalizedFallback.successfulUses ?? 0,
|
||
maxUses: normalizedFallback.maxUses ?? DEFAULT_MAX_USES,
|
||
statusAction: normalizedFallback.statusAction,
|
||
});
|
||
}
|
||
if (/^ACCESS_READY$/i.test(text) && normalizedFallback?.activationId) {
|
||
return normalizeActivation(normalizedFallback);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveActivationStatusAction(activation = {}) {
|
||
return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus';
|
||
}
|
||
|
||
function extractVerificationCode(rawCode) {
|
||
const trimmed = String(rawCode || '').trim();
|
||
if (!trimmed) {
|
||
return '';
|
||
}
|
||
return trimmed.match(/\b(\d{4,8})\b/)?.[1] || '';
|
||
}
|
||
|
||
async function setActivationStatus(state = {}, activation, status, deps = {}, actionLabel = 'HeroSMS setStatus') {
|
||
const normalizedActivation = normalizeActivation(activation);
|
||
if (!normalizedActivation) {
|
||
return '';
|
||
}
|
||
const config = resolveConfig(state, deps);
|
||
const payload = await fetchPayload(config, {
|
||
action: 'setStatus',
|
||
id: normalizedActivation.activationId,
|
||
status: Math.floor(Number(status) || 0),
|
||
}, actionLabel);
|
||
return describePayload(payload);
|
||
}
|
||
|
||
async function requestActivation(state = {}, options = {}, deps = {}) {
|
||
const config = resolveConfig(state, deps);
|
||
const allCountryCandidates = resolveCountryCandidates(state);
|
||
if (!allCountryCandidates.length) {
|
||
throw new Error('步骤 9:HeroSMS 未选择国家,请先在接码设置中至少选择 1 个国家。');
|
||
}
|
||
const blockedCountryIds = new Set(
|
||
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
|
||
.map((value) => normalizeHeroSmsCountryId(value, 0))
|
||
.filter((id) => id > 0)
|
||
);
|
||
let countryCandidates = allCountryCandidates.filter(
|
||
(entry) => !blockedCountryIds.has(normalizeHeroSmsCountryId(entry.id, 0))
|
||
);
|
||
if (!countryCandidates.length) {
|
||
countryCandidates = allCountryCandidates;
|
||
if (blockedCountryIds.size && typeof deps.addLog === 'function') {
|
||
await deps.addLog('步骤 9:已选国家均达到临时收码失败跳过阈值,本轮解除跳过并重新尝试。', 'warn');
|
||
}
|
||
}
|
||
const acquirePriority = normalizeHeroSmsAcquirePriority(state?.heroSmsAcquirePriority);
|
||
const priceRange = resolvePriceRange(state);
|
||
if (priceRange.invalidRange) {
|
||
throw new Error(`HeroSMS 价格区间无效:最低购买价 ${priceRange.minPriceLimit} 高于价格上限 ${priceRange.maxPriceLimit}。`);
|
||
}
|
||
const preferredPriceTier = normalizeHeroSmsPriceLimit(state?.heroSmsPreferredPrice);
|
||
const countryPriceFloorByCountryId = normalizePriceFloorMap(
|
||
options?.countryPriceFloorByCountryId,
|
||
(value) => String(normalizeHeroSmsCountryId(value, 0))
|
||
);
|
||
const requestActions = ['getNumber', 'getNumberV2'];
|
||
const maxAcquireRounds = Math.max(2, normalizeActivationRetryRounds(state?.heroSmsActivationRetryRounds));
|
||
const retryDelayMs = normalizeActivationRetryDelayMs(state?.heroSmsActivationRetryDelayMs);
|
||
let finalNoNumbersByCountry = [];
|
||
let finalLastError = null;
|
||
let finalLastFailureText = '';
|
||
|
||
for (let round = 1; round <= maxAcquireRounds; round += 1) {
|
||
if (maxAcquireRounds > 1 && typeof deps.addLog === 'function') {
|
||
await deps.addLog(`步骤 9:HeroSMS 正在获取手机号(第 ${round}/${maxAcquireRounds} 轮)...`, 'info');
|
||
}
|
||
const countryAttempts = countryCandidates.map((countryConfig, index) => ({
|
||
index,
|
||
countryConfig,
|
||
pricePlan: null,
|
||
orderingPrice: Number.POSITIVE_INFINITY,
|
||
}));
|
||
|
||
if (
|
||
acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE
|
||
|| acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
|
||
) {
|
||
for (const attempt of countryAttempts) {
|
||
const pricePlan = await resolvePricePlan(state, attempt.countryConfig, deps);
|
||
const orderedPrices = reorderPriceCandidates(pricePlan?.prices, acquirePriority, preferredPriceTier);
|
||
const rangeFilteredForRanking = filterPriceCandidatesWithinRange(
|
||
orderedPrices,
|
||
priceRange.minPriceLimit,
|
||
priceRange.maxPriceLimit
|
||
);
|
||
const rankingPrices = rangeFilteredForRanking.length
|
||
? rangeFilteredForRanking
|
||
: ((priceRange.hasMinPriceLimit || priceRange.hasMaxPriceLimit) ? [] : orderedPrices);
|
||
const numericPrices = rankingPrices
|
||
.map((value) => Number(value))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
const candidateOrderingPrice = numericPrices.length ? numericPrices[0] : null;
|
||
const cappedByUserLimit = (
|
||
pricePlan?.userLimit !== null
|
||
&& pricePlan?.userLimit !== undefined
|
||
&& pricePlan?.minCatalogPrice !== null
|
||
&& pricePlan?.minCatalogPrice !== undefined
|
||
&& Number(pricePlan.minCatalogPrice) > Number(pricePlan.userLimit)
|
||
);
|
||
attempt.pricePlan = pricePlan;
|
||
attempt.orderingPrice = cappedByUserLimit
|
||
? Number.POSITIVE_INFINITY
|
||
: (candidateOrderingPrice !== null ? candidateOrderingPrice : Number.POSITIVE_INFINITY);
|
||
}
|
||
}
|
||
|
||
if (
|
||
(acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE || acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH)
|
||
&& countryAttempts.length > 1
|
||
) {
|
||
countryAttempts.sort((left, right) => {
|
||
if (left.orderingPrice !== right.orderingPrice) {
|
||
return acquirePriority === HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH
|
||
? (right.orderingPrice - left.orderingPrice)
|
||
: (left.orderingPrice - right.orderingPrice);
|
||
}
|
||
return left.index - right.index;
|
||
});
|
||
}
|
||
|
||
const noNumbersByCountry = [];
|
||
const retryableNoNumberCountries = [];
|
||
let lastError = null;
|
||
let lastFailureText = '';
|
||
|
||
for (const attempt of countryAttempts) {
|
||
const countryConfig = attempt.countryConfig;
|
||
const countryIdKey = String(normalizeHeroSmsCountryId(countryConfig?.id, 0));
|
||
const countryPriceFloor = countryPriceFloorByCountryId.get(countryIdKey) ?? null;
|
||
const buildFallbackActivation = (requestAction) => ({
|
||
countryId: countryConfig.id,
|
||
...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}),
|
||
});
|
||
const pricePlan = attempt.pricePlan || await resolvePricePlan(state, countryConfig, deps);
|
||
let noNumbersObservedInCountry = false;
|
||
const orderedPrices = reorderPriceCandidates(pricePlan.prices, acquirePriority, preferredPriceTier);
|
||
const rangeFilteredPrices = filterPriceCandidatesWithinRange(
|
||
orderedPrices,
|
||
priceRange.minPriceLimit,
|
||
priceRange.maxPriceLimit
|
||
);
|
||
const candidatePrices = rangeFilteredPrices.length
|
||
? rangeFilteredPrices
|
||
: ((priceRange.hasMinPriceLimit || priceRange.hasMaxPriceLimit) ? [] : orderedPrices);
|
||
const floorFilteredPrices = filterPriceCandidatesAboveFloor(candidatePrices, countryPriceFloor);
|
||
const hasCountryPriceFloor = (
|
||
countryPriceFloor !== null
|
||
&& Number.isFinite(Number(countryPriceFloor))
|
||
&& Number(countryPriceFloor) > 0
|
||
);
|
||
const hasAlternativeCountries = countryAttempts.some((entry) => (
|
||
String(normalizeHeroSmsCountryId(entry?.countryConfig?.id, 0))
|
||
!== String(normalizeHeroSmsCountryId(countryConfig?.id, 0))
|
||
));
|
||
const pricesToTry = hasCountryPriceFloor
|
||
? (floorFilteredPrices.length ? floorFilteredPrices : (hasAlternativeCountries ? [] : candidatePrices.slice(0, 1)))
|
||
: (floorFilteredPrices.length ? floorFilteredPrices : candidatePrices);
|
||
|
||
if (typeof deps.addLog === 'function') {
|
||
const rawTierText = Array.isArray(pricePlan?.prices) && pricePlan.prices.length
|
||
? pricePlan.prices.map((value) => (value === null || value === undefined ? '自动' : String(value))).join(', ')
|
||
: '无';
|
||
await deps.addLog(
|
||
`步骤 9:HeroSMS ${countryConfig.label} 价格方案:档位=[${rawTierText}],用户上限=${pricePlan?.userLimit ?? '未设置'},目录最低价=${pricePlan?.minCatalogPrice ?? '未知'}。`,
|
||
'info'
|
||
);
|
||
}
|
||
|
||
if (!pricesToTry.length) {
|
||
if (priceRange.hasMinPriceLimit && !rangeFilteredPrices.length) {
|
||
noNumbersByCountry.push(`${countryConfig.label}: 价格区间 ${formatPriceRangeText(priceRange.minPriceLimit, priceRange.maxPriceLimit)} 内暂无可用号码`);
|
||
continue;
|
||
}
|
||
if (
|
||
countryPriceFloor !== null
|
||
&& Array.isArray(pricePlan.prices)
|
||
&& pricePlan.prices.length > 0
|
||
) {
|
||
noNumbersByCountry.push(`${countryConfig.label}: 当前回退尝试没有高于 ${countryPriceFloor} 的价格档位`);
|
||
continue;
|
||
}
|
||
if (
|
||
pricePlan.userLimit !== null
|
||
&& pricePlan.minCatalogPrice !== null
|
||
&& pricePlan.minCatalogPrice > pricePlan.userLimit
|
||
) {
|
||
noNumbersByCountry.push(`${countryConfig.label}: 价格上限 ${pricePlan.userLimit} 内暂无可用号码;平台最低价=${pricePlan.minCatalogPrice}`);
|
||
} else {
|
||
noNumbersByCountry.push(`${countryConfig.label}: ${lastFailureText || 'NO_NUMBERS'}`);
|
||
retryableNoNumberCountries.push(countryConfig.label);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
for (const maxPrice of pricesToTry) {
|
||
for (const requestAction of requestActions) {
|
||
try {
|
||
const fixedPrice = !Boolean(pricePlan.syntheticUserLimitProbe);
|
||
if (typeof deps.addLog === 'function') {
|
||
await deps.addLog(
|
||
`步骤 9:HeroSMS ${countryConfig.label} 正在尝试${formatActionName(requestAction)},价格档位 ${maxPrice === null || maxPrice === undefined ? '自动' : maxPrice}。`,
|
||
'info'
|
||
);
|
||
}
|
||
const payload = await requestActivationWithPrice(config, countryConfig, requestAction, maxPrice, {
|
||
userLimit: pricePlan.userLimit,
|
||
userMinLimit: priceRange.minPriceLimit,
|
||
fixedPrice,
|
||
});
|
||
const activation = parseActivationPayload(payload, buildFallbackActivation(requestAction));
|
||
if (activation) {
|
||
return {
|
||
...activation,
|
||
countryId: normalizeHeroSmsCountryId(countryConfig.id),
|
||
...(Number.isFinite(Number(maxPrice)) && Number(maxPrice) > 0
|
||
? { selectedPrice: Math.round(Number(maxPrice) * 10000) / 10000 }
|
||
: {}),
|
||
};
|
||
}
|
||
const payloadText = describePayload(payload);
|
||
if (isNoNumbersPayload(payload)) {
|
||
noNumbersObservedInCountry = true;
|
||
lastFailureText = payloadText || lastFailureText;
|
||
continue;
|
||
}
|
||
if (isTerminalError(payload)) {
|
||
throw createActionFailureError(requestAction, payloadText || 'empty response');
|
||
}
|
||
lastFailureText = payloadText || lastFailureText;
|
||
lastError = createActionFailureError(requestAction, payloadText || 'empty response');
|
||
} catch (error) {
|
||
if (error?.localizedPhoneSmsFailure) {
|
||
throw error;
|
||
}
|
||
const payloadOrMessage = error?.payload || error?.message;
|
||
if (isTerminalError(payloadOrMessage)) {
|
||
throw createActionFailureError(requestAction, payloadOrMessage || 'empty response');
|
||
}
|
||
if (isNoNumbersPayload(payloadOrMessage)) {
|
||
noNumbersObservedInCountry = true;
|
||
lastFailureText = describePayload(payloadOrMessage) || lastFailureText;
|
||
continue;
|
||
}
|
||
lastFailureText = describePayload(payloadOrMessage) || lastFailureText;
|
||
lastError = error;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (noNumbersObservedInCountry) {
|
||
const tiersTriedText = pricesToTry
|
||
.map((value) => (value === null || value === undefined ? '自动' : String(value)))
|
||
.join(', ');
|
||
noNumbersByCountry.push(
|
||
`${countryConfig.label}: ${lastFailureText || 'NO_NUMBERS'}${tiersTriedText ? `(已尝试档位:${tiersTriedText})` : ''}`
|
||
);
|
||
retryableNoNumberCountries.push(countryConfig.label);
|
||
}
|
||
}
|
||
|
||
finalNoNumbersByCountry = noNumbersByCountry;
|
||
finalLastError = lastError;
|
||
finalLastFailureText = lastFailureText;
|
||
if (
|
||
noNumbersByCountry.length
|
||
&& round < maxAcquireRounds
|
||
&& retryableNoNumberCountries.length > 0
|
||
) {
|
||
if (typeof deps.addLog === 'function') {
|
||
await deps.addLog(
|
||
`步骤 9:HeroSMS 暂无可用号码(第 ${round}/${maxAcquireRounds} 轮);${Math.ceil(retryDelayMs / 1000)} 秒后重试。国家:${retryableNoNumberCountries.join(', ')}。`,
|
||
'warn'
|
||
);
|
||
}
|
||
await deps.sleepWithStop?.(retryDelayMs);
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (finalNoNumbersByCountry.length) {
|
||
throw new Error(`HeroSMS 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${finalNoNumbersByCountry.join(' | ')}。`);
|
||
}
|
||
if (finalLastError) {
|
||
throw finalLastError;
|
||
}
|
||
throw new Error(`HeroSMS 获取手机号失败,最后状态:${finalLastFailureText || '未知'}。`);
|
||
}
|
||
|
||
async function reuseActivation(state = {}, activation, deps = {}) {
|
||
const normalizedActivation = normalizeActivation(activation);
|
||
if (!normalizedActivation) {
|
||
throw new Error('缺少可复用的 HeroSMS 手机号订单。');
|
||
}
|
||
const config = resolveConfig(state, deps);
|
||
const payload = await fetchPayload(config, {
|
||
action: 'reactivate',
|
||
id: normalizedActivation.activationId,
|
||
}, 'HeroSMS reactivate');
|
||
const nextActivation = parseActivationPayload(payload, normalizedActivation);
|
||
if (!nextActivation) {
|
||
throw new Error(`HeroSMS 复用手机号失败:${describePayload(payload) || '空响应'}`);
|
||
}
|
||
return nextActivation;
|
||
}
|
||
|
||
async function pollActivationCode(state = {}, activation, options = {}, deps = {}) {
|
||
const normalizedActivation = normalizeActivation(activation);
|
||
if (!normalizedActivation) {
|
||
throw new Error('缺少手机号接码订单。');
|
||
}
|
||
const config = resolveConfig(state, deps);
|
||
const statusAction = resolveActivationStatusAction(normalizedActivation);
|
||
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 lastResponse = '';
|
||
let pollCount = 0;
|
||
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, {
|
||
action: statusAction,
|
||
id: normalizedActivation.activationId,
|
||
}, `HeroSMS ${statusAction}`);
|
||
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,
|
||
timeoutMs,
|
||
});
|
||
}
|
||
const v2Code = (
|
||
payload
|
||
&& typeof payload === 'object'
|
||
&& !Array.isArray(payload)
|
||
&& (
|
||
extractVerificationCode(payload.sms?.code)
|
||
|| extractVerificationCode(payload.call?.code)
|
||
)
|
||
);
|
||
if (v2Code) {
|
||
return v2Code;
|
||
}
|
||
const okMatch = text.match(/^STATUS_OK:(.+)$/i);
|
||
if (okMatch) {
|
||
const code = extractVerificationCode(okMatch[1] || '');
|
||
if (code) {
|
||
return code;
|
||
}
|
||
await emitWaitingForCode(text || 'STATUS_OK');
|
||
await deps.sleepWithStop?.(intervalMs);
|
||
continue;
|
||
}
|
||
if (/^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)(?::.+)?$/i.test(text)) {
|
||
await emitWaitingForCode(text);
|
||
await deps.sleepWithStop?.(intervalMs);
|
||
continue;
|
||
}
|
||
if (statusAction === 'getStatusV2' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||
await emitWaitingForCode(text || 'PENDING');
|
||
await deps.sleepWithStop?.(intervalMs);
|
||
continue;
|
||
}
|
||
if (/^STATUS_CANCEL$/i.test(text)) {
|
||
throw new Error('HeroSMS 订单在短信到达前已被取消。');
|
||
}
|
||
throw createActionFailureError(statusAction, text || 'empty response');
|
||
}
|
||
throw new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}等待手机验证码超时。${lastResponse ? ` HeroSMS 最后状态:${lastResponse}` : ''}`);
|
||
}
|
||
|
||
async function finishActivation(state = {}, activation, deps = {}) {
|
||
return setActivationStatus(state, activation, 6, deps, 'HeroSMS setStatus(6)');
|
||
}
|
||
|
||
async function cancelActivation(state = {}, activation, deps = {}) {
|
||
return setActivationStatus(state, activation, 8, deps, 'HeroSMS setStatus(8)');
|
||
}
|
||
|
||
async function banActivation(state = {}, activation, deps = {}) {
|
||
return setActivationStatus(state, activation, 8, deps, 'HeroSMS setStatus(8)');
|
||
}
|
||
|
||
async function requestAdditionalSms(state = {}, activation, deps = {}) {
|
||
return setActivationStatus(state, activation, 3, deps, 'HeroSMS setStatus(3)');
|
||
}
|
||
|
||
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 || ''),
|
||
nextActivation: null,
|
||
};
|
||
}
|
||
|
||
function isWaitingStatusText(text) {
|
||
return /^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)(?::.+)?$/i.test(String(text || '').trim());
|
||
}
|
||
|
||
function isReadyForFreshSmsText(text) {
|
||
return /^STATUS_WAIT_CODE(?::.+)?$/i.test(String(text || '').trim());
|
||
}
|
||
|
||
async function prepareActivationForReuse(state = {}, activation, options = {}, deps = {}) {
|
||
const normalizedActivation = normalizeActivation(activation);
|
||
if (!normalizedActivation) {
|
||
return {
|
||
ok: false,
|
||
reason: 'missing_activation',
|
||
message: '已保存的免费复用手机号记录无效。',
|
||
};
|
||
}
|
||
const config = resolveConfig(state, deps);
|
||
const statusAction = resolveActivationStatusAction(normalizedActivation);
|
||
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 20000);
|
||
const intervalMs = Math.max(1000, Number(options.intervalMs) || 2000);
|
||
const maxRounds = Math.max(1, Math.floor(Number(options.maxRounds) || 10));
|
||
const start = Date.now();
|
||
let lastStatus = '';
|
||
let prepareRound = 0;
|
||
while (Date.now() - start < timeoutMs && prepareRound < maxRounds) {
|
||
deps.throwIfStopped?.();
|
||
prepareRound += 1;
|
||
try {
|
||
await requestAdditionalSms(state, normalizedActivation, deps);
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
reason: 'set_status_failed',
|
||
message: error.message || 'HeroSMS 更新订单状态失败。',
|
||
lastStatus,
|
||
prepareRound,
|
||
};
|
||
}
|
||
if (typeof deps.addLog === 'function') {
|
||
await deps.addLog(
|
||
`步骤 9:自动白嫖复用已刷新 ${normalizedActivation.phoneNumber},${Math.ceil(intervalMs / 1000)} 秒后检查等待状态(${prepareRound}/${maxRounds})。`,
|
||
'info'
|
||
);
|
||
}
|
||
await deps.sleepWithStop?.(intervalMs);
|
||
try {
|
||
const payload = await fetchPayload(config, {
|
||
action: statusAction,
|
||
id: normalizedActivation.activationId,
|
||
}, `HeroSMS 自动复用${statusAction}`);
|
||
const statusText = describePayload(payload);
|
||
lastStatus = statusText;
|
||
if (typeof deps.addLog === 'function') {
|
||
await deps.addLog(
|
||
`步骤 9:自动白嫖复用号码 ${normalizedActivation.phoneNumber} 状态:${statusText || '空响应'}(${prepareRound}/${maxRounds})。`,
|
||
'info'
|
||
);
|
||
}
|
||
const v2Waiting = statusAction === 'getStatusV2'
|
||
&& payload
|
||
&& typeof payload === 'object'
|
||
&& !Array.isArray(payload)
|
||
&& !payload.sms?.code
|
||
&& !payload.call?.code;
|
||
if (isReadyForFreshSmsText(statusText) || isWaitingStatusText(statusText) || v2Waiting) {
|
||
return {
|
||
ok: true,
|
||
activation: normalizedActivation,
|
||
};
|
||
}
|
||
if (/^STATUS_OK:/i.test(statusText)) {
|
||
if (typeof deps.addLog === 'function') {
|
||
await deps.addLog('步骤 9:自动白嫖复用仍看到旧验证码,将再次刷新等待短信状态。', 'warn');
|
||
}
|
||
continue;
|
||
}
|
||
if (/^STATUS_CANCEL$/i.test(statusText)) {
|
||
return {
|
||
ok: false,
|
||
reason: 'activation_cancelled',
|
||
message: 'HeroSMS 订单在自动白嫖复用前已被取消。',
|
||
lastStatus,
|
||
prepareRound,
|
||
};
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
reason: 'get_status_failed',
|
||
message: error.message || 'HeroSMS 查询短信状态失败。',
|
||
lastStatus,
|
||
prepareRound,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
ok: false,
|
||
reason: 'prepare_timeout',
|
||
message: `等待已保存手机号进入短信等待状态超时。最后状态:${lastStatus || '未知'}。`,
|
||
lastStatus,
|
||
prepareRound,
|
||
};
|
||
}
|
||
|
||
function createProvider(deps = {}) {
|
||
const providerDeps = {
|
||
fetchImpl: deps.fetchImpl,
|
||
sleepWithStop: deps.sleepWithStop,
|
||
throwIfStopped: deps.throwIfStopped,
|
||
addLog: deps.addLog,
|
||
setState: deps.setState,
|
||
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
||
};
|
||
const capabilities = Object.freeze({
|
||
supportsReusableActivation: true,
|
||
supportsAutomaticFreeReuse: true,
|
||
supportsFreeReusePreservation: true,
|
||
supportsPageResend: true,
|
||
supportsPageResendProbe: true,
|
||
requiresCountrySelection: true,
|
||
});
|
||
return {
|
||
id: PROVIDER_ID,
|
||
label: 'HeroSMS',
|
||
capabilities,
|
||
defaultCountryId: DEFAULT_COUNTRY_ID,
|
||
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
|
||
defaultProduct: DEFAULT_SERVICE_LABEL,
|
||
normalizeCountryId: normalizeHeroSmsCountryId,
|
||
normalizeCountryLabel: normalizeHeroSmsCountryLabel,
|
||
normalizeCountryFallback: normalizeHeroSmsCountryFallback,
|
||
normalizeCountryKey,
|
||
normalizeMaxPrice: normalizeHeroSmsMaxPrice,
|
||
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: (state, activation, options) => prepareActivationForReuse(state, activation, options, providerDeps),
|
||
canPersistReusableActivation: () => true,
|
||
canPreserveActivationForFreeReuse: (_state, activation) => {
|
||
const normalizedActivation = normalizeActivation(activation);
|
||
return Boolean(
|
||
normalizedActivation
|
||
&& activation
|
||
&& typeof activation === 'object'
|
||
&& activation.phoneCodeReceived
|
||
);
|
||
},
|
||
shouldUsePageResend: () => true,
|
||
shouldProbePageResend: () => true,
|
||
fetchBalance: (state) => fetchBalance(state, providerDeps),
|
||
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
|
||
resolvePricePlan: (state, countryConfig) => resolvePricePlan(state, countryConfig, providerDeps),
|
||
resolvePriceRange,
|
||
formatPriceRangeText,
|
||
describePayload,
|
||
};
|
||
}
|
||
|
||
return {
|
||
PROVIDER_ID,
|
||
DEFAULT_BASE_URL,
|
||
DEFAULT_COUNTRY_ID,
|
||
DEFAULT_COUNTRY_LABEL,
|
||
DEFAULT_SERVICE_CODE,
|
||
DEFAULT_SERVICE_LABEL,
|
||
createProvider,
|
||
describePayload,
|
||
normalizeHeroSmsCountryFallback,
|
||
normalizeHeroSmsCountryId,
|
||
normalizeHeroSmsCountryLabel,
|
||
normalizeCountryKey,
|
||
normalizeHeroSmsMaxPrice,
|
||
normalizeHeroSmsOperator,
|
||
normalizeActivation,
|
||
resolveActivationCountry,
|
||
};
|
||
});
|