diff --git a/background.js b/background.js index 0ecbd43..b566f8d 100644 --- a/background.js +++ b/background.js @@ -21,6 +21,7 @@ importScripts( 'gopay-utils.js', 'phone-sms/providers/hero-sms.js', 'phone-sms/providers/five-sim.js', + 'phone-sms/providers/nexsms.js', 'phone-sms/providers/madao.js', 'phone-sms/providers/registry.js', 'background/phone-verification-flow.js', diff --git a/phone-sms/providers/nexsms.js b/phone-sms/providers/nexsms.js new file mode 100644 index 0000000..971dd1d --- /dev/null +++ b/phone-sms/providers/nexsms.js @@ -0,0 +1,268 @@ +// 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, + }; +}); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 19809da..13855ad 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -1896,6 +1896,7 @@ + diff --git a/tests/phone-sms-provider-registry.test.js b/tests/phone-sms-provider-registry.test.js index 9b4d61a..a6c3155 100644 --- a/tests/phone-sms-provider-registry.test.js +++ b/tests/phone-sms-provider-registry.test.js @@ -3,6 +3,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const source = fs.readFileSync('phone-sms/providers/registry.js', 'utf8'); +const maDaoSource = fs.readFileSync('phone-sms/providers/madao.js', 'utf8'); +const nexSmsSource = fs.readFileSync('phone-sms/providers/nexsms.js', 'utf8'); function loadRegistry(root = {}) { return new Function('self', `${source}; return self.PhoneSmsProviderRegistry;`)(root); @@ -16,6 +18,9 @@ test('phone sms provider registry normalizes ids, order and labels consistently' PhoneSmsFiveSimProvider: { createProvider: (deps = {}) => ({ provider: '5sim', deps }), }, + PhoneSmsNexSmsProvider: { + createProvider: (deps = {}) => ({ provider: 'nexsms', deps }), + }, PhoneSmsMaDaoProvider: { createProvider: (deps = {}) => ({ provider: 'madao', deps }), }, @@ -52,5 +57,43 @@ test('phone sms provider registry normalizes ids, order and labels consistently' registry.createProvider('madao', { foo: 2 }), { provider: 'madao', deps: { foo: 2 } } ); - assert.throws(() => registry.createProvider('nexsms'), /接码平台模块未加载:nexsms/); + assert.deepStrictEqual( + registry.createProvider('nexsms', { foo: 3 }), + { provider: 'nexsms', deps: { foo: 3 } } + ); +}); + +test('phone sms provider registry can create the real MaDao provider module', () => { + const maDaoModule = new Function('self', `${maDaoSource}; return self.PhoneSmsMaDaoProvider;`)({}); + const registry = loadRegistry({ + PhoneSmsMaDaoProvider: maDaoModule, + }); + + const provider = registry.createProvider('madao', { fetchImpl: 'demo-fetch' }); + + assert.equal(provider.id, 'madao'); + assert.equal(provider.label, 'MaDao'); + assert.equal(provider.defaultProduct, 'openai'); + assert.equal(typeof provider.acquireActivation, 'function'); + assert.equal(typeof provider.pollActivation, 'function'); + assert.equal(typeof provider.releaseActivation, 'function'); + assert.equal(provider.mapTicketStatus('waiting_code'), 'waiting_code'); +}); + +test('phone sms provider registry can create the real NexSMS provider module', () => { + const nexSmsModule = new Function('self', `${nexSmsSource}; return self.PhoneSmsNexSmsProvider;`)({}); + const registry = loadRegistry({ + PhoneSmsNexSmsProvider: nexSmsModule, + }); + + const provider = registry.createProvider('nexsms', { fetchImpl: 'demo-fetch' }); + + assert.equal(provider.id, 'nexsms'); + assert.equal(provider.label, 'NexSMS'); + assert.equal(provider.defaultProduct, 'OpenAI'); + assert.equal(provider.defaultServiceCode, 'ot'); + assert.equal(typeof provider.fetchBalance, 'function'); + assert.equal(typeof provider.fetchPrices, 'function'); + assert.equal(provider.normalizeCountryId('8'), 8); + assert.equal(provider.normalizeServiceCode(' OT '), 'ot'); });