feat: add phone verification flow support

This commit is contained in:
sh2001sh
2026-04-20 17:39:01 +08:00
parent d85f7feaef
commit b098726a6f
13 changed files with 2001 additions and 21 deletions
+56 -2
View File
@@ -3,6 +3,7 @@
importScripts(
'managed-alias-utils.js',
'mail2925-utils.js',
'background/phone-verification-flow.js',
'background/account-run-history.js',
'background/contribution-oauth.js',
'background/mail-2925-session.js',
@@ -184,6 +185,11 @@ const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000;
const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
const DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php';
const HERO_SMS_SERVICE_CODE = 'dr';
const HERO_SMS_SERVICE_LABEL = 'OpenAI';
const HERO_SMS_COUNTRY_ID = 52;
const HERO_SMS_COUNTRY_LABEL = 'Thailand';
const DISPLAY_TIMEZONE = 'Asia/Shanghai';
const MICROSOFT_TOKEN_DNR_RULE_ID = 1001;
const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases'];
@@ -284,6 +290,9 @@ const PERSISTED_SETTING_DEFAULTS = {
cloudflareTempEmailDomains: [],
hotmailAccounts: [],
mail2925Accounts: [],
heroSmsApiKey: '',
heroSmsCountryId: HERO_SMS_COUNTRY_ID,
heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL,
};
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
@@ -341,6 +350,8 @@ const DEFAULT_STATE = {
luckmailPreserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
currentLuckmailPurchase: null,
currentLuckmailMailCursor: null,
currentPhoneActivation: null,
reusablePhoneActivation: null,
autoRunning: false, // 当前是否处于自动运行中。
autoRunPhase: 'idle', // 当前自动运行阶段。
autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。
@@ -933,6 +944,12 @@ function normalizePersistentSettingValue(key, value) {
return normalizeHotmailAccounts(value);
case 'mail2925Accounts':
return normalizeMail2925Accounts(value);
case 'heroSmsApiKey':
return String(value || '');
case 'heroSmsCountryId':
return Math.max(1, Math.floor(Number(value) || HERO_SMS_COUNTRY_ID));
case 'heroSmsCountryLabel':
return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL;
default:
return value;
}
@@ -6130,7 +6147,7 @@ async function resumeAutoRun() {
// ============================================================
const SIGNUP_ENTRY_URL = 'https://chatgpt.com/';
const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/signup-page.js'];
const SIGNUP_PAGE_INJECT_FILES = ['content/utils.js', 'content/auth-page-recovery.js', 'content/phone-auth.js', 'content/signup-page.js'];
const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({
chrome,
addLog,
@@ -6199,6 +6216,21 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create
throwIfStopped,
VERIFICATION_POLL_MAX_ROUNDS,
});
const phoneVerificationHelpers = self.MultiPageBackgroundPhoneVerification?.createPhoneVerificationHelpers({
addLog,
DEFAULT_HERO_SMS_BASE_URL,
ensureStep8SignupPageReady,
getOAuthFlowStepTimeoutMs,
getState,
HERO_SMS_COUNTRY_ID,
HERO_SMS_COUNTRY_LABEL,
HERO_SMS_SERVICE_CODE,
HERO_SMS_SERVICE_LABEL,
sendToContentScriptResilient,
setState,
sleepWithStop,
throwIfStopped,
});
const step1Executor = self.MultiPageBackgroundStep1?.createStep1Executor({
addLog,
completeStepFromBackground,
@@ -6886,10 +6918,22 @@ function isAddPhoneAuthState(authState = {}) {
async function getPostStep6AutoRestartDecision(step, error) {
const normalizedStep = Number(step);
const errorMessage = getErrorMessage(error);
const shouldForceRestartFromStep7 = /restart step 7 with a new number/i.test(errorMessage);
if (!Number.isFinite(normalizedStep) || normalizedStep < 7 || normalizedStep > LAST_STEP_ID) {
return {
shouldRestart: false,
blockedByAddPhone: false,
forcedByPhoneVerificationTimeout: false,
errorMessage,
authState: null,
};
}
if (shouldForceRestartFromStep7) {
return {
shouldRestart: true,
blockedByAddPhone: false,
forcedByPhoneVerificationTimeout: true,
errorMessage,
authState: null,
};
@@ -6899,6 +6943,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
return {
shouldRestart: false,
blockedByAddPhone: true,
forcedByPhoneVerificationTimeout: false,
errorMessage,
authState: null,
};
@@ -6921,6 +6966,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
return {
shouldRestart: false,
blockedByAddPhone: true,
forcedByPhoneVerificationTimeout: false,
errorMessage,
authState,
};
@@ -6929,6 +6975,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
return {
shouldRestart: true,
blockedByAddPhone: false,
forcedByPhoneVerificationTimeout: false,
errorMessage,
authState,
};
@@ -7050,7 +7097,7 @@ let step8TabUpdatedListener = null;
let step8PendingReject = null;
const STEP8_CLICK_EFFECT_TIMEOUT_MS = 15000;
const STEP8_CLICK_RETRY_DELAY_MS = 500;
const STEP8_READY_WAIT_TIMEOUT_MS = 30000;
const STEP8_READY_WAIT_TIMEOUT_MS = 180000;
const STEP8_MAX_ROUNDS = 5;
const STEP8_STRATEGIES = [
{ mode: 'content', strategy: 'requestSubmit', label: 'form.requestSubmit' },
@@ -7156,6 +7203,13 @@ async function waitForStep8Ready(tabId, timeoutMs = STEP8_READY_WAIT_TIMEOUT_MS)
if (pageState?.maxCheckAttemptsBlocked) {
throw new Error(`${CLOUDFLARE_SECURITY_BLOCK_ERROR_PREFIX}${CLOUDFLARE_SECURITY_BLOCK_USER_MESSAGE}`);
}
if (pageState?.addPhonePage || pageState?.phoneVerificationPage) {
await phoneVerificationHelpers.completePhoneVerificationFlow(tabId, pageState);
recovered = false;
retryRecovered = false;
await sleepWithStop(250);
continue;
}
if (pageState?.addPhonePage) {
throw new Error('步骤 9:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
}
+756
View File
@@ -0,0 +1,756 @@
(function attachBackgroundPhoneVerification(root, factory) {
root.MultiPageBackgroundPhoneVerification = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPhoneVerificationModule() {
function createPhoneVerificationHelpers(deps = {}) {
const {
addLog,
ensureStep8SignupPageReady,
fetchImpl = (...args) => fetch(...args),
getOAuthFlowStepTimeoutMs,
getState,
sendToContentScriptResilient,
setState,
sleepWithStop,
throwIfStopped,
DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php',
HERO_SMS_COUNTRY_ID = 52,
HERO_SMS_COUNTRY_LABEL = 'Thailand',
HERO_SMS_SERVICE_CODE = 'dr',
HERO_SMS_SERVICE_LABEL = 'OpenAI',
} = deps;
const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation';
const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation';
const DEFAULT_PHONE_POLL_INTERVAL_MS = 5000;
const DEFAULT_PHONE_POLL_TIMEOUT_MS = 180000;
const DEFAULT_PHONE_REQUEST_TIMEOUT_MS = 20000;
const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3;
const DEFAULT_PHONE_CODE_WAIT_WINDOW_MS = 60000;
const DEFAULT_PHONE_NUMBER_MAX_USES = 3;
const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::';
const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::';
function normalizeUrl(value, fallback = DEFAULT_HERO_SMS_BASE_URL) {
const trimmed = String(value || '').trim();
if (!trimmed) {
return fallback;
}
try {
return new URL(trimmed).toString();
} catch {
return fallback;
}
}
function normalizeApiKey(value) {
return String(value || '').trim();
}
function normalizeUseCount(value) {
return Math.max(0, Math.floor(Number(value) || 0));
}
function resolveCountryConfig(state = {}) {
return {
id: Math.max(1, Math.floor(Number(state.heroSmsCountryId) || HERO_SMS_COUNTRY_ID)),
label: String(state.heroSmsCountryLabel || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL,
};
}
function normalizeActivation(record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return null;
}
const activationId = String(
record.activationId ?? record.id ?? record.activation ?? ''
).trim();
const phoneNumber = String(
record.phoneNumber ?? record.number ?? record.phone ?? ''
).trim();
if (!activationId || !phoneNumber) {
return null;
}
return {
activationId,
phoneNumber,
provider: String(record.provider || 'hero-sms').trim() || 'hero-sms',
serviceCode: String(record.serviceCode || HERO_SMS_SERVICE_CODE).trim() || HERO_SMS_SERVICE_CODE,
countryId: Number(record.countryId) || HERO_SMS_COUNTRY_ID,
successfulUses: normalizeUseCount(record.successfulUses),
maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)),
};
}
function describeHeroSmsPayload(raw) {
if (typeof raw === 'string') {
return raw.trim();
}
if (raw && typeof raw === 'object') {
if (raw.title || raw.details) {
const title = String(raw.title || '').trim();
const details = String(raw.details || '').trim();
return details ? `${title}: ${details}` : title;
}
if (raw.status === 'false' && raw.msg) {
return String(raw.msg).trim();
}
try {
return JSON.stringify(raw);
} catch {
return String(raw);
}
}
return String(raw || '').trim();
}
function parseHeroSmsPayload(text) {
const trimmed = String(text || '').trim();
if (!trimmed) {
return '';
}
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
function buildHeroSmsUrl(baseUrl, query = {}) {
const url = new URL(normalizeUrl(baseUrl));
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
url.searchParams.set(key, String(value));
});
return url.toString();
}
function buildPhoneCodeTimeoutError(lastResponse = '') {
const suffix = lastResponse ? ` Last HeroSMS status: ${lastResponse}` : '';
return new Error(`${PHONE_CODE_TIMEOUT_ERROR_PREFIX}Timed out waiting for the phone verification code.${suffix}`);
}
function isPhoneCodeTimeoutError(error) {
return String(error?.message || '').startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX);
}
function buildPhoneRestartStep7Error(phoneNumber = '') {
const suffix = phoneNumber ? ` Current number: ${phoneNumber}.` : '';
return new Error(
`${PHONE_RESTART_STEP7_ERROR_PREFIX}Phone verification could not receive an SMS after resend. Restart step 7 with a new number.${suffix}`
);
}
function sanitizePhoneCodeTimeoutError(error) {
const message = String(error?.message || '');
if (!message.startsWith(PHONE_CODE_TIMEOUT_ERROR_PREFIX)) {
return error;
}
return new Error(message.slice(PHONE_CODE_TIMEOUT_ERROR_PREFIX.length).trim() || 'Timed out waiting for the phone verification code.');
}
function sanitizePhoneRestartStep7Error(error) {
const message = String(error?.message || '');
if (!message.startsWith(PHONE_RESTART_STEP7_ERROR_PREFIX)) {
return error;
}
return new Error(
message.slice(PHONE_RESTART_STEP7_ERROR_PREFIX.length).trim()
|| 'Phone verification could not receive an SMS after resend. Restart step 7 with a new number.'
);
}
async function fetchHeroSmsPayload(config, query, actionLabel) {
const requestUrl = buildHeroSmsUrl(config.baseUrl, {
api_key: config.apiKey,
...query,
});
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), DEFAULT_PHONE_REQUEST_TIMEOUT_MS)
: null;
try {
const response = await fetchImpl(requestUrl, {
method: 'GET',
signal: controller?.signal,
});
const text = await response.text();
const payload = parseHeroSmsPayload(text);
if (!response.ok) {
throw new Error(`${actionLabel} failed: ${describeHeroSmsPayload(payload) || response.status}`);
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`${actionLabel} timed out.`);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function resolvePhoneConfig(state = {}) {
const apiKey = normalizeApiKey(state.heroSmsApiKey);
if (!apiKey) {
throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.');
}
return {
apiKey,
baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL),
};
}
function parseActivationPayload(payload, fallback = null) {
const normalizedFallback = normalizeActivation(fallback);
const directActivation = normalizeActivation(payload);
if (directActivation) {
return {
...directActivation,
successfulUses: normalizedFallback?.successfulUses || directActivation.successfulUses,
maxUses: normalizedFallback?.maxUses || directActivation.maxUses,
};
}
const text = describeHeroSmsPayload(payload);
const accessNumberMatch = text.match(/^ACCESS_NUMBER:([^:]+):(.+)$/i);
if (accessNumberMatch) {
return {
activationId: String(accessNumberMatch[1] || '').trim(),
phoneNumber: String(accessNumberMatch[2] || '').trim(),
provider: normalizedFallback?.provider || 'hero-sms',
serviceCode: normalizedFallback?.serviceCode || HERO_SMS_SERVICE_CODE,
countryId: normalizedFallback?.countryId || HERO_SMS_COUNTRY_ID,
successfulUses: normalizedFallback?.successfulUses || 0,
maxUses: normalizedFallback?.maxUses || DEFAULT_PHONE_NUMBER_MAX_USES,
};
}
if (/^ACCESS_READY$/i.test(text) && normalizedFallback) {
return normalizedFallback;
}
return null;
}
async function requestPhoneActivation(state = {}) {
const config = resolvePhoneConfig(state);
const countryConfig = resolveCountryConfig(state);
const payload = await fetchHeroSmsPayload(config, {
action: 'getNumber',
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
}, 'HeroSMS getNumber');
const activation = parseActivationPayload(payload, {
countryId: countryConfig.id,
});
if (!activation) {
const text = describeHeroSmsPayload(payload);
throw new Error(`HeroSMS getNumber failed: ${text || 'empty response'}`);
}
return activation;
}
async function reactivatePhoneActivation(state = {}, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('Reusable phone activation is missing.');
}
const config = resolvePhoneConfig(state);
const payload = await fetchHeroSmsPayload(config, {
action: 'reactivate',
id: normalizedActivation.activationId,
}, 'HeroSMS reactivate');
const nextActivation = parseActivationPayload(payload, normalizedActivation);
if (!nextActivation) {
const text = describeHeroSmsPayload(payload);
throw new Error(`HeroSMS reactivate failed: ${text || 'empty response'}`);
}
return nextActivation;
}
async function setPhoneActivationStatus(state = {}, activation, status, actionLabel) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
return '';
}
const config = resolvePhoneConfig(state);
const payload = await fetchHeroSmsPayload(config, {
action: 'setStatus',
id: normalizedActivation.activationId,
status,
}, actionLabel);
return describeHeroSmsPayload(payload);
}
async function completePhoneActivation(state = {}, activation) {
await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)');
}
async function cancelPhoneActivation(state = {}, activation) {
try {
await setPhoneActivationStatus(state, activation, 8, 'HeroSMS setStatus(8)');
} catch (_) {
// Best-effort cleanup.
}
}
async function requestAdditionalPhoneSms(state = {}, activation) {
try {
await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)');
} catch (_) {
// Best-effort request only.
}
}
async function pollPhoneActivationCode(state = {}, activation, options = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('Phone activation is missing.');
}
const config = resolvePhoneConfig(state);
const configuredTimeoutMs = Math.max(1000, Number(options.timeoutMs) || 0);
const timeoutMs = configuredTimeoutMs || (
typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(
DEFAULT_PHONE_POLL_TIMEOUT_MS,
{ step: 9, actionLabel: options.actionLabel || 'poll phone verification code' }
)
: DEFAULT_PHONE_POLL_TIMEOUT_MS
);
const intervalMs = Math.max(1000, Number(options.intervalMs) || DEFAULT_PHONE_POLL_INTERVAL_MS);
const start = Date.now();
let lastResponse = '';
let pollCount = 0;
while (Date.now() - start < timeoutMs) {
throwIfStopped();
const payload = await fetchHeroSmsPayload(config, {
action: 'getStatus',
id: normalizedActivation.activationId,
}, 'HeroSMS getStatus');
const text = describeHeroSmsPayload(payload);
lastResponse = text;
pollCount += 1;
if (typeof options.onStatus === 'function') {
await options.onStatus({
activation: normalizedActivation,
elapsedMs: Date.now() - start,
pollCount,
statusText: text,
timeoutMs,
});
}
const okMatch = text.match(/^STATUS_OK:(.+)$/i);
if (okMatch) {
const rawCode = String(okMatch[1] || '').trim();
const digitMatch = rawCode.match(/\b(\d{4,8})\b/);
return digitMatch?.[1] || rawCode;
}
if (/^STATUS_(WAIT_CODE|WAIT_RETRY|WAIT_RESEND)$/i.test(text)) {
await sleepWithStop(intervalMs);
continue;
}
if (/^STATUS_CANCEL$/i.test(text)) {
throw new Error('HeroSMS activation was cancelled before the SMS arrived.');
}
throw new Error(`HeroSMS getStatus failed: ${text || 'empty response'}`);
}
throw buildPhoneCodeTimeoutError(lastResponse);
}
async function readPhonePageState(tabId, timeoutMs = 10000) {
await ensureStep8SignupPageReady(tabId, {
timeoutMs,
logMessage: 'Step 9: waiting for auth page content script to recover before phone verification.',
});
const result = await sendToContentScriptResilient('signup-page', {
type: 'STEP8_GET_STATE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: auth page is switching, waiting to inspect phone verification state again...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function submitPhoneNumber(tabId, phoneNumber) {
const state = await getState();
const countryConfig = resolveCountryConfig(state);
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'submit add-phone number' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'SUBMIT_PHONE_NUMBER',
source: 'background',
payload: {
phoneNumber,
countryId: countryConfig.id,
countryLabel: countryConfig.label,
},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: waiting for add-phone page to become ready...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function submitPhoneVerificationCode(tabId, code) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(45000, { step: 9, actionLabel: 'submit phone verification code' })
: 45000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
source: 'background',
payload: { code },
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: waiting for phone verification page before filling the SMS code...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function resendPhoneVerificationCode(tabId) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'resend phone verification code' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'RESEND_PHONE_VERIFICATION_CODE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: waiting for the phone verification resend button...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function returnToAddPhone(tabId) {
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(30000, { step: 9, actionLabel: 'return to add-phone page' })
: 30000;
const result = await sendToContentScriptResilient('signup-page', {
type: 'RETURN_TO_ADD_PHONE',
source: 'background',
payload: {},
}, {
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: 'Step 9: returning to add-phone page to replace the phone number...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function persistCurrentActivation(activation) {
await setState({
[PHONE_ACTIVATION_STATE_KEY]: activation || null,
});
}
async function persistReusableActivation(activation) {
await setState({
[REUSABLE_PHONE_ACTIVATION_STATE_KEY]: activation || null,
});
}
async function clearCurrentActivation() {
await persistCurrentActivation(null);
}
async function clearReusableActivation() {
await persistReusableActivation(null);
}
async function acquirePhoneActivation(state = {}) {
const countryConfig = resolveCountryConfig(state);
const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]);
if (
reusableActivation
&& reusableActivation.countryId === countryConfig.id
&& reusableActivation.successfulUses < reusableActivation.maxUses
) {
try {
const reactivated = await reactivatePhoneActivation(state, reusableActivation);
await addLog(
`Step 9: reusing ${countryConfig.label} number ${reactivated.phoneNumber} (${reactivated.successfulUses + 1}/${reactivated.maxUses}).`,
'info'
);
return reactivated;
} catch (error) {
await addLog(`Step 9: failed to reuse phone number ${reusableActivation.phoneNumber}, falling back to a new number. ${error.message}`, 'warn');
await clearReusableActivation();
}
} else if (reusableActivation && reusableActivation.countryId !== countryConfig.id) {
await clearReusableActivation();
}
const activation = await requestPhoneActivation(state);
await addLog(
`Step 9: acquired ${HERO_SMS_SERVICE_LABEL} / ${countryConfig.label} number ${activation.phoneNumber}.`,
'info'
);
return activation;
}
async function markActivationReusableAfterSuccess(activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
await clearReusableActivation();
return;
}
const successfulUses = normalizedActivation.successfulUses + 1;
if (successfulUses >= normalizedActivation.maxUses) {
await clearReusableActivation();
return;
}
await persistReusableActivation({
...normalizedActivation,
successfulUses,
});
}
async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('Phone activation is missing.');
}
let lastLoggedStatus = '';
let lastLoggedPollCount = 0;
for (let windowIndex = 1; windowIndex <= 2; windowIndex += 1) {
await addLog(
`Step 9: waiting up to 60 seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/2).`,
'info'
);
try {
const code = await pollPhoneActivationCode(state, normalizedActivation, {
actionLabel: windowIndex === 1
? 'poll phone verification code from HeroSMS'
: 'poll resent phone verification code from HeroSMS',
timeoutMs: DEFAULT_PHONE_CODE_WAIT_WINDOW_MS,
onStatus: async ({ elapsedMs, pollCount, statusText }) => {
const shouldLog = (
pollCount === 1
|| statusText !== lastLoggedStatus
|| pollCount - lastLoggedPollCount >= 3
);
if (!shouldLog) {
return;
}
lastLoggedStatus = statusText;
lastLoggedPollCount = pollCount;
await addLog(
`Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`,
'info'
);
},
});
return {
code,
replaceNumber: false,
};
} catch (error) {
if (!isPhoneCodeTimeoutError(error)) {
throw error;
}
if (windowIndex === 1) {
await addLog(
`Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within 60 seconds, requesting another SMS.`,
'warn'
);
await requestAdditionalPhoneSms(state, normalizedActivation);
try {
await resendPhoneVerificationCode(tabId);
await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info');
} catch (resendError) {
await addLog(`Step 9: failed to click resend on the phone verification page. ${resendError.message}`, 'warn');
}
continue;
}
await addLog(
`Step 9: still no SMS for ${normalizedActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`,
'warn'
);
throw buildPhoneRestartStep7Error(normalizedActivation.phoneNumber);
}
}
throw new Error('Phone verification did not complete successfully.');
}
async function completePhoneVerificationFlow(tabId, initialPageState = null) {
let state = await getState();
let activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]);
let pageState = initialPageState || await readPhonePageState(tabId);
let shouldCancelActivation = false;
let remainingResendRequests = Math.max(0, Number(state.verificationResendCount) || 0);
try {
while (true) {
state = await getState();
if (!activation) {
activation = normalizeActivation(state[PHONE_ACTIVATION_STATE_KEY]);
}
if (pageState?.addPhonePage) {
if (activation) {
await cancelPhoneActivation(state, activation);
await clearCurrentActivation();
activation = null;
shouldCancelActivation = false;
}
activation = await acquirePhoneActivation(state);
shouldCancelActivation = true;
await persistCurrentActivation(activation);
const submitResult = await submitPhoneNumber(tabId, activation.phoneNumber);
await addLog('Step 9: submitted the phone number on add-phone page.', 'info');
pageState = {
...pageState,
...submitResult,
addPhonePage: false,
phoneVerificationPage: true,
};
}
if (!pageState?.phoneVerificationPage) {
pageState = await readPhonePageState(tabId);
}
if (!pageState?.phoneVerificationPage) {
return pageState;
}
if (!activation) {
throw new Error('The auth page is waiting for a phone verification code, but no HeroSMS activation is stored for this run.');
}
let shouldReplaceNumber = false;
for (let attempt = 1; attempt <= DEFAULT_PHONE_SUBMIT_ATTEMPTS; attempt += 1) {
throwIfStopped();
const codeResult = await waitForPhoneCodeOrRotateNumber(tabId, state, activation);
if (codeResult.replaceNumber) {
shouldReplaceNumber = true;
break;
}
await addLog(`Step 9: received phone verification code ${codeResult.code}.`, 'info');
const submitResult = await submitPhoneVerificationCode(tabId, codeResult.code);
if (submitResult.invalidCode || submitResult.returnedToAddPhone) {
if (attempt >= DEFAULT_PHONE_SUBMIT_ATTEMPTS) {
throw new Error(
`Phone verification code was rejected after ${DEFAULT_PHONE_SUBMIT_ATTEMPTS} attempts: ${submitResult.errorText || submitResult.url || 'unknown error'}`
);
}
if (remainingResendRequests > 0) {
remainingResendRequests -= 1;
await requestAdditionalPhoneSms(state, activation);
try {
await resendPhoneVerificationCode(tabId);
await addLog('Step 9: clicked "Resend text message" after the phone code was rejected.', 'info');
} catch (resendError) {
await addLog(`Step 9: failed to click resend after code rejection. ${resendError.message}`, 'warn');
}
await addLog(
`Step 9: phone verification code was rejected, requested another SMS (${remainingResendRequests} resend attempts left).`,
'warn'
);
} else {
await addLog(
'Step 9: phone verification code was rejected and the configured resend budget is exhausted, retrying with the current activation window.',
'warn'
);
}
continue;
}
await completePhoneActivation(state, activation);
await markActivationReusableAfterSuccess(activation);
shouldCancelActivation = false;
await clearCurrentActivation();
await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok');
return submitResult;
}
if (!shouldReplaceNumber) {
throw new Error('Phone verification did not complete successfully.');
}
}
} catch (error) {
if (shouldCancelActivation && activation) {
await cancelPhoneActivation(state, activation);
}
await clearCurrentActivation();
throw sanitizePhoneRestartStep7Error(sanitizePhoneCodeTimeoutError(error));
}
}
return {
completePhoneVerificationFlow,
normalizeActivation,
pollPhoneActivationCode,
reactivatePhoneActivation,
requestPhoneActivation,
};
}
return {
createPhoneVerificationHelpers,
};
});
+2 -2
View File
@@ -41,11 +41,11 @@
await addLog('步骤 9:正在监听 localhost 回调地址...');
const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(120000, {
? await getOAuthFlowStepTimeoutMs(240000, {
step: 9,
actionLabel: 'OAuth localhost 回调',
})
: 120000;
: 240000;
return new Promise((resolve, reject) => {
let resolved = false;
+5 -6
View File
@@ -739,11 +739,6 @@
continue;
}
if (submitResult.addPhonePage) {
const urlPart = submitResult.url ? ` URL: ${submitResult.url}` : '';
throw new Error(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim());
}
await setState({
lastEmailTimestamp: result.emailTimestamp,
[stateKey]: result.code,
@@ -752,9 +747,13 @@
await completeStepFromBackground(step, {
emailTimestamp: result.emailTimestamp,
code: result.code,
phoneVerificationRequired: Boolean(submitResult.addPhonePage),
});
triggerPostSuccessMailboxCleanup(step, mail);
return;
return {
phoneVerificationRequired: Boolean(submitResult.addPhonePage),
url: submitResult.url || '',
};
}
}
+453
View File
@@ -0,0 +1,453 @@
(function attachPhoneAuthModule(root, factory) {
root.MultiPagePhoneAuth = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createPhoneAuthModule() {
function createPhoneAuthHelpers(deps = {}) {
const {
fillInput,
getActionText,
getPageTextSnapshot,
getVerificationErrorText,
humanPause,
isActionEnabled,
isAddPhonePageReady,
isConsentReady,
isPhoneVerificationPageReady,
isVisibleElement,
simulateClick,
sleep,
throwIfStopped,
waitForElement,
} = deps;
function dispatchInputEvents(element) {
if (!element) return;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
}
function normalizePhoneDigits(value) {
let digits = String(value || '').replace(/\D+/g, '');
if (digits.startsWith('00')) {
digits = digits.slice(2);
}
return digits;
}
function normalizeCountryLabel(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^\w\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function getOptionLabel(option) {
return String(option?.textContent || option?.label || '')
.replace(/\s+/g, ' ')
.trim();
}
function extractDialCodeFromText(value) {
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
return String(match?.[1] || match?.[2] || '').trim();
}
function getCountryButtonText() {
const form = getAddPhoneForm();
if (!form) return '';
const button = form.querySelector('button[aria-haspopup="listbox"]');
if (!button) return '';
const valueNode = button.querySelector('.react-aria-SelectValue');
return String(valueNode?.textContent || button.textContent || '')
.replace(/\s+/g, ' ')
.trim();
}
function getDisplayedDialCode() {
const buttonDialCode = extractDialCodeFromText(getCountryButtonText());
if (buttonDialCode) {
return buttonDialCode;
}
const phoneInput = getPhoneInput();
const fieldRoot = phoneInput?.closest('fieldset') || phoneInput?.closest('form') || getAddPhoneForm();
if (!fieldRoot) {
return '';
}
const visibleSpan = Array.from(fieldRoot.querySelectorAll('span'))
.find((element) => isVisibleElement(element) && /^\d{1,4}$/.test(String(element.textContent || '').trim()));
return String(visibleSpan?.textContent || '').trim();
}
function toNationalPhoneNumber(value, dialCode) {
const digits = normalizePhoneDigits(value);
const normalizedDialCode = normalizePhoneDigits(dialCode);
if (!digits) {
return '';
}
if (normalizedDialCode && digits.startsWith(normalizedDialCode) && digits.length > normalizedDialCode.length) {
return digits.slice(normalizedDialCode.length);
}
return digits;
}
function toE164PhoneNumber(value, dialCode) {
const digits = normalizePhoneDigits(value);
const normalizedDialCode = normalizePhoneDigits(dialCode);
if (!digits) {
return '';
}
if (!normalizedDialCode) {
return digits.startsWith('+') ? digits : `+${digits}`;
}
if (digits.startsWith(normalizedDialCode)) {
return `+${digits}`;
}
if (digits.startsWith('0')) {
return `+${normalizedDialCode}${digits.slice(1)}`;
}
return `+${normalizedDialCode}${digits}`;
}
function getAddPhoneForm() {
return document.querySelector('form[action*="/add-phone" i]');
}
function getPhoneVerificationForm() {
return document.querySelector('form[action*="/phone-verification" i]');
}
function getPhoneInput() {
const form = getAddPhoneForm();
if (!form) return null;
const input = form.querySelector(
'input[type="tel"], input[name="__reservedForPhoneNumberInput_tel"], input[autocomplete="tel"]'
);
return input && isVisibleElement(input) ? input : null;
}
function getHiddenPhoneNumberInput() {
const form = getAddPhoneForm();
if (!form) return null;
return form.querySelector('input[name="phoneNumber"]');
}
function getCountrySelect() {
const form = getAddPhoneForm();
if (!form) return null;
return form.querySelector('select');
}
function getSelectedCountryOption() {
const select = getCountrySelect();
if (!select || select.selectedIndex < 0) {
return null;
}
return select.options[select.selectedIndex] || null;
}
function findCountryOptionByLabel(countryLabel) {
const select = getCountrySelect();
if (!select) {
return null;
}
const normalizedTarget = normalizeCountryLabel(countryLabel);
if (!normalizedTarget) {
return null;
}
const options = Array.from(select.options);
return options.find((option) => normalizeCountryLabel(getOptionLabel(option)) === normalizedTarget)
|| options.find((option) => {
const optionLabel = normalizeCountryLabel(getOptionLabel(option));
return optionLabel && (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel));
})
|| null;
}
async function ensureCountrySelected(countryLabel) {
const select = getCountrySelect();
if (!select) {
return false;
}
const targetOption = findCountryOptionByLabel(countryLabel);
if (!targetOption) {
throw new Error(`Add-phone page is missing the country option for "${countryLabel}".`);
}
const selectedOption = getSelectedCountryOption();
if (selectedOption && normalizeCountryLabel(getOptionLabel(selectedOption)) === normalizeCountryLabel(getOptionLabel(targetOption))) {
return true;
}
select.value = String(targetOption.value || '');
dispatchInputEvents(select);
await sleep(250);
const nextSelectedOption = getSelectedCountryOption();
return Boolean(
nextSelectedOption
&& normalizeCountryLabel(getOptionLabel(nextSelectedOption)) === normalizeCountryLabel(getOptionLabel(targetOption))
);
}
function getAddPhoneSubmitButton() {
const form = getAddPhoneForm();
if (!form) return null;
const buttons = Array.from(form.querySelectorAll('button[type="submit"], input[type="submit"]'));
return buttons.find((button) => isVisibleElement(button) && isActionEnabled(button))
|| buttons.find((button) => isVisibleElement(button))
|| null;
}
function getPhoneVerificationCodeInput() {
const form = getPhoneVerificationForm();
if (!form) return null;
const input = form.querySelector(
'input[name="code"], input[autocomplete="one-time-code"], input[inputmode="numeric"]'
);
return input && isVisibleElement(input) ? input : null;
}
function getPhoneVerificationSubmitButton() {
const form = getPhoneVerificationForm();
if (!form) return null;
const buttons = Array.from(form.querySelectorAll('button[type="submit"], input[type="submit"]'));
return buttons.find((button) => {
if (!isVisibleElement(button) || !isActionEnabled(button)) return false;
const intent = String(button.getAttribute('value') || '').trim().toLowerCase();
if (intent === 'resend') return false;
return true;
}) || buttons.find((button) => isVisibleElement(button));
}
function getPhoneVerificationResendButton(options = {}) {
const { allowDisabled = false } = options;
const form = getPhoneVerificationForm();
if (!form) return null;
const buttons = Array.from(form.querySelectorAll('button, input[type="submit"], input[type="button"]'));
return buttons.find((button) => {
if (!isVisibleElement(button)) return false;
if (!allowDisabled && !isActionEnabled(button)) return false;
const intent = String(button.getAttribute('value') || '').trim().toLowerCase();
if (intent === 'resend') return true;
return /resend/i.test(getActionText(button));
}) || null;
}
function getPhoneVerificationDisplayedPhone() {
const text = getPageTextSnapshot();
const matches = text.match(/\+\d[\d\s-]{6,}\d/g);
return matches?.[0] ? matches[0].replace(/\s+/g, ' ').trim() : '';
}
async function waitForAddPhoneReady(timeout = 20000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (isAddPhonePageReady()) {
return true;
}
await sleep(150);
}
throw new Error('Timed out waiting for add-phone page.');
}
async function waitForPhoneVerificationReady(timeout = 20000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (isPhoneVerificationPageReady()) {
return {
phoneVerificationPage: true,
displayedPhone: getPhoneVerificationDisplayedPhone(),
url: location.href,
};
}
await sleep(150);
}
throw new Error('Timed out waiting for phone verification page.');
}
async function submitPhoneNumber(payload = {}) {
const countryLabel = String(payload.countryLabel || '').trim();
if (!countryLabel) {
throw new Error('Missing country label for add-phone submission.');
}
await waitForAddPhoneReady();
const countrySelected = await ensureCountrySelected(countryLabel);
if (!countrySelected) {
throw new Error(`Failed to select "${countryLabel}" on the add-phone page.`);
}
const dialCode = getDisplayedDialCode();
if (!dialCode) {
throw new Error(`Could not determine the dial code for "${countryLabel}" on the add-phone page.`);
}
const phoneNumber = toE164PhoneNumber(payload.phoneNumber, dialCode);
const nationalPhoneNumber = toNationalPhoneNumber(payload.phoneNumber, dialCode);
if (!phoneNumber || !nationalPhoneNumber) {
throw new Error('Missing phone number for add-phone submission.');
}
const phoneInput = getPhoneInput() || await waitForElement(
'input[type="tel"], input[name="__reservedForPhoneNumberInput_tel"], input[autocomplete="tel"]',
10000
);
const hiddenPhoneNumberInput = getHiddenPhoneNumberInput();
const submitButton = getAddPhoneSubmitButton();
if (!phoneInput) {
throw new Error('Add-phone page is missing the phone number input.');
}
if (!submitButton) {
throw new Error('Add-phone page is missing the submit button.');
}
await humanPause(250, 700);
fillInput(phoneInput, nationalPhoneNumber);
if (hiddenPhoneNumberInput) {
hiddenPhoneNumberInput.value = phoneNumber;
dispatchInputEvents(hiddenPhoneNumberInput);
}
await sleep(250);
simulateClick(submitButton);
return waitForPhoneVerificationReady();
}
async function waitForPhoneVerificationOutcome(timeout = 30000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const errorText = getVerificationErrorText();
if (errorText) {
return {
invalidCode: true,
errorText,
url: location.href,
};
}
if (isConsentReady()) {
return {
success: true,
consentReady: true,
url: location.href,
};
}
if (isAddPhonePageReady()) {
return {
returnedToAddPhone: true,
url: location.href,
};
}
await sleep(150);
}
if (isPhoneVerificationPageReady()) {
return {
invalidCode: true,
errorText: getVerificationErrorText() || 'Phone verification page stayed in place after code submission.',
url: location.href,
};
}
return {
success: true,
assumed: true,
url: location.href,
};
}
async function submitPhoneVerificationCode(payload = {}) {
const code = String(payload.code || '').trim();
if (!code) {
throw new Error('Missing phone verification code.');
}
await waitForPhoneVerificationReady();
const codeInput = getPhoneVerificationCodeInput() || await waitForElement(
'input[name="code"], input[autocomplete="one-time-code"], input[inputmode="numeric"]',
10000
);
const submitButton = getPhoneVerificationSubmitButton();
if (!codeInput) {
throw new Error('Phone verification page is missing the code input.');
}
if (!submitButton) {
throw new Error('Phone verification page is missing the submit button.');
}
await humanPause(250, 700);
fillInput(codeInput, code);
await sleep(250);
simulateClick(submitButton);
return waitForPhoneVerificationOutcome();
}
async function resendPhoneVerificationCode(timeout = 45000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const resendButton = getPhoneVerificationResendButton({ allowDisabled: true });
if (resendButton && isActionEnabled(resendButton)) {
await humanPause(250, 700);
simulateClick(resendButton);
await sleep(1000);
return {
resent: true,
url: location.href,
};
}
await sleep(250);
}
throw new Error('Timed out waiting for the phone verification resend button.');
}
async function returnToAddPhone(timeout = 20000) {
if (isAddPhonePageReady()) {
return {
addPhonePage: true,
url: location.href,
};
}
if (!isPhoneVerificationPageReady()) {
throw new Error('The auth page is not currently on phone verification or add-phone page.');
}
location.assign('/add-phone');
await waitForAddPhoneReady(timeout);
return {
addPhonePage: true,
url: location.href,
};
}
return {
getPhoneVerificationDisplayedPhone,
isPhoneVerificationPageReady,
resendPhoneVerificationCode,
returnToAddPhone,
submitPhoneNumber,
submitPhoneVerificationCode,
toE164PhoneNumber,
};
}
return {
createPhoneAuthHelpers,
};
});
+86
View File
@@ -21,6 +21,10 @@ if (document.documentElement.getAttribute(SIGNUP_PAGE_LISTENER_SENTINEL) !== '1'
|| message.type === 'PREPARE_SIGNUP_VERIFICATION'
|| message.type === 'RECOVER_AUTH_RETRY_PAGE'
|| message.type === 'RESEND_VERIFICATION_CODE'
|| message.type === 'SUBMIT_PHONE_NUMBER'
|| message.type === 'SUBMIT_PHONE_VERIFICATION_CODE'
|| message.type === 'RESEND_PHONE_VERIFICATION_CODE'
|| message.type === 'RETURN_TO_ADD_PHONE'
|| message.type === 'ENSURE_SIGNUP_ENTRY_READY'
|| message.type === 'ENSURE_SIGNUP_PASSWORD_PAGE_READY'
) {
@@ -76,6 +80,14 @@ async function handleCommand(message) {
return await recoverCurrentAuthRetryPage(message.payload);
case 'RESEND_VERIFICATION_CODE':
return await resendVerificationCode(message.step);
case 'SUBMIT_PHONE_NUMBER':
return await phoneAuthHelpers.submitPhoneNumber(message.payload);
case 'SUBMIT_PHONE_VERIFICATION_CODE':
return await phoneAuthHelpers.submitPhoneVerificationCode(message.payload);
case 'RESEND_PHONE_VERIFICATION_CODE':
return await phoneAuthHelpers.resendPhoneVerificationCode();
case 'RETURN_TO_ADD_PHONE':
return await phoneAuthHelpers.returnToAddPhone();
case 'ENSURE_SIGNUP_ENTRY_READY':
return await ensureSignupEntryReady();
case 'ENSURE_SIGNUP_PASSWORD_PAGE_READY':
@@ -746,6 +758,12 @@ function getLoginVerificationDisplayedEmail() {
return matches[0] ? String(matches[0]).trim().toLowerCase() : '';
}
function getPhoneVerificationDisplayedPhone() {
const pageText = getPageTextSnapshot();
const matches = pageText.match(/\+\d[\d\s-]{6,}\d/g) || [];
return matches[0] ? String(matches[0]).replace(/\s+/g, ' ').trim() : '';
}
function getOAuthConsentForm() {
return document.querySelector(OAUTH_CONSENT_FORM_SELECTOR);
}
@@ -806,6 +824,9 @@ function isVerificationPageStillVisible() {
if (getCurrentAuthRetryPageState('signup_password') || getCurrentAuthRetryPageState('login')) {
return false;
}
if (isPhoneVerificationPageReady()) {
return false;
}
if (getVerificationCodeTarget()) return true;
if (findResendVerificationCodeTrigger({ allowDisabled: true })) return true;
if (document.querySelector('form[action*="email-verification" i]')) return true;
@@ -831,15 +852,68 @@ function isAddPhonePageReady() {
return ADD_PHONE_PAGE_PATTERN.test(getPageTextSnapshot());
}
function isPhoneVerificationPageReady() {
const path = `${location.pathname || ''} ${location.href || ''}`;
if (/\/phone-verification(?:[/?#]|$)/i.test(path)) {
return true;
}
const form = document.querySelector('form[action*="/phone-verification" i]');
if (form && isVisibleElement(form)) {
return true;
}
if (document.querySelector('button[name="intent"][value="resend"]') && getPhoneVerificationDisplayedPhone()) {
return true;
}
const pageText = getPageTextSnapshot();
const displayedPhone = getPhoneVerificationDisplayedPhone();
return Boolean(getVerificationCodeTarget())
&& Boolean(displayedPhone)
&& /check\s+your\s+phone|phone\s+verification|verify\s+your\s+phone|sms|text\s+message|code\s+to\s+\+/.test(pageText);
}
function isStep8Ready() {
const continueBtn = getPrimaryContinueButton();
if (!continueBtn) return false;
if (isVerificationPageStillVisible()) return false;
if (isPhoneVerificationPageReady()) return false;
if (isAddPhonePageReady()) return false;
return isOAuthConsentPage();
}
const phoneAuthHelpers = self.MultiPagePhoneAuth?.createPhoneAuthHelpers?.({
fillInput,
getActionText,
getPageTextSnapshot,
getVerificationErrorText,
humanPause,
isActionEnabled,
isAddPhonePageReady,
isConsentReady: isStep8Ready,
isPhoneVerificationPageReady,
isVisibleElement,
simulateClick,
sleep,
throwIfStopped,
waitForElement,
}) || {
submitPhoneNumber: async () => {
throw new Error('Phone auth helpers are unavailable.');
},
submitPhoneVerificationCode: async () => {
throw new Error('Phone auth helpers are unavailable.');
},
resendPhoneVerificationCode: async () => {
throw new Error('Phone auth helpers are unavailable.');
},
returnToAddPhone: async () => {
throw new Error('Phone auth helpers are unavailable.');
},
};
function normalizeInlineText(text) {
return (text || '').replace(/\s+/g, ' ').trim();
}
@@ -1240,6 +1314,7 @@ function inspectLoginAuthState() {
const submitButton = getLoginSubmitButton({ allowDisabled: true });
const verificationVisible = isVerificationPageStillVisible();
const addPhonePage = isAddPhonePageReady();
const phoneVerificationPage = isPhoneVerificationPageReady();
const consentReady = isStep8Ready();
const oauthConsentPage = isOAuthConsentPage();
const baseState = {
@@ -1259,10 +1334,19 @@ function inspectLoginAuthState() {
switchTrigger,
verificationVisible,
addPhonePage,
phoneVerificationPage,
oauthConsentPage,
consentReady,
};
if (phoneVerificationPage) {
return {
...baseState,
state: 'phone_verification_page',
displayedPhone: getPhoneVerificationDisplayedPhone(),
};
}
if (verificationTarget) {
return {
...baseState,
@@ -1325,6 +1409,7 @@ function serializeLoginAuthState(snapshot) {
hasSwitchTrigger: Boolean(snapshot?.switchTrigger),
verificationVisible: Boolean(snapshot?.verificationVisible),
addPhonePage: Boolean(snapshot?.addPhonePage),
phoneVerificationPage: Boolean(snapshot?.phoneVerificationPage),
oauthConsentPage: Boolean(snapshot?.oauthConsentPage),
consentReady: Boolean(snapshot?.consentReady),
};
@@ -2157,6 +2242,7 @@ function getStep8State() {
consentReady: isStep8Ready(),
verificationPage: isVerificationPageStillVisible(),
addPhonePage: isAddPhonePageReady(),
phoneVerificationPage: isPhoneVerificationPageReady(),
retryPage: Boolean(retryState),
retryEnabled: Boolean(retryState?.retryEnabled),
retryTitleMatched: Boolean(retryState?.titleMatched),
+1
View File
@@ -48,6 +48,7 @@
"content/activation-utils.js",
"content/utils.js",
"content/auth-page-recovery.js",
"content/phone-auth.js",
"content/signup-page.js"
],
"run_at": "document_idle"
+14
View File
@@ -362,6 +362,20 @@
<span class="data-label">同步服务</span>
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono" placeholder="http://127.0.0.1:17373" />
</div>
<div class="data-row">
<span class="data-label">接码平台</span>
<span id="display-hero-sms-platform" class="data-value mono">HeroSMS / OpenAI / Thailand</span>
</div>
<div class="data-row">
<span class="data-label">接码国家</span>
<select id="select-hero-sms-country" class="data-input mono">
<option value="52" selected>Thailand</option>
</select>
</div>
<div class="data-row">
<span class="data-label">接码 API</span>
<input type="password" id="input-hero-sms-api-key" class="data-input mono" placeholder="请输入 HeroSMS API Key" />
</div>
<div class="data-row">
<span class="data-label">OAuth</span>
<span id="display-oauth-url" class="data-value mono truncate">等待中...</span>
+134 -2
View File
@@ -201,6 +201,9 @@ const inputAutoDelayMinutes = document.getElementById('input-auto-delay-minutes'
const inputAutoStepDelaySeconds = document.getElementById('input-auto-step-delay-seconds');
const inputVerificationResendCount = document.getElementById('input-verification-resend-count');
const rowAccountRunHistoryTextEnabled = document.getElementById('row-account-run-history-text-enabled');
const inputHeroSmsApiKey = document.getElementById('input-hero-sms-api-key');
const selectHeroSmsCountry = document.getElementById('select-hero-sms-country');
const displayHeroSmsPlatform = document.getElementById('display-hero-sms-platform');
const inputAccountRunHistoryTextEnabled = document.getElementById('input-account-run-history-text-enabled');
const rowAccountRunHistoryHelperBaseUrl = document.getElementById('row-account-run-history-helper-base-url');
const inputAccountRunHistoryHelperBaseUrl = document.getElementById('input-account-run-history-helper-base-url');
@@ -251,6 +254,9 @@ const DEFAULT_LUCKMAIL_BASE_URL = 'https://mails.luckyous.com';
const DEFAULT_LUCKMAIL_EMAIL_TYPE = 'ms_graph';
const DISPLAY_TIMEZONE = 'Asia/Shanghai';
const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = 'http://127.0.0.1:17373';
const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/';
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
function getManagedAliasUtils() {
return window.MultiPageManagedAliasUtils || null;
@@ -1431,6 +1437,15 @@ function collectSettingsPayload() {
const mail2925UseAccountPool = typeof inputMail2925UseAccountPool !== 'undefined'
? Boolean(inputMail2925UseAccountPool?.checked)
: Boolean(latestState?.mail2925UseAccountPool);
const heroSmsApiKeyValue = typeof inputHeroSmsApiKey !== 'undefined' && inputHeroSmsApiKey
? (inputHeroSmsApiKey.value || '')
: '';
const heroSmsCountry = typeof getSelectedHeroSmsCountryOption === 'function'
? getSelectedHeroSmsCountryOption()
: {
id: typeof DEFAULT_HERO_SMS_COUNTRY_ID !== 'undefined' ? DEFAULT_HERO_SMS_COUNTRY_ID : 52,
label: typeof DEFAULT_HERO_SMS_COUNTRY_LABEL !== 'undefined' ? DEFAULT_HERO_SMS_COUNTRY_LABEL : 'Thailand',
};
return {
panelMode: selectPanelMode.value,
vpsUrl: inputVpsUrl.value.trim(),
@@ -1481,6 +1496,9 @@ function collectSettingsPayload() {
inputVerificationResendCount?.value,
DEFAULT_VERIFICATION_RESEND_COUNT
),
heroSmsApiKey: heroSmsApiKeyValue,
heroSmsCountryId: heroSmsCountry.id,
heroSmsCountryLabel: heroSmsCountry.label,
};
}
@@ -1529,6 +1547,75 @@ function normalizeAccountRunHistoryHelperBaseUrlValue(value = '') {
}
}
function normalizeHeroSmsCountryId(value) {
return Math.max(1, Math.floor(Number(value) || DEFAULT_HERO_SMS_COUNTRY_ID));
}
function normalizeHeroSmsCountryLabel(value = '') {
return String(value || '').trim() || DEFAULT_HERO_SMS_COUNTRY_LABEL;
}
function getSelectedHeroSmsCountryOption() {
if (!selectHeroSmsCountry) {
return {
id: DEFAULT_HERO_SMS_COUNTRY_ID,
label: DEFAULT_HERO_SMS_COUNTRY_LABEL,
};
}
const option = selectHeroSmsCountry.options[selectHeroSmsCountry.selectedIndex];
return {
id: normalizeHeroSmsCountryId(selectHeroSmsCountry.value),
label: normalizeHeroSmsCountryLabel(option?.textContent),
};
}
function updateHeroSmsPlatformDisplay(label = '') {
if (!displayHeroSmsPlatform) {
return;
}
const countryLabel = normalizeHeroSmsCountryLabel(label);
displayHeroSmsPlatform.textContent = `HeroSMS / OpenAI / ${countryLabel}`;
}
async function loadHeroSmsCountries() {
if (!selectHeroSmsCountry) {
return;
}
const previousValue = selectHeroSmsCountry.value || String(DEFAULT_HERO_SMS_COUNTRY_ID);
try {
const response = await fetch('https://hero-sms.com/stubs/handler_api.php?action=getCountries');
const payload = await response.json();
const countries = Array.isArray(payload?.value) ? payload.value : (Array.isArray(payload) ? payload : []);
if (!countries.length) {
throw new Error('empty country list');
}
const optionsHtml = countries
.filter((item) => Number(item?.id) > 0 && String(item?.eng || '').trim())
.sort((left, right) => String(left.eng || '').localeCompare(String(right.eng || '')))
.map((item) => {
const id = normalizeHeroSmsCountryId(item.id);
const label = String(item.eng || '').trim();
return `<option value="${id}">${label}</option>`;
})
.join('');
if (optionsHtml) {
selectHeroSmsCountry.innerHTML = optionsHtml;
}
} catch (error) {
console.warn('Failed to load HeroSMS countries:', error);
selectHeroSmsCountry.innerHTML = `<option value="${DEFAULT_HERO_SMS_COUNTRY_ID}">${DEFAULT_HERO_SMS_COUNTRY_LABEL}</option>`;
}
selectHeroSmsCountry.value = Array.from(selectHeroSmsCountry.options).some((option) => option.value === previousValue)
? previousValue
: String(DEFAULT_HERO_SMS_COUNTRY_ID);
updateHeroSmsPlatformDisplay(getSelectedHeroSmsCountryOption().label);
}
function getSelectedLocalCpaStep9Mode() {
const activeButton = localCpaStep9ModeButtons.find((button) => button.classList.contains('is-active'));
return normalizeLocalCpaStep9Mode(activeButton?.dataset.localCpaStep9Mode);
@@ -1895,6 +1982,16 @@ function applySettingsState(state) {
normalizeVerificationResendCount(restoredVerificationResendCount, DEFAULT_VERIFICATION_RESEND_COUNT)
);
}
if (inputHeroSmsApiKey) {
inputHeroSmsApiKey.value = state?.heroSmsApiKey || '';
}
if (selectHeroSmsCountry) {
const restoredCountryId = String(normalizeHeroSmsCountryId(state?.heroSmsCountryId));
if (Array.from(selectHeroSmsCountry.options).some((option) => option.value === restoredCountryId)) {
selectHeroSmsCountry.value = restoredCountryId;
}
updateHeroSmsPlatformDisplay(state?.heroSmsCountryLabel || getSelectedHeroSmsCountryOption().label);
}
if (state?.autoRunTotalRuns) {
inputRunCount.value = String(state.autoRunTotalRuns);
}
@@ -4311,6 +4408,20 @@ inputVerificationResendCount?.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
inputHeroSmsApiKey?.addEventListener('input', () => {
markSettingsDirty(true);
scheduleSettingsAutoSave();
});
inputHeroSmsApiKey?.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
selectHeroSmsCountry?.addEventListener('change', () => {
updateHeroSmsPlatformDisplay(getSelectedHeroSmsCountryOption().label);
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
});
// ============================================================
// Listen for Background broadcasts
// ============================================================
@@ -4552,6 +4663,25 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
)
);
}
if (message.payload.heroSmsApiKey !== undefined && inputHeroSmsApiKey) {
inputHeroSmsApiKey.value = message.payload.heroSmsApiKey || '';
}
if (
(message.payload.heroSmsCountryId !== undefined || message.payload.heroSmsCountryLabel !== undefined)
&& selectHeroSmsCountry
) {
const nextCountryId = String(
normalizeHeroSmsCountryId(
message.payload.heroSmsCountryId !== undefined
? message.payload.heroSmsCountryId
: selectHeroSmsCountry.value
)
);
if (Array.from(selectHeroSmsCountry.options).some((option) => option.value === nextCountryId)) {
selectHeroSmsCountry.value = nextCountryId;
}
updateHeroSmsPlatformDisplay(message.payload.heroSmsCountryLabel || getSelectedHeroSmsCountryOption().label);
}
renderContributionMode();
break;
}
@@ -4647,11 +4777,13 @@ setMail2925Mode(DEFAULT_MAIL_2925_MODE);
initializeReleaseInfo().catch((err) => {
console.error('Failed to initialize release info:', err);
});
restoreState().then(() => {
loadHeroSmsCountries().catch((err) => {
console.error('Failed to load HeroSMS countries:', err);
}).finally(() => restoreState().then(() => {
syncPasswordToggleLabel();
syncVpsUrlToggleLabel();
syncVpsPasswordToggleLabel();
updatePanelModeUI();
updateButtonStates();
updateStatusDisplay(latestState);
});
}));
+14
View File
@@ -215,6 +215,20 @@ test('auto-run stops restarting on generic phone-page failure messages even with
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run restarts from step 7 when phone verification cannot receive SMS after resend', async () => {
const harness = createHarness({
failureStep: 9,
failureBudget: 1,
failureMessage: 'Phone verification could not receive an SMS after resend. Restart step 7 with a new number. Current number: 66959916439.',
authState: { state: 'add_phone_page', url: 'https://auth.openai.com/add-phone' },
});
const events = await harness.run();
assert.equal(events.invalidations.length, 1);
assert.deepStrictEqual(events.steps, [7, 8, 9, 7, 8, 9, 10]);
});
test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => {
const harness = createHarness({
failureStep: 9,
+426
View File
@@ -0,0 +1,426 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/phone-verification-flow.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPhoneVerification;`)(globalScope);
test('phone verification helper requests HeroSMS numbers with fixed OpenAI and Thailand parameters', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
requests.push(new URL(url));
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
assert.deepStrictEqual(activation, {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
});
assert.equal(requests.length, 1);
assert.equal(requests[0].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('service'), 'dr');
assert.equal(requests[0].searchParams.get('country'), '52');
assert.equal(requests[0].searchParams.get('api_key'), 'demo-key');
});
test('phone verification helper completes add-phone flow, clears current activation, and stores reusable number state', async () => {
const requests = [];
const stateUpdates = [];
let currentState = {
heroSmsApiKey: 'demo-key',
verificationResendCount: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
}
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:654321',
};
}
if (action === 'setStatus') {
return {
ok: true,
text: async () => 'ACCESS_ACTIVATION',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return {
phoneVerificationPage: true,
url: 'https://auth.openai.com/phone-verification',
};
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
};
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
stateUpdates.push(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.deepStrictEqual(stateUpdates, [
{
currentPhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
},
{
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
maxUses: 3,
},
},
{
currentPhoneActivation: null,
},
]);
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, ['getNumber', 'getStatus', 'setStatus']);
});
test('phone verification helper uses the configured HeroSMS country for both number acquisition and add-phone submission', async () => {
const requests = [];
const submittedPayloads = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsCountryId: 16,
heroSmsCountryLabel: 'United Kingdom',
verificationResendCount: 0,
currentPhoneActivation: null,
reusablePhoneActivation: null,
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:654321:447911123456',
};
}
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:112233',
};
}
if (action === 'setStatus') {
return {
ok: true,
text: async () => 'ACCESS_ACTIVATION',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
submittedPayloads.push(message.payload);
return {
phoneVerificationPage: true,
url: 'https://auth.openai.com/phone-verification',
};
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
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(requests[0].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('country'), '16');
assert.deepStrictEqual(submittedPayloads, [{
phoneNumber: '447911123456',
countryId: 16,
countryLabel: 'United Kingdom',
}]);
});
test('phone verification helper throws a step-7 restart error after 60 seconds plus one resend window without SMS', async () => {
const requests = [];
const messages = [];
let currentState = {
heroSmsApiKey: 'demo-key',
verificationResendCount: 0,
currentPhoneActivation: null,
reusablePhoneActivation: null,
};
const statusCallsById = {};
const realDateNow = Date.now;
let fakeNow = 0;
Date.now = () => fakeNow;
try {
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
const id = parsedUrl.searchParams.get('id');
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
}
if (action === 'getStatus') {
statusCallsById[id] = (statusCallsById[id] || 0) + 1;
return {
ok: true,
text: async () => 'STATUS_WAIT_CODE',
};
}
if (action === 'setStatus') {
return {
ok: true,
text: async () => 'ACCESS_ACTIVATION',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
messages.push(message.type);
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return {
phoneVerificationPage: true,
url: 'https://auth.openai.com/phone-verification',
};
}
if (message.type === 'RESEND_PHONE_VERIFICATION_CODE') {
return {
resent: 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 () => {
fakeNow += 61000;
},
throwIfStopped: () => {},
});
await assert.rejects(
helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/Restart step 7 with a new number/i
);
assert.ok(statusCallsById['123456'] >= 2, 'first number should be polled twice before being replaced');
assert.deepStrictEqual(messages, [
'SUBMIT_PHONE_NUMBER',
'RESEND_PHONE_VERIFICATION_CODE',
]);
const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`);
assert.deepStrictEqual(actions, [
'getNumber:',
'getStatus:123456',
'setStatus:123456',
'getStatus:123456',
'setStatus:123456',
]);
assert.equal(currentState.currentPhoneActivation, null);
} finally {
Date.now = realDateNow;
}
});
test('phone verification helper reuses the same number up to three successful registrations', async () => {
const requests = [];
let currentState = {
heroSmsApiKey: 'demo-key',
verificationResendCount: 0,
currentPhoneActivation: null,
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 2,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'reactivate') {
return {
ok: true,
text: async () => JSON.stringify({
activationId: '222333',
phoneNumber: '66959916439',
}),
};
}
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:654321',
};
}
if (action === 'setStatus') {
return {
ok: true,
text: async () => 'ACCESS_ACTIVATION',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return {
phoneVerificationPage: true,
url: 'https://auth.openai.com/phone-verification',
};
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
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(requests[0].searchParams.get('action'), 'reactivate');
assert.equal(requests[0].searchParams.get('id'), '123456');
assert.deepStrictEqual(currentState.reusablePhoneActivation, null);
});
+42
View File
@@ -53,6 +53,8 @@ function extractFunction(name) {
const bundle = [
extractFunction('getPageTextSnapshot'),
extractFunction('getLoginVerificationDisplayedEmail'),
extractFunction('getPhoneVerificationDisplayedPhone'),
extractFunction('isPhoneVerificationPageReady'),
extractFunction('inspectLoginAuthState'),
extractFunction('normalizeStep6Snapshot'),
].join('\n');
@@ -69,6 +71,9 @@ const document = {
innerText: ${JSON.stringify(overrides.pageText || '')},
textContent: ${JSON.stringify(overrides.pageText || '')},
},
querySelector() {
return null;
},
};
function getLoginTimeoutErrorPageState() {
@@ -103,6 +108,10 @@ function isAddPhonePageReady() {
return ${JSON.stringify(Boolean(overrides.addPhonePage))};
}
function isVisibleElement() {
return true;
}
function isStep8Ready() {
return ${JSON.stringify(Boolean(overrides.consentReady))};
}
@@ -115,6 +124,7 @@ ${bundle}
return {
inspectLoginAuthState,
isPhoneVerificationPageReady,
normalizeStep6Snapshot,
};
`)();
@@ -146,6 +156,38 @@ return {
assert.strictEqual(snapshot.displayedEmail, 'display.user@example.com');
}
{
const api = createApi({
pathname: '/email-verification',
href: 'https://auth.openai.com/email-verification',
verificationTarget: { id: 'otp' },
pageText: 'We just sent to display.user@example.com. Enter it below.',
});
assert.strictEqual(
api.isPhoneVerificationPageReady(),
false,
'邮箱验证码页不应被误判为手机验证码页'
);
const snapshot = api.inspectLoginAuthState();
assert.strictEqual(snapshot.state, 'verification_page');
}
{
const api = createApi({
pathname: '/phone-verification',
href: 'https://auth.openai.com/phone-verification',
verificationTarget: { id: 'otp' },
pageText: 'Check your phone. We just sent a code to +66 81 234 5678.',
});
assert.strictEqual(api.isPhoneVerificationPageReady(), true);
const snapshot = api.inspectLoginAuthState();
assert.strictEqual(snapshot.state, 'phone_verification_page');
}
{
const api = createApi({
oauthConsentPage: true,
+12 -9
View File
@@ -238,7 +238,7 @@ test('verification flow clears 2925 mailbox before polling and after successful
assert.deepStrictEqual(mailMessages, ['DELETE_ALL_EMAILS', 'POLL_EMAIL', 'DELETE_ALL_EMAILS']);
});
test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => {
test('verification flow completes step 8 and flags phone verification when add-phone appears after login code submit', async () => {
const events = [];
const helpers = api.createVerificationFlowHelpers({
@@ -288,18 +288,21 @@ test('verification flow treats add-phone after login code submit as fatal instea
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await assert.rejects(
() => helpers.resolveVerificationStep(
8,
{ email: 'user@example.com', lastLoginCode: null },
{ provider: 'qq', label: 'QQ 邮箱' },
{}
),
/验证码提交后页面进入手机号页面/
const result = await helpers.resolveVerificationStep(
8,
{ email: 'user@example.com', lastLoginCode: null },
{ provider: 'qq', label: 'QQ Mail' },
{}
);
assert.deepStrictEqual(result, {
phoneVerificationRequired: true,
url: 'https://auth.openai.com/add-phone',
});
assert.deepStrictEqual(events, [
['submit', '654321'],
['state', '654321'],
['complete', '654321'],
]);
});