feat: continue OAuth flow through HeroSMS phone verification
- 合并 PR #122 的核心改动:在 OAuth 登录链路接入 HeroSMS 手机验证,支持 add-phone / phone-verification 页面续跑 - 本地补充修复:解决与 dev 主线的 sidepanel 初始化冲突,修正 Step 9 等待逻辑中的未声明变量,并同步结构/链路文档与相关回归测试 - 影响范围:OAuth 步骤 8/9、HeroSMS 配置、认证页内容脚本、sidepanel 初始化、项目文档与测试
This commit is contained in:
+55
-2
@@ -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',
|
||||
@@ -188,6 +189,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'];
|
||||
@@ -295,6 +301,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);
|
||||
@@ -354,6 +363,8 @@ const DEFAULT_STATE = {
|
||||
luckmailPreserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||
currentLuckmailPurchase: null,
|
||||
currentLuckmailMailCursor: null,
|
||||
currentPhoneActivation: null,
|
||||
reusablePhoneActivation: null,
|
||||
autoRunning: false, // 当前是否处于自动运行中。
|
||||
autoRunPhase: 'idle', // 当前自动运行阶段。
|
||||
autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。
|
||||
@@ -1022,6 +1033,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;
|
||||
}
|
||||
@@ -6429,7 +6446,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,
|
||||
@@ -6502,6 +6519,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,
|
||||
@@ -7211,10 +7243,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,
|
||||
};
|
||||
@@ -7224,6 +7268,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
|
||||
return {
|
||||
shouldRestart: false,
|
||||
blockedByAddPhone: true,
|
||||
forcedByPhoneVerificationTimeout: false,
|
||||
errorMessage,
|
||||
authState: null,
|
||||
};
|
||||
@@ -7246,6 +7291,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
|
||||
return {
|
||||
shouldRestart: false,
|
||||
blockedByAddPhone: true,
|
||||
forcedByPhoneVerificationTimeout: false,
|
||||
errorMessage,
|
||||
authState,
|
||||
};
|
||||
@@ -7254,6 +7300,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
|
||||
return {
|
||||
shouldRestart: true,
|
||||
blockedByAddPhone: false,
|
||||
forcedByPhoneVerificationTimeout: false,
|
||||
errorMessage,
|
||||
authState,
|
||||
};
|
||||
@@ -7375,7 +7422,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' },
|
||||
@@ -7480,6 +7527,12 @@ 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;
|
||||
await sleepWithStop(250);
|
||||
continue;
|
||||
}
|
||||
if (pageState?.addPhonePage) {
|
||||
throw new Error('步骤 9:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
(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.returnedToAddPhone) {
|
||||
await addLog(
|
||||
'Step 9: phone verification returned to add-phone after code submission, replacing the current number.',
|
||||
'warn'
|
||||
);
|
||||
shouldReplaceNumber = true;
|
||||
pageState = {
|
||||
...pageState,
|
||||
...submitResult,
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (submitResult.invalidCode) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -887,11 +887,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,
|
||||
@@ -900,10 +895,14 @@
|
||||
await completeStepFromBackground(step, {
|
||||
emailTimestamp: result.emailTimestamp,
|
||||
code: result.code,
|
||||
phoneVerificationRequired: Boolean(submitResult.addPhonePage),
|
||||
...(step === 4 && submitResult?.skipProfileStep ? { skipProfileStep: true } : {}),
|
||||
});
|
||||
triggerPostSuccessMailboxCleanup(step, mail);
|
||||
return;
|
||||
return {
|
||||
phoneVerificationRequired: Boolean(submitResult.addPhonePage),
|
||||
url: submitResult.url || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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':
|
||||
@@ -959,6 +971,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);
|
||||
}
|
||||
@@ -1019,6 +1037,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;
|
||||
@@ -1044,15 +1065,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();
|
||||
}
|
||||
@@ -1453,6 +1527,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 = {
|
||||
@@ -1472,6 +1547,7 @@ function inspectLoginAuthState() {
|
||||
switchTrigger,
|
||||
verificationVisible,
|
||||
addPhonePage,
|
||||
phoneVerificationPage,
|
||||
oauthConsentPage,
|
||||
consentReady,
|
||||
};
|
||||
@@ -1483,6 +1559,14 @@ function inspectLoginAuthState() {
|
||||
};
|
||||
}
|
||||
|
||||
if (phoneVerificationPage) {
|
||||
return {
|
||||
...baseState,
|
||||
state: 'phone_verification_page',
|
||||
displayedPhone: getPhoneVerificationDisplayedPhone(),
|
||||
};
|
||||
}
|
||||
|
||||
if (verificationTarget) {
|
||||
return {
|
||||
...baseState,
|
||||
@@ -1538,6 +1622,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),
|
||||
};
|
||||
@@ -2791,6 +2876,7 @@ function getStep8State() {
|
||||
consentReady: isStep8Ready(),
|
||||
verificationPage: isVerificationPageStillVisible(),
|
||||
addPhonePage: isAddPhonePageReady(),
|
||||
phoneVerificationPage: isPhoneVerificationPageReady(),
|
||||
retryPage: Boolean(retryState),
|
||||
retryEnabled: Boolean(retryState?.retryEnabled),
|
||||
retryTitleMatched: Boolean(retryState?.titleMatched),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -379,6 +379,20 @@
|
||||
<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>
|
||||
|
||||
+149
-15
@@ -223,6 +223,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');
|
||||
@@ -276,6 +279,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;
|
||||
@@ -1654,6 +1660,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(),
|
||||
@@ -1717,6 +1732,9 @@ function collectSettingsPayload() {
|
||||
inputVerificationResendCount?.value,
|
||||
DEFAULT_VERIFICATION_RESEND_COUNT
|
||||
),
|
||||
heroSmsApiKey: heroSmsApiKeyValue,
|
||||
heroSmsCountryId: heroSmsCountry.id,
|
||||
heroSmsCountryLabel: heroSmsCountry.label,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1765,6 +1783,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);
|
||||
@@ -2150,6 +2237,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);
|
||||
}
|
||||
@@ -4975,6 +5072,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
|
||||
// ============================================================
|
||||
@@ -5222,6 +5333,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;
|
||||
}
|
||||
@@ -5325,19 +5455,23 @@ setMail2925Mode(DEFAULT_MAIL_2925_MODE);
|
||||
initializeReleaseInfo().catch((err) => {
|
||||
console.error('Failed to initialize release info:', err);
|
||||
});
|
||||
restoreState().then(() => {
|
||||
syncPasswordToggleLabel();
|
||||
syncVpsUrlToggleLabel();
|
||||
syncVpsPasswordToggleLabel();
|
||||
updatePanelModeUI();
|
||||
updateButtonStates();
|
||||
updateStatusDisplay(latestState);
|
||||
return refreshContributionContentHint()
|
||||
.catch((error) => {
|
||||
console.warn('Failed to refresh contribution content hint during initialization:', error);
|
||||
return null;
|
||||
})
|
||||
.then(() => maybeShowNewUserGuidePrompt());
|
||||
}).catch((err) => {
|
||||
console.error('Failed to initialize sidepanel state:', err);
|
||||
loadHeroSmsCountries().catch((err) => {
|
||||
console.error('Failed to load HeroSMS countries:', err);
|
||||
}).finally(() => {
|
||||
return restoreState().then(() => {
|
||||
syncPasswordToggleLabel();
|
||||
syncVpsUrlToggleLabel();
|
||||
syncVpsPasswordToggleLabel();
|
||||
updatePanelModeUI();
|
||||
updateButtonStates();
|
||||
updateStatusDisplay(latestState);
|
||||
return refreshContributionContentHint()
|
||||
.catch((error) => {
|
||||
console.warn('Failed to refresh contribution content hint during initialization:', error);
|
||||
return null;
|
||||
})
|
||||
.then(() => maybeShowNewUserGuidePrompt());
|
||||
}).catch((err) => {
|
||||
console.error('Failed to initialize sidepanel state:', err);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
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 replaces the number when code submission returns to add-phone', async () => {
|
||||
const requests = [];
|
||||
const messages = [];
|
||||
let currentState = {
|
||||
heroSmsApiKey: 'demo-key',
|
||||
verificationResendCount: 1,
|
||||
currentPhoneActivation: null,
|
||||
reusablePhoneActivation: null,
|
||||
};
|
||||
|
||||
const numbers = [
|
||||
{ activationId: '111111', phoneNumber: '66950000001' },
|
||||
{ activationId: '222222', phoneNumber: '66950000002' },
|
||||
];
|
||||
let numberIndex = 0;
|
||||
let submitCodeCount = 0;
|
||||
|
||||
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') {
|
||||
const nextNumber = numbers[numberIndex];
|
||||
numberIndex += 1;
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => `ACCESS_NUMBER:${nextNumber.activationId}:${nextNumber.phoneNumber}`,
|
||||
};
|
||||
}
|
||||
if (action === 'getStatus') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => 'STATUS_OK:654321',
|
||||
};
|
||||
}
|
||||
if (action === 'setStatus') {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => `STATUS_UPDATED:${id}`,
|
||||
};
|
||||
}
|
||||
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 === 'SUBMIT_PHONE_VERIFICATION_CODE') {
|
||||
submitCodeCount += 1;
|
||||
return submitCodeCount === 1
|
||||
? {
|
||||
returnedToAddPhone: true,
|
||||
url: 'https://auth.openai.com/add-phone',
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
consentReady: true,
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
};
|
||||
}
|
||||
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 () => {},
|
||||
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(messages, [
|
||||
'SUBMIT_PHONE_NUMBER',
|
||||
'SUBMIT_PHONE_VERIFICATION_CODE',
|
||||
'SUBMIT_PHONE_NUMBER',
|
||||
'SUBMIT_PHONE_VERIFICATION_CODE',
|
||||
]);
|
||||
|
||||
const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`);
|
||||
assert.deepStrictEqual(actions, [
|
||||
'getNumber:',
|
||||
'getStatus:111111',
|
||||
'setStatus:111111',
|
||||
'getNumber:',
|
||||
'getStatus:222222',
|
||||
'setStatus:222222',
|
||||
]);
|
||||
assert.deepStrictEqual(currentState.currentPhoneActivation, null);
|
||||
assert.deepStrictEqual(currentState.reusablePhoneActivation, {
|
||||
activationId: '222222',
|
||||
phoneNumber: '66950000002',
|
||||
provider: 'hero-sms',
|
||||
serviceCode: 'dr',
|
||||
countryId: 52,
|
||||
successfulUses: 1,
|
||||
maxUses: 3,
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ test('sidepanel html keeps a single contribution mode button in header', () => {
|
||||
|
||||
assert.equal(matches.length, 1);
|
||||
assert.match(html, /id="btn-contribution-mode"[^>]*title="进入贡献模式并打开官网页"/);
|
||||
assert.match(html, />贡献\/使用<\/button>/);
|
||||
assert.match(html, />贡献\/使用教程<\/button>/);
|
||||
assert.match(html, /<\/header>\s*<div id="contribution-update-layer"/);
|
||||
assert.match(html, /id="contribution-update-layer"/);
|
||||
assert.match(html, /id="contribution-update-hint"/);
|
||||
|
||||
@@ -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({
|
||||
pathname: '/email-verification',
|
||||
|
||||
@@ -131,3 +131,72 @@ return {
|
||||
/当前认证页已进入重试页/
|
||||
);
|
||||
});
|
||||
|
||||
test('step 8 ready check completes phone verification flow before waiting for OAuth consent', async () => {
|
||||
const api = new Function(`
|
||||
let pollCount = 0;
|
||||
const phoneVerificationCalls = [];
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleepWithStop() {}
|
||||
async function ensureStep8SignupPageReady() {}
|
||||
const phoneVerificationHelpers = {
|
||||
async completePhoneVerificationFlow(tabId, pageState) {
|
||||
phoneVerificationCalls.push({ tabId, pageState });
|
||||
return {
|
||||
success: true,
|
||||
consentReady: true,
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
};
|
||||
},
|
||||
};
|
||||
async function getStep8PageState() {
|
||||
pollCount += 1;
|
||||
if (pollCount === 1) {
|
||||
return {
|
||||
url: 'https://auth.openai.com/add-phone',
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
addPhonePage: false,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
${extractFunction('waitForStep8Ready')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return {
|
||||
result: await waitForStep8Ready(88, 1000),
|
||||
phoneVerificationCalls,
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const { result, phoneVerificationCalls } = await api.run();
|
||||
|
||||
assert.deepStrictEqual(phoneVerificationCalls, [
|
||||
{
|
||||
tabId: 88,
|
||||
pageState: {
|
||||
url: 'https://auth.openai.com/add-phone',
|
||||
addPhonePage: true,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(result, {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
addPhonePage: false,
|
||||
phoneVerificationPage: false,
|
||||
consentReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,7 +280,7 @@ test('verification flow skips 2925 mailbox preclear when using a fixed signup ma
|
||||
assert.deepStrictEqual(mailMessages, ['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({
|
||||
@@ -330,18 +330,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'],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
+16
-4
@@ -43,6 +43,7 @@
|
||||
- 在 sidepanel 初始化和点击“自动”按钮前刷新一次贡献站公开内容摘要;如果刷新失败,不阻塞主自动流程
|
||||
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
|
||||
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新
|
||||
- 展示 HeroSMS 的接码国家与 API Key 设置,用于 OAuth 登录链路命中手机号验证页时直接续跑手机验证
|
||||
- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
|
||||
|
||||
### 2.2 Background Service Worker
|
||||
@@ -135,6 +136,8 @@
|
||||
- OAuth 链接
|
||||
- 当前邮箱 / 密码
|
||||
- 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail`
|
||||
- 当前手机号验证激活记录 `currentPhoneActivation`
|
||||
- 可复用的手机号验证激活记录 `reusablePhoneActivation`
|
||||
- localhost 回调地址
|
||||
- 自动运行轮次信息
|
||||
- 当前自动运行 session 标识 `autoRunSessionId`
|
||||
@@ -159,6 +162,7 @@
|
||||
- 2925 是否启用号池模式 `mail2925UseAccountPool`
|
||||
- 2925 当前选中的号池账号 ID `currentMail2925AccountId`
|
||||
- Cloudflare / Temp Email 设置
|
||||
- HeroSMS 的 API Key 与默认国家设置
|
||||
- iCloud 相关偏好
|
||||
- LuckMail API 配置
|
||||
- 自动运行默认配置
|
||||
@@ -412,7 +416,7 @@
|
||||
3. 打开邮箱页或 API 轮询入口
|
||||
4. 轮询登录验证码
|
||||
5. 回填登录验证码
|
||||
6. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则立即判为 fatal 错误,不再把步骤 8 视为成功
|
||||
6. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则步骤 8 会保留“登录验证码已提交成功”的结果,并把后续手机号验证需求继续交给步骤 9 处理
|
||||
7. 如遇邮箱轮询类失败或显式的 `STEP8_RESTART_STEP7` 恢复错误,则按有限次数回到 Step 7 重试
|
||||
8. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9
|
||||
|
||||
@@ -421,6 +425,7 @@
|
||||
- 对 `2925 provide` 而言,Step 8 仍不依赖验证码页显示邮箱做收件匹配,而是直接测试所有命中 ChatGPT / OpenAI 过滤条件的邮件。
|
||||
- 对 `2925 receive` 而言,Step 8 会把当前目标注册邮箱一并传给 2925 内容脚本;只有当邮件里显式写出了其他邮箱时才会跳过,从而在不破坏历史兼容性的前提下,尽量降低误收验证码的概率。
|
||||
- 对 `custom` provider 而言,Step 8 仍使用手动验证码确认弹窗;弹窗当前额外提供“出现手机号验证”按钮,点击后会直接抛出与真实 `add-phone` 页面一致的 fatal 错误,供 Auto 按既有 add-phone 分支继续下一邮箱。
|
||||
- 当登录验证码提交后进入 `phone-verification` 页时,内容脚本会显式把该页面识别为“手机验证码页”,避免与邮箱验证码页混淆。
|
||||
|
||||
### Step 9
|
||||
|
||||
@@ -431,9 +436,16 @@
|
||||
流程:
|
||||
|
||||
1. 监听 localhost callback
|
||||
2. 准备 OAuth 同意页
|
||||
3. 尝试多轮点击“继续”
|
||||
4. 一旦捕获 localhost callback,写入状态并完成步骤
|
||||
2. 准备 OAuth 同意页;如果页面已进入 `add-phone / phone-verification`,先切入手机号验证共享流程
|
||||
3. 手机号验证共享流程会按当前 sidepanel 中保存的 HeroSMS 国家与 API Key 申请或复用号码、提交号码、轮询短信验证码,并在验证码被拒绝或长时间收不到短信时决定重发、换号或把自动流拉回 Step 7
|
||||
4. 手机号验证完成后,继续等待 OAuth 同意页出现
|
||||
5. 尝试多轮点击“继续”
|
||||
6. 一旦捕获 localhost callback,写入状态并完成步骤
|
||||
|
||||
补充:
|
||||
|
||||
- HeroSMS 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。
|
||||
- 如果同一个号码在重发短信后 60 秒仍收不到验证码,后台会抛出“回到步骤 7 重新拿新号码”的恢复错误,而不是把当前号码无限重试下去。
|
||||
|
||||
### Step 10
|
||||
|
||||
|
||||
+10
-7
@@ -49,10 +49,11 @@
|
||||
- `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
|
||||
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息。
|
||||
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。
|
||||
- `background/phone-verification-flow.js`:手机号验证共享流程模块,负责在 OAuth 链路命中 `add-phone / phone-verification` 页面后向 HeroSMS 申请或复用号码、轮询短信验证码、提交手机号码与短信验证码,并在号码长期收不到短信时把后续自动流拉回步骤 7 重新拿号。
|
||||
- `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。
|
||||
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。
|
||||
- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。
|
||||
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。
|
||||
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若登录验证码提交后页面转入 `add-phone / 手机号页`,则会把“后续需要手机号验证”的结果继续传给步骤 9,而不是误判为普通失败;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。
|
||||
|
||||
## `background/steps/`
|
||||
|
||||
@@ -78,8 +79,9 @@
|
||||
- `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。
|
||||
- `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。
|
||||
- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。
|
||||
- `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。
|
||||
- `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
|
||||
- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击。
|
||||
- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击;当前还会在登录链路中显式识别 `phone-verification` 页面,避免把手机验证码页误判成邮箱验证码页或普通未知页。
|
||||
- `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。
|
||||
- `content/utils.js`:内容脚本公共工具层,负责日志、READY / COMPLETE / ERROR 上报、元素等待、输入与点击。
|
||||
- `content/vps-panel.js`:CPA 面板内容脚本,负责获取 OAuth 地址、提交回调 URL,并基于精确成功徽标与错误态做步骤 10 判定。
|
||||
@@ -122,8 +124,8 @@
|
||||
- `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。
|
||||
- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。
|
||||
- `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。
|
||||
- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时额外显示 `自定义号池` 文本框;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
|
||||
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
|
||||
- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时额外显示 `自定义号池` 文本框;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;设置卡片中当前还新增 HeroSMS 的接码平台、国家与 API Key 行,用于 OAuth 链路的手机号验证;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
|
||||
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;`自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示,同时在首次初始化后按现有规则决定是否弹出新手引导提示。
|
||||
- `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。
|
||||
|
||||
## `tests/`
|
||||
@@ -163,6 +165,7 @@
|
||||
- `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。
|
||||
- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成时的 Stop 中断行为。
|
||||
- `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。
|
||||
- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。
|
||||
- `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
|
||||
- `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。
|
||||
- `tests/hotmail-api-mode.test.js`:测试 Hotmail API 模式相关文案和集成方式。
|
||||
@@ -193,11 +196,11 @@
|
||||
- `tests/step3-direct-complete.test.js`:测试步骤 3 在提交前先完成上报,并保留延迟提交回调的可执行性验证。
|
||||
- `tests/step5-age-consent.test.js`:测试步骤 5 在年龄页会先勾选顶部“我同意以下所有各项”复选框,再点击提交。
|
||||
- `tests/step5-direct-complete.test.js`:测试步骤 5 在资料页点击提交后立即完成当前步骤。
|
||||
- `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路。
|
||||
- `tests/step6-login-state.test.js`:测试 OAuth 登录状态识别逻辑,当前对应步骤 7 链路,并覆盖 `phone-verification` 页面不会被误判成邮箱验证码页。
|
||||
- `tests/step6-timeout-recovery.test.js`:测试 OAuth 登录超时报错页的可恢复结果构造,当前对应步骤 7 链路。
|
||||
- `tests/step8-callback-handling.test.js`:测试 localhost 回调地址捕获逻辑,当前对应步骤 9。
|
||||
- `tests/step8-debugger-stop.test.js`:测试调试器点击在 Stop 场景下的中止行为,当前对应步骤 9。
|
||||
- `tests/step8-retry-page-recovery.test.js`:测试 OAuth 同意页点击后的重试页恢复分支,当前对应步骤 9。
|
||||
- `tests/step8-retry-page-recovery.test.js`:测试 OAuth 同意页点击后的重试页恢复分支,以及手机号验证完成后会继续回到 OAuth 同意页等待,当前对应步骤 9。
|
||||
- `tests/step8-state-timeout-retry.test.js`:测试认证页通信错误是否可判定为可重试,当前对应步骤 9。
|
||||
- `tests/step8-stop-cleanup.test.js`:测试 OAuth 确认流程在 Stop 后能正确清理监听器与挂起状态,当前对应步骤 9。
|
||||
- `tests/step9-cpa-mode.test.js`:测试本地 CPA 平台回调跳过策略判断,当前作用于步骤 10。
|
||||
@@ -205,4 +208,4 @@
|
||||
- `tests/step9-status-diagnostics.test.js`:测试平台回调成功判定、红色错误态过滤,以及成功徽标与失败提示并存时的优先级,当前对应步骤 10。
|
||||
- `tests/update-service.test.js`:测试侧边栏更新检查服务在 `Pro` / `v` 混合版本和旧缓存场景下都能按版本族正确排序,不会把 legacy `v` 版本误显示成最新可升级版本。
|
||||
- `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。
|
||||
- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数、验证码提交流程中的 `beforeSubmit` 钩子执行顺序,以及步骤 8 提交验证码后进入手机号页时不会误判成功。
|
||||
- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数、验证码提交流程中的 `beforeSubmit` 钩子执行顺序,以及步骤 8 提交验证码后进入手机号页时会把“继续手机号验证”状态交给步骤 9 处理。
|
||||
|
||||
Reference in New Issue
Block a user