Merge branch 'dev' into master
This commit is contained in:
+2
-1
@@ -6,4 +6,5 @@
|
||||
|
||||
/data/account-run-history.txt
|
||||
/data/account-run-history.json
|
||||
.npm-test.log
|
||||
.npm-test.log
|
||||
.omx/
|
||||
|
||||
@@ -150,6 +150,17 @@ http(s)://<your-host>/management.html#/oauth
|
||||
|
||||
Step 1 和 Step 10 都依赖这个地址。
|
||||
|
||||
### `SUB2API`
|
||||
|
||||
当 `来源 = SUB2API` 时,需要配置:
|
||||
|
||||
- `SUB2API`:后台账号管理页地址
|
||||
- `账号 / 密码`:SUB2API 管理员登录信息
|
||||
- `分组`:目标 OpenAI 分组,留空时默认 `codex`
|
||||
- `默认代理`:创建账号时必须绑定的代理名称或代理 ID,默认 `shadowrocket`
|
||||
|
||||
插件会在 Step 1 和 Step 10 自动从 `/api/v1/admin/proxies/all` 解析这个代理,并分别把 `proxy_id` 传给 OAuth 链接生成、授权码交换和账号创建请求。如果名称匹配到多个代理,请改填代理 ID。
|
||||
|
||||
### `Mail`
|
||||
|
||||
支持五种验证码来源:
|
||||
|
||||
+21
-1
@@ -141,6 +141,7 @@ const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000;
|
||||
const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000;
|
||||
const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts';
|
||||
const DEFAULT_SUB2API_GROUP_NAME = 'codex';
|
||||
const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket';
|
||||
const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
||||
const AUTO_RUN_TIMER_ALARM_NAME = 'auto-run-timer';
|
||||
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
|
||||
@@ -215,6 +216,7 @@ const PERSISTED_SETTING_DEFAULTS = {
|
||||
sub2apiEmail: '',
|
||||
sub2apiPassword: '',
|
||||
sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
|
||||
sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME,
|
||||
customPassword: '',
|
||||
autoRunSkipFailures: false,
|
||||
autoRunFallbackThreadIntervalMinutes: 0,
|
||||
@@ -287,6 +289,7 @@ const DEFAULT_STATE = {
|
||||
sub2apiOAuthState: null, // SUB2API OpenAI Auth state。
|
||||
sub2apiGroupId: null, // SUB2API 目标分组 ID。
|
||||
sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。
|
||||
sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。
|
||||
flowStartTime: null, // 当前流程开始时间。
|
||||
tabRegistry: {}, // 程序维护的标签页注册表。
|
||||
sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。
|
||||
@@ -828,6 +831,8 @@ function normalizePersistentSettingValue(key, value) {
|
||||
return String(value || '');
|
||||
case 'sub2apiGroupName':
|
||||
return String(value || '').trim();
|
||||
case 'sub2apiDefaultProxyName':
|
||||
return String(value || '').trim() || DEFAULT_SUB2API_PROXY_NAME;
|
||||
case 'customPassword':
|
||||
return String(value || '');
|
||||
case 'autoRunSkipFailures':
|
||||
@@ -3839,6 +3844,14 @@ function isVerificationMailPollingError(error) {
|
||||
return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
|
||||
}
|
||||
|
||||
function isAddPhoneAuthFailure(error) {
|
||||
if (typeof loggingStatus !== 'undefined' && loggingStatus?.isAddPhoneAuthFailure) {
|
||||
return loggingStatus.isAddPhoneAuthFailure(error);
|
||||
}
|
||||
const message = getErrorMessage(error);
|
||||
return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message);
|
||||
}
|
||||
|
||||
function getLoginAuthStateLabel(state) {
|
||||
if (typeof loggingStatus !== 'undefined' && loggingStatus?.getLoginAuthStateLabel) {
|
||||
return loggingStatus.getLoginAuthStateLabel(state);
|
||||
@@ -5176,6 +5189,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
|
||||
getState,
|
||||
getStopRequested: () => stopRequested,
|
||||
hasSavedProgress,
|
||||
isAddPhoneAuthFailure,
|
||||
isRestartCurrentAttemptError,
|
||||
isStopError,
|
||||
launchAutoRunTimerPlan,
|
||||
@@ -5783,6 +5797,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({
|
||||
getLoginAuthStateLabel,
|
||||
getOAuthFlowStepTimeoutMs,
|
||||
getState,
|
||||
isAddPhoneAuthFailure,
|
||||
isStep6RecoverableResult,
|
||||
isStep6SuccessResult,
|
||||
refreshOAuthUrlBeforeStep6,
|
||||
@@ -6361,7 +6376,7 @@ async function getPostStep6AutoRestartDecision(step, error) {
|
||||
};
|
||||
}
|
||||
|
||||
if (isAddPhoneAuthUrl(errorMessage)) {
|
||||
if (isAddPhoneAuthFailure(error) || isAddPhoneAuthUrl(errorMessage)) {
|
||||
return {
|
||||
shouldRestart: false,
|
||||
blockedByAddPhone: true,
|
||||
@@ -6435,6 +6450,11 @@ async function ensureStep8VerificationPageReady(options = {}) {
|
||||
throw new Error(`STEP8_RESTART_STEP7::步骤 8:当前认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim());
|
||||
}
|
||||
|
||||
if (pageState.state === 'add_phone_page') {
|
||||
const urlPart = pageState.url ? ` URL: ${pageState.url}` : '';
|
||||
throw new Error(`步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。${urlPart}`.trim());
|
||||
}
|
||||
|
||||
const stateLabel = getLoginAuthStateLabel(pageState.state);
|
||||
const urlPart = pageState.url ? ` URL: ${pageState.url}` : '';
|
||||
throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:${stateLabel}.${urlPart}`.trim());
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
getRunningSteps,
|
||||
getState,
|
||||
hasSavedProgress,
|
||||
isAddPhoneAuthFailure,
|
||||
isRestartCurrentAttemptError,
|
||||
isStopError,
|
||||
launchAutoRunTimerPlan,
|
||||
@@ -456,12 +457,36 @@
|
||||
|
||||
const reason = getErrorMessage(err);
|
||||
roundSummary.failureReasons.push(reason);
|
||||
const canRetry = autoRunSkipFailures && attemptRun < maxAttemptsForRound;
|
||||
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
|
||||
const canRetry = !blockedByAddPhone && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
|
||||
|
||||
if (blockedByAddPhone) {
|
||||
roundSummary.status = 'failed';
|
||||
roundSummary.finalFailureReason = reason;
|
||||
}
|
||||
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
});
|
||||
|
||||
if (blockedByAddPhone) {
|
||||
await appendRoundRecordIfNeeded('failed', reason);
|
||||
cancelPendingCommands('当前轮因认证流程进入 add-phone 已终止。');
|
||||
await broadcastStopToContentScripts();
|
||||
await addLog(
|
||||
`第 ${targetRun}/${totalRuns} 轮触发 add-phone/手机号页,当前自动运行将立即停止,不再重试或进入下一轮。`,
|
||||
'warn'
|
||||
);
|
||||
stoppedEarly = true;
|
||||
await broadcastAutoRunStatus('stopped', {
|
||||
currentRun: targetRun,
|
||||
totalRuns,
|
||||
attemptRun,
|
||||
sessionId: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (canRetry) {
|
||||
const retryIndex = attemptRun;
|
||||
if (isRestartCurrentAttemptError(err)) {
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message);
|
||||
}
|
||||
|
||||
function isAddPhoneAuthFailure(error) {
|
||||
const message = getErrorMessage(error);
|
||||
return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message);
|
||||
}
|
||||
|
||||
function getLoginAuthStateLabel(state) {
|
||||
state = state === 'oauth_consent_page' ? 'unknown' : state;
|
||||
switch (state) {
|
||||
@@ -148,6 +153,7 @@
|
||||
return {
|
||||
addLog,
|
||||
getAutoRunStatusPayload,
|
||||
isAddPhoneAuthFailure,
|
||||
getErrorMessage,
|
||||
getFirstUnfinishedStep,
|
||||
getLoginAuthStateLabel,
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
|
||||
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
|
||||
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
|
||||
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
|
||||
if (Object.keys(updates).length) {
|
||||
await setState(updates);
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
sub2apiEmail: state.sub2apiEmail,
|
||||
sub2apiPassword: state.sub2apiPassword,
|
||||
sub2apiGroupName: groupName,
|
||||
sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
|
||||
logStep: 6,
|
||||
},
|
||||
}, {
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
getLoginAuthStateLabel,
|
||||
getOAuthFlowStepTimeoutMs,
|
||||
getState,
|
||||
isAddPhoneAuthFailure = (error) => {
|
||||
const message = String(typeof error === 'string' ? error : error?.message || '');
|
||||
return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|\badd-phone\b|添加手机号|手机号码|手机号页|手机号页面|手机号|phone\s+number|telephone/i.test(message);
|
||||
},
|
||||
isStep6RecoverableResult,
|
||||
isStep6SuccessResult,
|
||||
refreshOAuthUrlBeforeStep6,
|
||||
@@ -19,11 +23,6 @@
|
||||
throwIfStopped,
|
||||
} = deps;
|
||||
|
||||
function isAddPhoneAuthFailure(error) {
|
||||
const message = String(typeof error === 'string' ? error : error?.message || '');
|
||||
return /https:\/\/auth\.openai\.com\/add-phone(?:[/?#]|$)|add-phone|手机号页面|手机号码|手机号|phone\s+number|telephone/i.test(message);
|
||||
}
|
||||
|
||||
async function executeStep7(state) {
|
||||
if (!state.email) {
|
||||
throw new Error('缺少邮箱地址,请先完成步骤 3。');
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
sub2apiEmail: state.sub2apiEmail,
|
||||
sub2apiPassword: state.sub2apiPassword,
|
||||
sub2apiGroupName: state.sub2apiGroupName,
|
||||
sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
|
||||
sub2apiProxyId: state.sub2apiProxyId,
|
||||
sub2apiSessionId: state.sub2apiSessionId,
|
||||
sub2apiOAuthState: state.sub2apiOAuthState,
|
||||
sub2apiGroupId: state.sub2apiGroupId,
|
||||
|
||||
@@ -642,6 +642,11 @@
|
||||
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,
|
||||
|
||||
@@ -1442,7 +1442,7 @@ async function waitForVerificationSubmitOutcome(step, timeout) {
|
||||
}
|
||||
|
||||
if (step === 8 && isAddPhonePageReady()) {
|
||||
return { success: true, addPhonePage: true };
|
||||
return { success: true, addPhonePage: true, url: location.href };
|
||||
}
|
||||
|
||||
await sleep(150);
|
||||
@@ -1499,7 +1499,7 @@ async function fillVerificationCode(step, payload) {
|
||||
if (outcome.invalidCode) {
|
||||
log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn');
|
||||
} else if (outcome.addPhonePage) {
|
||||
log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok');
|
||||
log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn');
|
||||
} else {
|
||||
log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok');
|
||||
}
|
||||
@@ -1541,7 +1541,7 @@ async function fillVerificationCode(step, payload) {
|
||||
if (outcome.invalidCode) {
|
||||
log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn');
|
||||
} else if (outcome.addPhonePage) {
|
||||
log(`步骤 ${step}:验证码已通过,并已跳转到手机号页面。`, 'ok');
|
||||
log(`步骤 ${step}:验证码提交后页面进入手机号页面,当前流程将停止自动授权。`, 'warn');
|
||||
} else {
|
||||
log(`步骤 ${step}:验证码已通过${outcome.assumed ? '(按成功推定)' : ''}。`, 'ok');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ console.log('[MultiPage:sub2api-panel] Content script loaded on', location.href)
|
||||
|
||||
const SUB2API_PANEL_LISTENER_SENTINEL = 'data-multipage-sub2api-panel-listener';
|
||||
const SUB2API_DEFAULT_GROUP_NAME = 'codex';
|
||||
const SUB2API_DEFAULT_PROXY_NAME = 'shadowrocket';
|
||||
const SUB2API_DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
||||
const SUB2API_DEFAULT_CONCURRENCY = 10;
|
||||
const SUB2API_DEFAULT_PRIORITY = 1;
|
||||
@@ -188,6 +189,149 @@ async function getGroupByName(origin, token, groupName) {
|
||||
return group;
|
||||
}
|
||||
|
||||
function normalizeSub2ApiProxyPreference(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function resolveSub2ApiProxyPreference(payload = {}, backgroundState = {}) {
|
||||
if (payload.sub2apiDefaultProxyName !== undefined) {
|
||||
return normalizeSub2ApiProxyPreference(payload.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME;
|
||||
}
|
||||
if (backgroundState.sub2apiDefaultProxyName !== undefined) {
|
||||
return normalizeSub2ApiProxyPreference(backgroundState.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME;
|
||||
}
|
||||
return SUB2API_DEFAULT_PROXY_NAME;
|
||||
}
|
||||
|
||||
function normalizeProxyId(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const normalized = Number(value);
|
||||
if (!Number.isSafeInteger(normalized) || normalized <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildProxyDisplayName(proxy = {}) {
|
||||
const id = normalizeProxyId(proxy.id);
|
||||
const name = String(proxy.name || '').trim();
|
||||
const protocol = String(proxy.protocol || '').trim();
|
||||
const host = String(proxy.host || '').trim();
|
||||
const port = proxy.port === undefined || proxy.port === null ? '' : String(proxy.port).trim();
|
||||
const address = protocol && host && port ? `${protocol}://${host}:${port}` : '';
|
||||
const parts = [
|
||||
name || '(未命名代理)',
|
||||
id ? `#${id}` : '',
|
||||
address,
|
||||
].filter(Boolean);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function buildProxySearchText(proxy = {}) {
|
||||
return [
|
||||
proxy.id,
|
||||
proxy.name,
|
||||
proxy.protocol,
|
||||
proxy.host,
|
||||
proxy.port,
|
||||
buildProxyDisplayName(proxy),
|
||||
]
|
||||
.filter((value) => value !== undefined && value !== null && value !== '')
|
||||
.map((value) => String(value).trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function isActiveProxy(proxy = {}) {
|
||||
const status = String(proxy.status || '').trim().toLowerCase();
|
||||
return !status || status === 'active';
|
||||
}
|
||||
|
||||
function findSub2ApiProxy(proxies = [], preference = '') {
|
||||
const activeProxies = (Array.isArray(proxies) ? proxies : [])
|
||||
.filter(isActiveProxy)
|
||||
.filter((proxy) => normalizeProxyId(proxy.id));
|
||||
const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase();
|
||||
const preferredId = normalizeProxyId(normalizedPreference);
|
||||
|
||||
if (preferredId) {
|
||||
const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId);
|
||||
return {
|
||||
proxy: matchedById || null,
|
||||
reason: matchedById ? 'id' : 'missing-id',
|
||||
candidates: activeProxies,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPreference) {
|
||||
const exactMatches = activeProxies.filter((proxy) => {
|
||||
const name = String(proxy.name || '').trim().toLowerCase();
|
||||
return name === normalizedPreference;
|
||||
});
|
||||
if (exactMatches.length === 1) {
|
||||
return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies };
|
||||
}
|
||||
if (exactMatches.length > 1) {
|
||||
return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches };
|
||||
}
|
||||
|
||||
const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference));
|
||||
if (fuzzyMatches.length === 1) {
|
||||
return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies };
|
||||
}
|
||||
if (fuzzyMatches.length > 1) {
|
||||
return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches };
|
||||
}
|
||||
|
||||
return { proxy: null, reason: 'missing-name', candidates: activeProxies };
|
||||
}
|
||||
|
||||
if (activeProxies.length === 1) {
|
||||
return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies };
|
||||
}
|
||||
return {
|
||||
proxy: null,
|
||||
reason: activeProxies.length ? 'no-preference' : 'none-active',
|
||||
candidates: activeProxies,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSub2ApiProxy(origin, token, preference = '') {
|
||||
const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
if (!Array.isArray(proxies)) {
|
||||
throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。');
|
||||
}
|
||||
|
||||
const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference);
|
||||
if (proxy) {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)';
|
||||
const available = (candidates || [])
|
||||
.slice(0, 8)
|
||||
.map(buildProxyDisplayName)
|
||||
.join(',') || '无可用代理';
|
||||
if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') {
|
||||
throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`);
|
||||
}
|
||||
if (reason === 'missing-id') {
|
||||
throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`);
|
||||
}
|
||||
if (reason === 'missing-name') {
|
||||
throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`);
|
||||
}
|
||||
if (reason === 'no-preference') {
|
||||
throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID。可用代理:${available}`);
|
||||
}
|
||||
throw new Error('SUB2API 没有可用代理;当前流程要求账号必须绑定代理。');
|
||||
}
|
||||
|
||||
function buildDraftAccountName(groupName) {
|
||||
const prefix = (groupName || SUB2API_DEFAULT_GROUP_NAME)
|
||||
.trim()
|
||||
@@ -306,9 +450,13 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
|
||||
const { origin, token } = await loginSub2Api(payload);
|
||||
const group = await getGroupByName(origin, token, groupName);
|
||||
const proxyPreference = resolveSub2ApiProxyPreference(payload);
|
||||
const proxy = await resolveSub2ApiProxy(origin, token, proxyPreference);
|
||||
const proxyId = normalizeProxyId(proxy.id);
|
||||
const draftName = buildDraftAccountName(group.name || groupName);
|
||||
|
||||
log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${group.name}(#${group.id})。`);
|
||||
log(`步骤 ${logStep}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
|
||||
log(`步骤 ${logStep}:正在向 SUB2API 生成 OpenAI Auth 链接,回调地址为 ${redirectUri}。`);
|
||||
|
||||
const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', {
|
||||
@@ -316,6 +464,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
token,
|
||||
body: {
|
||||
redirect_uri: redirectUri,
|
||||
proxy_id: proxyId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -334,6 +483,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
|
||||
sub2apiOAuthState: oauthState,
|
||||
sub2apiGroupId: group.id,
|
||||
sub2apiDraftName: draftName,
|
||||
sub2apiProxyId: proxyId,
|
||||
};
|
||||
if (report) {
|
||||
reportComplete(1, result);
|
||||
@@ -354,6 +504,10 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
|| buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
|
||||
const { origin, token } = await loginSub2Api(payload);
|
||||
const proxyPreference = resolveSub2ApiProxyPreference(payload, backgroundState);
|
||||
const preferredProxyId = normalizeProxyId(payload.sub2apiProxyId || backgroundState.sub2apiProxyId);
|
||||
const proxy = await resolveSub2ApiProxy(origin, token, preferredProxyId || proxyPreference);
|
||||
const proxyId = normalizeProxyId(proxy.id);
|
||||
const group = payload.sub2apiGroupId
|
||||
? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }
|
||||
: await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
|
||||
@@ -366,6 +520,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
}
|
||||
|
||||
log('步骤 10:正在向 SUB2API 交换 OpenAI 授权码...');
|
||||
log(`步骤 10:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
|
||||
const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', {
|
||||
method: 'POST',
|
||||
token,
|
||||
@@ -373,6 +528,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
session_id: sessionId,
|
||||
code: callback.code,
|
||||
state: callback.state,
|
||||
proxy_id: proxyId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -391,6 +547,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
|
||||
concurrency: SUB2API_DEFAULT_CONCURRENCY,
|
||||
priority: SUB2API_DEFAULT_PRIORITY,
|
||||
rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
|
||||
proxy_id: proxyId,
|
||||
group_ids: [groupId],
|
||||
auto_pause_on_expired: true,
|
||||
};
|
||||
|
||||
@@ -140,6 +140,11 @@
|
||||
<span class="data-label">分组</span>
|
||||
<input type="text" id="input-sub2api-group" class="data-input" placeholder="默认 codex" />
|
||||
</div>
|
||||
<div class="data-row" id="row-sub2api-default-proxy" style="display:none;">
|
||||
<span class="data-label">默认代理</span>
|
||||
<input type="text" id="input-sub2api-default-proxy" class="data-input"
|
||||
placeholder="shadowrocket 或代理 ID" />
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">账户密码</span>
|
||||
<div class="input-with-icon">
|
||||
|
||||
@@ -74,6 +74,8 @@ const rowSub2ApiPassword = document.getElementById('row-sub2api-password');
|
||||
const inputSub2ApiPassword = document.getElementById('input-sub2api-password');
|
||||
const rowSub2ApiGroup = document.getElementById('row-sub2api-group');
|
||||
const inputSub2ApiGroup = document.getElementById('input-sub2api-group');
|
||||
const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy');
|
||||
const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy');
|
||||
const selectMailProvider = document.getElementById('select-mail-provider');
|
||||
const btnMailLogin = document.getElementById('btn-mail-login');
|
||||
const rowMail2925Mode = document.getElementById('row-mail-2925-mode');
|
||||
@@ -202,6 +204,8 @@ const VERIFICATION_RESEND_COUNT_MIN = 0;
|
||||
const VERIFICATION_RESEND_COUNT_MAX = 20;
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
|
||||
const DEFAULT_CPA_CALLBACK_MODE = 'step8';
|
||||
const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket';
|
||||
const MAIL_2925_MODE_PROVIDE = 'provide';
|
||||
const MAIL_2925_MODE_RECEIVE = 'receive';
|
||||
const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE;
|
||||
@@ -1306,6 +1310,7 @@ function collectSettingsPayload() {
|
||||
sub2apiEmail: inputSub2ApiEmail.value.trim(),
|
||||
sub2apiPassword: inputSub2ApiPassword.value,
|
||||
sub2apiGroupName: inputSub2ApiGroup.value.trim(),
|
||||
sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim() || DEFAULT_SUB2API_PROXY_NAME,
|
||||
customPassword: inputPassword.value,
|
||||
mailProvider: selectMailProvider.value,
|
||||
mail2925Mode: getSelectedMail2925Mode(),
|
||||
@@ -1676,6 +1681,7 @@ function applySettingsState(state) {
|
||||
inputSub2ApiEmail.value = state?.sub2apiEmail || '';
|
||||
inputSub2ApiPassword.value = state?.sub2apiPassword || '';
|
||||
inputSub2ApiGroup.value = state?.sub2apiGroupName || '';
|
||||
inputSub2ApiDefaultProxy.value = state?.sub2apiDefaultProxyName || DEFAULT_SUB2API_PROXY_NAME;
|
||||
const restoredMailProvider = isCustomMailProvider(state?.mailProvider)
|
||||
|| [ICLOUD_PROVIDER, 'hotmail-api', GMAIL_PROVIDER, 'luckmail-api', '163', '163-vip', 'qq', 'inbucket', '2925', 'cloudflare-temp-email'].includes(String(state?.mailProvider || '').trim())
|
||||
? String(state?.mailProvider || '163').trim()
|
||||
@@ -2470,6 +2476,7 @@ function updatePanelModeUI() {
|
||||
rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
|
||||
rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
|
||||
rowSub2ApiGroup.style.display = useSub2Api ? '' : 'none';
|
||||
rowSub2ApiDefaultProxy.style.display = useSub2Api ? '' : 'none';
|
||||
|
||||
const step9Btn = document.querySelector('.step-btn[data-step-key="platform-verify"]');
|
||||
if (step9Btn) {
|
||||
@@ -3701,6 +3708,14 @@ inputSub2ApiGroup.addEventListener('blur', () => {
|
||||
saveSettings({ silent: true }).catch(() => { });
|
||||
});
|
||||
|
||||
inputSub2ApiDefaultProxy.addEventListener('input', () => {
|
||||
markSettingsDirty(true);
|
||||
scheduleSettingsAutoSave();
|
||||
});
|
||||
inputSub2ApiDefaultProxy.addEventListener('blur', () => {
|
||||
saveSettings({ silent: true }).catch(() => { });
|
||||
});
|
||||
|
||||
inputEmailPrefix.addEventListener('input', () => {
|
||||
maybeClearGeneratedAliasAfterEmailPrefixChange().catch(() => { });
|
||||
syncManagedAliasBaseEmailDraftFromInput();
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
|
||||
|
||||
test('auto-run controller does not retry add-phone failures even when auto retry is enabled', async () => {
|
||||
const events = {
|
||||
logs: [],
|
||||
broadcasts: [],
|
||||
accountRecords: [],
|
||||
runCalls: 0,
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
stepStatuses: {},
|
||||
vpsUrl: 'https://example.com/vps',
|
||||
vpsPassword: 'secret',
|
||||
customPassword: '',
|
||||
autoRunSkipFailures: true,
|
||||
autoRunFallbackThreadIntervalMinutes: 0,
|
||||
autoRunDelayEnabled: false,
|
||||
autoRunDelayMinutes: 30,
|
||||
autoStepDelaySeconds: null,
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'duck',
|
||||
gmailBaseEmail: '',
|
||||
mail2925BaseEmail: '',
|
||||
emailPrefix: 'demo',
|
||||
inbucketHost: '',
|
||||
inbucketMailbox: '',
|
||||
cloudflareDomain: '',
|
||||
cloudflareDomains: [],
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
autoRunRoundSummaries: [],
|
||||
};
|
||||
|
||||
const runtime = {
|
||||
state: {
|
||||
autoRunActive: false,
|
||||
autoRunCurrentRun: 0,
|
||||
autoRunTotalRuns: 1,
|
||||
autoRunAttemptRun: 0,
|
||||
autoRunSessionId: 0,
|
||||
},
|
||||
get() {
|
||||
return { ...this.state };
|
||||
},
|
||||
set(updates = {}) {
|
||||
this.state = { ...this.state, ...updates };
|
||||
},
|
||||
};
|
||||
|
||||
let sessionSeed = 0;
|
||||
|
||||
const controller = api.createAutoRunController({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
appendAccountRunRecord: async (status, _state, reason) => {
|
||||
events.accountRecords.push({ status, reason });
|
||||
return { status, reason };
|
||||
},
|
||||
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
|
||||
AUTO_RUN_RETRY_DELAY_MS: 3000,
|
||||
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
|
||||
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
|
||||
broadcastAutoRunStatus: async (phase, payload = {}) => {
|
||||
events.broadcasts.push({ phase, ...payload });
|
||||
currentState = {
|
||||
...currentState,
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
|
||||
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
|
||||
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
|
||||
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
|
||||
};
|
||||
},
|
||||
broadcastStopToContentScripts: async () => {},
|
||||
cancelPendingCommands: () => {},
|
||||
clearStopRequest: () => {},
|
||||
createAutoRunSessionId: () => {
|
||||
sessionSeed += 1;
|
||||
return sessionSeed;
|
||||
},
|
||||
getAutoRunStatusPayload: (phase, payload = {}) => ({
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? 0,
|
||||
autoRunTotalRuns: payload.totalRuns ?? 1,
|
||||
autoRunAttemptRun: payload.attemptRun ?? 0,
|
||||
autoRunSessionId: payload.sessionId ?? 0,
|
||||
}),
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getFirstUnfinishedStep: () => 1,
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getRunningSteps: () => [],
|
||||
getState: async () => ({
|
||||
...currentState,
|
||||
stepStatuses: { ...(currentState.stepStatuses || {}) },
|
||||
tabRegistry: { ...(currentState.tabRegistry || {}) },
|
||||
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
|
||||
}),
|
||||
getStopRequested: () => false,
|
||||
hasSavedProgress: () => false,
|
||||
isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
|
||||
isRestartCurrentAttemptError: () => false,
|
||||
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
|
||||
launchAutoRunTimerPlan: async () => false,
|
||||
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
|
||||
persistAutoRunTimerPlan: async () => ({}),
|
||||
resetState: async () => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
stepStatuses: {},
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
};
|
||||
},
|
||||
runAutoSequenceFromStep: async () => {
|
||||
events.runCalls += 1;
|
||||
throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone');
|
||||
},
|
||||
runtime,
|
||||
setState: async (updates = {}) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
|
||||
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
|
||||
};
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfAutoRunSessionStopped: (sessionId) => {
|
||||
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
|
||||
throw new Error('流程已被用户停止。');
|
||||
}
|
||||
},
|
||||
waitForRunningStepsToFinish: async () => currentState,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.autoRunLoop(1, {
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
});
|
||||
|
||||
assert.equal(events.runCalls, 1, 'add-phone fatal failure should stop before the next auto attempt starts');
|
||||
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'add-phone fatal failure should not enter retrying phase');
|
||||
assert.equal(events.accountRecords.length, 1, 'fatal add-phone should still persist a failed round record');
|
||||
assert.equal(events.accountRecords[0].status, 'failed');
|
||||
assert.match(events.accountRecords[0].reason, /add-phone/);
|
||||
assert.ok(events.logs.some(({ message }) => /add-phone\/手机号页/.test(message)));
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
@@ -53,6 +53,7 @@ function extractFunction(name) {
|
||||
}
|
||||
|
||||
const bundle = [
|
||||
extractFunction('isAddPhoneAuthFailure'),
|
||||
extractFunction('isAddPhoneAuthUrl'),
|
||||
extractFunction('isAddPhoneAuthState'),
|
||||
extractFunction('getPostStep6AutoRestartDecision'),
|
||||
@@ -198,6 +199,22 @@ test('auto-run stops restarting once add-phone is detected', async () => {
|
||||
assert.ok(result.events.logs.some(({ message }) => /进入 add-phone/.test(message)));
|
||||
});
|
||||
|
||||
test('auto-run stops restarting on generic phone-page failure messages even without add-phone url', async () => {
|
||||
const harness = createHarness({
|
||||
failureStep: 9,
|
||||
failureBudget: 1,
|
||||
failureMessage: '步骤 8:当前认证页进入手机号页面,当前流程无法继续自动授权。',
|
||||
authState: { state: 'password_page', url: 'https://auth.openai.com/log-in' },
|
||||
});
|
||||
|
||||
const result = await harness.runAndCaptureError();
|
||||
|
||||
assert.ok(result?.error);
|
||||
assert.equal(result.events.invalidations.length, 0);
|
||||
assert.deepStrictEqual(result.events.steps, [7, 8, 9]);
|
||||
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
|
||||
});
|
||||
|
||||
test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => {
|
||||
const harness = createHarness({
|
||||
failureStep: 9,
|
||||
|
||||
@@ -61,6 +61,7 @@ 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 DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket';
|
||||
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
|
||||
const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
|
||||
const VERIFICATION_RESEND_COUNT_MIN = 0;
|
||||
@@ -109,4 +110,12 @@ return {
|
||||
api.normalizeAccountRunHistoryHelperBaseUrl(''),
|
||||
'http://127.0.0.1:17373'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ''),
|
||||
'shadowrocket'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
|
||||
'proxy-a'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -139,3 +139,65 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
|
||||
assert.equal(capturedOptions.beforeSubmit, undefined);
|
||||
assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function');
|
||||
});
|
||||
|
||||
test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => {
|
||||
const calls = {
|
||||
executeStep7: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep8Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
calls.logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
|
||||
executeStep7: async () => {
|
||||
calls.executeStep7 += 1;
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
getMailConfig: () => ({
|
||||
provider: 'qq',
|
||||
label: 'QQ 邮箱',
|
||||
source: 'mail-qq',
|
||||
url: 'https://mail.qq.com',
|
||||
navigateOnReuse: false,
|
||||
}),
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
isVerificationMailPollingError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
resolveVerificationStep: async () => {
|
||||
throw new Error('步骤 8:验证码提交后页面进入手机号页面,当前流程无法继续自动授权。 URL: https://auth.openai.com/add-phone');
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep8({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
}),
|
||||
/add-phone/
|
||||
);
|
||||
|
||||
assert.equal(calls.executeStep7, 0);
|
||||
assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message)));
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ test('sidepanel html contains account records overlay and manager script', () =>
|
||||
assert.match(html, /id="account-records-list"/);
|
||||
assert.match(html, /id="account-records-stats"/);
|
||||
assert.match(html, /id="btn-clear-account-records"/);
|
||||
assert.match(html, /id="input-sub2api-default-proxy"/);
|
||||
assert.notEqual(managerIndex, -1);
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(managerIndex < sidepanelIndex);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function createJsonResponse(payload, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function createSub2ApiPanelContext(fetchCalls = []) {
|
||||
const storage = new Map();
|
||||
const documentElement = {
|
||||
attrs: new Map(),
|
||||
getAttribute(name) {
|
||||
return this.attrs.get(name) || null;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attrs.set(name, String(value));
|
||||
},
|
||||
};
|
||||
|
||||
const context = {
|
||||
URL,
|
||||
console: { log() {} },
|
||||
setTimeout() {},
|
||||
document: { documentElement },
|
||||
location: {
|
||||
href: 'https://sub.example/admin/accounts',
|
||||
origin: 'https://sub.example',
|
||||
pathname: '/admin/accounts',
|
||||
replace() {},
|
||||
},
|
||||
chrome: {
|
||||
runtime: {
|
||||
onMessage: { addListener() {} },
|
||||
sendMessage: async () => ({
|
||||
email: 'flow@example.com',
|
||||
sub2apiDefaultProxyName: 'shadowrocket',
|
||||
}),
|
||||
},
|
||||
},
|
||||
localStorage: {
|
||||
setItem(key, value) { storage.set(`local:${key}`, String(value)); },
|
||||
removeItem(key) { storage.delete(`local:${key}`); },
|
||||
},
|
||||
sessionStorage: {
|
||||
removeItem(key) { storage.delete(`session:${key}`); },
|
||||
},
|
||||
log() {},
|
||||
reportComplete(step, payload) {
|
||||
context.completed.push({ step, payload });
|
||||
},
|
||||
reportReady() {},
|
||||
reportError() {},
|
||||
resetStopState() {},
|
||||
throwIfStopped() {},
|
||||
isStopError() { return false; },
|
||||
completed: [],
|
||||
fetch: async (url, options = {}) => {
|
||||
const parsed = new URL(url);
|
||||
const body = options.body ? JSON.parse(options.body) : null;
|
||||
fetchCalls.push({ path: parsed.pathname, search: parsed.search, method: options.method || 'GET', body });
|
||||
|
||||
if (parsed.pathname === '/api/v1/auth/login') {
|
||||
return createJsonResponse({
|
||||
code: 0,
|
||||
data: {
|
||||
access_token: 'admin-token',
|
||||
refresh_token: 'refresh-admin',
|
||||
expires_in: 3600,
|
||||
user: { id: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (parsed.pathname === '/api/v1/admin/groups/all') {
|
||||
return createJsonResponse({
|
||||
code: 0,
|
||||
data: [{ id: 5, name: 'codex', platform: 'openai' }],
|
||||
});
|
||||
}
|
||||
if (parsed.pathname === '/api/v1/admin/proxies/all') {
|
||||
return createJsonResponse({
|
||||
code: 0,
|
||||
data: [{
|
||||
id: 7,
|
||||
name: 'shadowrocket',
|
||||
protocol: 'socks5',
|
||||
host: '127.0.0.1',
|
||||
port: 1080,
|
||||
status: 'active',
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (parsed.pathname === '/api/v1/admin/openai/generate-auth-url') {
|
||||
return createJsonResponse({
|
||||
code: 0,
|
||||
data: {
|
||||
auth_url: 'https://auth.openai.com/oauth?state=oauth-state',
|
||||
session_id: 'session-1',
|
||||
state: 'oauth-state',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (parsed.pathname === '/api/v1/admin/openai/exchange-code') {
|
||||
return createJsonResponse({
|
||||
code: 0,
|
||||
data: {
|
||||
access_token: 'openai-access',
|
||||
refresh_token: 'openai-refresh',
|
||||
expires_at: 1770000000,
|
||||
email: 'flow@example.com',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (parsed.pathname === '/api/v1/admin/accounts') {
|
||||
return createJsonResponse({
|
||||
code: 0,
|
||||
data: { id: 11 },
|
||||
});
|
||||
}
|
||||
|
||||
return createJsonResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
|
||||
},
|
||||
};
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync('content/sub2api-panel.js', 'utf8'), context);
|
||||
return context;
|
||||
}
|
||||
|
||||
test('SUB2API step 1 selects the configured default proxy before generating OAuth URL', async () => {
|
||||
const fetchCalls = [];
|
||||
const context = createSub2ApiPanelContext(fetchCalls);
|
||||
|
||||
const result = await vm.runInContext(`
|
||||
step1_generateOpenAiAuthUrl({
|
||||
sub2apiEmail: 'admin@example.com',
|
||||
sub2apiPassword: 'secret',
|
||||
sub2apiGroupName: 'codex',
|
||||
sub2apiDefaultProxyName: 'shadowrocket'
|
||||
}, { report: false })
|
||||
`, context);
|
||||
|
||||
const generateCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/generate-auth-url');
|
||||
assert.equal(result.sub2apiProxyId, 7);
|
||||
assert.equal(generateCall.body.proxy_id, 7);
|
||||
});
|
||||
|
||||
test('SUB2API step 10 uses the same proxy for code exchange and account creation', async () => {
|
||||
const fetchCalls = [];
|
||||
const context = createSub2ApiPanelContext(fetchCalls);
|
||||
|
||||
await vm.runInContext(`
|
||||
step9_submitOpenAiCallback({
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
sub2apiUrl: 'https://sub.example/admin/accounts',
|
||||
sub2apiEmail: 'admin@example.com',
|
||||
sub2apiPassword: 'secret',
|
||||
sub2apiGroupName: 'codex',
|
||||
sub2apiSessionId: 'session-1',
|
||||
sub2apiOAuthState: 'oauth-state',
|
||||
sub2apiGroupId: 5,
|
||||
sub2apiProxyId: 7
|
||||
})
|
||||
`, context);
|
||||
|
||||
const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code');
|
||||
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
|
||||
|
||||
assert.equal(exchangeCall.body.proxy_id, 7);
|
||||
assert.equal(createCall.body.proxy_id, 7);
|
||||
assert.equal(createCall.body.group_ids[0], 5);
|
||||
assert.equal(context.completed[0].step, 10);
|
||||
});
|
||||
@@ -109,6 +109,71 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
|
||||
]);
|
||||
});
|
||||
|
||||
test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => {
|
||||
const events = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async (_step, payload) => {
|
||||
events.push(['complete', payload.code]);
|
||||
},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'FILL_CODE') {
|
||||
events.push(['submit', message.payload.code]);
|
||||
return {
|
||||
addPhonePage: true,
|
||||
url: 'https://auth.openai.com/add-phone',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({
|
||||
code: '654321',
|
||||
emailTimestamp: 123,
|
||||
}),
|
||||
setState: async (payload) => {
|
||||
events.push(['state', payload.lastLoginCode || payload.lastSignupCode]);
|
||||
},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => helpers.resolveVerificationStep(
|
||||
8,
|
||||
{ email: 'user@example.com', lastLoginCode: null },
|
||||
{ provider: 'qq', label: 'QQ 邮箱' },
|
||||
{}
|
||||
),
|
||||
/验证码提交后页面进入手机号页面/
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(events, [
|
||||
['submit', '654321'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('verification flow caps mail polling timeout to the remaining oauth budget', async () => {
|
||||
const mailPollCalls = [];
|
||||
|
||||
|
||||
+5
-2
@@ -314,8 +314,9 @@
|
||||
2. 打开邮箱页或 API 轮询入口
|
||||
3. 轮询登录验证码
|
||||
4. 回填登录验证码
|
||||
5. 如遇邮箱轮询类失败,则按有限次数回到 Step 7 重试
|
||||
6. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9
|
||||
5. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则立即判为 fatal 错误,不再把步骤 8 视为成功
|
||||
6. 如遇邮箱轮询类失败或显式的 `STEP8_RESTART_STEP7` 恢复错误,则按有限次数回到 Step 7 重试
|
||||
7. 获取到登录验证码后不再触发“刷新 OAuth 并重走 Step 7”的前置回放,直接在当前验证码页提交并继续进入 Step 9
|
||||
|
||||
### Step 9
|
||||
|
||||
@@ -449,7 +450,9 @@
|
||||
4. 每轮执行前重置必要运行态
|
||||
5. 执行 `runAutoSequenceFromStep`
|
||||
- 步骤 7 内部仍保留登录态恢复的有限重试,但 `add-phone / 手机号页` 属于立即跳出的不可重试错误
|
||||
- 步骤 8 若在验证码提交后进入 `add-phone / 手机号页`,会直接抛出 fatal 错误,不再先标记步骤成功
|
||||
- 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开
|
||||
- 如果命中 `add-phone / 手机号页` 这类 fatal 错误,则不仅不会回到步骤 7 重开,也不会进入 controller 的下一次自动重试 attempt
|
||||
- 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开”
|
||||
6. 如果失败,根据设置决定:
|
||||
- 立即停止
|
||||
|
||||
+5
-4
@@ -43,13 +43,13 @@
|
||||
- `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。
|
||||
- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。
|
||||
- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。
|
||||
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层。
|
||||
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。
|
||||
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。
|
||||
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。
|
||||
- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
|
||||
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。
|
||||
- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。
|
||||
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数。
|
||||
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;步骤 8 提交登录验证码后若页面进入 `add-phone / 手机号页`,会直接抛出 fatal 错误而不是把当前步骤视为成功。
|
||||
|
||||
## `background/steps/`
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
- `tests/auth-page-recovery.test.js`:测试认证页共享恢复层的重试页识别与恢复行为。
|
||||
- `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文,并补充 `gmailBaseEmail` / `mail2925BaseEmail` 在 fresh reset 后仍被保留的回归验证。
|
||||
- `tests/auto-run-timer-session-guard.test.js`:测试自动运行的旧计时计划在 Stop 使 session 失效后,不能再把已停止的自动流程重新拉起。
|
||||
- `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 时停止重开。
|
||||
- `tests/auto-run-add-phone-stop.test.js`:测试自动运行控制器在开启自动重试时,遇到 `add-phone / 手机号页` 也会立即停止,不会再开新 attempt。
|
||||
- `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 或泛化手机号页 fatal 错误时停止重开。
|
||||
- `tests/auto-step-random-delay.test.js`:测试自动运行步间延迟与旧配置键兼容解析。
|
||||
- `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
|
||||
- `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败标签、计算自动重试次数,并支持清理后同步完整快照。
|
||||
@@ -179,4 +180,4 @@
|
||||
- `tests/step9-localhost-cleanup-scope.test.js`:测试 localhost callback 与路径前缀残留页的精确清理范围,当前对应步骤 10 收尾。
|
||||
- `tests/step9-status-diagnostics.test.js`:测试平台回调成功判定、红色错误态过滤,以及成功徽标与失败提示并存时的优先级,当前对应步骤 10。
|
||||
- `tests/verification-stop-propagation.test.js`:测试验证码流程在 Stop 场景下的错误传播与不中途降级。
|
||||
- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数,以及验证码提交流程中的 `beforeSubmit` 钩子执行顺序。
|
||||
- `tests/verification-flow-polling.test.js`:测试 2925 长轮询参数、验证码提交流程中的 `beforeSubmit` 钩子执行顺序,以及步骤 8 提交验证码后进入手机号页时不会误判成功。
|
||||
|
||||
Reference in New Issue
Block a user