Files
FlowPilot/phone-sms/providers/sms-bower.js
T
2026-05-30 23:14:41 +08:00

469 lines
20 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/sms-bower.js — SMS Bower 接码平台适配层
(function attachSmsBowerProvider(root, factory) {
root.PhoneSmsBowerProvider = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createSmsBowerProviderModule() {
const PROVIDER_ID = 'sms-bower';
const DEFAULT_BASE_URL = 'https://smsbower.page/stubs/handler_api.php';
const DEFAULT_SERVICE_CODE = 'ot';
const DEFAULT_SERVICE_LABEL = 'OpenAI';
const DEFAULT_COUNTRY_ID = 6;
const DEFAULT_COUNTRY_LABEL = 'Indonesia';
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
const DEFAULT_POLL_TIMEOUT_MS = 180000;
const DEFAULT_POLL_INTERVAL_MS = 5000;
const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::';
const MAX_PRICE_CANDIDATES = 8;
function normalizeText(value = '', fallback = '') {
return String(value || '').trim() || fallback;
}
function normalizeSmsBowerCountryId(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 normalizeSmsBowerCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) {
return normalizeText(value, fallback);
}
function normalizeCountryKey(value) {
const countryId = normalizeSmsBowerCountryId(value, -1);
return countryId >= 0 ? String(countryId) : '';
}
function normalizeSmsBowerServiceCode(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 normalizeSmsBowerMaxPrice(value = '') {
const normalized = normalizePriceLimit(value);
return normalized === null ? '' : String(normalized);
}
function normalizeCsvIds(value = '') {
const source = Array.isArray(value) ? value : String(value || '').split(/[\r\n,;]+/);
const seen = new Set();
return source
.map((entry) => String(entry || '').trim().replace(/[^0-9a-zA-Z_-]+/g, ''))
.filter((entry) => {
if (!entry || seen.has(entry)) return false;
seen.add(entry);
return true;
})
.join(',');
}
function normalizeCountryOrder(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 = [];
source.forEach((entry) => {
let id = -1;
let label = '';
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
id = normalizeSmsBowerCountryId(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 = normalizeSmsBowerCountryId(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 normalizeBaseUrl(value = '') {
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
try {
const parsed = new URL(trimmed);
return parsed.toString();
} catch {
return DEFAULT_BASE_URL;
}
}
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 = normalizeText(raw.message || raw.msg || raw.error || raw.title || raw.status, '');
if (direct) return direct;
try { return JSON.stringify(raw); } catch { return String(raw); }
}
return String(raw || '').trim();
}
function resolveConfig(state = {}, deps = {}) {
return {
apiKey: normalizeText(state.smsBowerApiKey),
baseUrl: normalizeBaseUrl(state.smsBowerBaseUrl || DEFAULT_BASE_URL),
serviceCode: normalizeSmsBowerServiceCode(state.smsBowerServiceCode, DEFAULT_SERVICE_CODE),
fetchImpl: deps.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null),
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
}
function buildUrl(config = {}, query = {}) {
const url = new URL(config.baseUrl || DEFAULT_BASE_URL);
url.searchParams.set('api_key', config.apiKey);
Object.entries(query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, String(value));
});
return url.toString();
}
async function fetchPayload(config = {}, query = {}, actionLabel = 'SMS Bower 请求') {
if (!config.fetchImpl) throw new Error('SMS Bower 网络请求实现不可用。');
if (!config.apiKey) throw new Error('SMS Bower 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 response = await config.fetchImpl(buildUrl(config, query), {
method: 'GET',
signal: controller?.signal,
headers: { Accept: 'application/json, text/plain, */*' },
});
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;
}
const message = describePayload(payload);
if (/^(BAD_KEY|BAD_ACTION|BAD_SERVICE|BAD_STATUS|NO_ACTIVATION|EARLY_CANCEL_DENIED|BAD_COUNTRY)\b/i.test(message)) {
const error = new Error(`${actionLabel}失败:${message}`);
error.payload = payload;
throw error;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') throw new Error(`${actionLabel}超时。`);
throw error;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
function resolveCountryCandidates(state = {}) {
const candidates = normalizeCountryOrder(state.smsBowerCountryOrder);
if (candidates.length) return candidates;
return [{
id: normalizeSmsBowerCountryId(state.smsBowerCountryId, DEFAULT_COUNTRY_ID),
label: normalizeSmsBowerCountryLabel(state.smsBowerCountryLabel, DEFAULT_COUNTRY_LABEL),
}];
}
function resolveCountryLabel(state = {}, countryId = DEFAULT_COUNTRY_ID) {
const key = normalizeCountryKey(countryId);
const matched = resolveCountryCandidates(state).find((entry) => normalizeCountryKey(entry.id) === key);
return matched?.label || (key ? `Country #${key}` : DEFAULT_COUNTRY_LABEL);
}
function resolvePriceRange(state = {}) {
const minPriceLimit = normalizePriceLimit(state.smsBowerMinPrice);
const maxPriceLimit = normalizePriceLimit(state.smsBowerMaxPrice);
return {
minPriceLimit,
maxPriceLimit,
hasMinPriceLimit: minPriceLimit !== null,
hasMaxPriceLimit: maxPriceLimit !== null,
invalidRange: minPriceLimit !== null && maxPriceLimit !== null && minPriceLimit > maxPriceLimit,
};
}
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 fetchBalance(state = {}, deps = {}) {
const config = resolveConfig(state, deps);
const payload = await fetchPayload(config, { action: 'getBalance' }, 'SMS Bower 查询余额');
const matched = describePayload(payload).match(/ACCESS_BALANCE\s*:\s*([\d.]+)/i);
return { balance: matched ? Number(matched[1]) : null, raw: payload };
}
async function fetchPrices(state = {}, countryConfig = {}, deps = {}) {
const config = resolveConfig(state, deps);
return fetchPayload(config, {
action: 'getPrices',
service: config.serviceCode,
country: normalizeSmsBowerCountryId(countryConfig?.id ?? state.smsBowerCountryId, DEFAULT_COUNTRY_ID),
}, 'SMS Bower 查询价格');
}
function collectPriceEntries(payload, entries = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectPriceEntries(entry, entries));
return entries;
}
if (!payload || typeof payload !== 'object') return entries;
const cost = Number(payload.cost ?? payload.price ?? payload.Price);
if (Number.isFinite(cost) && cost >= 0) {
const count = Number(payload.count ?? payload.qty ?? payload.Qty);
entries.push({ cost, count: Number.isFinite(count) ? count : 0, inStock: !Number.isFinite(count) || count > 0 });
}
Object.values(payload).forEach((entry) => collectPriceEntries(entry, entries));
return entries;
}
function normalizeActivation(payload, fallback = {}) {
if (typeof payload === 'string') {
const matched = payload.match(/^ACCESS_NUMBER\s*:\s*([^:]+)\s*:\s*(.+)$/i);
if (matched) {
payload = { activationId: matched[1], phoneNumber: matched[2] };
}
}
const source = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
const activationId = normalizeText(source.activationId ?? source.id ?? fallback.activationId);
const phoneNumber = normalizeText(source.phoneNumber ?? source.phone ?? fallback.phoneNumber);
if (!activationId || !phoneNumber) return null;
const countryId = normalizeSmsBowerCountryId(source.countryId ?? source.country ?? fallback.countryId, DEFAULT_COUNTRY_ID);
return {
activationId,
phoneNumber,
provider: PROVIDER_ID,
serviceCode: normalizeSmsBowerServiceCode(source.serviceCode || fallback.serviceCode || DEFAULT_SERVICE_CODE),
countryId,
countryCode: countryId,
countryLabel: normalizeSmsBowerCountryLabel(source.countryLabel || fallback.countryLabel, `Country #${countryId}`),
successfulUses: Math.max(0, Math.floor(Number(source.successfulUses ?? fallback.successfulUses) || 0)),
maxUses: 1,
};
}
function resolveActivationCountry(activation = {}, state = {}) {
const normalized = normalizeActivation(activation) || (activation && typeof activation === 'object' ? activation : {});
const id = normalizeSmsBowerCountryId(normalized.countryId ?? normalized.countryCode ?? normalized.country, DEFAULT_COUNTRY_ID);
return { id, label: resolveCountryLabel(state, id) };
}
function getActivationCountryKey(activation = {}) {
return normalizeCountryKey(activation?.countryCode ?? activation?.countryId ?? activation?.country);
}
function getActivationPrice(activation = {}) {
const price = Number(activation?.selectedPrice ?? activation?.price ?? activation?.maxPrice);
return Number.isFinite(price) && price >= 0 ? price : null;
}
function buildAcquireQuery(state = {}, config = resolveConfig(state), countryConfig = {}) {
const range = resolvePriceRange(state);
if (range.invalidRange) {
throw new Error(`SMS Bower 价格区间无效:最低购买价 ${range.minPriceLimit} 高于价格上限 ${range.maxPriceLimit}`);
}
return {
action: 'getNumber',
service: config.serviceCode,
country: normalizeSmsBowerCountryId(countryConfig.id, DEFAULT_COUNTRY_ID),
maxPrice: range.maxPriceLimit,
minPrice: range.minPriceLimit,
};
}
async function requestActivation(state = {}, options = {}, deps = {}) {
const config = resolveConfig(state, deps);
const blocked = new Set((Array.isArray(options.blockedCountryIds) ? options.blockedCountryIds : []).map((v) => normalizeCountryKey(v)).filter(Boolean));
const candidates = resolveCountryCandidates(state).filter((entry) => !blocked.has(normalizeCountryKey(entry.id)));
const countryList = candidates.length ? candidates : resolveCountryCandidates(state);
const errors = [];
for (const countryConfig of countryList) {
try {
const payload = await fetchPayload(config, buildAcquireQuery(state, config, countryConfig), `SMS Bower 购买手机号(${countryConfig.label || countryConfig.id}`);
const activation = normalizeActivation(payload, {
countryId: countryConfig.id,
countryLabel: countryConfig.label,
serviceCode: config.serviceCode,
});
if (activation) return activation;
throw new Error(`SMS Bower 购买手机号返回不可用响应:${describePayload(payload) || '空响应'}`);
} catch (error) {
errors.push(error.message || String(error));
}
}
throw new Error(`SMS Bower 购买手机号失败:${errors.join(' | ')}`);
}
async function setActivationStatus(state = {}, activation, status, actionLabel, deps = {}) {
const normalized = normalizeActivation(activation);
if (!normalized) return '';
const config = resolveConfig(state, deps);
const payload = await fetchPayload(config, {
action: 'setStatus',
status,
id: normalized.activationId,
}, actionLabel);
return describePayload(payload);
}
async function finishActivation(state = {}, activation, deps = {}) {
return setActivationStatus(state, activation, 6, 'SMS Bower 完成订单', deps);
}
async function cancelActivation(state = {}, activation, deps = {}) {
return setActivationStatus(state, activation, 8, 'SMS Bower 取消订单', deps);
}
async function banActivation(state = {}, activation, deps = {}) {
return cancelActivation(state, activation, deps);
}
async function requestAdditionalSms(state = {}, activation, deps = {}) {
return setActivationStatus(state, activation, 3, 'SMS Bower 请求下一条短信', deps);
}
async function reuseActivation(state = {}, activation, deps = {}) {
await requestAdditionalSms(state, activation, deps);
return normalizeActivation(activation);
}
async function rotateActivation(state = {}, activation, options = {}, deps = {}) {
await (String(options?.releaseAction || '').trim().toLowerCase() === 'ban'
? banActivation(state, activation, deps)
: cancelActivation(state, activation, deps));
return { currentTicketId: String(activation?.activationId || activation?.id || ''), nextActivation: null };
}
function extractVerificationCode(raw = '') {
const trimmed = String(raw || '').trim();
if (!trimmed) return '';
const matched = trimmed.match(/\b(\d{4,8})\b/);
return matched?.[1] || trimmed.replace(/^STATUS_OK\s*:\s*/i, '').trim();
}
async function pollActivationCode(state = {}, activation, options = {}, deps = {}) {
const normalized = normalizeActivation(activation);
if (!normalized) 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 = '';
while (Date.now() - start < timeoutMs) {
if (maxRounds > 0 && pollCount >= maxRounds) break;
deps.throwIfStopped?.();
const payload = await fetchPayload(config, { action: 'getStatus', id: normalized.activationId }, 'SMS Bower 查询验证码');
pollCount += 1;
lastResponse = describePayload(payload);
if (typeof options.onStatus === 'function') {
await options.onStatus({ activation: normalized, elapsedMs: Date.now() - start, pollCount, statusText: lastResponse, timeoutMs });
}
if (/^STATUS_OK\s*:/i.test(lastResponse)) return extractVerificationCode(lastResponse);
if (/^STATUS_CANCEL\b/i.test(lastResponse)) throw new Error('SMS Bower 查询验证码失败:订单已取消');
if (typeof options.onWaitingForCode === 'function') {
await options.onWaitingForCode({ activation: normalized, elapsedMs: Date.now() - start, pollCount, statusText: lastResponse, timeoutMs });
}
await deps.sleepWithStop?.(intervalMs);
}
throw new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}等待手机验证码超时。${lastResponse ? ` SMS Bower 最后状态:${lastResponse}` : ''}`);
}
function createProvider(deps = {}) {
const providerDeps = {
fetchImpl: deps.fetchImpl,
sleepWithStop: deps.sleepWithStop,
throwIfStopped: deps.throwIfStopped,
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
const capabilities = Object.freeze({
supportsReusableActivation: true,
supportsAutomaticFreeReuse: true,
supportsFreeReusePreservation: false,
supportsPageResend: false,
supportsPageResendProbe: true,
requiresCountrySelection: true,
});
return {
id: PROVIDER_ID,
label: 'SMS Bower',
capabilities,
defaultCountryId: DEFAULT_COUNTRY_ID,
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
defaultProduct: DEFAULT_SERVICE_LABEL,
defaultServiceCode: DEFAULT_SERVICE_CODE,
normalizeCountryId: normalizeSmsBowerCountryId,
normalizeCountryLabel: normalizeSmsBowerCountryLabel,
normalizeCountryKey,
normalizeServiceCode: normalizeSmsBowerServiceCode,
normalizeMaxPrice: normalizeSmsBowerMaxPrice,
normalizeCountryFallback: normalizeCountryOrder,
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: async (_state, activation) => ({ activation: normalizeActivation(activation), ignoredPhoneCodeKeys: [] }),
canPersistReusableActivation: () => false,
canPreserveActivationForFreeReuse: () => false,
shouldUsePageResend: () => false,
shouldProbePageResend: () => true,
fetchBalance: (state) => fetchBalance(state, providerDeps),
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
resolvePriceRange,
formatPriceRangeText,
collectPriceEntries,
describePayload,
};
}
return {
PROVIDER_ID,
DEFAULT_COUNTRY_ID,
DEFAULT_COUNTRY_LABEL,
DEFAULT_SERVICE_CODE,
createProvider,
normalizeSmsBowerCountryId,
normalizeSmsBowerCountryLabel,
normalizeCountryKey,
normalizeSmsBowerServiceCode,
normalizeSmsBowerMaxPrice,
normalizeActivation,
};
});