a38fb250ea
补回 NexSMS provider adapter,并在 background 与 sidepanel 加载。 同时补充 registry 创建真实 NexSMS provider 的测试,避免后续回归。
269 lines
8.8 KiB
JavaScript
269 lines
8.8 KiB
JavaScript
// 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;
|
||
|
||
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 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 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 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 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 createProvider(deps = {}) {
|
||
const providerDeps = {
|
||
fetchImpl: deps.fetchImpl,
|
||
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
|
||
};
|
||
return {
|
||
id: PROVIDER_ID,
|
||
label: 'NexSMS',
|
||
defaultCountryId: DEFAULT_COUNTRY_ID,
|
||
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
|
||
defaultProduct: DEFAULT_SERVICE_LABEL,
|
||
defaultServiceCode: DEFAULT_SERVICE_CODE,
|
||
normalizeCountryId: normalizeNexSmsCountryId,
|
||
normalizeCountryLabel: normalizeNexSmsCountryLabel,
|
||
normalizeCountryOrder: normalizeNexSmsCountryOrder,
|
||
normalizeServiceCode: normalizeNexSmsServiceCode,
|
||
resolveCountryCandidates,
|
||
fetchBalance: (state) => fetchBalance(state, providerDeps),
|
||
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
|
||
describePayload,
|
||
isSuccessPayload,
|
||
};
|
||
}
|
||
|
||
return {
|
||
PROVIDER_ID,
|
||
DEFAULT_BASE_URL,
|
||
DEFAULT_COUNTRY_ID,
|
||
DEFAULT_COUNTRY_LABEL,
|
||
DEFAULT_SERVICE_CODE,
|
||
DEFAULT_SERVICE_LABEL,
|
||
createProvider,
|
||
describePayload,
|
||
isSuccessPayload,
|
||
normalizeNexSmsCountryId,
|
||
normalizeNexSmsCountryLabel,
|
||
normalizeNexSmsCountryOrder,
|
||
normalizeNexSmsServiceCode,
|
||
};
|
||
});
|