Ver Fonte

Refine verification retry and auto-run settings

chendeben há 4 meses atrás
pai
commit
8c25d1cf24

+ 105 - 2
background.js

@@ -222,6 +222,7 @@ const PERSISTED_SETTING_DEFAULTS = {
   sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
   sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME,
   customPassword: '',
+  autoRunTotalRuns: 1,
   autoRunSkipFailures: false,
   autoRunFallbackThreadIntervalMinutes: 0,
   autoRunAddPhonePauseMinutes: DEFAULT_ADD_PHONE_PAUSE_MINUTES,
@@ -287,8 +288,8 @@ const DEFAULT_STATE = {
   manualAliasUsage: {},
   preservedAliases: {},
   lastEmailTimestamp: null, // 最近一次获取到邮箱数据的运行时时间戳。
-  lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入
-  lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入
+  lastSignupCode: null, // 最近一次已尝试/成功提交的注册验证码,用于避免重复提交旧验证码
+  lastLoginCode: null, // 最近一次已尝试/成功提交的登录验证码,用于避免重复提交旧验证码
   localhostUrl: null, // 运行时捕获到的 localhost 回调地址,不要手动预填。
   sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。
   sub2apiOAuthState: null, // SUB2API OpenAI Auth state。
@@ -861,6 +862,8 @@ function normalizePersistentSettingValue(key, value) {
     case 'autoRunSkipFailures':
     case 'autoRunDelayEnabled':
       return Boolean(value);
+    case 'autoRunTotalRuns':
+      return normalizeRunCount(value);
     case 'autoRunFallbackThreadIntervalMinutes':
       return normalizeAutoRunFallbackThreadIntervalMinutes(value);
     case 'autoRunAddPhonePauseMinutes':
@@ -5299,6 +5302,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR
   broadcastAutoRunStatus,
   broadcastStopToContentScripts,
   cancelPendingCommands,
+  cleanupAfterAddPhone: (...args) => runAddPhoneCooldownCleanup(...args),
   chooseAddPhonePauseMinutes: async () => normalizeAddPhonePauseMinutes(
     (await getState()).autoRunAddPhonePauseMinutes,
     DEFAULT_ADD_PHONE_PAUSE_MINUTES
@@ -6308,6 +6312,20 @@ function shouldClearPreLoginCookie(cookie) {
   ));
 }
 
+function shouldClosePreLoginCleanupTab(url) {
+  if (!url) return false;
+  try {
+    const parsed = new URL(String(url));
+    const domain = normalizeCookieDomainForMatch(parsed.hostname);
+    if (!domain) return false;
+    return PRE_LOGIN_COOKIE_CLEAR_DOMAINS.some((target) => (
+      domain === target || domain.endsWith(`.${target}`)
+    ));
+  } catch {
+    return false;
+  }
+}
+
 function buildCookieRemovalUrl(cookie) {
   const host = normalizeCookieDomainForMatch(cookie?.domain);
   const path = String(cookie?.path || '/').startsWith('/')
@@ -6348,6 +6366,37 @@ async function collectCookiesForPreLoginCleanup() {
   return cookies;
 }
 
+async function closeOpenAITabsForSessionCleanup() {
+  if (!chrome.tabs?.query || !chrome.tabs?.remove) {
+    return 0;
+  }
+
+  const tabs = await chrome.tabs.query({});
+  const matchedIds = tabs
+    .filter((tab) => Number.isInteger(tab?.id) && shouldClosePreLoginCleanupTab(tab.url))
+    .map((tab) => tab.id);
+
+  if (!matchedIds.length) {
+    return 0;
+  }
+
+  await chrome.tabs.remove(matchedIds).catch(() => { });
+
+  const registry = { ...((await getState()).tabRegistry || {}) };
+  let registryChanged = false;
+  for (const [source, entry] of Object.entries(registry)) {
+    if (entry?.tabId && matchedIds.includes(entry.tabId)) {
+      registry[source] = null;
+      registryChanged = true;
+    }
+  }
+  if (registryChanged) {
+    await setState({ tabRegistry: registry });
+  }
+
+  return matchedIds.length;
+}
+
 async function removeCookieDirectly(cookie) {
   const details = {
     url: buildCookieRemovalUrl(cookie),
@@ -6453,6 +6502,60 @@ async function runPreStep1SessionCleanup() {
   await addLog(`步骤 1:已清理登录态(cookies ${removedCookieCount} 个),准备以干净状态打开官网。`, 'ok');
 }
 
+async function runAddPhoneCooldownCleanup(context = {}) {
+  const currentRun = Number(context?.currentRun) || 0;
+  const totalRuns = Number(context?.totalRuns) || 0;
+  const attemptRun = Number(context?.attemptRun) || 0;
+  const label = currentRun > 0 && totalRuns > 0
+    ? `第 ${currentRun}/${totalRuns} 轮(尝试 ${attemptRun || 1})add-phone 冷却前`
+    : 'add-phone 冷却前';
+
+  await addLog(`${label}:正在关闭 ChatGPT / OpenAI 页面并清理登录态...`, 'info');
+
+  const closedTabCount = await closeOpenAITabsForSessionCleanup();
+
+  let removedCookieCount = 0;
+  if (chrome.cookies?.getAll && chrome.cookies?.remove) {
+    const cookies = await collectCookiesForPreLoginCleanup();
+    for (const cookie of cookies) {
+      if (await removeCookieDirectly(cookie)) {
+        removedCookieCount += 1;
+      }
+    }
+  }
+
+  if (chrome.browsingData?.remove) {
+    try {
+      await chrome.browsingData.remove({
+        since: 0,
+        origins: PRE_LOGIN_COOKIE_CLEAR_ORIGINS,
+      }, {
+        cookies: true,
+        localStorage: true,
+        cacheStorage: true,
+        indexedDB: true,
+        serviceWorkers: true,
+      });
+    } catch (err) {
+      await addLog(`${label}:清理站点存储失败:${getErrorMessage(err)}`, 'warn');
+    }
+  } else if (chrome.browsingData?.removeCookies) {
+    try {
+      await chrome.browsingData.removeCookies({
+        since: 0,
+        origins: PRE_LOGIN_COOKIE_CLEAR_ORIGINS,
+      });
+    } catch (err) {
+      await addLog(`${label}:browsingData 清理 cookies 失败:${getErrorMessage(err)}`, 'warn');
+    }
+  }
+
+  await addLog(
+    `${label}:已关闭 ${closedTabCount} 个 ChatGPT / OpenAI 标签页,并清理登录态(cookies ${removedCookieCount} 个)。`,
+    'ok'
+  );
+}
+
 // ============================================================
 // Step 7: Login and ensure the auth page reaches the login verification page
 // ============================================================

+ 16 - 0
background/auto-run-controller.js

@@ -12,6 +12,7 @@
       broadcastAutoRunStatus,
       broadcastStopToContentScripts,
       cancelPendingCommands,
+      cleanupAfterAddPhone,
       chooseAddPhonePauseMinutes,
       clearStopRequest,
       createAutoRunSessionId,
@@ -520,6 +521,21 @@
               await appendRoundRecordIfNeeded('failed', reason);
               cancelPendingCommands('当前轮因认证流程进入 add-phone 已终止。');
               await broadcastStopToContentScripts();
+              if (typeof cleanupAfterAddPhone === 'function') {
+                try {
+                  await cleanupAfterAddPhone({
+                    currentRun: targetRun,
+                    totalRuns,
+                    attemptRun,
+                    reason,
+                  });
+                } catch (cleanupError) {
+                  await addLog(
+                    `第 ${targetRun}/${totalRuns} 轮触发 add-phone 后清理 OpenAI 页面/登录态失败:${getErrorMessage(cleanupError)}`,
+                    'warn'
+                  );
+                }
+              }
               if (targetRun < totalRuns) {
                 const pauseMinutes = await getAddPhonePauseMinutes();
                 await addLog(

+ 3 - 0
background/verification-flow.js

@@ -636,6 +636,9 @@
             });
           }
           throwIfStopped();
+          await setState({
+            [stateKey]: result.code,
+          });
           const submitResult = await submitVerificationCode(step, result.code, options);
 
           if (submitResult.invalidCode) {

+ 3 - 3
sidepanel/sidepanel.css

@@ -313,9 +313,9 @@ header {
 }
 
 .run-count-input {
-  width: 42px;
-  padding: 6px 4px;
-  text-align: center;
+  width: 88px;
+  padding: 6px 8px;
+  text-align: right;
   background: var(--bg-base);
   border: 1px solid var(--border);
   border-radius: var(--radius-sm);

+ 6 - 0
sidepanel/sidepanel.js

@@ -1374,6 +1374,7 @@ function collectSettingsPayload() {
     cloudflareTempEmailReceiveMailbox: normalizeCloudflareTempEmailReceiveMailboxValue(inputTempEmailReceiveMailbox.value),
     cloudflareTempEmailDomain: selectedCloudflareTempEmailDomain,
     cloudflareTempEmailDomains: tempEmailDomains,
+    autoRunTotalRuns: getRunCountValue(),
     autoRunSkipFailures: inputAutoSkipFailures.checked,
     autoRunFallbackThreadIntervalMinutes: normalizeAutoRunThreadIntervalMinutes(inputAutoSkipFailuresThreadIntervalMinutes.value),
     autoRunAddPhonePauseMinutes: normalizeAddPhonePauseMinutes(inputAutoAddPhonePauseMinutes?.value),
@@ -3828,11 +3829,16 @@ inputInbucketHost.addEventListener('blur', () => {
 });
 
 inputRunCount.addEventListener('input', () => {
+  inputRunCount.value = inputRunCount.value.replace(/[^\d]/g, '');
+  markSettingsDirty(true);
   updateFallbackThreadIntervalInputState();
+  scheduleSettingsAutoSave();
 });
 inputRunCount.addEventListener('blur', () => {
   inputRunCount.value = String(getRunCountValue());
+  markSettingsDirty(true);
   updateFallbackThreadIntervalInputState();
+  saveSettings({ silent: true }).catch(() => { });
 });
 
 inputAutoSkipFailures.addEventListener('change', async () => {

+ 12 - 0
tests/auto-run-add-phone-stop.test.js

@@ -11,6 +11,7 @@ test('auto-run controller does not retry add-phone failures even when auto retry
     logs: [],
     broadcasts: [],
     accountRecords: [],
+    cleanupCalls: [],
     runCalls: 0,
   };
 
@@ -82,6 +83,9 @@ test('auto-run controller does not retry add-phone failures even when auto retry
     },
     broadcastStopToContentScripts: async () => {},
     cancelPendingCommands: () => {},
+    cleanupAfterAddPhone: async (payload = {}) => {
+      events.cleanupCalls.push(payload);
+    },
     clearStopRequest: () => {},
     createAutoRunSessionId: () => {
       sessionSeed += 1;
@@ -161,6 +165,8 @@ test('auto-run controller does not retry add-phone failures even when auto retry
   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.equal(events.cleanupCalls.length, 1, 'add-phone failure should trigger cleanup before stopping');
+  assert.equal(events.cleanupCalls[0].currentRun, 1);
   assert.ok(events.logs.some(({ message }) => /add-phone\/手机号页/.test(message)));
   assert.equal(runtime.state.autoRunActive, false);
   assert.equal(runtime.state.autoRunSessionId, 0);
@@ -171,6 +177,7 @@ test('auto-run controller parks 30~60 minutes and continues next round after add
     logs: [],
     broadcasts: [],
     accountRecords: [],
+    cleanupCalls: [],
     timerPlans: [],
     runCalls: 0,
   };
@@ -245,6 +252,9 @@ test('auto-run controller parks 30~60 minutes and continues next round after add
     broadcastAutoRunStatus,
     broadcastStopToContentScripts: async () => {},
     cancelPendingCommands: () => {},
+    cleanupAfterAddPhone: async (payload = {}) => {
+      events.cleanupCalls.push(payload);
+    },
     chooseAddPhonePauseMinutes: () => 30,
     clearStopRequest: () => {},
     createAutoRunSessionId: () => {
@@ -334,6 +344,8 @@ test('auto-run controller parks 30~60 minutes and continues next round after add
   assert.equal(events.runCalls, 1, 'add-phone should stop current round immediately without retrying the same round');
   assert.equal(events.accountRecords.length, 1, 'failed round should still be recorded');
   assert.equal(events.accountRecords[0].status, 'failed');
+  assert.equal(events.cleanupCalls.length, 1, 'add-phone cooldown should trigger immediate cleanup');
+  assert.equal(events.cleanupCalls[0].currentRun, 1);
   assert.equal(events.timerPlans.length, 1, 'should schedule a delayed continue timer');
   assert.equal(events.timerPlans[0].plan.kind, 'between_rounds');
   assert.equal(events.timerPlans[0].plan.currentRun, 1);

+ 4 - 0
tests/background-account-history-settings.test.js

@@ -50,6 +50,7 @@ function extractFunction(name) {
 
 test('background account history settings are normalized independently from hotmail service mode', () => {
   const bundle = [
+    extractFunction('normalizeRunCount'),
     extractFunction('normalizeHotmailLocalBaseUrl'),
     extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
     extractFunction('normalizeVerificationResendCount'),
@@ -70,6 +71,7 @@ const AUTO_RUN_ADD_PHONE_PAUSE_MAX_MINUTES = 1440;
 const VERIFICATION_RESEND_COUNT_MIN = 0;
 const VERIFICATION_RESEND_COUNT_MAX = 20;
 const PERSISTED_SETTING_DEFAULTS = {
+  autoRunTotalRuns: 1,
   autoStepDelaySeconds: null,
   mailProvider: '163',
 };
@@ -107,6 +109,8 @@ return {
   assert.equal(api.normalizePersistentSettingValue('accountRunHistoryTextEnabled', 1), true);
   assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '7'), 7);
   assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '-1'), 0);
+  assert.equal(api.normalizePersistentSettingValue('autoRunTotalRuns', '123'), 123);
+  assert.equal(api.normalizePersistentSettingValue('autoRunTotalRuns', ''), 1);
   assert.equal(api.normalizePersistentSettingValue('autoRunAddPhonePauseMinutes', '10'), 10);
   assert.equal(api.normalizePersistentSettingValue('autoRunAddPhonePauseMinutes', '5'), 5);
   assert.equal(

+ 31 - 0
tests/sidepanel-run-count-settings.test.js

@@ -0,0 +1,31 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const sidepanelJs = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
+const sidepanelCss = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
+
+test('collectSettingsPayload persists autoRunTotalRuns from run count input', () => {
+  assert.match(
+    sidepanelJs,
+    /autoRunTotalRuns:\s*getRunCountValue\(\)/
+  );
+});
+
+test('run count input autosaves on input and blur', () => {
+  assert.match(
+    sidepanelJs,
+    /inputRunCount\.addEventListener\('input',[\s\S]*markSettingsDirty\(true\);[\s\S]*scheduleSettingsAutoSave\(\);[\s\S]*\}\);/
+  );
+  assert.match(
+    sidepanelJs,
+    /inputRunCount\.addEventListener\('blur',[\s\S]*saveSettings\(\{\s*silent:\s*true\s*\}\)\.catch\(\(\)\s*=>\s*\{\s*\}\);[\s\S]*\}\);/
+  );
+});
+
+test('run count input is wide enough for larger totals', () => {
+  assert.match(
+    sidepanelCss,
+    /\.run-count-input\s*\{[\s\S]*width:\s*88px;/
+  );
+});

+ 81 - 0
tests/verification-flow-polling.test.js

@@ -155,12 +155,92 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
 
   assert.deepStrictEqual(events, [
     ['beforeSubmit', '654321'],
+    ['state', '654321'],
     ['submit', '654321'],
     ['state', '654321'],
     ['complete', '654321'],
   ]);
 });
 
+test('verification flow excludes the previously submitted code on the next retry', async () => {
+  const pollPayloads = [];
+  let currentState = {
+    email: 'user@example.com',
+    signupVerificationRequestedAt: 0,
+    lastSignupCode: null,
+  };
+  let submitCount = 0;
+
+  const helpers = api.createVerificationFlowHelpers({
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    completeStepFromBackground: async () => {},
+    confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
+    getHotmailVerificationPollConfig: () => ({}),
+    getHotmailVerificationRequestTimestamp: () => 0,
+    getState: async () => currentState,
+    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 (_step, _state, payload) => {
+      pollPayloads.push(payload);
+      return {
+        code: Array.isArray(payload.excludeCodes) && payload.excludeCodes.includes('111111') ? '222222' : '111111',
+        emailTimestamp: Date.now(),
+      };
+    },
+    pollLuckmailVerificationCode: async () => ({}),
+    sendToContentScript: async (_source, message) => {
+      if (message.type === 'FILL_CODE') {
+        submitCount += 1;
+        if (submitCount === 1) {
+          throw new Error('页面通信异常');
+        }
+        return {};
+      }
+      return {};
+    },
+    sendToMailContentScriptResilient: async () => ({}),
+    setState: async (payload) => {
+      currentState = { ...currentState, ...payload };
+    },
+    setStepStatus: async () => {},
+    sleepWithStop: async () => {},
+    throwIfStopped: () => {},
+    VERIFICATION_POLL_MAX_ROUNDS: 5,
+  });
+
+  await assert.rejects(
+    () => helpers.resolveVerificationStep(
+      4,
+      currentState,
+      { provider: 'hotmail-api', label: 'Hotmail' },
+      {}
+    ),
+    /页面通信异常/
+  );
+
+  assert.equal(currentState.lastSignupCode, '111111');
+
+  await helpers.resolveVerificationStep(
+    4,
+    currentState,
+    { provider: 'hotmail-api', label: 'Hotmail' },
+    {}
+  );
+
+  assert.equal(currentState.lastSignupCode, '222222');
+  assert.ok(
+    pollPayloads.some((payload) => Array.isArray(payload.excludeCodes) && payload.excludeCodes.includes('111111')),
+    'next retry should exclude the previously submitted code'
+  );
+});
+
 test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => {
   const events = [];
 
@@ -222,6 +302,7 @@ test('verification flow treats add-phone after login code submit as fatal instea
   );
 
   assert.deepStrictEqual(events, [
+    ['state', '654321'],
     ['submit', '654321'],
   ]);
 });