feat: add SMS Bower phone SMS provider

This commit is contained in:
chick
2026-05-30 21:30:14 +08:00
parent 8cee2611ca
commit 5f871c3c40
10 changed files with 692 additions and 5 deletions
@@ -128,7 +128,8 @@ const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
const PHONE_SMS_PROVIDER_MADAO = 'madao';
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = ['hero-sms', '5sim', 'nexsms', 'madao'];
const PHONE_SMS_PROVIDER_SMS_BOWER = 'sms-bower';
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = ['hero-sms', '5sim', 'nexsms', 'madao', 'sms-bower'];
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const DEFAULT_MADAO_BASE_URL = 'http://127.0.0.1:7822';
const DEFAULT_MADAO_MODE = 'routing_plan';
+14 -4
View File
@@ -24,30 +24,36 @@ test('phone sms provider registry normalizes ids, order and labels consistently'
PhoneSmsMaDaoProvider: {
createProvider: (deps = {}) => ({ provider: 'madao', deps }),
},
PhoneSmsBowerProvider: {
createProvider: (deps = {}) => ({ provider: 'sms-bower', deps }),
},
});
assert.deepStrictEqual(registry.getProviderIds(), ['hero-sms', '5sim', 'nexsms', 'madao']);
assert.deepStrictEqual(registry.getProviderIds(), ['hero-sms', '5sim', 'nexsms', 'madao', 'sms-bower']);
assert.equal(registry.normalizeProviderId(' NEXSMS '), 'nexsms');
assert.equal(registry.normalizeProviderId(' MaDao '), 'madao');
assert.equal(registry.normalizeProviderId('unknown-provider'), 'hero-sms');
assert.equal(registry.getProviderLabel('nexsms'), 'NexSMS');
assert.equal(registry.getProviderLabel('madao'), 'MaDao');
assert.equal(registry.getProviderLabel('sms-bower'), 'SMS Bower');
assert.equal(registry.getProviderDefinition('nexsms').moduleKey, 'PhoneSmsNexSmsProvider');
assert.equal(registry.getProviderDefinition('madao').moduleKey, 'PhoneSmsMaDaoProvider');
assert.equal(registry.getProviderDefinition('sms-bower').moduleKey, 'PhoneSmsBowerProvider');
assert.deepStrictEqual(
registry.normalizeProviderOrder([
{ provider: 'madao' },
{ provider: 'sms-bower' },
{ provider: 'nexsms' },
{ id: '5sim' },
{ value: 'hero-sms' },
'MADAO',
'NEXSMS',
]),
['madao', 'nexsms', '5sim', 'hero-sms']
['madao', 'sms-bower', 'nexsms', '5sim', 'hero-sms']
);
assert.deepStrictEqual(
registry.normalizeProviderOrder([], ['madao', 'nexsms', '5sim', 'nexsms']),
['madao', 'nexsms', '5sim']
registry.normalizeProviderOrder([], ['madao', 'sms-bower', 'nexsms', '5sim', 'nexsms']),
['madao', 'sms-bower', 'nexsms', '5sim']
);
assert.deepStrictEqual(
registry.createProvider('5sim', { foo: 1 }),
@@ -61,6 +67,10 @@ test('phone sms provider registry normalizes ids, order and labels consistently'
registry.createProvider('nexsms', { foo: 3 }),
{ provider: 'nexsms', deps: { foo: 3 } }
);
assert.deepStrictEqual(
registry.createProvider('sms-bower', { foo: 4 }),
{ provider: 'sms-bower', deps: { foo: 4 } }
);
});
test('phone sms provider registry can create the real MaDao provider module', () => {
@@ -933,6 +933,7 @@ const rowNexSmsApiKey = { style: { display: 'none' } };
const rowNexSmsCountry = { style: { display: 'none' } };
const rowNexSmsCountryFallback = { style: { display: 'none' } };
const rowNexSmsServiceCode = { style: { display: 'none' } };
const rowSmsBowerApiKey = { style: { display: 'none' } };
const rowMaDaoBaseUrl = { style: { display: 'none' } };
const rowMaDaoHttpSecret = { style: { display: 'none' } };
const rowMaDaoMode = { style: { display: 'none' } };
@@ -1009,6 +1010,11 @@ const PHONE_SMS_PROVIDER_UI_DESCRIPTORS = ${JSON.stringify({
'rowNexSmsServiceCode',
],
},
'sms-bower': {
rowKeys: [
'rowSmsBowerApiKey',
],
},
madao: {
rowKeys: [
'rowMaDaoBaseUrl',
@@ -1091,6 +1097,7 @@ return {
rowNexSmsCountry,
rowNexSmsCountryFallback,
rowNexSmsServiceCode,
rowSmsBowerApiKey,
rowMaDaoBaseUrl,
rowMaDaoHttpSecret,
rowMaDaoMode,
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('phone-sms/providers/sms-bower.js', 'utf8');
const api = new Function('self', `${source}; return self.PhoneSmsBowerProvider;`)({});
function createTextResponse(payload, ok = true, status = ok ? 200 : 400, statusText = ok ? 'OK' : 'Bad Request') {
return {
ok,
status,
statusText,
text: async () => (typeof payload === 'string' ? payload : JSON.stringify(payload)),
};
}
test('SMS Bower provider uses handler API key query for balance and price catalog', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
requests.push({ url: parsed, options });
if (parsed.searchParams.get('action') === 'getBalance') {
return createTextResponse('ACCESS_BALANCE:12.34');
}
if (parsed.searchParams.get('action') === 'getPrices') {
return createTextResponse({ 52: { ot: { cost: 0.21, count: 7 } } });
}
throw new Error(`unexpected ${parsed.toString()}`);
},
});
const balance = await provider.fetchBalance({ smsBowerApiKey: 'demo-key' });
const prices = await provider.fetchPrices({ smsBowerApiKey: 'demo-key', smsBowerCountryId: 52 });
const entries = provider.collectPriceEntries(prices, []);
assert.equal(requests[0].url.origin + requests[0].url.pathname, 'https://smsbower.page/stubs/handler_api.php');
assert.equal(requests[0].url.searchParams.get('api_key'), 'demo-key');
assert.equal(requests[0].url.searchParams.get('action'), 'getBalance');
assert.equal(balance.balance, 12.34);
assert.equal(requests[1].url.searchParams.get('action'), 'getPrices');
assert.equal(requests[1].url.searchParams.get('service'), 'ot');
assert.equal(requests[1].url.searchParams.get('country'), '52');
assert.deepStrictEqual(entries, [{ cost: 0.21, count: 7, inStock: true }]);
});
test('SMS Bower provider acquires number, polls code, and updates activation statuses', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
const action = parsed.searchParams.get('action');
requests.push({ url: parsed, options });
if (action === 'getNumber') {
return createTextResponse('ACCESS_NUMBER:12345:447700900123');
}
if (action === 'getStatus') {
return createTextResponse('STATUS_OK:Your OpenAI code is 654321');
}
if (action === 'setStatus') {
return createTextResponse(parsed.searchParams.get('status') === '6' ? 'ACCESS_ACTIVATION' : 'ACCESS_CANCEL');
}
throw new Error(`unexpected ${parsed.toString()}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const state = {
smsBowerApiKey: 'demo-key',
smsBowerServiceCode: 'ot',
smsBowerCountryId: 16,
smsBowerCountryLabel: 'United Kingdom',
smsBowerMaxPrice: '0.3',
smsBowerMinPrice: '0.1',
smsBowerProviderIds: '3170,3180',
};
const activation = await provider.requestActivation(state);
const code = await provider.pollActivationCode(state, activation, { timeoutMs: 1000, intervalMs: 1, maxRounds: 1 });
await provider.finishActivation(state, activation);
await provider.cancelActivation(state, activation);
await provider.banActivation(state, activation);
const reused = await provider.reuseActivation(state, activation);
assert.equal(activation.provider, 'sms-bower');
assert.equal(activation.activationId, '12345');
assert.equal(activation.phoneNumber, '447700900123');
assert.equal(activation.countryId, 16);
assert.equal(code, '654321');
assert.equal(reused.activationId, '12345');
const acquireUrl = requests[0].url;
assert.equal(acquireUrl.searchParams.get('action'), 'getNumber');
assert.equal(acquireUrl.searchParams.get('service'), 'ot');
assert.equal(acquireUrl.searchParams.get('country'), '16');
assert.equal(acquireUrl.searchParams.get('maxPrice'), '0.3');
assert.equal(acquireUrl.searchParams.get('minPrice'), '0.1');
assert.equal(acquireUrl.searchParams.get('providerIds'), '3170,3180');
assert.deepStrictEqual(
requests.map((entry) => [entry.url.searchParams.get('action'), entry.url.searchParams.get('status')]),
[
['getNumber', null],
['getStatus', null],
['setStatus', '6'],
['setStatus', '8'],
['setStatus', '8'],
['setStatus', '3'],
]
);
});
+25
View File
@@ -0,0 +1,25 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function loadRegistry() {
const sandbox = { globalThis: {} };
sandbox.self = sandbox.globalThis;
vm.runInNewContext(fs.readFileSync('phone-sms/providers/sms-bower.js', 'utf8'), sandbox);
vm.runInNewContext(fs.readFileSync('phone-sms/providers/registry.js', 'utf8'), sandbox);
return sandbox.globalThis.PhoneSmsProviderRegistry;
}
test('SMS Bower is registered as a known phone SMS provider', () => {
const registry = loadRegistry();
assert.equal(registry.normalizeProviderId('sms-bower'), 'sms-bower');
assert.equal(registry.getProviderLabel('sms-bower'), 'SMS Bower');
assert.ok(registry.getProviderIds().includes('sms-bower'));
assert.deepEqual(
Array.from(registry.normalizeProviderOrder(['sms-bower', 'hero-sms'])),
['sms-bower', 'hero-sms']
);
const provider = registry.createProvider('sms-bower', { fetchImpl: async () => ({ ok: true, text: async () => 'ACCESS_BALANCE:1.23' }) });
assert.equal(provider.id, 'sms-bower');
});