diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index c7f0277..a43c2dd 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -1384,7 +1384,7 @@ const recordedAt = Math.max(0, Number(record?.recordedAt) || 0); return { ...normalized, - provider: PHONE_SMS_PROVIDER_HERO, + provider: normalized.provider, source: 'free-manual-reuse', ...(recordedAt ? { recordedAt } : {}), }; @@ -3901,25 +3901,10 @@ const config = resolvePhoneConfig(state); if (config.provider === PHONE_SMS_PROVIDER_5SIM) { - const reuseProduct = normalizeFiveSimCountryCode( - normalizedActivation.serviceCode || config.product || DEFAULT_FIVE_SIM_PRODUCT, - DEFAULT_FIVE_SIM_PRODUCT - ); - const reuseNumber = String(normalizedActivation.phoneNumber || '').replace(/[^\d]/g, ''); - if (!reuseNumber) { - throw new Error('5sim 复用手机号失败:手机号缺失。'); - } - const payload = await fetchFiveSimPayload( - config, - `/user/reuse/${reuseProduct}/${reuseNumber}`, - '5sim reuse activation' - ); - const nextActivation = parseFiveSimActivationPayload(payload, normalizedActivation); - if (!nextActivation) { - const text = describeFiveSimPayload(payload); - throw createPhoneSmsActionFailureError('5sim reuse activation', text || 'empty response'); - } - return nextActivation; + return { + ...normalizedActivation, + source: '5sim-retained-reuse', + }; } if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) { throw new Error('NexSMS 当前流程不支持复用手机号订单。'); @@ -4078,6 +4063,38 @@ return normalizeActivation(activation)?.source === 'free-auto-reuse'; } + function isFiveSimRetainedReuseActivation(activation) { + const normalizedActivation = normalizeActivation(activation); + return Boolean( + normalizedActivation + && normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM + && normalizedActivation.source === '5sim-retained-reuse' + ); + } + + function shouldRetireFreeReusableActivationOnFailure(state, activation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + return false; + } + if (normalizedActivation.phoneCodeReceived) { + return false; + } + if (isFreeAutoReuseActivation(normalizedActivation)) { + return true; + } + const savedFreeActivation = normalizeFreeReusablePhoneActivation( + state?.[FREE_REUSABLE_PHONE_ACTIVATION_STATE_KEY] + ); + return Boolean( + savedFreeActivation + && ( + isSameActivation(savedFreeActivation, normalizedActivation) + || phoneNumbersMatch(savedFreeActivation.phoneNumber, normalizedActivation.phoneNumber) + ) + ); + } + async function banPhoneActivation(state = {}, activation) { try { if (shouldSkipTerminalStatusForFreeReuse(state, activation)) { @@ -4139,6 +4156,15 @@ message: '免费复用手机号激活记录缺失。', }; } + if (normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM) { + return { + ok: true, + activation: { + ...normalizedActivation, + source: 'free-auto-reuse', + }, + }; + } if (!String(normalizedActivation.activationId || '').trim()) { return { ok: false, @@ -5293,8 +5319,13 @@ const normalizedActivation = normalizeActivation(activation); return Boolean( normalizedActivation - && normalizedActivation.provider === PHONE_SMS_PROVIDER_HERO - && normalizedActivation.source === 'hero-sms-new' + && ( + ( + normalizedActivation.provider === PHONE_SMS_PROVIDER_HERO + && normalizedActivation.source === 'hero-sms-new' + ) + || normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM + ) && normalizedActivation.phoneCodeReceived ); } @@ -5304,12 +5335,21 @@ return false; } const normalizedActivation = normalizeActivation(activation); - if (!normalizedActivation || normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO) { + if ( + !normalizedActivation + || ( + normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO + && normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM + ) + ) { return false; } if (isFreeAutoReuseActivation(normalizedActivation)) { return true; } + if (isFiveSimRetainedReuseActivation(normalizedActivation)) { + return true; + } if (normalizedActivation.source === 'free-manual-reuse') { return true; } @@ -5345,7 +5385,10 @@ const normalizedActivation = normalizeActivation(activation); if ( !normalizedActivation - || normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO + || ( + normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO + && normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM + ) || !normalizedActivation.phoneCodeReceived || isFreeAutoReuseActivation(normalizedActivation) ) { @@ -5412,7 +5455,10 @@ const normalizedActivation = normalizeActivation(activation); if ( !normalizedActivation - || normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO + || ( + normalizedActivation.provider !== PHONE_SMS_PROVIDER_HERO + && normalizedActivation.provider !== PHONE_SMS_PROVIDER_5SIM + ) || isFreeAutoReuseActivation(normalizedActivation) ) { return; @@ -5439,7 +5485,7 @@ } const maxUses = Math.max(1, Math.floor(Number(savedActivation.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)); - const successfulUses = Math.min(maxUses, Math.max(1, normalizeUseCount(savedActivation.successfulUses))); + const successfulUses = Math.min(maxUses, normalizeUseCount(savedActivation.successfulUses) + 1); if (successfulUses >= maxUses) { await clearFreeReusableActivation(); await addLog( @@ -6643,7 +6689,7 @@ `步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`, 'warn' ); - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 反复返回添加手机号页。` ); @@ -6703,7 +6749,7 @@ activation, await getState() ); - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 被目标站拒绝。` ); @@ -6844,7 +6890,7 @@ activation, await getState() ); - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 被目标站拒绝。` ); @@ -6922,8 +6968,9 @@ const latestSuccessState = await getState(); if (shouldSkipTerminalStatusForFreeReuse(latestSuccessState, activation)) { + const terminalProviderLabel = getPhoneSmsProviderLabel(activation.provider); await addLog( - `步骤 9:已跳过 HeroSMS 完成状态,保留 ${activation.phoneNumber} 供白嫖复用。`, + `步骤 9:已跳过 ${terminalProviderLabel} 完成状态,保留 ${activation.phoneNumber} 供白嫖复用。`, 'info' ); await markFreeReusableActivationAfterInitialSuccess(latestSuccessState, activation); @@ -6973,7 +7020,7 @@ if (shouldCancelActivation && activation) { await cancelPhoneActivation(state, activation); } - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 在失败后被更换。` ); @@ -7042,7 +7089,7 @@ ) { throw error; } - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 执行失败:${errorMessage || 'unknown error'}。` ); diff --git a/phone-sms/providers/five-sim.js b/phone-sms/providers/five-sim.js index 313557f..a942ec6 100644 --- a/phone-sms/providers/five-sim.js +++ b/phone-sms/providers/five-sim.js @@ -746,14 +746,12 @@ if (!phoneDigits) { throw new Error('可复用的 5sim 手机号无效。'); } - const config = resolveConfig(state, deps); - const numberWithoutPlus = String(normalizedActivation.phoneNumber || '') - .replace(/^\+/, '') - .replace(/[^0-9]+/g, ''); - const payload = await fetchJson(config, `/v1/user/reuse/${DEFAULT_PRODUCT}/${numberWithoutPlus || phoneDigits}`, { - actionLabel: '5sim 复用手机号', - }); - return normalizeActivation(payload, normalizedActivation); + // 5sim 的 /user/reuse 会按手机号重新创建付费订单,并不是保留原订单继续收短信。 + // 真正的复用应继续轮询原 activationId,避免产生新的订单。 + return { + ...normalizedActivation, + source: '5sim-retained-reuse', + }; } async function finishActivation(state = {}, activation, deps = {}) { diff --git a/tests/five-sim-provider.test.js b/tests/five-sim-provider.test.js index 87771a1..bc9a041 100644 --- a/tests/five-sim-provider.test.js +++ b/tests/five-sim-provider.test.js @@ -71,7 +71,7 @@ test('5sim provider maps countries and prices', async () => { assert.deepStrictEqual(entries, [{ cost: 10, count: 2, inStock: true }]); }); -test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation', async () => { +test('5sim provider buys, checks, finishes, cancels, bans, and keeps original activation on reuse', async () => { const requests = []; const provider = api.createProvider({ fetchImpl: async (url, options = {}) => { @@ -92,9 +92,7 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation if (parsed.pathname === '/v1/user/finish/1001') return createTextResponse({ status: 'FINISHED' }); if (parsed.pathname === '/v1/user/cancel/1001') return createTextResponse({ status: 'CANCELED' }); if (parsed.pathname === '/v1/user/ban/1001') return createTextResponse({ status: 'BANNED' }); - if (parsed.pathname === '/v1/user/reuse/openai/84901123456') { - return createTextResponse({ id: 1002, phone: '+84901123456', country: 'vietnam', status: 'PENDING' }); - } + if (parsed.pathname.includes('/reuse/')) throw new Error(`5sim free reuse should not create a new order: ${parsed.pathname}`); throw new Error(`unexpected ${parsed.pathname}`); }, sleepWithStop: async () => {}, @@ -113,7 +111,7 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation assert.equal(activation.activationId, '1001'); assert.equal(activation.countryId, 'vietnam'); assert.equal(code, '112233'); - assert.equal(reused.activationId, '1002'); + assert.equal(reused.activationId, '1001'); const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation')); assert.equal(buy.url.searchParams.get('maxPrice'), '12'); assert.equal(buy.url.searchParams.get('reuse'), '1'); @@ -127,7 +125,6 @@ test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation '/v1/user/finish/1001', '/v1/user/cancel/1001', '/v1/user/ban/1001', - '/v1/user/reuse/openai/84901123456', ] ); }); diff --git a/tests/phone-verification-flow.test.js b/tests/phone-verification-flow.test.js index ce31cc0..27a416d 100644 --- a/tests/phone-verification-flow.test.js +++ b/tests/phone-verification-flow.test.js @@ -2872,7 +2872,7 @@ test('phone verification helper treats HeroSMS STATUS_WAIT_RETRY payload status assert.equal(statusUpdates[0], 'STATUS_WAIT_RETRY:846171'); }); -test('phone verification helper reuses 5sim number via product-plus-number endpoint', async () => { +test('phone verification helper reuses 5sim by keeping the original activation', async () => { const requests = []; const helpers = api.createPhoneVerificationHelpers({ addLog: async () => {}, @@ -2880,20 +2880,7 @@ test('phone verification helper reuses 5sim number via product-plus-number endpo fetchImpl: async (url) => { const parsedUrl = new URL(url); requests.push(parsedUrl.pathname); - if (parsedUrl.pathname !== '/v1/user/reuse/openai/447911123456') { - throw new Error(`Unexpected 5sim request: ${parsedUrl.pathname}`); - } - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ - id: 700002, - phone: '+447911123456', - country: 'england', - country_name: 'England', - product: 'openai', - }), - }; + throw new Error(`Unexpected 5sim request: ${parsedUrl.pathname}`); }, getState: async () => ({ phoneSmsProvider: '5sim', @@ -2929,17 +2916,17 @@ test('phone verification helper reuses 5sim number via product-plus-number endpo ); assert.deepStrictEqual(nextActivation, { - activationId: '700002', - phoneNumber: '+447911123456', + activationId: '600001', + phoneNumber: '+44 7911-123-456', provider: '5sim', serviceCode: 'openai', countryId: 'england', countryCode: 'england', - countryLabel: 'England', successfulUses: 0, maxUses: 1, + source: '5sim-retained-reuse', }); - assert.deepStrictEqual(requests, ['/v1/user/reuse/openai/447911123456']); + assert.deepStrictEqual(requests, []); }); test('phone verification helper acquires a number from NexSMS with ordered fallback countries', async () => { @@ -6101,6 +6088,180 @@ test('phone verification helper preserves newly saved free-reuse activation afte ); }); +test('phone verification helper auto free-reuses 5sim by polling the retained order without reuse or finish', async () => { + const requests = []; + let currentState = { + phoneSmsProvider: '5sim', + fiveSimApiKey: 'demo-key', + fiveSimCountryOrder: ['vietnam'], + fiveSimOperator: 'any', + freePhoneReuseEnabled: true, + freePhoneReuseAutoEnabled: true, + verificationResendCount: 0, + phoneCodeWaitSeconds: 60, + phoneCodeTimeoutWindows: 1, + phoneCodePollIntervalSeconds: 1, + phoneCodePollMaxRounds: 1, + currentPhoneActivation: null, + reusablePhoneActivation: null, + freeReusablePhoneActivation: { + activationId: 'five-free-1', + phoneNumber: '+84901122334', + provider: '5sim', + serviceCode: 'openai', + countryId: 'vietnam', + countryLabel: '越南 (Vietnam)', + successfulUses: 1, + maxUses: 3, + source: 'free-manual-reuse', + }, + }; + + const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8'); + const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({}); + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + createFiveSimProvider: fiveSimModule.createProvider, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + if (parsedUrl.pathname === '/v1/user/check/five-free-1') { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 'five-free-1', + phone: '+84901122334', + status: 'RECEIVED', + sms: [{ code: '334455' }], + }), + }; + } + if (parsedUrl.pathname.includes('/reuse/') || parsedUrl.pathname.includes('/finish/')) { + throw new Error(`5sim free reuse should not call terminal/reuse endpoint: ${parsedUrl.pathname}`); + } + throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'SUBMIT_PHONE_NUMBER') { + assert.equal(message.payload.phoneNumber, '+84901122334'); + return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' }; + } + if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + assert.equal(message.payload.code, '334455'); + return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize' }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }); + + assert.deepStrictEqual(result, { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }); + assert.equal(currentState.freeReusablePhoneActivation.provider, '5sim'); + assert.equal(currentState.freeReusablePhoneActivation.activationId, 'five-free-1'); + assert.equal(currentState.freeReusablePhoneActivation.successfulUses, 2); + assert.equal(currentState.reusablePhoneActivation, null); + assert.deepStrictEqual(requests.map((url) => url.pathname), ['/v1/user/check/five-free-1']); +}); + +test('phone verification helper retires failed 5sim free-reuse record instead of retrying stale order', async () => { + const requests = []; + let currentState = { + phoneSmsProvider: '5sim', + fiveSimApiKey: 'demo-key', + fiveSimCountryOrder: ['vietnam'], + fiveSimOperator: 'any', + freePhoneReuseEnabled: true, + freePhoneReuseAutoEnabled: true, + verificationResendCount: 0, + phoneCodeWaitSeconds: 60, + phoneCodeTimeoutWindows: 1, + phoneCodePollIntervalSeconds: 1, + phoneCodePollMaxRounds: 1, + currentPhoneActivation: null, + reusablePhoneActivation: null, + freeReusablePhoneActivation: { + activationId: 'five-free-stale', + phoneNumber: '+84901122999', + provider: '5sim', + serviceCode: 'openai', + countryId: 'vietnam', + countryLabel: '越南 (Vietnam)', + successfulUses: 1, + maxUses: 3, + source: 'free-manual-reuse', + }, + }; + + const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8'); + const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({}); + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + createFiveSimProvider: fiveSimModule.createProvider, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + if (parsedUrl.pathname === '/v1/user/check/five-free-stale') { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 'five-free-stale', + phone: '+84901122999', + status: 'FINISHED', + sms: [], + }), + }; + } + throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'SUBMIT_PHONE_NUMBER') { + assert.equal(message.payload.phoneNumber, '+84901122999'); + return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + await assert.rejects( + helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }), + /5sim 查询验证码失败:订单状态 FINISHED|5sim 订单在收到短信前已结束:FINISHED/ + ); + + assert.equal(currentState.freeReusablePhoneActivation, null); + assert.deepStrictEqual(requests.map((url) => url.pathname), ['/v1/user/check/five-free-stale']); +}); + test('phone verification helper replaces number immediately when resend is throttled and does not spam resend clicks', async () => { const requests = []; const messages = []; @@ -8321,7 +8482,7 @@ test('phone verification helper routes 5sim buy, check, and finish by current ac ); }); -test('phone verification helper routes 5sim reusable activation through reuse endpoint', async () => { +test('phone verification helper keeps 5sim reusable activation on the original order', async () => { const requests = []; let currentState = { phoneSmsProvider: '5sim', @@ -8357,14 +8518,11 @@ test('phone verification helper routes 5sim reusable activation through reuse en fetchImpl: async (url) => { const parsedUrl = new URL(url); requests.push(parsedUrl); - if (parsedUrl.pathname === '/v1/user/reuse/openai/84901122334') { - return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+84901122334', country: 'vietnam', status: 'PENDING' }) }; + if (parsedUrl.pathname === '/v1/user/check/4001') { + return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4001, phone: '+84901122334', status: 'RECEIVED', sms: [{ code: '654321' }] }) }; } - if (parsedUrl.pathname === '/v1/user/check/4002') { - return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+447911223344', status: 'RECEIVED', sms: [{ code: '654321' }] }) }; - } - if (parsedUrl.pathname === '/v1/user/finish/4002') { - return { ok: true, status: 200, text: async () => JSON.stringify({ status: 'FINISHED' }) }; + if (parsedUrl.pathname.includes('/reuse/') || parsedUrl.pathname.includes('/finish/')) { + throw new Error(`5sim free reuse should not call terminal/reuse endpoint: ${parsedUrl.pathname}`); } throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`); }, @@ -8397,14 +8555,12 @@ test('phone verification helper routes 5sim reusable activation through reuse en consentReady: true, url: 'https://auth.openai.com/authorize', }); - assert.equal(currentState.reusablePhoneActivation.activationId, '4002'); - assert.equal(currentState.reusablePhoneActivation.successfulUses, 1); + assert.equal(currentState.reusablePhoneActivation.activationId, '4001'); + assert.equal(currentState.reusablePhoneActivation.successfulUses, 2); assert.deepStrictEqual( requests.map((url) => url.pathname), [ - '/v1/user/reuse/openai/84901122334', - '/v1/user/check/4002', - '/v1/user/finish/4002', + '/v1/user/check/4001', ] ); });