diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index c7f0277..11783d4 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -317,6 +317,36 @@ return Math.max(0, Math.floor(Number(value) || 0)); } + function normalizeStringList(value = []) { + const source = Array.isArray(value) ? value : []; + const seen = new Set(); + const normalized = []; + source.forEach((entry) => { + const text = String(entry || '').trim(); + if (!text || seen.has(text)) { + return; + } + seen.add(text); + normalized.push(text); + }); + return normalized; + } + + function buildPhoneSmsCodeKey(message = {}) { + return [ + message.id ?? message.ID ?? '', + message.created_at ?? message.date ?? '', + message.code ?? '', + message.text ?? '', + message.message ?? '', + ].map((part) => String(part || '').trim()).filter(Boolean).join('::'); + } + + function collectPhoneSmsCodeKeys(payload) { + const smsList = Array.isArray(payload?.sms) ? payload.sms : []; + return normalizeStringList(smsList.map((message) => buildPhoneSmsCodeKey(message)).filter(Boolean)); + } + function normalizePhoneDigits(value) { return String(value || '').replace(/\D+/g, ''); } @@ -1322,6 +1352,7 @@ ? normalizeNexSmsCountryId(rawCountryId, 0) : normalizeCountryId(rawCountryId, fallbackCountryId) ); + const ignoredPhoneCodeKeys = normalizeStringList(record.ignoredPhoneCodeKeys); return { activationId, phoneNumber, @@ -1337,6 +1368,7 @@ ...(record.source ? { source: String(record.source || '').trim() } : {}), ...(record.phoneCodeReceived ? { phoneCodeReceived: true } : {}), ...(record.phoneCodeReceivedAt ? { phoneCodeReceivedAt: Math.max(0, Number(record.phoneCodeReceivedAt) || 0) } : {}), + ...(ignoredPhoneCodeKeys.length ? { ignoredPhoneCodeKeys } : {}), }; } @@ -1382,12 +1414,16 @@ return null; } const recordedAt = Math.max(0, Number(record?.recordedAt) || 0); - return { + const reusableActivation = { ...normalized, - provider: PHONE_SMS_PROVIDER_HERO, + provider: normalized.provider, source: 'free-manual-reuse', ...(recordedAt ? { recordedAt } : {}), }; + delete reusableActivation.phoneCodeReceived; + delete reusableActivation.phoneCodeReceivedAt; + delete reusableActivation.ignoredPhoneCodeKeys; + return reusableActivation; } function markActivationPhoneCodeReceived(activation) { @@ -3901,25 +3937,16 @@ 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' + `/user/check/${encodeURIComponent(normalizedActivation.activationId)}`, + '5sim reuse activation baseline' ); - 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', + ignoredPhoneCodeKeys: collectPhoneSmsCodeKeys(payload), + }; } if (config.provider === PHONE_SMS_PROVIDER_NEXSMS) { throw new Error('NexSMS 当前流程不支持复用手机号订单。'); @@ -4078,6 +4105,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 +4198,35 @@ message: '免费复用手机号激活记录缺失。', }; } + if (normalizedActivation.provider === PHONE_SMS_PROVIDER_5SIM) { + const provider = getFiveSimProviderForState(state); + if (provider) { + let retainedActivation = null; + try { + retainedActivation = await provider.reuseActivation(state, normalizedActivation); + } catch (error) { + return { + ok: false, + reason: 'five_sim_reuse_check_failed', + message: error.message || '5sim 复用手机号基线检查失败。', + }; + } + return { + ok: true, + activation: { + ...retainedActivation, + source: 'free-auto-reuse', + }, + }; + } + return { + ok: true, + activation: { + ...normalizedActivation, + source: 'free-auto-reuse', + }, + }; + } if (!String(normalizedActivation.activationId || '').trim()) { return { ok: false, @@ -4311,10 +4399,20 @@ pollCount += 1; const smsList = Array.isArray(payload?.sms) ? payload.sms : []; + const ignoredPhoneCodeKeys = new Set(normalizeStringList(normalizedActivation.ignoredPhoneCodeKeys)); const directCode = extractVerificationCode(payload?.code || payload?.sms_code); - const smsCode = directCode || smsList - .map((smsItem) => extractVerificationCode(smsItem?.code || smsItem?.text || smsItem?.message || '')) - .find(Boolean); + let smsCode = ''; + for (let index = smsList.length - 1; index >= 0; index -= 1) { + const smsItem = smsList[index] || {}; + if (ignoredPhoneCodeKeys.has(buildPhoneSmsCodeKey(smsItem))) { + continue; + } + smsCode = extractVerificationCode(smsItem?.code || smsItem?.text || smsItem?.message || ''); + if (smsCode) { + break; + } + } + smsCode = directCode || smsCode; if (smsCode) { return smsCode; } @@ -5269,6 +5367,7 @@ }; delete nextReusableActivation.phoneCodeReceived; delete nextReusableActivation.phoneCodeReceivedAt; + delete nextReusableActivation.ignoredPhoneCodeKeys; await upsertReusableActivationPool(nextReusableActivation, { state }); if (!normalizePhoneSmsReuseEnabled(state)) { await clearReusableActivation(); @@ -5293,8 +5392,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 +5408,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 +5458,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 +5528,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 +5558,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 +6762,7 @@ `步骤 9:当前号码 ${activation.phoneNumber} 反复返回添加手机号页,正在更换号码(${usedNumberReplacementAttempts}/${maxNumberReplacementAttempts})。`, 'warn' ); - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 反复返回添加手机号页。` ); @@ -6703,7 +6822,7 @@ activation, await getState() ); - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 被目标站拒绝。` ); @@ -6844,7 +6963,7 @@ activation, await getState() ); - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 被目标站拒绝。` ); @@ -6922,8 +7041,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 +7093,7 @@ if (shouldCancelActivation && activation) { await cancelPhoneActivation(state, activation); } - if (isFreeAutoReuseActivation(activation)) { + if (shouldRetireFreeReusableActivationOnFailure(await getState(), activation)) { await retireFreeReusableActivation( `自动白嫖复用号码 ${activation.phoneNumber} 在失败后被更换。` ); @@ -7042,7 +7162,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..15312f6 100644 --- a/phone-sms/providers/five-sim.js +++ b/phone-sms/providers/five-sim.js @@ -229,6 +229,21 @@ return price; } + function normalizeStringList(value = []) { + const source = Array.isArray(value) ? value : []; + const seen = new Set(); + const normalized = []; + source.forEach((entry) => { + const text = String(entry || '').trim(); + if (!text || seen.has(text)) { + return; + } + seen.add(text); + normalized.push(text); + }); + return normalized; + } + function buildSortedUniquePriceCandidates(values = []) { return Array.from( new Set( @@ -549,6 +564,9 @@ operator: normalizeFiveSimOperator(record.operator || fallback.operator), ...(record.price !== undefined ? { price: Number(record.price) } : {}), ...(record.status ? { status: String(record.status) } : {}), + ...(normalizeStringList(record.ignoredPhoneCodeKeys || fallback.ignoredPhoneCodeKeys).length + ? { ignoredPhoneCodeKeys: normalizeStringList(record.ignoredPhoneCodeKeys || fallback.ignoredPhoneCodeKeys) } + : {}), }; } @@ -746,14 +764,17 @@ if (!phoneDigits) { throw new Error('可复用的 5sim 手机号无效。'); } + // 5sim 的 /user/reuse 会按手机号重新创建付费订单,并不是保留原订单继续收短信。 + // 真正的复用应继续轮询原 activationId,避免产生新的订单。 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 复用手机号', + const payload = await fetchJson(config, `/v1/user/check/${encodeURIComponent(normalizedActivation.activationId)}`, { + actionLabel: '5sim 复用手机号基线检查', }); - return normalizeActivation(payload, normalizedActivation); + return { + ...normalizedActivation, + source: '5sim-retained-reuse', + ignoredPhoneCodeKeys: collectSmsCodeKeys(payload), + }; } async function finishActivation(state = {}, activation, deps = {}) { @@ -795,10 +816,30 @@ return digitMatch?.[1] || trimmed; } - function extractCodeFromOrder(payload) { + function buildSmsCodeKey(message = {}) { + const text = [ + message.id ?? message.ID ?? '', + message.created_at ?? message.date ?? '', + message.code ?? '', + message.text ?? '', + message.message ?? '', + ].map((part) => String(part || '').trim()).filter(Boolean).join('::'); + return text; + } + + function collectSmsCodeKeys(payload) { const smsList = Array.isArray(payload?.sms) ? payload.sms : []; + return normalizeStringList(smsList.map((message) => buildSmsCodeKey(message)).filter(Boolean)); + } + + function extractCodeFromOrder(payload, ignoredPhoneCodeKeys = []) { + const smsList = Array.isArray(payload?.sms) ? payload.sms : []; + const ignoredKeys = new Set(normalizeStringList(ignoredPhoneCodeKeys)); for (let index = smsList.length - 1; index >= 0; index -= 1) { const message = smsList[index] || {}; + if (ignoredKeys.has(buildSmsCodeKey(message))) { + continue; + } const code = extractVerificationCode(message.code) || extractVerificationCode(message.text); if (code) { return code; @@ -840,7 +881,7 @@ timeoutMs, }); } - const code = extractCodeFromOrder(payload); + const code = extractCodeFromOrder(payload, normalizedActivation.ignoredPhoneCodeKeys); if (code) { return code; } diff --git a/tests/five-sim-provider.test.js b/tests/five-sim-provider.test.js index 87771a1..f41dd42 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,8 @@ 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'); + assert.deepStrictEqual(reused.ignoredPhoneCodeKeys, ['code 112233']); 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 +126,7 @@ 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', + '/v1/user/check/1001', ] ); }); diff --git a/tests/phone-verification-flow.test.js b/tests/phone-verification-flow.test.js index ce31cc0..7663ba5 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,19 @@ 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}`); + if (parsedUrl.pathname === '/v1/user/check/600001') { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: '600001', + phone: '+44 7911-123-456', + status: 'RECEIVED', + sms: [{ id: 'old', code: '111111' }], + }), + }; } - 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 +2928,18 @@ 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', + ignoredPhoneCodeKeys: ['old::111111'], }); - assert.deepStrictEqual(requests, ['/v1/user/reuse/openai/447911123456']); + assert.deepStrictEqual(requests, ['/v1/user/check/600001']); }); test('phone verification helper acquires a number from NexSMS with ordered fallback countries', async () => { @@ -6101,6 +6101,189 @@ 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 checkCount = 0; + let currentState = { + phoneSmsProvider: '5sim', + fiveSimApiKey: 'demo-key', + fiveSimCountryOrder: ['vietnam'], + fiveSimOperator: 'any', + freePhoneReuseEnabled: true, + freePhoneReuseAutoEnabled: true, + verificationResendCount: 0, + phoneCodeWaitSeconds: 60, + phoneCodeTimeoutWindows: 1, + phoneCodePollIntervalSeconds: 1, + phoneCodePollMaxRounds: 2, + 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', + phoneCodeReceived: true, + phoneCodeReceivedAt: 1716000000000, + }, + }; + + 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') { + checkCount += 1; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 'five-free-1', + phone: '+84901122334', + status: 'RECEIVED', + sms: checkCount === 1 + ? [{ id: 'old', code: '111111' }] + : [{ id: 'old', code: '111111' }, { id: 'new', 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(Object.prototype.hasOwnProperty.call(currentState.freeReusablePhoneActivation, 'phoneCodeReceived'), false); + assert.equal(currentState.reusablePhoneActivation, null); + assert.deepStrictEqual(requests.map((url) => url.pathname), ['/v1/user/check/five-free-1', '/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', + phoneCodeReceived: true, + phoneCodeReceivedAt: 1716000000000, + }, + }; + + 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: [{ id: 'old', code: '111111' }], + }), + }; + } + 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', '/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,8 +8504,9 @@ 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 checkCount = 0; let currentState = { phoneSmsProvider: '5sim', fiveSimApiKey: 'demo-key', @@ -8334,7 +8518,7 @@ test('phone verification helper routes 5sim reusable activation through reuse en phoneCodeWaitSeconds: 60, phoneCodeTimeoutWindows: 1, phoneCodePollIntervalSeconds: 1, - phoneCodePollMaxRounds: 1, + phoneCodePollMaxRounds: 2, currentPhoneActivation: null, reusablePhoneActivation: { activationId: '4001', @@ -8357,14 +8541,23 @@ 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') { + checkCount += 1; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + id: 4001, + phone: '+84901122334', + status: 'RECEIVED', + sms: checkCount === 1 + ? [{ id: 'old', code: '111111' }] + : [{ id: 'old', code: '111111' }, { id: 'new', 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}`); }, @@ -8375,6 +8568,7 @@ test('phone verification helper routes 5sim reusable activation through reuse en return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' }; } if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + assert.equal(message.payload.code, '654321'); return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize' }; } throw new Error(`Unexpected content-script message: ${message.type}`); @@ -8397,14 +8591,13 @@ 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', + '/v1/user/check/4001', ] ); });