diff --git a/README.md b/README.md index 880ba7e..f6b7cb3 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,67 @@ -SMS/Luban Phone Fill Tool +SMS/Luban/5SIM Phone Fill Tool -用途:点击扩展图标后,在当前网页右上角打开一个常驻悬浮窗。支持 SMSBower 和 Luban SMS 两个接码平台;点一次“获取并填入”会获取手机号,自动填入当前页面最像手机号的输入框、复制到剪贴板,并自动点击页面上的下一步/发送验证码按钮。再次获取会先释放上一个激活,再拿新号替换页面输入框并自动提交。 +用途:点击扩展图标后,在当前网页右上角打开一个常驻悬浮窗。支持 SMSBower、Luban SMS、5SIM 三个接码平台;点一次“获取并填入”会获取手机号,自动填入当前页面最像手机号的输入框、复制到剪贴板,并自动点击页面上的下一步/发送验证码按钮。再次获取会先释放上一个激活/订单,再拿新号替换页面输入框并自动提交。 已集成功能: -- 接码平台可选:SMSBower / Luban SMS。 -- SMSBower 使用 `api_key`、`getPricesV3`、`getNumberV2`、`getStatus`、`setStatus`。 -- Luban SMS 使用 `apikey`、`getNumber`、`getSms`、`setStatus status=reject`。 -- 悬浮窗打开后默认开启“自动切号:开”。 -- 页面提示“该电话/手机号已被使用、已绑定、已注册”时,只要确认是当前新号码触发的错误,插件会自动切换下一个号码。 -- 页面提示“无法向此电话号码发送验证码”等发送失败时,插件会自动按可恢复失败换号。 -- “检测报错换号”和“已使用换号”仍保留为手动兜底按钮。 -- 填号后会自动寻找并点击目标页面上的“下一步/继续/发送验证码/提交”等按钮;如果找不到按钮,会尝试提交表单或发送 Enter。 +- 接码平台可选:SMSBower / Luban SMS / 5SIM +- 自动保存 API Key、平台、服务、价格、国家/产品等配置到 chrome.storage.local +- SMSBower:按多个国家 + 价格范围查询库存,价低优先获取号码 +- Luban SMS:按 service_id 获取号码,支持 service 列表价格过滤 +- 5SIM:按 country/operator/product 获取号码,使用 Bearer Token 鉴权,支持 maxPrice +- 自动取消/释放旧号码后获取新号码 +- 自动填入当前页面手机号输入框 +- 自动复制手机号到剪贴板 +- 自动点击下一步/继续/发送验证码/提交按钮 +- 自动检测页面号码错误并按当前号码自动切换下一个号 +- 验证码查询轮询:按钮点一次后每秒自动查,拿到验证码后停止 +- 一键复制验证码 +- 手动“检测报错换号”“已使用换号”“取消当前”兜底 + +接码平台配置: + +1. SMSBower +- API Key:SMSBower key +- SMSBower API 地址:默认 https://smsbower.app/stubs/handler_api.php +- SMSBower 项目:默认 dr +- 国家顺序:选择一个或多个国家 +- 最低价/最高价:可空 + +2. Luban SMS +- API Key:Luban apikey +- Luban API 地址:默认 https://lubansms.com/v2/api +- Luban service_id:必填,从 Luban 控制台或 List 接口查询 +- 最低价/最高价:可空;如果 List 能查到 service_id cost,会按范围过滤 + +3. 5SIM +- API Key:5SIM token,插件会用 Authorization: Bearer +- 5SIM API 地址:默认 https://5sim.net +- 5SIM 国家:默认 vietnam;可填官方 country slug,也可填 any +- 5SIM 运营商:默认 any +- 5SIM 产品:默认 openai +- 最高价:可空;对应官方 buy activation 接口 maxPrice +- 最低价:5SIM 官方购买接口不支持最低价;使用 5SIM 时请留空 + +5SIM API 对应关系: +- 买号:GET /v1/user/buy/activation/{country}/{operator}/{product}?maxPrice=... +- 查码:GET /v1/user/check/{id} +- 取消:GET /v1/user/cancel/{id} +- 拉黑:GET /v1/user/ban/{id} + +自动切号逻辑: +- 默认开启“自动切号:开” +- 页面出现“该电话已被使用 / 手机号已绑定 / 无法向此电话号码发送验证码”等错误时,插件会自动释放当前号码、获取下一个号码、填入、复制并自动提交 +- 判定按当前号码进行:新号码触发相同错误文案也会继续切号 +- 同一号码重复触发同一轮错误时不会并发重复切号 安装/更新: -1. 打开 Chrome/Edge 扩展管理页。 -2. 开启“开发者模式”。 -3. 加载已解压的扩展,选择本目录。 -4. 如果之前已经加载过,点扩展卡片上的“重新加载”。 +1. 打开 chrome://extensions/ +2. 开启“开发者模式” +3. 加载已解压的扩展程序,选择本目录 +4. 已安装旧版时,点“重新加载”,然后刷新目标网页 -使用: -1. 打开任意网页,先点一下你要填写的输入框。 -2. 点浏览器工具栏里的“接码填号”扩展图标。 -3. 页面右上角会出现悬浮窗,可拖动。 -4. 选择接码平台: - - SMSBower:填写 API Key、项目代码、价格上下限、国家顺序。 - - Luban SMS:填写 API Key 和 `service_id`;`service_id` 可在 Luban 控制台或 API 服务列表中查询。Luban 的国家/服务由 `service_id` 决定,所以国家选择会隐藏。 -5. 顶部“获取并填入”会立即拿号、填入、复制,并自动提交/点击发送验证码。 -6. 再点一次“获取并填入”会释放上一个激活,替换输入框为新手机号。 -7. 如果目标页面拒绝号码,保持悬浮窗打开即可:插件会自动扫描页面错误文本并切换下一个号码。 - - “自动切号:开/关”可以临时启停自动扫描; - - “检测报错换号”用于手动触发一次扫描; - - “已使用换号”用于你已经确认号码被使用时立即换号。 -8. 点“获取验证码”后会每 1 秒查询一次当前激活的验证码状态;收到验证码后显示并停止。 -9. 看到验证码后,点“复制验证码”会复制当前显示的验证码。 -10. 点悬浮窗右上角 × 只是隐藏;再次点扩展图标会重新显示。 - -说明: -- “复制验证码”只复制当前已查询到的验证码,不会自动填验证码。 -- SMSBower 拿号优先级:价格低优先;同价格按你选择国家的顺序优先。 -- Luban SMS 官方文档基础地址为 `https://lubansms.com/v2/api/`,扩展会按接口自动拼接 `getNumber/getSms/setStatus/List`。 -- 本地验证:`node --test tests/provider.test.js`。 +测试: +node --check background.js +node --check content.js +node --check popup.js +python3 -m json.tool manifest.json +node --test tests/provider.test.js diff --git a/background.js b/background.js index 5569e53..988fc2d 100644 --- a/background.js +++ b/background.js @@ -3,8 +3,12 @@ const DEFAULT_SETTINGS = Object.freeze({ apiKey: '', baseUrl: 'https://smsbower.app/stubs/handler_api.php', lubanBaseUrl: 'https://lubansms.com/v2/api', + fiveSimBaseUrl: 'https://5sim.net', serviceCode: 'dr', lubanServiceId: '', + fiveSimCountry: 'vietnam', + fiveSimOperator: 'any', + fiveSimProduct: 'openai', minPrice: '', maxPrice: '', countries: [{ id: 6, label: 'Indonesia' }], @@ -28,6 +32,9 @@ const PROVIDERS = Object.freeze({ 'luban-sms': { id: 'luban-sms', name: 'Luban SMS', apiKeyParam: 'apikey', baseUrlKey: 'lubanBaseUrl', defaultBaseUrl: 'https://lubansms.com/v2/api', }, + '5sim': { + id: '5sim', name: '5SIM', apiKeyParam: '', baseUrlKey: 'fiveSimBaseUrl', defaultBaseUrl: 'https://5sim.net', + }, }); function asText(value = '', fallback = '') { return String(value ?? '').trim() || fallback; } @@ -37,6 +44,10 @@ function normalizeCountryId(value, fallback = 6) { const parsed = Math.floor(Number(value)); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; } +function normalizeFiveSimSlug(value, fallback = '') { + const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, ''); + return normalized || fallback; +} function normalizePrice(value) { if (value === undefined || value === null || String(value).trim() === '') return null; const n = Number(value); @@ -72,8 +83,12 @@ function normalizeSettings(input = {}) { apiKey: asText(input.apiKey), baseUrl: asText(input.baseUrl, DEFAULT_SETTINGS.baseUrl), lubanBaseUrl: asText(input.lubanBaseUrl, DEFAULT_SETTINGS.lubanBaseUrl), + fiveSimBaseUrl: asText(input.fiveSimBaseUrl, DEFAULT_SETTINGS.fiveSimBaseUrl), serviceCode: asText(input.serviceCode, DEFAULT_SETTINGS.serviceCode).toLowerCase().replace(/[^a-z0-9_-]+/g, '') || DEFAULT_SETTINGS.serviceCode, lubanServiceId: asText(input.lubanServiceId ?? input.serviceId ?? input.luban_service_id, DEFAULT_SETTINGS.lubanServiceId).replace(/[^0-9a-zA-Z_-]+/g, ''), + fiveSimCountry: normalizeFiveSimSlug(input.fiveSimCountry, DEFAULT_SETTINGS.fiveSimCountry), + fiveSimOperator: normalizeFiveSimSlug(input.fiveSimOperator, DEFAULT_SETTINGS.fiveSimOperator), + fiveSimProduct: normalizeFiveSimSlug(input.fiveSimProduct, DEFAULT_SETTINGS.fiveSimProduct), minPrice: normalizePrice(input.minPrice) === null ? '' : String(normalizePrice(input.minPrice)), maxPrice: normalizePrice(input.maxPrice) === null ? '' : String(normalizePrice(input.maxPrice)), countries, @@ -104,9 +119,13 @@ function buildUrl(settings, query = {}) { const root = base.replace(/\/+(getBalance|getNumber|getSms|setStatus|getAgainNmuber|countries|List|smsHistory|getKeywordNumber|getKeywordSms|delKeywordNumber|keywordSmsHistory|bulksms)\/?$/i, ''); base = `${root.replace(/\/+$/, '')}/${endpoint}`; } + } else if (provider.id === '5sim') { + const path = String(queryEntries.__path || '').replace(/^\/+/, ''); + delete queryEntries.__path; + if (path) base = `${base.replace(/\/+$/, '')}/${path}`; } const url = new URL(base); - if (settings.apiKey) url.searchParams.set(provider.apiKeyParam, settings.apiKey); + if (settings.apiKey && provider.apiKeyParam) url.searchParams.set(provider.apiKeyParam, settings.apiKey); Object.entries(queryEntries).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); }); return url.toString(); } @@ -128,7 +147,9 @@ function describePayload(payload) { async function apiRequest(settings, query, label = '接码平台请求') { const provider = getProvider(settings); if (!settings.apiKey) throw new Error(`请先填写 ${provider.name} API Key。`); - const response = await fetch(buildUrl(settings, query), { method: 'GET', headers: { Accept: 'application/json, text/plain, */*' } }); + const headers = { Accept: 'application/json, text/plain, */*' }; + if (provider.id === '5sim') headers.Authorization = `Bearer ${settings.apiKey}`; + const response = await fetch(buildUrl(settings, query), { method: 'GET', headers }); const payload = parsePayload(await response.text()); const message = describePayload(payload); const apiStatus = payload && typeof payload === 'object' && !Array.isArray(payload) ? Number(payload.status) : null; @@ -147,12 +168,14 @@ function normalizeActivation(payload, fallback = {}) { const activationId = asText(src.activationId ?? src.activation_id ?? src.request_id ?? src.id ?? fallback.activationId); const phoneNumber = asText(src.phoneNumber ?? src.phone_number ?? src.phone ?? src.number ?? fallback.phoneNumber); if (!activationId || !phoneNumber) return null; - const countryId = normalizeCountryId(src.countryId ?? src.country_id ?? src.country ?? fallback.countryId, fallback.countryId ?? 6); + const provider = fallback.provider || 'sms-bower'; + const rawCountry = src.countryId ?? src.country_id ?? src.country ?? fallback.countryId; + const countryId = provider === '5sim' ? normalizeFiveSimSlug(rawCountry, fallback.countryId || DEFAULT_SETTINGS.fiveSimCountry) : normalizeCountryId(rawCountry, fallback.countryId ?? 6); return { - activationId, phoneNumber, provider: fallback.provider || 'sms-bower', - serviceCode: asText(src.serviceCode ?? src.service_id ?? fallback.serviceCode, fallback.serviceCode || DEFAULT_SETTINGS.serviceCode), + activationId, phoneNumber, provider, + serviceCode: asText(src.serviceCode ?? src.service_id ?? src.product ?? fallback.serviceCode, fallback.serviceCode || DEFAULT_SETTINGS.serviceCode), countryId, - countryLabel: asText(src.countryLabel ?? src.country_name_zh ?? src.country_name_en ?? fallback.countryLabel, fallback.countryLabel || `Country #${countryId}`), + countryLabel: asText(src.countryLabel ?? src.country_name_zh ?? src.country_name_en ?? src.country ?? fallback.countryLabel, provider === '5sim' ? countryId : `Country #${countryId}`), price: Number.isFinite(Number(src.price ?? src.cost ?? fallback.price)) ? Number(src.price ?? src.cost ?? fallback.price) : null, acquiredAt: Date.now(), }; @@ -206,8 +229,19 @@ async function fetchLubanCandidates(settings) { if (normalizePrice(settings.maxPrice) !== null && price !== null && price > normalizePrice(settings.maxPrice)) return []; return [{ country: { id: -1, label: 'Luban service' }, countryIndex: 0, price: price ?? 0, count: 1, serviceId: settings.lubanServiceId }]; } +async function fetchFiveSimCandidates(settings) { + const country = normalizeFiveSimSlug(settings.fiveSimCountry, DEFAULT_SETTINGS.fiveSimCountry); + const operator = normalizeFiveSimSlug(settings.fiveSimOperator, DEFAULT_SETTINGS.fiveSimOperator); + const product = normalizeFiveSimSlug(settings.fiveSimProduct, DEFAULT_SETTINGS.fiveSimProduct); + if (!country || !operator || !product) throw new Error('请先填写 5SIM 国家、运营商和产品。'); + const max = normalizePrice(settings.maxPrice); + return [{ country: { id: country, label: country }, countryIndex: 0, price: max ?? 0, count: 1, operator, product }]; +} async function fetchPriceCandidates(settings) { - return getProvider(settings).id === 'luban-sms' ? fetchLubanCandidates(settings) : fetchSmsBowerCandidates(settings); + const providerId = getProvider(settings).id; + if (providerId === 'luban-sms') return fetchLubanCandidates(settings); + if (providerId === '5sim') return fetchFiveSimCandidates(settings); + return fetchSmsBowerCandidates(settings); } async function releaseActivation(settings, activation, releaseAction = 'cancel', quiet = false) { if (!activation?.activationId) return { ok: false, skipped: true, releaseAction }; @@ -216,6 +250,7 @@ async function releaseActivation(settings, activation, releaseAction = 'cancel', try { let payload; if (provider.id === 'luban-sms') payload = await apiRequest(settings, { __endpoint: 'setStatus', request_id: activation.activationId, status: 'reject' }, '释放 Luban 号码'); + else if (provider.id === '5sim') payload = await apiRequest(settings, { __path: `v1/user/${normalizedAction === 'ban' ? 'ban' : 'cancel'}/${encodeURIComponent(activation.activationId)}` }, normalizedAction === 'ban' ? '5SIM 拉黑号码' : '5SIM 取消订单'); else payload = await apiRequest(settings, { action: 'setStatus', status: 8, id: activation.activationId }, normalizedAction === 'ban' ? '释放被拒号码' : '取消上一个号码激活'); return { ok: true, payload: describePayload(payload), releaseAction: normalizedAction, activationId: activation.activationId }; } catch (error) { @@ -233,6 +268,19 @@ async function requestActivation(settings, candidate) { if (!activation) throw new Error(`购买手机号返回不可用响应:${describePayload(payload)}`); return activation; } + if (provider.id === '5sim') { + if (normalizePrice(settings.minPrice) !== null) throw new Error('5SIM 官方购买接口只支持 maxPrice,不支持最低价;请清空最低价。'); + const query = {}; + const maxPrice = normalizePrice(settings.maxPrice); + if (maxPrice !== null) query.maxPrice = maxPrice; + const payload = await apiRequest(settings, { + __path: `v1/user/buy/activation/${encodeURIComponent(candidate.country.id)}/${encodeURIComponent(candidate.operator)}/${encodeURIComponent(candidate.product)}`, + ...query, + }, `购买 5SIM 手机号 ${candidate.country.label}`); + const activation = normalizeActivation(payload, { provider: provider.id, serviceCode: candidate.product, price: payload?.price ?? candidate.price, countryId: candidate.country.id, countryLabel: candidate.country.label }); + if (!activation) throw new Error(`购买手机号返回不可用响应:${describePayload(payload)}`); + return activation; + } const payload = await apiRequest(settings, { action: 'getNumberV2', service: settings.serviceCode, country: candidate.country.id, minPrice: normalizePrice(settings.minPrice), maxPrice: Number.isFinite(candidate.price) ? candidate.price : normalizePrice(settings.maxPrice) }, `购买手机号 ${candidate.country.label}`); const activation = normalizeActivation(payload, { provider: provider.id, countryId: candidate.country.id, countryLabel: candidate.country.label, serviceCode: settings.serviceCode, price: candidate.price }); if (!activation) throw new Error(`购买手机号返回不可用响应:${describePayload(payload)}`); @@ -268,7 +316,9 @@ async function fetchActiveCode() { const provider = getProvider(settings); const payload = provider.id === 'luban-sms' ? await apiRequest(settings, { __endpoint: 'getSms', request_id: activation.activationId }, '查询 Luban 验证码') - : await apiRequest(settings, { action: 'getStatus', id: activation.activationId }, '查询当前验证码'); + : (provider.id === '5sim' + ? await apiRequest(settings, { __path: `v1/user/check/${encodeURIComponent(activation.activationId)}` }, '查询 5SIM 验证码') + : await apiRequest(settings, { action: 'getStatus', id: activation.activationId }, '查询当前验证码')); const message = describePayload(payload); if (provider.id === 'luban-sms') { if (payload?.msg === 'success' && payload.sms_code) { @@ -279,6 +329,20 @@ async function fetchActiveCode() { if (payload?.msg === 'wait') throw new Error('验证码还没到,稍后再点“复制验证码”。'); throw new Error(`暂未获取到验证码:${message || '空响应'}`); } + if (provider.id === '5sim') { + const smsList = Array.isArray(payload?.sms) ? payload.sms : []; + for (let index = smsList.length - 1; index >= 0; index -= 1) { + const item = smsList[index] || {}; + const code = extractVerificationCode(item.code || item.text || ''); + if (code) { + await saveSettings({ activeActivation: { ...activation, lastCode: code, lastCodeRaw: message, lastCodeAt: Date.now() } }); + return { code, raw: message, activationId: activation.activationId, phoneNumber: activation.phoneNumber }; + } + } + const status = String(payload?.status || '').toUpperCase(); + if (['CANCELED', 'BANNED', 'FINISHED', 'TIMEOUT'].includes(status)) throw new Error(`5SIM 订单状态异常:${status}`); + throw new Error('验证码还没到,稍后再点“复制验证码”。'); + } if (/^STATUS_OK\s*:/i.test(message)) { const code = extractVerificationCode(message); await saveSettings({ activeActivation: { ...activation, lastCode: code, lastCodeRaw: message, lastCodeAt: Date.now() } }); @@ -290,6 +354,7 @@ async function fetchActiveCode() { } async function fetchCountries(settings) { if (getProvider(settings).id === 'luban-sms') return [{ id: -1, label: 'Luban service_id 模式' }]; + if (getProvider(settings).id === '5sim') return [{ id: settings.fiveSimCountry || DEFAULT_SETTINGS.fiveSimCountry, label: '5SIM country slug 模式' }]; try { const payload = await apiRequest(settings, { action: 'getCountries' }, '获取国家列表'); const list = []; diff --git a/content.js b/content.js index 395815e..1ff5205 100644 --- a/content.js +++ b/content.js @@ -181,8 +181,12 @@ apiKey: $('.sb-api-key')?.value.trim() || '', baseUrl: $('.sb-base-url')?.value.trim() || '', lubanBaseUrl: $('.sb-luban-base-url')?.value.trim() || '', + fiveSimBaseUrl: $('.sb-five-sim-base-url')?.value.trim() || '', serviceCode: $('.sb-service-code')?.value.trim() || 'dr', lubanServiceId: $('.sb-luban-service-id')?.value.trim() || '', + fiveSimCountry: $('.sb-five-sim-country')?.value.trim() || 'vietnam', + fiveSimOperator: $('.sb-five-sim-operator')?.value.trim() || 'any', + fiveSimProduct: $('.sb-five-sim-product')?.value.trim() || 'openai', minPrice: $('.sb-min-price')?.value.trim() || '', maxPrice: $('.sb-max-price')?.value.trim() || '', countries: selectedCountries, @@ -370,6 +374,20 @@ } } + function updateProviderFields() { + const provider = $('.sb-provider')?.value || 'sms-bower'; + panel?.querySelectorAll('.sb-smsbower-only').forEach((el) => { el.style.display = provider === 'sms-bower' ? '' : 'none'; }); + panel?.querySelectorAll('.sb-luban-only').forEach((el) => { el.style.display = provider === 'luban-sms' ? '' : 'none'; }); + panel?.querySelectorAll('.sb-five-sim-only').forEach((el) => { el.style.display = provider === '5sim' ? '' : 'none'; }); + const minPrice = $('.sb-min-price'); + if (minPrice) { + minPrice.closest('label').style.display = provider === '5sim' ? 'none' : ''; + if (provider === '5sim') minPrice.value = ''; + } + const apiKey = $('.sb-api-key'); + if (apiKey) apiKey.placeholder = provider === '5sim' ? '5SIM Bearer Token' : 'API Key / apikey'; + } + function renderCountries() { const selectedBox = $('.sb-selected-countries'); const list = $('.sb-country-list'); @@ -465,14 +483,18 @@ - + +
- + + + +
@@ -558,8 +580,12 @@ $('.sb-api-key').value = latestSettings.apiKey || ''; $('.sb-base-url').value = latestSettings.baseUrl || ''; $('.sb-luban-base-url').value = latestSettings.lubanBaseUrl || 'https://lubansms.com/v2/api'; + $('.sb-five-sim-base-url').value = latestSettings.fiveSimBaseUrl || 'https://5sim.net'; $('.sb-service-code').value = latestSettings.serviceCode || 'dr'; $('.sb-luban-service-id').value = latestSettings.lubanServiceId || ''; + $('.sb-five-sim-country').value = latestSettings.fiveSimCountry || 'vietnam'; + $('.sb-five-sim-operator').value = latestSettings.fiveSimOperator || 'any'; + $('.sb-five-sim-product').value = latestSettings.fiveSimProduct || 'openai'; $('.sb-min-price').value = latestSettings.minPrice || ''; $('.sb-max-price').value = latestSettings.maxPrice || ''; if (latestSettings.activeActivation) { diff --git a/manifest.json b/manifest.json index a27b2c5..648d7b3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "manifest_version": 3, "name": "SMSBower Phone Fill Tool", - "version": "1.5.0", + "version": "1.6.0", "description": "Fetch SMSBower or Luban SMS phone numbers, fill them into the current page, copy, auto-submit, rotate rejected numbers, and fetch codes.", "permissions": ["activeTab", "scripting", "storage", "clipboardWrite"], - "host_permissions": ["https://smsbower.app/*", "https://smsbower.page/*", "https://smsbower.online/*", "https://lubansms.com/*"], + "host_permissions": ["https://smsbower.app/*", "https://smsbower.page/*", "https://smsbower.online/*", "https://lubansms.com/*", "https://5sim.net/*"], "background": { "service_worker": "background.js" }, "action": { "default_title": "SMSBower 悬浮填号" } } diff --git a/tests/provider.test.js b/tests/provider.test.js index 780b2bf..91a9f0d 100644 --- a/tests/provider.test.js +++ b/tests/provider.test.js @@ -25,7 +25,7 @@ function load(extra = {}) { Set, ...extra, }; - const fn = new Function('globalThis', 'self', `with (globalThis) { ${source}; return { collectPriceEntries, filterPriceEntries, normalizeActivation, extractVerificationCode, isPhoneNumberUsedError, isPhoneNumberDeliveryRefusedError, getPhoneReplacementReleaseAction, fetchNextPhone, normalizeSettings, buildUrl, fetchActiveCode }; }`); + const fn = new Function('globalThis', 'self', `with (globalThis) { ${source}; return { collectPriceEntries, filterPriceEntries, normalizeActivation, extractVerificationCode, isPhoneNumberUsedError, isPhoneNumberDeliveryRefusedError, getPhoneReplacementReleaseAction, fetchNextPhone, normalizeSettings, buildUrl, fetchActiveCode, releaseActivation }; }`); return fn(sandbox, sandbox); } test('collectPriceEntries parses keyed price inventory', () => { @@ -202,3 +202,103 @@ test('fetchActiveCode parses Luban success SMS code', async () => { assert.equal(result.code, '380682'); assert.equal(stored.activeActivation.lastCode, '380682'); }); + + +test('buildUrl uses 5SIM bearer-token style URL without api_key query', () => { + const api = load(); + const url = new URL(api.buildUrl( + { provider: '5sim', fiveSimBaseUrl: 'https://5sim.net', apiKey: 'five-token' }, + { __path: 'v1/user/buy/activation/vietnam/any/openai', maxPrice: 0.2 } + )); + assert.equal(url.origin, 'https://5sim.net'); + assert.equal(url.pathname, '/v1/user/buy/activation/vietnam/any/openai'); + assert.equal(url.searchParams.get('api_key'), null); + assert.equal(url.searchParams.get('apikey'), null); + assert.equal(url.searchParams.get('maxPrice'), '0.2'); +}); + +test('fetchNextPhone acquires 5SIM number with Authorization bearer token', async () => { + const requests = []; + const stored = { + provider: '5sim', + apiKey: 'five-token', + fiveSimBaseUrl: 'https://5sim.net', + fiveSimCountry: 'vietnam', + fiveSimOperator: 'any', + fiveSimProduct: 'openai', + maxPrice: '0.25', + }; + const api = load({ + chrome: { + action: { onClicked: { addListener() {} } }, + tabs: { async sendMessage() {} }, + scripting: { async executeScript() {} }, + runtime: { onMessage: { addListener() {} } }, + storage: { local: { async get() { return stored; }, async set(update) { Object.assign(stored, update); } } } + }, + fetch: async (url, options = {}) => { + const parsed = new URL(url); + requests.push([parsed.pathname, parsed.searchParams.get('maxPrice'), options.headers.Authorization]); + if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') { + return new Response(JSON.stringify({ id: 11631253, phone: '+84901234567', operator: 'viettel', product: 'openai', price: 0.2, status: 'PENDING', country: 'vietnam' }), { status: 200 }); + } + throw new Error(`unexpected ${url}`); + }, + Response, + }); + const result = await api.fetchNextPhone({ reason: 'manual_replace', releaseAction: 'cancel' }); + assert.equal(result.activation.provider, '5sim'); + assert.equal(result.activation.activationId, '11631253'); + assert.equal(result.activation.phoneNumber, '+84901234567'); + assert.equal(stored.activeActivation.activationId, '11631253'); + assert.deepEqual(requests, [['/v1/user/buy/activation/vietnam/any/openai', '0.25', 'Bearer five-token']]); +}); + +test('fetchActiveCode parses 5SIM order SMS code', async () => { + const stored = { + provider: '5sim', + apiKey: 'five-token', + fiveSimBaseUrl: 'https://5sim.net', + activeActivation: { activationId: '11631253', phoneNumber: '+84901234567', provider: '5sim' }, + }; + const api = load({ + chrome: { + action: { onClicked: { addListener() {} } }, + tabs: { async sendMessage() {} }, + scripting: { async executeScript() {} }, + runtime: { onMessage: { addListener() {} } }, + storage: { local: { async get() { return stored; }, async set(update) { Object.assign(stored, update); } } } + }, + fetch: async (url, options = {}) => { + const parsed = new URL(url); + assert.equal(parsed.pathname, '/v1/user/check/11631253'); + assert.equal(options.headers.Authorization, 'Bearer five-token'); + return new Response(JSON.stringify({ id: 11631253, status: 'RECEIVED', sms: [{ text: 'Your code is 09363', code: '09363' }] }), { status: 200 }); + }, + Response, + }); + const result = await api.fetchActiveCode(); + assert.equal(result.code, '09363'); + assert.equal(stored.activeActivation.lastCode, '09363'); +}); + +test('releaseActivation cancels or bans 5SIM order using management endpoints', async () => { + const requests = []; + const stored = { provider: '5sim', apiKey: 'five-token', fiveSimBaseUrl: 'https://5sim.net' }; + const api = load({ + fetch: async (url, options = {}) => { + const parsed = new URL(url); + requests.push([parsed.pathname, options.headers.Authorization]); + return new Response(JSON.stringify({ id: 1001, status: parsed.pathname.includes('/ban/') ? 'BANNED' : 'CANCELED' }), { status: 200 }); + }, + Response, + }); + const cancel = await api.releaseActivation(stored, { activationId: '1001' }, 'cancel'); + const ban = await api.releaseActivation(stored, { activationId: '1002' }, 'ban'); + assert.equal(cancel.ok, true); + assert.equal(ban.ok, true); + assert.deepEqual(requests, [ + ['/v1/user/cancel/1001', 'Bearer five-token'], + ['/v1/user/ban/1002', 'Bearer five-token'], + ]); +});