From b71bccaf8ad4c7477a616afffdc846df218ff27d Mon Sep 17 00:00:00 2001 From: Isulew <224964+netcookies@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:57:51 +0800 Subject: [PATCH 01/21] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=202925=20?= =?UTF-8?q?=E6=94=B6=E4=BB=B6=E6=A8=A1=E5=BC=8F=E8=BD=AE=E8=AF=A2=E4=B8=8E?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - receive 模式下跳过 2925 邮箱页顶部账号校验 - 解析 bounce 转发地址与中文日期,避免误过滤验证码邮件 - 为内容脚本增加重复注入保护,避免重复声明导致轮询失效 --- background/steps/fetch-login-code.js | 4 ++ background/steps/fetch-signup-code.js | 4 ++ content/mail-2925.js | 60 ++++++++++++++++++++++++++- content/signup-page.js | 2 + content/utils.js | 10 ++--- tests/mail-2925-content.test.js | 1 + 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index c25b2f4..41b0e3b 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -69,6 +69,10 @@ } function getExpectedMail2925MailboxEmail(state = {}) { + if (String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive') { + return ''; + } + if (Boolean(state?.mail2925UseAccountPool)) { const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index 88778a3..791d0c2 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -26,6 +26,10 @@ } = deps; function getExpectedMail2925MailboxEmail(state = {}) { + if (String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive') { + return ''; + } + if (Boolean(state?.mail2925UseAccountPool)) { const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; diff --git a/content/mail-2925.js b/content/mail-2925.js index 1e698c4..4b723e2 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -1,6 +1,13 @@ // content/mail-2925.js - Content script for 2925 Mail (steps 4, 8) // Injected dynamically on: 2925.com +(function initMail2925ContentScript() { +if (window.__MULTIPAGE_MAIL2925_SCRIPT_LOADED__) { + console.log('[MultiPage:mail-2925] Duplicate injection skipped on', location.href); + return; +} +window.__MULTIPAGE_MAIL2925_SCRIPT_LOADED__ = true; + const MAIL2925_PREFIX = '[MultiPage:mail-2925]'; const isTopFrame = window === window.top; @@ -699,10 +706,29 @@ function extractEmails(text = '') { return [...new Set(matches.map((item) => item.toLowerCase()))]; } +function extractForwardedTargetEmails(text = '') { + const normalizedText = String(text || '').toLowerCase(); + const matches = normalizedText.match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@(?:tm\d*\.openai\.com|em\d+\.tm\.openai\.com)/gi) || []; + const decoded = matches + .map((candidate) => { + const match = String(candidate || '').match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@/i); + if (!match) { + return ''; + } + return `${match[1].toLowerCase()}@${match[2].toLowerCase()}`; + }) + .filter(Boolean); + return [...new Set(decoded)]; +} + function emailMatchesTarget(candidate, targetEmail) { const normalizedCandidate = String(candidate || '').trim().toLowerCase(); const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); - return Boolean(normalizedCandidate && normalizedTarget && normalizedCandidate === normalizedTarget); + if (!normalizedCandidate || !normalizedTarget) { + return false; + } + + return normalizedCandidate === normalizedTarget; } function getTargetEmailMatchState(text, targetEmail) { @@ -717,12 +743,30 @@ function getTargetEmailMatchState(text, targetEmail) { } const extractedEmails = extractEmails(normalizedText); + const forwardedTargetEmails = extractForwardedTargetEmails(normalizedText); if (!extractedEmails.length) { + return forwardedTargetEmails.length + ? { + matches: forwardedTargetEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)), + hasExplicitEmail: true, + } + : { matches: true, hasExplicitEmail: false }; + } + + const targetDomain = normalizedTarget.includes('@') + ? normalizedTarget.split('@').pop() + : ''; + const comparableEmails = [...new Set( + (targetDomain + ? [...extractedEmails, ...forwardedTargetEmails].filter((candidate) => String(candidate || '').trim().toLowerCase().endsWith(`@${targetDomain}`)) + : [...extractedEmails, ...forwardedTargetEmails]) + )]; + if (!comparableEmails.length) { return { matches: true, hasExplicitEmail: false }; } return { - matches: extractedEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)), + matches: comparableEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)), hasExplicitEmail: true, }; } @@ -782,6 +826,17 @@ function parseMailItemTimestamp(item) { return date.getTime(); } + match = timeText.match(/(\d{1,2})月(\d{1,2})日(?:\s*(\d{1,2}):(\d{2}))?/); + if (match) { + date.setMonth(Number(match[1]) - 1, Number(match[2])); + if (match[3] && match[4]) { + date.setHours(Number(match[3]), Number(match[4]), 0, 0); + } else { + date.setHours(0, 0, 0, 0); + } + return date.getTime(); + } + match = timeText.match(/(\d{4})-(\d{1,2})-(\d{1,2})\s*(\d{1,2}):(\d{2})/); if (match) { return new Date( @@ -1168,3 +1223,4 @@ async function handlePollEmail(step, payload) { } } +})(); diff --git a/content/signup-page.js b/content/signup-page.js index f36207b..c742b51 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -2,6 +2,7 @@ // Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com // Dynamically injected on: chatgpt.com +(function initSignupPageContentScript() { console.log('[MultiPage:signup-page] Content script loaded on', location.href); const SIGNUP_PAGE_LISTENER_SENTINEL = 'data-multipage-signup-page-listener'; @@ -3560,3 +3561,4 @@ async function step5_fillNameBirthday(payload) { log('步骤 5:已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。'); return completionPayload; } +})(); diff --git a/content/utils.js b/content/utils.js index f208aca..71e711e 100644 --- a/content/utils.js +++ b/content/utils.js @@ -1,6 +1,6 @@ // content/utils.js — Shared utilities for all content scripts -const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy; +var getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy; function detectScriptSource({ injectedSource, @@ -26,7 +26,7 @@ function detectScriptSource({ return 'vps-panel'; } -const SCRIPT_SOURCE = (() => { +var SCRIPT_SOURCE = (() => { return detectScriptSource({ injectedSource: window.__MULTIPAGE_SOURCE, url: location.href, @@ -38,9 +38,9 @@ function getRuntimeScriptSource() { return window.__MULTIPAGE_SOURCE || SCRIPT_SOURCE; } -const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`; -const STOP_ERROR_MESSAGE = '流程已被用户停止。'; -let flowStopped = false; +var LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`; +var STOP_ERROR_MESSAGE = '流程已被用户停止。'; +var flowStopped = false; if (!window.__MULTIPAGE_UTILS_LISTENER_READY__) { window.__MULTIPAGE_UTILS_LISTENER_READY__ = true; diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index 29f5306..c824ee0 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -319,6 +319,7 @@ return { test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => { const bundle = [ extractFunction('extractEmails'), + extractFunction('extractForwardedTargetEmails'), extractFunction('emailMatchesTarget'), extractFunction('getTargetEmailMatchState'), extractFunction('normalizeMinuteTimestamp'), From 941102200fe2e96ec05102e465294bd3b2457e0a Mon Sep 17 00:00:00 2001 From: Isulew <224964+netcookies@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:15:06 +0800 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20=E6=94=BE=E5=BC=80=202925=20?= =?UTF-8?q?=E5=85=A8=E6=A8=A1=E5=BC=8F=E7=9B=AE=E6=A0=87=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 让 2925 提供邮箱与接收邮箱模式都启用 targetEmail 弱匹配 - 同步更新轮询测试,验证 provide 与 receive 模式行为一致 --- background/verification-flow.js | 3 +-- tests/verification-flow-polling.test.js | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/background/verification-flow.js b/background/verification-flow.js index 229bed7..84bdfb5 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -214,8 +214,7 @@ function getVerificationPollPayload(step, state, overrides = {}) { const is2925Provider = state?.mailProvider === '2925'; - const mail2925MatchTargetEmail = is2925Provider - && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; + const mail2925MatchTargetEmail = is2925Provider; if (step === 4) { return { filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(4, state), diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 426b828..d241d93 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -43,7 +43,7 @@ test('verification flow keeps 2925 polling cadence in the default payload', () = assert.equal(step8Payload.intervalMs, 15000); }); -test('verification flow only enables 2925 target email matching in receive mode', () => { +test('verification flow enables 2925 target email matching in both provide and receive modes', () => { const helpers = api.createVerificationFlowHelpers({ addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, @@ -82,7 +82,7 @@ test('verification flow only enables 2925 target email matching in receive mode' mail2925Mode: 'receive', }); - assert.equal(providePayload.mail2925MatchTargetEmail, false); + assert.equal(providePayload.mail2925MatchTargetEmail, true); assert.equal(receivePayload.mail2925MatchTargetEmail, true); }); From cf2a720531732c01fe4a38bbc15db4f75a73a90e Mon Sep 17 00:00:00 2001 From: Isulew <224964+netcookies@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:50:29 +0800 Subject: [PATCH 03/21] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=202925=20?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E4=BF=AE=E5=A4=8D=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回退非必要的 2925 行为改动,仅保留已验证有效的修复 - 保留 bounce 转发地址解析、中文日期解析与邮箱页复用时的 ready 恢复 - 同步最小化测试断言,保持当前代码与测试一致 --- background/mail-2925-session.js | 20 +++++++++---------- background/steps/fetch-login-code.js | 4 ---- background/steps/fetch-signup-code.js | 4 ---- background/verification-flow.js | 3 ++- content/mail-2925.js | 8 -------- content/signup-page.js | 2 -- content/utils.js | 10 +++++----- ...background-mail2925-entry-behavior.test.js | 2 +- tests/verification-flow-polling.test.js | 4 ++-- 9 files changed, 20 insertions(+), 37 deletions(-) diff --git a/background/mail-2925-session.js b/background/mail-2925-session.js index 502362a..e80dc4c 100644 --- a/background/mail-2925-session.js +++ b/background/mail-2925-session.js @@ -537,6 +537,16 @@ } } + if (typeof ensureContentScriptReadyOnTab === 'function') { + await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, { + inject: MAIL2925_INJECT, + injectSource: MAIL2925_INJECT_SOURCE, + timeoutMs: 20000, + retryDelayMs: 800, + logMessage: '步骤 0:2925 登录页内容脚本未就绪,正在等待页面稳定后继续登录...', + }); + } + if (!forceRelogin && !isMail2925LoginUrl(openedUrl) && !normalizedExpectedMailboxEmail) { await addLog('2925:当前邮箱页未跳转到登录页,将直接复用已登录会话。', 'info'); return buildSuccessPayload(); @@ -553,16 +563,6 @@ }); } - if (typeof ensureContentScriptReadyOnTab === 'function') { - await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, { - inject: MAIL2925_INJECT, - injectSource: MAIL2925_INJECT_SOURCE, - timeoutMs: 20000, - retryDelayMs: 800, - logMessage: '步骤 0:2925 登录页内容脚本未就绪,正在等待页面稳定后继续登录...', - }); - } - if (forceRelogin && typeof sleepWithStop === 'function') { await addLog('2925:登录页已打开,等待 3 秒后开始检查输入框并执行登录...', 'info'); await sleepWithStop(3000); diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index 41b0e3b..c25b2f4 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -69,10 +69,6 @@ } function getExpectedMail2925MailboxEmail(state = {}) { - if (String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive') { - return ''; - } - if (Boolean(state?.mail2925UseAccountPool)) { const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js index 791d0c2..88778a3 100644 --- a/background/steps/fetch-signup-code.js +++ b/background/steps/fetch-signup-code.js @@ -26,10 +26,6 @@ } = deps; function getExpectedMail2925MailboxEmail(state = {}) { - if (String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive') { - return ''; - } - if (Boolean(state?.mail2925UseAccountPool)) { const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; diff --git a/background/verification-flow.js b/background/verification-flow.js index 84bdfb5..229bed7 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -214,7 +214,8 @@ function getVerificationPollPayload(step, state, overrides = {}) { const is2925Provider = state?.mailProvider === '2925'; - const mail2925MatchTargetEmail = is2925Provider; + const mail2925MatchTargetEmail = is2925Provider + && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; if (step === 4) { return { filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(4, state), diff --git a/content/mail-2925.js b/content/mail-2925.js index 4b723e2..d2ba01a 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -1,13 +1,6 @@ // content/mail-2925.js - Content script for 2925 Mail (steps 4, 8) // Injected dynamically on: 2925.com -(function initMail2925ContentScript() { -if (window.__MULTIPAGE_MAIL2925_SCRIPT_LOADED__) { - console.log('[MultiPage:mail-2925] Duplicate injection skipped on', location.href); - return; -} -window.__MULTIPAGE_MAIL2925_SCRIPT_LOADED__ = true; - const MAIL2925_PREFIX = '[MultiPage:mail-2925]'; const isTopFrame = window === window.top; @@ -1223,4 +1216,3 @@ async function handlePollEmail(step, payload) { } } -})(); diff --git a/content/signup-page.js b/content/signup-page.js index c742b51..f36207b 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -2,7 +2,6 @@ // Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com // Dynamically injected on: chatgpt.com -(function initSignupPageContentScript() { console.log('[MultiPage:signup-page] Content script loaded on', location.href); const SIGNUP_PAGE_LISTENER_SENTINEL = 'data-multipage-signup-page-listener'; @@ -3561,4 +3560,3 @@ async function step5_fillNameBirthday(payload) { log('步骤 5:已点击“完成帐户创建”,当前步骤直接完成,不再等待页面结果。'); return completionPayload; } -})(); diff --git a/content/utils.js b/content/utils.js index 71e711e..f208aca 100644 --- a/content/utils.js +++ b/content/utils.js @@ -1,6 +1,6 @@ // content/utils.js — Shared utilities for all content scripts -var getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy; +const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy; function detectScriptSource({ injectedSource, @@ -26,7 +26,7 @@ function detectScriptSource({ return 'vps-panel'; } -var SCRIPT_SOURCE = (() => { +const SCRIPT_SOURCE = (() => { return detectScriptSource({ injectedSource: window.__MULTIPAGE_SOURCE, url: location.href, @@ -38,9 +38,9 @@ function getRuntimeScriptSource() { return window.__MULTIPAGE_SOURCE || SCRIPT_SOURCE; } -var LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`; -var STOP_ERROR_MESSAGE = '流程已被用户停止。'; -var flowStopped = false; +const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +let flowStopped = false; if (!window.__MULTIPAGE_UTILS_LISTENER_READY__) { window.__MULTIPAGE_UTILS_LISTENER_READY__ = true; diff --git a/tests/background-mail2925-entry-behavior.test.js b/tests/background-mail2925-entry-behavior.test.js index fe26b5c..8aeed04 100644 --- a/tests/background-mail2925-entry-behavior.test.js +++ b/tests/background-mail2925-entry-behavior.test.js @@ -68,7 +68,7 @@ test('ensureMail2925MailboxSession reuses current mailbox page without sending l }); assert.equal(sendCalls, 0); - assert.equal(readyCalls, 0); + assert.equal(readyCalls, 1); assert.equal(result.result.usedExistingSession, true); }); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index d241d93..426b828 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -43,7 +43,7 @@ test('verification flow keeps 2925 polling cadence in the default payload', () = assert.equal(step8Payload.intervalMs, 15000); }); -test('verification flow enables 2925 target email matching in both provide and receive modes', () => { +test('verification flow only enables 2925 target email matching in receive mode', () => { const helpers = api.createVerificationFlowHelpers({ addLog: async () => {}, chrome: { tabs: { update: async () => {} } }, @@ -82,7 +82,7 @@ test('verification flow enables 2925 target email matching in both provide and r mail2925Mode: 'receive', }); - assert.equal(providePayload.mail2925MatchTargetEmail, true); + assert.equal(providePayload.mail2925MatchTargetEmail, false); assert.equal(receivePayload.mail2925MatchTargetEmail, true); }); From 0ac0e65e6cc93ebe12ff0b9003019eb590defb76 Mon Sep 17 00:00:00 2001 From: EmptyDust <1422492074@qq.com> Date: Tue, 28 Apr 2026 10:30:56 +0800 Subject: [PATCH 04/21] feat: verify hotmail mailbox before auto-run attempts --- background.js | 163 +++++++++++- background/auto-run-controller.js | 10 + tests/auto-run-hotmail-preflight.test.js | 164 ++++++++++++ tests/hotmail-auto-run-preflight.test.js | 314 +++++++++++++++++++++++ 4 files changed, 641 insertions(+), 10 deletions(-) create mode 100644 tests/auto-run-hotmail-preflight.test.js create mode 100644 tests/hotmail-auto-run-preflight.test.js diff --git a/background.js b/background.js index e62b730..8e9e345 100644 --- a/background.js +++ b/background.js @@ -2121,30 +2121,78 @@ async function setCurrentHotmailAccount(accountId, options = {}) { return account; } -async function ensureHotmailAccountForFlow(options = {}) { - const { allowAllocate = true, markUsed = false, preferredAccountId = null } = options; - const state = await getState(); - const accounts = normalizeHotmailAccounts(state.hotmailAccounts); - const isAccountAllocatable = (candidate) => Boolean(candidate) +function isAuthorizedHotmailRunAccount(candidate) { + return Boolean(candidate) && candidate.status === 'authorized' && !candidate.used && Boolean(candidate.refreshToken); +} + +function isPendingHotmailVerificationCandidate(candidate) { + return Boolean(candidate) + && candidate.status === 'pending' + && !candidate.used + && Boolean(candidate.refreshToken); +} + +function compareHotmailAccountAllocationPriority(left, right) { + const leftUsedAt = Number(left?.lastUsedAt) || 0; + const rightUsedAt = Number(right?.lastUsedAt) || 0; + if (leftUsedAt !== rightUsedAt) { + return leftUsedAt - rightUsedAt; + } + + return String(left?.email || '').localeCompare(String(right?.email || '')); +} + +function pickPendingHotmailAccountForVerification(accounts, options = {}) { + const excludeIds = new Set((options.excludeIds || []).filter(Boolean)); + const candidates = normalizeHotmailAccounts(accounts) + .filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !excludeIds.has(candidate.id)); + if (!candidates.length) { + return null; + } + + const preferredAccountId = String(options.preferredAccountId || '').trim(); + if (preferredAccountId) { + const preferredCandidate = candidates.find((candidate) => candidate.id === preferredAccountId); + if (preferredCandidate) { + return preferredCandidate; + } + } + + return candidates + .slice() + .sort(compareHotmailAccountAllocationPriority)[0] || null; +} + +async function ensureHotmailAccountForFlow(options = {}) { + const { + allowAllocate = true, + markUsed = false, + preferredAccountId = null, + excludeIds = [], + } = options; + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const excludedAccountIds = new Set((excludeIds || []).filter(Boolean)); + const availableAccounts = accounts.filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !excludedAccountIds.has(candidate.id)); let account = null; - if (preferredAccountId) { + if (preferredAccountId && !excludedAccountIds.has(preferredAccountId)) { account = findHotmailAccount(accounts, preferredAccountId); } - if (!account && state.currentHotmailAccountId) { + if ((!account || !isAuthorizedHotmailRunAccount(account)) && state.currentHotmailAccountId && !excludedAccountIds.has(state.currentHotmailAccountId)) { account = findHotmailAccount(accounts, state.currentHotmailAccountId); } - if ((!account || !isAccountAllocatable(account)) && allowAllocate) { - account = pickHotmailAccountForRun(accounts, {}); + if ((!account || !isAuthorizedHotmailRunAccount(account)) && allowAllocate) { + account = availableAccounts.length ? pickHotmailAccountForRun(availableAccounts, {}) : null; } if (!account) { throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); } - if (!isAccountAllocatable(account)) { + if (!isAuthorizedHotmailRunAccount(account)) { throw new Error(`Hotmail 账号 ${account.email || account.id} 尚未就绪,无法读取邮件。`); } @@ -2482,6 +2530,100 @@ async function verifyHotmailAccount(accountId) { }; } +async function ensureHotmailMailboxReadyForAutoRunRound(options = {}) { + const { + targetRun = 0, + totalRuns = 0, + attemptRun = 1, + } = options; + const state = await getState(); + if (!isHotmailProvider(state)) { + return null; + } + + const buildRoundLabel = () => { + if (targetRun > 0 && totalRuns > 0) { + return `第 ${targetRun}/${totalRuns} 轮`; + } + return '当前轮'; + }; + const exhaustedAccountIds = new Set(); + let preferredAccountId = state.currentHotmailAccountId || null; + let lastError = null; + + while (true) { + throwIfStopped(); + const latestState = await getState(); + const latestAccounts = normalizeHotmailAccounts(latestState.hotmailAccounts); + const remainingAuthorizedAccounts = latestAccounts + .filter((candidate) => isAuthorizedHotmailRunAccount(candidate) && !exhaustedAccountIds.has(candidate.id)); + const remainingPendingAccounts = latestAccounts + .filter((candidate) => isPendingHotmailVerificationCandidate(candidate) && !exhaustedAccountIds.has(candidate.id)); + if (!remainingAuthorizedAccounts.length && !remainingPendingAccounts.length) { + if (lastError) { + throw new Error(`自动运行${buildRoundLabel()}开始前未找到可通过校验的 Hotmail 账号:${lastError.message}`); + } + throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); + } + + let account = null; + if (remainingAuthorizedAccounts.length) { + account = await ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: false, + preferredAccountId, + excludeIds: [...exhaustedAccountIds], + }); + } else { + const pendingAccount = pickPendingHotmailAccountForVerification(latestAccounts, { + preferredAccountId, + excludeIds: [...exhaustedAccountIds], + }); + if (!pendingAccount) { + throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带刷新令牌(refresh token)的账号。'); + } + account = await setCurrentHotmailAccount(pendingAccount.id, { + markUsed: false, + syncEmail: true, + }); + await addLog( + `自动运行${buildRoundLabel()}开始前未找到已校验 Hotmail 账号,正在尝试校验待校验账号 ${account.email}。`, + 'warn' + ); + } + + try { + await addLog( + `自动运行${buildRoundLabel()}第 ${attemptRun} 次尝试开始前,正在校验 Hotmail 账号 ${account.email} 的邮箱可用性。`, + 'info' + ); + const result = await verifyHotmailAccount(account.id); + await addLog( + `自动运行${buildRoundLabel()}开始前已校验 Hotmail 账号 ${result.account?.email || account.email},INBOX 当前 ${result.messageCount} 封邮件。`, + 'ok' + ); + return result.account; + } catch (error) { + lastError = error; + exhaustedAccountIds.add(account.id); + preferredAccountId = null; + const latestErrorMessage = error?.message || '未知错误'; + await addLog( + `自动运行${buildRoundLabel()}开始前校验 Hotmail 账号 ${account.email} 失败:${latestErrorMessage}`, + 'warn' + ); + const nextState = await getState(); + const hasRemainingAccounts = normalizeHotmailAccounts(nextState.hotmailAccounts) + .some((candidate) => ( + isAuthorizedHotmailRunAccount(candidate) || isPendingHotmailVerificationCandidate(candidate) + ) && !exhaustedAccountIds.has(candidate.id)); + if (hasRemainingAccounts) { + await addLog(`自动运行${buildRoundLabel()}开始前将切换下一个 Hotmail 账号并重试。`, 'warn'); + } + } + } +} + async function testHotmailAccountMailAccess(accountId) { const state = await getState(); const account = findHotmailAccount(state.hotmailAccounts, accountId); @@ -7638,6 +7780,7 @@ const autoRunController = self.MultiPageBackgroundAutoRunController?.createAutoR cancelPendingCommands, clearStopRequest: () => clearStopRequest(), createAutoRunSessionId: () => createAutoRunSessionId(), + ensureHotmailMailboxReadyForAutoRunRound: (...args) => ensureHotmailMailboxReadyForAutoRunRound(...args), getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 37705a9..e1a6d92 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -14,6 +14,7 @@ cancelPendingCommands, clearStopRequest, createAutoRunSessionId, + ensureHotmailMailboxReadyForAutoRunRound, getAutoRunStatusPayload, getErrorMessage, getFirstUnfinishedStep, @@ -439,6 +440,15 @@ sessionId, }); + if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') { + await ensureHotmailMailboxReadyForAutoRunRound({ + targetRun, + totalRuns, + attemptRun, + sessionId, + }); + } + await runAutoSequenceFromStep(startStep, { targetRun, totalRuns, diff --git a/tests/auto-run-hotmail-preflight.test.js b/tests/auto-run-hotmail-preflight.test.js new file mode 100644 index 0000000..c62fa2c --- /dev/null +++ b/tests/auto-run-hotmail-preflight.test.js @@ -0,0 +1,164 @@ +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 verifies hotmail mailbox before each fresh attempt starts', async () => { + const events = { + order: [], + preflightCalls: [], + runCalls: 0, + }; + + let currentState = { + stepStatuses: {}, + vpsUrl: 'https://example.com/vps', + vpsPassword: 'secret', + customPassword: '', + autoRunSkipFailures: false, + autoRunFallbackThreadIntervalMinutes: 0, + autoRunDelayEnabled: false, + autoRunDelayMinutes: 30, + autoStepDelaySeconds: null, + mailProvider: 'hotmail-api', + 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 () => {}, + appendAccountRunRecord: async () => null, + 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 = {}) => { + 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; + }, + ensureHotmailMailboxReadyForAutoRunRound: async (payload = {}) => { + events.order.push('preflight'); + events.preflightCalls.push({ ...payload }); + }, + 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: () => false, + isRestartCurrentAttemptError: () => false, + isSignupUserAlreadyExistsFailure: () => 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.order.push('run'); + events.runCalls += 1; + }, + 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: false, + mode: 'restart', + }); + + assert.equal(events.runCalls, 1); + assert.equal(events.preflightCalls.length, 1); + assert.deepEqual(events.order, ['preflight', 'run']); + assert.match( + JSON.stringify(events.preflightCalls[0]), + /"targetRun":1/ + ); +}); diff --git a/tests/hotmail-auto-run-preflight.test.js b/tests/hotmail-auto-run-preflight.test.js new file mode 100644 index 0000000..ce7a585 --- /dev/null +++ b/tests/hotmail-auto-run-preflight.test.js @@ -0,0 +1,314 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const { pickHotmailAccountForRun } = require('../hotmail-utils.js'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + return ''; + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let index = start; index < source.length; index += 1) { + const ch = source[index]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = index; + break; + } + } + + if (braceStart < 0) { + return ''; + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const isAuthorizedHotmailRunAccountSource = extractFunction('isAuthorizedHotmailRunAccount'); +const isPendingHotmailVerificationCandidateSource = extractFunction('isPendingHotmailVerificationCandidate'); +const compareHotmailAccountAllocationPrioritySource = extractFunction('compareHotmailAccountAllocationPriority'); +const pickPendingHotmailAccountForVerificationSource = extractFunction('pickPendingHotmailAccountForVerification'); +const ensureHotmailAccountForFlowSource = extractFunction('ensureHotmailAccountForFlow'); +const ensureHotmailMailboxReadyForAutoRunRoundSource = extractFunction('ensureHotmailMailboxReadyForAutoRunRound'); + +function createHotmailPreflightApi(initialState, verifyImpl = async () => ({ account: null, messageCount: 0 })) { + const factory = new Function('deps', ` +let currentState = JSON.parse(JSON.stringify(deps.initialState)); +const getState = async () => ({ + ...currentState, + hotmailAccounts: Array.isArray(currentState.hotmailAccounts) + ? currentState.hotmailAccounts.map((account) => ({ ...account })) + : [], +}); +const normalizeHotmailAccounts = (accounts) => Array.isArray(accounts) + ? accounts.map((account) => ({ ...account })) + : []; +const findHotmailAccount = (accounts, accountId) => normalizeHotmailAccounts(accounts) + .find((account) => account.id === accountId) || null; +const setCurrentHotmailAccount = async (accountId) => { + const state = await getState(); + const account = findHotmailAccount(state.hotmailAccounts, accountId); + if (!account) { + throw new Error('missing Hotmail account'); + } + currentState = { + ...currentState, + currentHotmailAccountId: accountId, + }; + return account; +}; +const pickHotmailAccountForRun = deps.pickHotmailAccountForRun; +const verifyHotmailAccount = async (accountId) => deps.verifyHotmailAccount(accountId, async () => getState()); +const isHotmailProvider = (stateOrProvider) => { + const provider = typeof stateOrProvider === 'string' + ? stateOrProvider + : stateOrProvider?.mailProvider; + return provider === 'hotmail-api'; +}; +const addLog = async (message, level = 'info') => { + deps.logs.push({ message, level }); +}; +const throwIfStopped = () => {}; +${isAuthorizedHotmailRunAccountSource} +${isPendingHotmailVerificationCandidateSource} +${compareHotmailAccountAllocationPrioritySource} +${pickPendingHotmailAccountForVerificationSource} +${ensureHotmailAccountForFlowSource} +${ensureHotmailMailboxReadyForAutoRunRoundSource} +return { + ensureHotmailAccountForFlow, + ensureHotmailMailboxReadyForAutoRunRound: typeof ensureHotmailMailboxReadyForAutoRunRound === 'function' + ? ensureHotmailMailboxReadyForAutoRunRound + : undefined, + getState, +}; + `); + + const logs = []; + return { + api: factory({ + initialState, + logs, + pickHotmailAccountForRun, + verifyHotmailAccount: verifyImpl, + }), + logs, + }; +} + +test('ensureHotmailAccountForFlow skips excluded current hotmail account when allocating a fresh account', async () => { + const { api } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: 'primary', + hotmailAccounts: [ + { + id: 'primary', + email: 'primary@hotmail.com', + status: 'authorized', + refreshToken: 'rt-primary', + used: false, + lastUsedAt: 1, + }, + { + id: 'backup', + email: 'backup@hotmail.com', + status: 'authorized', + refreshToken: 'rt-backup', + used: false, + lastUsedAt: 2, + }, + ], + }); + + const account = await api.ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: false, + excludeIds: ['primary'], + }); + + assert.equal(account.id, 'backup'); +}); + +test('ensureHotmailMailboxReadyForAutoRunRound switches to another hotmail account after a verification failure', async () => { + const verifyCalls = []; + const { api, logs } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: 'primary', + hotmailAccounts: [ + { + id: 'primary', + email: 'primary@hotmail.com', + status: 'authorized', + refreshToken: 'rt-primary', + used: false, + lastUsedAt: 1, + }, + { + id: 'backup', + email: 'backup@hotmail.com', + status: 'authorized', + refreshToken: 'rt-backup', + used: false, + lastUsedAt: 2, + }, + ], + }, async (accountId, getState) => { + verifyCalls.push(accountId); + const state = await getState(); + const account = state.hotmailAccounts.find((item) => item.id === accountId); + if (accountId === 'primary') { + throw new Error('INBOX unavailable'); + } + return { + account, + messageCount: 4, + }; + }); + + assert.equal(typeof api.ensureHotmailMailboxReadyForAutoRunRound, 'function'); + const account = await Promise.race([ + api.ensureHotmailMailboxReadyForAutoRunRound({ + targetRun: 1, + totalRuns: 3, + attemptRun: 1, + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Hotmail auto-run preflight timed out')), 200); + }), + ]); + + const state = await api.getState(); + + assert.equal(account.id, 'backup'); + assert.equal(state.currentHotmailAccountId, 'backup'); + assert.deepEqual(verifyCalls, ['primary', 'backup']); + assert.ok(logs.some(({ message }) => /切换下一个 Hotmail 账号/.test(message))); +}); + +test('ensureHotmailMailboxReadyForAutoRunRound verifies pending hotmail accounts when no authorized account exists yet', async () => { + const verifyCalls = []; + const { api } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: null, + hotmailAccounts: [ + { + id: 'pending-1', + email: 'pending-1@hotmail.com', + status: 'pending', + refreshToken: 'rt-pending-1', + used: false, + lastUsedAt: 0, + }, + ], + }, async (accountId, getState) => { + verifyCalls.push(accountId); + const state = await getState(); + const account = state.hotmailAccounts.find((item) => item.id === accountId); + return { + account: { + ...account, + status: 'authorized', + }, + messageCount: 2, + }; + }); + + const account = await api.ensureHotmailMailboxReadyForAutoRunRound({ + targetRun: 1, + totalRuns: 1, + attemptRun: 1, + }); + const state = await api.getState(); + + assert.equal(account.id, 'pending-1'); + assert.equal(state.currentHotmailAccountId, 'pending-1'); + assert.deepEqual(verifyCalls, ['pending-1']); +}); + +test('ensureHotmailMailboxReadyForAutoRunRound falls back to pending hotmail accounts after authorized accounts fail', async () => { + const verifyCalls = []; + const { api, logs } = createHotmailPreflightApi({ + mailProvider: 'hotmail-api', + currentHotmailAccountId: 'authorized-primary', + hotmailAccounts: [ + { + id: 'authorized-primary', + email: 'authorized-primary@hotmail.com', + status: 'authorized', + refreshToken: 'rt-authorized-primary', + used: false, + lastUsedAt: 1, + }, + { + id: 'pending-backup', + email: 'pending-backup@hotmail.com', + status: 'pending', + refreshToken: 'rt-pending-backup', + used: false, + lastUsedAt: 2, + }, + ], + }, async (accountId, getState) => { + verifyCalls.push(accountId); + const state = await getState(); + const account = state.hotmailAccounts.find((item) => item.id === accountId); + if (accountId === 'authorized-primary') { + throw new Error('INBOX unavailable'); + } + return { + account: { + ...account, + status: 'authorized', + }, + messageCount: 3, + }; + }); + + const account = await Promise.race([ + api.ensureHotmailMailboxReadyForAutoRunRound({ + targetRun: 1, + totalRuns: 2, + attemptRun: 1, + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Hotmail auto-run pending fallback timed out')), 200); + }), + ]); + + const state = await api.getState(); + + assert.equal(account.id, 'pending-backup'); + assert.equal(state.currentHotmailAccountId, 'pending-backup'); + assert.deepEqual(verifyCalls, ['authorized-primary', 'pending-backup']); + assert.ok(logs.some(({ message }) => /待校验|未校验/.test(message))); +}); From b429ff187051fd4f2de61257886bf0a3f80ed2d3 Mon Sep 17 00:00:00 2001 From: EmptyDust <1422492074@qq.com> Date: Tue, 28 Apr 2026 11:32:20 +0800 Subject: [PATCH 05/21] fix: encode hotmail helper message URLs --- scripts/hotmail_helper.py | 71 +++++++++++++++++++++------ tests/hotmail_helper_logging_test.py | 73 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 tests/hotmail_helper_logging_test.py diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py index 0a21d10..806ecad 100644 --- a/scripts/hotmail_helper.py +++ b/scripts/hotmail_helper.py @@ -76,8 +76,11 @@ def json_response(handler, status, payload): handler.send_header("Access-Control-Allow-Origin", "*") handler.send_header("Access-Control-Allow-Headers", "Content-Type") handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") - handler.end_headers() - handler.wfile.write(body) + try: + handler.end_headers() + handler.wfile.write(body) + except (BrokenPipeError, ConnectionResetError) as exc: + log_info(f"response aborted by client status={status} detail={compact_text(exc)}") def read_json_payload(handler): @@ -120,6 +123,45 @@ def log_info(message): print(f"[HotmailHelper] {message}", flush=True) +def get_proxy_debug_context(): + names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"] + parts = [] + for name in names: + value = str(os.environ.get(name) or "").strip() + if value: + parts.append(f"{name}={value}") + return ",".join(parts) if parts else "direct" + + +def classify_token_refresh_failure(result): + detail = str(result.get("error") or "").strip().lower() + if "invalid_grant" in detail or "aadsts70000" in detail: + return "invalid_grant" + if "proxy authentication required" in detail: + return "proxy_auth_failed" + if "connection refused" in detail: + return "proxy_connect_failed" if get_proxy_debug_context() != "direct" else "connection_refused" + if "eof occurred in violation of protocol" in detail or "wrong version number" in detail: + return "proxy_tls_failed" if get_proxy_debug_context() != "direct" else "tls_failed" + if "timed out" in detail or "timeout" in detail: + return "network_timeout" + return "request_failed" + + +def log_token_refresh_failure_diagnosis(result): + category = classify_token_refresh_failure(result) + message = ( + "token refresh diagnosis " + f"endpoint={result['endpoint']} " + f"category={category}" + ) + if category.startswith("proxy_"): + message += f" proxy={get_proxy_debug_context()}" + elif category == "invalid_grant": + message += " hint=refresh_token_or_scope_invalid" + log_info(message) + + def append_account_log(email_addr, password, status, recorded_at="", reason=""): normalized_email = str(email_addr or "").strip() normalized_password = str(password or "").strip() @@ -320,6 +362,7 @@ def refresh_access_token(client_id, refresh_token, strategy_names=None): f"elapsedMs={result['elapsed_ms']} " f"detail={result['error']}" ) + log_token_refresh_failure_diagnosis(result) details = " | ".join( f"{item['endpoint']}({item['status']}): {item['error']}" @@ -532,12 +575,12 @@ def normalize_outlook_message(message, mailbox): def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT): mailbox_id = normalize_mailbox_id(mailbox) - url = ( - f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages" - f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}" - f"&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime" - f"&$orderby=receivedDateTime desc" - ) + query = urlencode({ + "$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)), + "$select": "id,internetMessageId,subject,from,bodyPreview,receivedDateTime", + "$orderby": "receivedDateTime desc", + }) + url = f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages?{query}" try: _, payload = get_json(url, headers={ "Accept": "application/json", @@ -555,12 +598,12 @@ def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT) def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT): mailbox_id = normalize_mailbox_id(mailbox) - url = ( - f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages" - f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}" - f"&$select=Id,Subject,From,BodyPreview,ReceivedDateTime" - f"&$orderby=ReceivedDateTime desc" - ) + query = urlencode({ + "$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)), + "$select": "Id,Subject,From,BodyPreview,ReceivedDateTime", + "$orderby": "ReceivedDateTime desc", + }) + url = f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages?{query}" try: _, payload = get_json(url, headers={ "Accept": "application/json", diff --git a/tests/hotmail_helper_logging_test.py b/tests/hotmail_helper_logging_test.py new file mode 100644 index 0000000..dade9e4 --- /dev/null +++ b/tests/hotmail_helper_logging_test.py @@ -0,0 +1,73 @@ +import importlib.util +import io +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + + +def load_hotmail_helper(): + module_path = Path(__file__).resolve().parents[1] / "scripts" / "hotmail_helper.py" + spec = importlib.util.spec_from_file_location("hotmail_helper", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +hotmail_helper = load_hotmail_helper() + + +class HotmailHelperLoggingTest(unittest.TestCase): + def test_refresh_access_token_logs_invalid_grant_and_direct_connection_refused_separately(self): + failures = [ + { + "ok": False, + "endpoint": "entra-common-delegated", + "url": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "status": 400, + "error": '{"error":"invalid_grant","error_description":"AADSTS70000"}', + "elapsed_ms": 101, + }, + { + "ok": False, + "endpoint": "entra-consumers-delegated", + "url": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", + "status": None, + "error": "Token request failed: ", + "elapsed_ms": 88, + }, + ] + + with mock.patch.object(hotmail_helper, "try_refresh_access_token", side_effect=failures): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(RuntimeError): + hotmail_helper.refresh_access_token( + "client-id-demo", + "refresh-token-demo", + ["entra-common-delegated", "entra-consumers-delegated"], + ) + + rendered = output.getvalue() + self.assertIn("category=invalid_grant", rendered) + self.assertIn("category=connection_refused", rendered) + + def test_graph_and_outlook_message_urls_are_encoded(self): + captured_urls = [] + + def fake_get_json(url, headers=None): + captured_urls.append(url) + return 200, {"value": []} + + with mock.patch.object(hotmail_helper, "get_json", side_effect=fake_get_json): + hotmail_helper.fetch_graph_messages("access-token-demo", mailbox="INBOX", top=5) + hotmail_helper.fetch_outlook_api_messages("access-token-demo", mailbox="INBOX", top=5) + + self.assertEqual(len(captured_urls), 2) + self.assertTrue(all(" " not in url for url in captured_urls)) + self.assertIn("%24orderby=receivedDateTime+desc", captured_urls[0]) + self.assertIn("%24orderby=ReceivedDateTime+desc", captured_urls[1]) + + +if __name__ == "__main__": + unittest.main() From 42bff1e0a38a76107bfef9068389e7fa228ce8aa Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Tue, 28 Apr 2026 12:37:56 +0800 Subject: [PATCH 06/21] =?UTF-8?q?=E6=9B=B4=E6=96=B0md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- docs/使用教程.md | 152 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 6 deletions(-) diff --git a/docs/使用教程.md b/docs/使用教程.md index 4926491..0512821 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -1,6 +1,6 @@ # Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程 -本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 +本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email`、`iCloud 隐私邮箱` 与 `QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。 ## 适用场景 @@ -9,7 +9,9 @@ - 需要把 `Cloudflare Temp Email` 用作 `邮箱生成` 或 `邮箱服务` - 需要使用 `iCloud+` 的 `隐藏邮件地址` 作为隐私邮箱 - 需要临时切换 `QQ 邮箱` 地址继续使用 +- 需要使用 `网易邮箱`、`网易邮箱大师` 注册多个邮箱或替身邮箱 - 需要注册并使用 `PayPal` 个人账户 +- 需要检查当前节点的出口 IP、纯净度、泄露情况和访问速度 - 需要在 `Clash Verge` 中启用 `🔁 非港轮询` ## 准备内容 @@ -21,6 +23,7 @@ - 一个已开通 `iCloud+` 的 Apple ID - 一个用于接收转发邮件的邮箱 - 一个可正常登录的 `QQ 邮箱` +- 手机端已安装 `网易邮箱` 或 `网易邮箱大师` - 一个可正常接收短信的手机号 - 一张可在线支付的借记卡或信用卡 - 如需部署 `cpa`,部署环境必须可以访问 `OpenAI` @@ -163,7 +166,7 @@ `iCloud 邮件` 主地址创建说明:[Create a primary email address for iCloud Mail](https://support.apple.com/is-is/guide/icloud/mmdd8d1c5c/icloud) `隐藏邮件地址` 创建与转发说明:[Create and edit Hide My Email addresses on iCloud.com](https://support.apple.com/lv-lv/guide/icloud/mm1a876f7aed/icloud) -### 第五部分:`QQ 邮箱`切换邮箱使用教程 +### 第五部分:邮箱方法一:`QQ 邮箱`切换邮箱使用教程 1. 登录 `QQ 邮箱` 先登录你当前正在使用的 `QQ 邮箱` 账号。 @@ -182,7 +185,41 @@ 这两个邮箱地址使用完成后,可以直接删除。 删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。 -### 第六部分:`PayPal` 注册与绑卡使用教程 +### 第六部分:邮箱方法二:`网易邮箱` 注册与替身邮箱使用教程 + +1. 安装手机端 App + 在手机上下载安装 `网易邮箱` 或 `网易邮箱大师`。 + 打开后,先使用一个手机号登录。 + +2. 进入注册邮箱入口 + 点击右下角的 `我`。 + 找到并点击 `注册邮箱`。 + +3. 选择 `网易免费邮箱` + 在注册邮箱页面中选择 `网易免费邮箱`。 + 进入后,可以使用同一个手机号分别注册 `163`、`126` 和 `yeah` 邮箱。 + +4. 控制注册节奏 + 同一个手机号理论上可以注册多个网易邮箱账号。 + `163`、`126` 和 `yeah` 加起来大约可以注册 `15` 个。 + 不要短时间连续注册,连续注册太快容易触发频繁提示或限制。 + 更稳妥的做法是注册一个后暂停一会儿,或者换一个网络环境再继续。 + +5. 添加 `替身邮箱` + 注册完成后,回到 `注册邮箱` 入口附近。 + 点击旁边的 `替身邮箱`。 + 进入后,每个网易邮箱账号可以添加 `2` 个替身邮箱。 + +6. 计算可用邮箱数量 + 如果一个手机号注册了约 `15` 个网易邮箱账号,每个账号再添加 `2` 个替身邮箱,那么总共大约可以得到 `45` 个可用邮箱地址。 + 这些邮箱地址后续可以用于其他需要独立邮箱的注册或接收邮件场景。 + +7. 注意域名和注册环境 + `163`、`126` 和 `yeah` 这类网易邮箱域名通常比较常见。 + 只要网络环境不要太差,并且不要连续高频注册,一般不容易马上触发额外手机号验证。 + 建议注册一个后换节奏再继续,不要一口气连续创建。 + +### 第七部分:`PayPal` 注册与绑卡使用教程 1. 打开注册页面 打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。 @@ -234,14 +271,14 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第七部分:0元试用 ChatGPT Plus 教程 +### 第八部分:0元试用 ChatGPT Plus 教程 本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 #### 准备工作 1. 已有一个登录状态的 ChatGPT 账户。 -2. 一个可用的 PayPal 账户(参考第六部分进行注册和绑卡)。 +2. 一个可用的 PayPal 账户(参考第七部分进行注册和绑卡)。 3. 能够接收生成的账单地址的真实地址或虚拟地址。 4. Chrome 浏览器(推荐使用地址补全功能)。 @@ -389,7 +426,41 @@ - **PayPal 登录后页面无法继续跳转** 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 -### 第八部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第九部分:节点检测与纯净度检查网站 + +本部分用于检查当前代理节点的出口 IP、归属地、风险标签、泄露情况和网站访问速度。 +建议每次切换节点后都重新打开这些网站检查一次。 + +1. 使用 [IP111](https://ip111.cn/) 检查出口 IP + `IP111` 会展示当前访问网站时识别到的 IP 信息。 + 它适合用来快速确认当前节点是否已经生效,以及国内外访问看到的出口 IP 是否一致。 + 如果你切换节点后页面仍显示原来的 IP,说明代理可能没有切换成功,或者浏览器还有缓存/分流规则没有生效。 + +2. 使用 [IPData](https://ipdata.co/) 查看 IP 资料和风险信息 + `IPData` 偏向 IP 数据查询。 + 它可以查看 IP 的国家、地区、运营商、组织、ASN、时区、货币等基础信息。 + 它也会展示威胁和风险相关信息,例如是否像代理、VPN、Tor、数据中心 IP、匿名 IP,或者是否存在滥用记录。 + 适合用来判断节点的基础身份和风险标签。 + +3. 使用 [IPPure](https://ippure.com/) 检查 IP 纯净度 + `IPPure` 更偏向一键检测 IP 纯净度。 + 它会显示 IP 位置、风险检测、代理/VPN/黑名单等信息。 + 页面中还包含浏览器指纹、出口地图、`WebRTC` 泄露、`DNS` 泄露等检测项。 + 适合用来判断节点是否干净,以及浏览器是否暴露了真实网络环境。 + +4. 使用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 检查访问速度 + `TCPTest` 主要用于网站测速和连通性检测。 + 它可以测试 `Ping`、`TCPing`、`HTTP` 速度、`DNS` 解析、路由追踪和 `MTR`。 + 如果某个网站访问慢、打不开,或者想比较不同节点访问同一网站的速度,可以用它测试连接耗时、状态码、解析耗时和线路表现。 + 这类测速结果不等于 IP 纯净度,但可以帮助判断节点访问目标网站是否稳定。 + +5. 推荐检查顺序 + 先打开 [IP111](https://ip111.cn/) 确认出口 IP 是否切换成功。 + 再打开 [IPData](https://ipdata.co/) 查看 IP 归属、ASN 和风险标签。 + 接着打开 [IPPure](https://ippure.com/) 检查纯净度、黑名单、代理识别和泄露情况。 + 最后用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 测试目标网站的访问速度和连通性。 + +### 第十部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 @@ -497,6 +568,48 @@ function main(config, profileName) { 4. 确认右上角的 `代理模式`(`Mode`)已经设置为 `规则模式`(`Rule`)。 5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。 ![2026 04 25 003703](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163745--2026-04-25-003703--21e892504b82.png) + +### 第十一部分:订阅节点与自建推荐 + +如果你还没有订阅节点或想要寻找稳定、便宜的科学上网方式,可以参考以下三种方案获取。 + +#### 方法一:获取日常免费节点(85LA) + +1. 打开 [85LA 科学上网免费节点](https://www.85la.com/internet-access)。 +2. 网站每天会更新包含数百个测试通过的高速节点订阅,支持 `v2ray`、`Clash`、`SING-BOX` 等主流客户端。 +3. 复制最新日期的订阅链接,直接导入你的客户端即可免费使用。 + +#### 方法二:选择限时免费与高性价比机场推荐(二毛博客) + +1. 打开 [2026年翻墙机场推荐评测](https://www.ermao.net/posts/vpn/)。 +2. 该页面整理了数十家机场的短期与长期流量包价格,其中像 `ssone`、`flybit` 等很多机场在注册后均赠送限时免费试用(如送 1 天 1G/2G 流量等)。 +3. 可以根据网页中的评测挑选符合你需求与预算的低价机场来保障长期稳定。 + +#### 方法三:十分钟自建 Cloudflare 永久免费节点 + +如果你不想花钱,且希望获得一个长期稳定的私人免费节点,可以利用 `Cloudflare Pages` 快速搭建。 + +1. **第一步:注册免费域名** + 访问诸如 [DNS.HE](https://www.dnshe.com/) 或 [DigitalPlat](https://digitalplat.org/) 注册,并获取一个永久免费的域名(例如结尾是 `ccwu.cc` 或 `us.ci` 的域名)。 + +2. **第二步:托管域名至 Cloudflare** + 登录 [Cloudflare 后台](https://www.cloudflare.com/),将刚刚申请好的免费域名添加进去进行托管,并根据提示修改域名的 NS 记录直到激活显示。 + +3. **第三步:创建 KV 命名空间** + 在 Cloudflare 后台侧边栏找到 `存储和数据库` -> `Worker KV`,点击 `创建命名空间`,可以随意命名(主要是为了稍后绑定缓存数据)。 + +4. **第四步:部署 Pages 节点服务** + - 展开侧边栏 `Workers 和 Pages`,点击 `创建`,然后选择 `Pages` 标签页中的 `上传资产`。 + - 下载由 `cmliu` 开源的 [EdgeTunnel 资源库](https://github.com/cmliu/edgetunnel/archive/refs/heads/main.zip) 原程序压缩包(`main.zip`)。 + - 将 `main.zip` 上传并部署。 + - 部署完成后点击 `继续处理站点`,进入项目的 `设置` -> `环境变量` -> `添加变量`,变量名填入 `ADMIN`,值设为你想要的后台登录管理员密码,点击 `保存`。 + - 在同一个 `设置` 页中选择 `绑定` -> `添加` -> `KV 命名空间`,变量名称统一填写 `KV`,并选中你在第三步创建好的那个命名空间进行保存。 + - 返回 `部署` 选项卡重新上传 `main.zip` 进行覆盖部署即可使得配置生效。 + - 返回项目的 `自定义域` 标签卡,添加一条你的免费域名(如 `node.ccwu.cc`),并按要求去 DNS 面板配置 CNAME 指向分配的 `pages.dev` 后激活。 + +5. **第五步:获取订阅与后台管理** + 直接在浏览器访问你的自定义域名并加上后台路径(如 `https://node.ccwu.cc/admin`),通过第四步设置的密码登录后台。在后台即可复制你的专属 VLESS / Trojan 节点订阅地址,也可导入到各路客户端中直接使用。 + ## 常见问题 ### 为什么第十步显示认证成功,但没有认证文件? @@ -511,6 +624,16 @@ function main(config, profileName) { 可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。 +### `网易邮箱` 一个手机号大约可以注册多少个邮箱? + +按目前使用经验,同一个手机号可以分别注册 `163`、`126` 和 `yeah` 邮箱,加起来大约 `15` 个账号。 +每个账号还可以添加 `2` 个 `替身邮箱`,所以总共大约可以得到 `45` 个可用邮箱地址。 + +### `网易邮箱` 注册时提示频繁怎么办? + +先停止连续注册,隔一段时间再继续。 +短时间连续注册容易触发频繁提示或限制,建议注册一个后暂停一会儿,或者换一个网络环境再继续。 + ### `iCloud 隐私邮箱` 插件刷新后没有邮箱怎么办? 先确认你已经在网页中登录 `iCloud`,并且已经在 `隐藏邮件地址` 里手动创建过隐私邮箱。 @@ -538,6 +661,18 @@ function main(config, profileName) { 请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。 +### 网站测速结果好,是否代表节点纯净? + +不代表。 +`TCPTest` 主要看连接速度、解析耗时、状态码和线路稳定性。 +节点纯净度还需要结合 [IPData](https://ipdata.co/) 和 [IPPure](https://ippure.com/) 的风险标签、代理识别、黑名单、`DNS` 泄露和 `WebRTC` 泄露结果一起判断。 + +### 切换节点后为什么检测网站结果没变? + +先确认 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 已经切换到目标节点或目标策略组。 +然后刷新检测网站,必要时关闭浏览器重新打开。 +如果 [IP111](https://ip111.cn/) 仍显示旧 IP,说明当前浏览器流量可能没有走新节点,或者分流规则没有命中。 + ## 注意事项 - 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。 @@ -548,9 +683,14 @@ function main(config, profileName) { - 如果条件允许,建议使用非日常主力 Apple ID 配置 `iCloud 隐私邮箱`,避免影响平时常用账号。 - 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。 - `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。 +- `网易邮箱` 注册时不要短时间连续创建多个账号,否则容易触发频繁提示或限制。 +- `网易邮箱` 的 `替身邮箱` 是在单个邮箱账号下添加的,注册完成主邮箱后再进入 `替身邮箱` 页面添加。 - 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。 - `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。 - 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。 +- 检查节点时,先确认出口 IP,再看风险标签和泄露检测,最后再看网站测速。 +- [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 主要用于判断访问速度和连通性,不要单独用它判断节点纯净度。 +- [IPPure](https://ippure.com/) 如果提示 `WebRTC` 或 `DNS` 泄露,请优先检查浏览器和代理客户端的相关设置。 - 使用 `git pull` 更新扩展是最方便、最推荐的方式。 - 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。 From a6e8de2f08d1568ba02dc9e9b1d3a42a7ee43c4f Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Tue, 28 Apr 2026 12:54:53 +0800 Subject: [PATCH 07/21] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sidepanel/sidepanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 5d66c65..69e94f0 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -747,7 +747,7 @@ const MAIL_PROVIDER_LOGIN_CONFIGS = { const IP_PROXY_SERVICE_LOGIN_CONFIGS = { '711proxy': { label: '711Proxy', - url: 'https://www.711proxy.com/', + url: 'https://www.711proxy.com/signup?code=AD2497', buttonLabel: '登录', }, }; From 87fc3338482aecac3d054684d890e6963ae3681f Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Tue, 28 Apr 2026 13:00:27 +0800 Subject: [PATCH 08/21] chore(release): bump version to Ultra2.0 --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index f1e2895..b6cb432 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "codex-oauth-automation-extension", - "version": "1.4", - "version_name": "Ultra1.4", + "version": "2.0", + "version_name": "Ultra2.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", From 127fd1c653bb893afc89fe67d0e43b0e4b2e6eab Mon Sep 17 00:00:00 2001 From: zhangkun <1184253234@qq.com> Date: Tue, 28 Apr 2026 02:51:48 +0800 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20=E6=89=8B=E5=8A=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20HeroSMS=20maxPrice=20=E5=B9=B6=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=A4=8D=E7=94=A8=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 3 + background/phone-verification-flow.js | 203 +++------ sidepanel/sidepanel.html | 5 + sidepanel/sidepanel.js | 35 +- tests/phone-verification-flow.test.js | 406 +++++++----------- ...epanel-phone-verification-settings.test.js | 8 + 6 files changed, 264 insertions(+), 396 deletions(-) diff --git a/background.js b/background.js index 97b85b1..187e9f6 100644 --- a/background.js +++ b/background.js @@ -477,6 +477,7 @@ const PERSISTED_SETTING_DEFAULTS = { hotmailAccounts: [], mail2925Accounts: [], heroSmsApiKey: '', + heroSmsMaxPrice: '', heroSmsCountryId: HERO_SMS_COUNTRY_ID, heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, }; @@ -1316,6 +1317,8 @@ function normalizePersistentSettingValue(key, value) { return normalizeMail2925Accounts(value); case 'heroSmsApiKey': return String(value || ''); + case 'heroSmsMaxPrice': + return String(value || '').trim(); case 'heroSmsCountryId': return Math.max(1, Math.floor(Number(value) || HERO_SMS_COUNTRY_ID)); case 'heroSmsCountryLabel': diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index ca16eae..6b7fc84 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -27,7 +27,6 @@ const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3; const DEFAULT_PHONE_CODE_WAIT_WINDOW_MS = 60000; const DEFAULT_PHONE_NUMBER_MAX_USES = 3; - const DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS = 3; const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::'; const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::'; @@ -47,6 +46,18 @@ return String(value || '').trim(); } + function normalizeManualHeroSmsMaxPrice(value) { + const trimmed = String(value ?? '').trim(); + if (!trimmed) { + return null; + } + const price = Number(trimmed); + if (!Number.isFinite(price) || price <= 0) { + return null; + } + return String(price); + } + function normalizeUseCount(value) { return Math.max(0, Math.floor(Number(value) || 0)); } @@ -241,8 +252,13 @@ if (!apiKey) { throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.'); } + const maxPrice = normalizeManualHeroSmsMaxPrice(state.heroSmsMaxPrice); + if (!maxPrice) { + throw new Error('HeroSMS maxPrice is missing. Fill it in below the country selector before running the phone flow.'); + } return { apiKey, + maxPrice, baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), }; } @@ -289,91 +305,10 @@ return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus'; } - function normalizeHeroSmsPrice(value) { - const price = Number(value); - if (!Number.isFinite(price) || price < 0) { - return null; - } - return price; - } - - function collectHeroSmsPriceCandidates(payload, candidates = []) { - if (Array.isArray(payload)) { - payload.forEach((entry) => collectHeroSmsPriceCandidates(entry, candidates)); - return candidates; - } - if (!payload || typeof payload !== 'object') { - return candidates; - } - - const cost = normalizeHeroSmsPrice(payload.cost); - if (cost !== null) { - const count = Number(payload.count); - const physicalCount = Number(payload.physicalCount); - const hasCount = Number.isFinite(count); - const hasPhysicalCount = Number.isFinite(physicalCount); - if ((!hasCount && !hasPhysicalCount) || count > 0 || physicalCount > 0) { - candidates.push(cost); - } - } - - Object.values(payload).forEach((value) => collectHeroSmsPriceCandidates(value, candidates)); - return candidates; - } - - function findLowestHeroSmsPrice(payload) { - const candidates = collectHeroSmsPriceCandidates(payload, []); - if (!candidates.length) { - return null; - } - return Math.min(...candidates); - } - function isHeroSmsNoNumbersPayload(payload) { return /\bNO_NUMBERS\b/i.test(describeHeroSmsPayload(payload)); } - function extractHeroSmsWrongMaxPrice(payload) { - if (payload && typeof payload === 'object') { - const title = String(payload.title || '').trim(); - const minPrice = normalizeHeroSmsPrice(payload.info?.min); - if (/^WRONG_MAX_PRICE$/i.test(title) && minPrice !== null) { - return minPrice; - } - } - - const text = describeHeroSmsPayload(payload); - const match = text.match(/\bWRONG_MAX_PRICE:(\d+(?:\.\d+)?)\b/i); - if (!match) { - return null; - } - return normalizeHeroSmsPrice(match[1]); - } - - function isNetworkFetchFailure(error) { - const message = String(error?.message || '').trim(); - return /failed to fetch|networkerror|load failed/i.test(message); - } - - async function resolveCheapestPhoneActivationPrice(config, countryConfig) { - for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { - try { - const payload = await fetchHeroSmsPayload(config, { - action: 'getPrices', - service: HERO_SMS_SERVICE_CODE, - country: countryConfig.id, - }, 'HeroSMS getPrices'); - const price = findLowestHeroSmsPrice(payload); - if (price !== null) { - return price; - } - } catch (_) { - // Best-effort lookup only. - } - } - return null; - } - async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) { const query = { action, @@ -387,49 +322,10 @@ return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`); } - async function requestPhoneActivationWithPrice(config, countryConfig, action, maxPrice) { - let nextMaxPrice = maxPrice; - let retriedWithUpdatedPrice = false; - let retriedWithoutPrice = false; - - while (true) { - try { - return await fetchPhoneActivationPayload(config, countryConfig, action, { - maxPrice: nextMaxPrice, - }); - } catch (error) { - const updatedMaxPrice = extractHeroSmsWrongMaxPrice(error?.payload || error?.message); - if ( - nextMaxPrice !== null - && nextMaxPrice !== undefined - && !retriedWithUpdatedPrice - && updatedMaxPrice !== null - ) { - nextMaxPrice = updatedMaxPrice; - retriedWithUpdatedPrice = true; - continue; - } - - if ( - nextMaxPrice !== null - && nextMaxPrice !== undefined - && !retriedWithoutPrice - && isNetworkFetchFailure(error) - ) { - nextMaxPrice = null; - retriedWithoutPrice = true; - continue; - } - - throw error; - } - } - } - async function requestPhoneActivation(state = {}) { const config = resolvePhoneConfig(state); const countryConfig = resolveCountryConfig(state); - const maxPrice = await resolveCheapestPhoneActivationPrice(config, countryConfig); + const maxPrice = config.maxPrice; const buildFallbackActivation = (requestAction) => ({ countryId: countryConfig.id, ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), @@ -438,19 +334,19 @@ let payload; try { - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); } catch (error) { if (!isHeroSmsNoNumbersPayload(error?.payload || error?.message)) { throw error; } requestAction = 'getNumberV2'; - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); } let activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); if (!activation && requestAction === 'getNumber' && isHeroSmsNoNumbersPayload(payload)) { requestAction = 'getNumberV2'; - payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice }); activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); } @@ -496,7 +392,7 @@ } async function completePhoneActivation(state = {}, activation) { - await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)'); + await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)'); } async function cancelPhoneActivation(state = {}, activation) { @@ -738,6 +634,20 @@ await persistReusableActivation(null); } + async function incrementCurrentActivationUseCount(activation) { + const normalizedActivation = normalizeActivation(activation); + if (!normalizedActivation) { + return null; + } + + const nextActivation = { + ...normalizedActivation, + successfulUses: Math.min(normalizedActivation.successfulUses + 1, normalizedActivation.maxUses), + }; + await persistCurrentActivation(nextActivation); + return nextActivation; + } + async function acquirePhoneActivation(state = {}) { const countryConfig = resolveCountryConfig(state); const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]); @@ -769,28 +679,24 @@ return activation; } - async function markActivationReusableAfterSuccess(activation) { + async function syncReusableActivationAfterUse(activation) { const normalizedActivation = normalizeActivation(activation); if (!normalizedActivation) { await clearReusableActivation(); return; } - const successfulUses = normalizedActivation.successfulUses + 1; - if (successfulUses >= normalizedActivation.maxUses) { + if (normalizedActivation.successfulUses >= normalizedActivation.maxUses) { await clearReusableActivation(); return; } - await persistReusableActivation({ - ...normalizedActivation, - successfulUses, - }); + await persistReusableActivation(normalizedActivation); } async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) { - const normalizedActivation = normalizeActivation(activation); - if (!normalizedActivation) { + let currentActivation = normalizeActivation(activation); + if (!currentActivation) { throw new Error('Phone activation is missing.'); } @@ -799,11 +705,11 @@ for (let windowIndex = 1; windowIndex <= 2; windowIndex += 1) { await addLog( - `Step 9: waiting up to 60 seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/2).`, + `Step 9: waiting up to 60 seconds for SMS on ${currentActivation.phoneNumber} (${windowIndex}/2).`, 'info' ); try { - const code = await pollPhoneActivationCode(state, normalizedActivation, { + const code = await pollPhoneActivationCode(state, currentActivation, { actionLabel: windowIndex === 1 ? 'poll phone verification code from HeroSMS' : 'poll resent phone verification code from HeroSMS', @@ -820,7 +726,7 @@ lastLoggedStatus = statusText; lastLoggedPollCount = pollCount; await addLog( - `Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`, + `Step 9: HeroSMS status for ${currentActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`, 'info' ); }, @@ -836,10 +742,10 @@ if (windowIndex === 1) { await addLog( - `Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within 60 seconds, requesting another SMS.`, + `Step 9: no SMS arrived for ${currentActivation.phoneNumber} within 60 seconds, requesting another SMS.`, 'warn' ); - await requestAdditionalPhoneSms(state, normalizedActivation); + await requestAdditionalPhoneSms(state, currentActivation); try { await resendPhoneVerificationCode(tabId); await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info'); @@ -850,10 +756,10 @@ } await addLog( - `Step 9: still no SMS for ${normalizedActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`, + `Step 9: still no SMS for ${currentActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`, 'warn' ); - throw buildPhoneRestartStep7Error(normalizedActivation.phoneNumber); + throw buildPhoneRestartStep7Error(currentActivation.phoneNumber); } } @@ -965,8 +871,17 @@ continue; } - await completePhoneActivation(state, activation); - await markActivationReusableAfterSuccess(activation); + activation = await incrementCurrentActivationUseCount(activation); + try { + await completePhoneActivation(state, activation); + await syncReusableActivationAfterUse(activation); + } catch (activationStatusError) { + await clearReusableActivation(); + await addLog( + `Step 9: phone verification succeeded, but HeroSMS setStatus(3) failed. The next flow will request a new number. ${activationStatusError.message}`, + 'warn' + ); + } shouldCancelActivation = false; await clearCurrentActivation(); await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok'); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 7db0a30..c0b7e0e 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -569,6 +569,11 @@ + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 69e94f0..08bdbf5 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -748,7 +748,7 @@ const IP_PROXY_SERVICE_LOGIN_CONFIGS = { '711proxy': { label: '711Proxy', url: 'https://www.711proxy.com/signup?code=AD2497', - buttonLabel: '登录', + buttonLabel: '注册', }, }; @@ -3844,9 +3844,10 @@ function updateIpProxyServiceLoginButtonState(options = {}) { ? Boolean(options.enabled) : Boolean(getSelectedIpProxyEnabled()); btnIpProxyServiceLogin.disabled = !enabled || !loginUrl; - btnIpProxyServiceLogin.textContent = loginConfig?.buttonLabel || '登录'; + const buttonLabel = loginConfig?.buttonLabel || '登录'; + btnIpProxyServiceLogin.textContent = buttonLabel; btnIpProxyServiceLogin.title = loginUrl - ? `打开 ${loginConfig?.label || service} 登录页` + ? `打开 ${loginConfig?.label || service} ${buttonLabel}页` : '当前代理服务没有可跳转的登录页'; } diff --git a/tests/paypal-flow-content.test.js b/tests/paypal-flow-content.test.js index d2f0ffd..f5995f1 100644 --- a/tests/paypal-flow-content.test.js +++ b/tests/paypal-flow-content.test.js @@ -145,6 +145,57 @@ return { `)(document, window); } +function createSubmitApi(overrides = {}) { + const bindings = { + waitForDocumentComplete: async () => {}, + normalizeText: (text = '') => String(text || '').replace(/\s+/g, ' ').trim(), + findPasswordInput: () => null, + findEmailInput: () => null, + findEmailNextButton: () => null, + isEnabledControl: () => true, + findPasswordLoginButton: () => null, + fillInput: () => {}, + simulateClick: () => {}, + waitUntil: async (predicate) => predicate(), + findLoginNextButton: () => null, + sleep: async () => {}, + ...overrides, + }; + + return new Function( + 'waitForDocumentComplete', + 'normalizeText', + 'findPasswordInput', + 'findEmailInput', + 'findEmailNextButton', + 'isEnabledControl', + 'findPasswordLoginButton', + 'fillInput', + 'simulateClick', + 'waitUntil', + 'findLoginNextButton', + 'sleep', + ` +${extractFunction('refillPayPalEmailInput')} +${extractFunction('submitPayPalLogin')} +return { refillPayPalEmailInput, submitPayPalLogin }; +` + )( + bindings.waitForDocumentComplete, + bindings.normalizeText, + bindings.findPasswordInput, + bindings.findEmailInput, + bindings.findEmailNextButton, + bindings.isEnabledControl, + bindings.findPasswordLoginButton, + bindings.fillInput, + bindings.simulateClick, + bindings.waitUntil, + bindings.findLoginNextButton, + bindings.sleep + ); +} + test('PayPal email page ignores hidden pre-rendered password input', () => { const hiddenPanel = createElement({ attrs: { 'aria-hidden': 'true' } }); const emailInput = createElement({ @@ -203,3 +254,57 @@ test('PayPal combined login page still sees visible password input', () => { assert.equal(api.findPasswordLoginButton(), loginButton); assert.equal(api.getPayPalLoginPhase(emailInput, passwordInput), 'login_combined'); }); + +test('PayPal email submit refills a prefilled email before clicking next', async () => { + const emailInput = createElement({ + tag: 'input', + type: 'text', + id: 'login_email', + name: 'login_email', + value: 'user@example.com', + placeholder: 'Email', + }); + const nextButton = createElement({ + tag: 'button', + id: 'btnNext', + text: 'Next', + }); + const fillValues = []; + const clicked = []; + let focusCount = 0; + let blurCount = 0; + + emailInput.focus = () => { + focusCount += 1; + }; + emailInput.blur = () => { + blurCount += 1; + }; + + const api = createSubmitApi({ + findEmailInput: () => emailInput, + findEmailNextButton: () => nextButton, + fillInput: (element, value) => { + fillValues.push(value); + element.value = value; + }, + simulateClick: (element) => { + clicked.push(element); + }, + }); + + const result = await api.submitPayPalLogin({ + email: 'user@example.com', + password: 'secret', + }); + + assert.deepEqual(fillValues, ['', 'user@example.com']); + assert.equal(focusCount, 1); + assert.equal(blurCount, 1); + assert.deepEqual(clicked, [nextButton]); + assert.deepEqual(result, { + submitted: false, + phase: 'email_submitted', + awaiting: 'password_page', + }); +}); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 77acb92..909a646 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -500,7 +500,7 @@ Plus 模式可见步骤: 1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。 2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`。 3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frame,Google 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。 -4. 第 8 步 `PayPal 登录与授权`:在 PayPal 页面填写 `paypalEmail / paypalPassword`,登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。 +4. 第 8 步 `PayPal 登录与授权`:在 PayPal 页面填写 `paypalEmail / paypalPassword`。当页面处于账号输入阶段时,内容脚本会固定对邮箱输入框执行 `focus -> clear -> fill -> blur -> click next`,即使文本框里已经预填了同一个账号,也会重新触发输入事件,避免 PayPal 因跳过重填而停在邮箱页;登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。 5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。 6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。 7. 第 11 步:复用原 Step 8 登录验证码执行器,但状态和日志按 Plus 可见第 11 步记录。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index db94a0d..15be872 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -86,7 +86,7 @@ - `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。 - `content/mail-163.js`:163 / 163 VIP / 126 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。 - `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。 -- `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。 +- `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、在账号页固定执行 `focus -> clear -> fill -> blur` 重新触发邮箱输入事件、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。 - `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。 - `content/plus-checkout.js`:ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。 - `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。 @@ -202,6 +202,8 @@ - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 - `tests/phone-auth-country-match.test.js`:测试认证页手机号国家选择在页面本地化时,仍能用 HeroSMS 的英文国家名匹配对应国家选项。 - `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 +- `tests/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。 +- `tests/paypal-flow-content.test.js`:测试 PayPal 内容脚本对可见邮箱/密码输入框的识别,并覆盖邮箱页即使已预填相同账号也会先清空再重填后继续下一步。 - `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。 - `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。 From 8007a99b7209c4127241f394605f5b30c220bcee Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Tue, 28 Apr 2026 16:23:30 +0800 Subject: [PATCH 15/21] =?UTF-8?q?Paypal=E5=A2=9E=E5=8A=A0=E8=BD=BB?= =?UTF-8?q?=E9=87=8F=E7=BA=A7=E5=8F=B7=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 92 +++++++ background/message-router.js | 16 ++ background/paypal-account-store.js | 110 +++++++++ background/steps/paypal-approve.js | 21 +- paypal-utils.js | 56 +++++ sidepanel/form-dialog.js | 233 ++++++++++++++++++ sidepanel/paypal-manager.js | 191 ++++++++++++++ sidepanel/sidepanel.css | 30 +++ sidepanel/sidepanel.html | 32 ++- sidepanel/sidepanel.js | 111 ++++++--- ...ground-paypal-account-store-module.test.js | 91 +++++++ tests/paypal-approve-detection.test.js | 26 ++ tests/sidepanel-paypal-manager.test.js | 133 ++++++++++ tests/step-definitions-module.test.js | 5 +- 项目完整链路说明.md | 6 +- 项目文件结构说明.md | 15 +- 16 files changed, 1121 insertions(+), 47 deletions(-) create mode 100644 background/paypal-account-store.js create mode 100644 paypal-utils.js create mode 100644 sidepanel/form-dialog.js create mode 100644 sidepanel/paypal-manager.js create mode 100644 tests/background-paypal-account-store-module.test.js create mode 100644 tests/sidepanel-paypal-manager.test.js diff --git a/background.js b/background.js index 8e9e345..4c24a89 100644 --- a/background.js +++ b/background.js @@ -3,10 +3,12 @@ importScripts( 'managed-alias-utils.js', 'mail2925-utils.js', + 'paypal-utils.js', 'background/phone-verification-flow.js', 'background/account-run-history.js', 'background/contribution-oauth.js', 'background/mail-2925-session.js', + 'background/paypal-account-store.js', 'background/ip-proxy-provider-711proxy.js', 'background/ip-proxy-core.js', 'background/panel-bridge.js', @@ -436,6 +438,7 @@ const PERSISTED_SETTING_DEFAULTS = { plusModeEnabled: false, paypalEmail: '', paypalPassword: '', + currentPayPalAccountId: '', autoRunSkipFailures: false, autoRunFallbackThreadIntervalMinutes: 0, autoRunDelayEnabled: false, @@ -476,6 +479,7 @@ const PERSISTED_SETTING_DEFAULTS = { cloudflareTempEmailDomains: [], hotmailAccounts: [], mail2925Accounts: [], + paypalAccounts: [], heroSmsApiKey: '', heroSmsCountryId: HERO_SMS_COUNTRY_ID, heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, @@ -575,6 +579,7 @@ const DEFAULT_STATE = { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + currentPayPalAccountId: null, currentHotmailAccountId: null, currentMail2925AccountId: null, preferredIcloudHost: '', @@ -1242,6 +1247,8 @@ function normalizePersistentSettingValue(key, value) { return String(value || '').trim(); case 'paypalPassword': return String(value || ''); + case 'currentPayPalAccountId': + return String(value || '').trim(); case 'autoRunSkipFailures': case 'autoRunDelayEnabled': case 'phoneVerificationEnabled': @@ -1314,6 +1321,8 @@ function normalizePersistentSettingValue(key, value) { return normalizeHotmailAccounts(value); case 'mail2925Accounts': return normalizeMail2925Accounts(value); + case 'paypalAccounts': + return normalizePayPalAccounts(value); case 'heroSmsApiKey': return String(value || ''); case 'heroSmsCountryId': @@ -1929,6 +1938,50 @@ function generatePassword() { return pw.split('').sort(() => Math.random() - 0.5).join(''); } +function normalizePayPalAccount(account = {}) { + if (self.PayPalUtils?.normalizePayPalAccount) { + return self.PayPalUtils.normalizePayPalAccount(account); + } + return { + id: String(account.id || crypto.randomUUID()), + email: String(account.email || '').trim().toLowerCase(), + password: String(account.password || ''), + createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : Date.now(), + updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : Date.now(), + lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0, + }; +} + +function normalizePayPalAccounts(accounts) { + if (self.PayPalUtils?.normalizePayPalAccounts) { + return self.PayPalUtils.normalizePayPalAccounts(accounts); + } + return Array.isArray(accounts) ? accounts.map((account) => normalizePayPalAccount(account)) : []; +} + +function findPayPalAccount(accounts, accountId) { + if (self.PayPalUtils?.findPayPalAccount) { + return self.PayPalUtils.findPayPalAccount(accounts, accountId); + } + const normalizedId = String(accountId || '').trim(); + if (!normalizedId) return null; + return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null; +} + +function upsertPayPalAccountInList(accounts, nextAccount) { + if (self.PayPalUtils?.upsertPayPalAccountInList) { + return self.PayPalUtils.upsertPayPalAccountInList(accounts, nextAccount); + } + const normalizedNext = normalizePayPalAccount(nextAccount); + const list = normalizePayPalAccounts(accounts); + const existingIndex = list.findIndex((account) => account.id === normalizedNext.id); + if (existingIndex >= 0) { + list[existingIndex] = normalizedNext; + return list; + } + return [...list, normalizedNext]; +} + function normalizeHotmailAccount(account = {}) { const normalizedLastAuthAt = Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0; const normalizedStatus = String( @@ -7544,6 +7597,39 @@ function isMail2925PoolExhaustedPauseError(error) { return /^MAIL2925_POOL_EXHAUSTED_PAUSE::/.test(message); } +const payPalAccountStore = self.MultiPageBackgroundPayPalAccountStore?.createPayPalAccountStore({ + broadcastDataUpdate, + findPayPalAccount, + getState, + normalizePayPalAccount, + normalizePayPalAccounts, + setPersistentSettings, + setState, + upsertPayPalAccountInList, +}); + +async function syncPayPalAccounts(accounts) { + return payPalAccountStore?.syncPayPalAccounts?.(accounts) || []; +} + +async function upsertPayPalAccount(input = {}) { + if (!payPalAccountStore?.upsertPayPalAccount) { + throw new Error('PayPal 账号存储能力尚未接入。'); + } + return payPalAccountStore.upsertPayPalAccount(input); +} + +async function setCurrentPayPalAccount(accountId) { + if (!payPalAccountStore?.setCurrentPayPalAccount) { + throw new Error('PayPal 账号选择能力尚未接入。'); + } + return payPalAccountStore.setCurrentPayPalAccount(accountId); +} + +function getCurrentPayPalAccount(state = null) { + return payPalAccountStore?.getCurrentPayPalAccount?.(state || {}) || null; +} + const generatedEmailHelpers = self.MultiPageGeneratedEmailHelpers?.createGeneratedEmailHelpers({ addLog, buildGeneratedAliasEmail, @@ -8729,6 +8815,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter deleteHotmailAccounts, deleteIcloudAlias, deleteUsedIcloudAliases, + findPayPalAccount, disableUsedLuckmailPurchases, doesStepUseCompletionSignal, ensureMail2925MailboxSession, @@ -8773,9 +8860,11 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter listIcloudAliases, listLuckmailPurchasesForManagement, refreshIpProxyPool, + getCurrentPayPalAccount, getCurrentMail2925Account, normalizeHotmailAccounts, normalizeMail2925Accounts, + normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, notifyStepComplete, @@ -8791,6 +8880,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter selectLuckmailPurchase, switchIpProxy, changeIpProxyExit, + setCurrentPayPalAccount, setCurrentHotmailAccount, setCurrentMail2925Account, setContributionMode, @@ -8810,9 +8900,11 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter startAutoRunLoop, pollContributionStatus: (...args) => contributionOAuthManager?.pollContributionStatus?.(...args), syncHotmailAccounts, + syncPayPalAccounts, deleteMail2925Account, deleteMail2925Accounts, testHotmailAccountMailAccess, + upsertPayPalAccount, upsertMail2925Account, upsertHotmailAccount, verifyHotmailAccount, diff --git a/background/message-router.js b/background/message-router.js index 2c5886e..0944a3d 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -35,8 +35,10 @@ finalizeStep3Completion, finalizeIcloudAliasAfterSuccessfulFlow, findHotmailAccount, + findPayPalAccount, flushCommand, getCurrentLuckmailPurchase, + getCurrentPayPalAccount, getCurrentMail2925Account, getPendingAutoRunTimerPlan, getSourceLabel, @@ -62,6 +64,7 @@ refreshIpProxyPool, normalizeHotmailAccounts, normalizeMail2925Accounts, + normalizePayPalAccounts, normalizeRunCount, AUTO_RUN_TIMER_KIND_SCHEDULED_START, notifyStepComplete, @@ -79,6 +82,7 @@ selectLuckmailPurchase, switchIpProxy, changeIpProxyExit, + setCurrentPayPalAccount, setCurrentMail2925Account, setCurrentHotmailAccount, setContributionMode, @@ -99,7 +103,9 @@ deleteMail2925Account, deleteMail2925Accounts, syncHotmailAccounts, + syncPayPalAccounts, testHotmailAccountMailAccess, + upsertPayPalAccount, upsertMail2925Account, upsertHotmailAccount, verifyHotmailAccount, @@ -779,6 +785,16 @@ return { ok: true, account }; } + case 'UPSERT_PAYPAL_ACCOUNT': { + const account = await upsertPayPalAccount(message.payload || {}); + return { ok: true, account }; + } + + case 'SELECT_PAYPAL_ACCOUNT': { + const account = await setCurrentPayPalAccount(String(message.payload?.accountId || '')); + return { ok: true, account }; + } + case 'DELETE_HOTMAIL_ACCOUNT': { await deleteHotmailAccount(String(message.payload?.accountId || '')); return { ok: true }; diff --git a/background/paypal-account-store.js b/background/paypal-account-store.js new file mode 100644 index 0000000..6d6c889 --- /dev/null +++ b/background/paypal-account-store.js @@ -0,0 +1,110 @@ +(function attachBackgroundPayPalAccountStore(root, factory) { + root.MultiPageBackgroundPayPalAccountStore = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPayPalAccountStoreModule() { + function createPayPalAccountStore(deps = {}) { + const { + broadcastDataUpdate, + findPayPalAccount, + getState, + normalizePayPalAccount, + normalizePayPalAccounts, + setPersistentSettings, + setState, + upsertPayPalAccountInList, + } = deps; + + async function syncSelectedPayPalAccountState(account = null) { + const updates = account + ? { + currentPayPalAccountId: account.id, + paypalEmail: String(account.email || '').trim(), + paypalPassword: String(account.password || ''), + } + : { + currentPayPalAccountId: '', + paypalEmail: '', + paypalPassword: '', + }; + + await setPersistentSettings(updates); + await setState({ + currentPayPalAccountId: updates.currentPayPalAccountId || null, + paypalEmail: updates.paypalEmail, + paypalPassword: updates.paypalPassword, + }); + broadcastDataUpdate({ + currentPayPalAccountId: updates.currentPayPalAccountId || null, + paypalEmail: updates.paypalEmail, + paypalPassword: updates.paypalPassword, + }); + } + + async function syncPayPalAccounts(accounts) { + const normalized = normalizePayPalAccounts(accounts); + await setPersistentSettings({ paypalAccounts: normalized }); + await setState({ paypalAccounts: normalized }); + broadcastDataUpdate({ paypalAccounts: normalized }); + + const state = await getState(); + if (state.currentPayPalAccountId && !findPayPalAccount(normalized, state.currentPayPalAccountId)) { + await syncSelectedPayPalAccountState(null); + } + return normalized; + } + + function getCurrentPayPalAccount(state = {}) { + return findPayPalAccount(state.paypalAccounts, state.currentPayPalAccountId) || null; + } + + async function upsertPayPalAccount(input = {}) { + const state = await getState(); + const accounts = normalizePayPalAccounts(state.paypalAccounts); + const normalizedEmail = String(input?.email || '').trim().toLowerCase(); + const existing = input?.id + ? findPayPalAccount(accounts, input.id) + : accounts.find((account) => account.email === normalizedEmail) || null; + const normalized = normalizePayPalAccount({ + ...(existing || {}), + ...input, + id: input?.id || existing?.id || crypto.randomUUID(), + createdAt: existing?.createdAt || Date.now(), + updatedAt: Date.now(), + }); + + const nextAccounts = typeof upsertPayPalAccountInList === 'function' + ? upsertPayPalAccountInList(accounts, normalized) + : accounts.concat(normalized); + + await syncPayPalAccounts(nextAccounts); + + if (state.currentPayPalAccountId === normalized.id) { + await syncSelectedPayPalAccountState(normalized); + } + + return normalized; + } + + async function setCurrentPayPalAccount(accountId) { + const state = await getState(); + const accounts = normalizePayPalAccounts(state.paypalAccounts); + const account = findPayPalAccount(accounts, accountId); + if (!account) { + throw new Error('未找到对应的 PayPal 账号。'); + } + + await syncSelectedPayPalAccountState(account); + return account; + } + + return { + getCurrentPayPalAccount, + setCurrentPayPalAccount, + syncPayPalAccounts, + upsertPayPalAccount, + }; + } + + return { + createPayPalAccountStore, + }; +}); diff --git a/background/steps/paypal-approve.js b/background/steps/paypal-approve.js index d69540d..3d1e390 100644 --- a/background/steps/paypal-approve.js +++ b/background/steps/paypal-approve.js @@ -99,17 +99,30 @@ return result || {}; } + function resolvePayPalCredentials(state = {}) { + const currentId = String(state?.currentPayPalAccountId || '').trim(); + const accounts = Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : []; + const selectedAccount = currentId + ? accounts.find((account) => String(account?.id || '').trim() === currentId) || null + : null; + return { + email: String(selectedAccount?.email || state?.paypalEmail || '').trim(), + password: String(selectedAccount?.password || state?.paypalPassword || ''), + }; + } + async function submitLogin(tabId, state = {}) { - if (!state.paypalPassword) { - throw new Error('步骤 8:未配置 PayPal 密码,请先在侧边栏填写。'); + const credentials = resolvePayPalCredentials(state); + if (!credentials.password) { + throw new Error('步骤 8:未配置可用的 PayPal 账号,请先在侧边栏添加并选择账号。'); } await addLog('步骤 8:正在填写 PayPal 登录信息并提交...', 'info'); const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, { type: 'PAYPAL_SUBMIT_LOGIN', source: 'background', payload: { - email: state.paypalEmail || '', - password: state.paypalPassword || '', + email: credentials.email, + password: credentials.password, }, }); if (result?.error) { diff --git a/paypal-utils.js b/paypal-utils.js new file mode 100644 index 0000000..b949bc7 --- /dev/null +++ b/paypal-utils.js @@ -0,0 +1,56 @@ +(function attachPayPalUtils(root, factory) { + root.PayPalUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createPayPalUtils() { + function normalizePayPalAccount(account = {}) { + const normalizedEmail = String(account.email || '').trim().toLowerCase(); + const now = Date.now(); + return { + id: String(account.id || crypto.randomUUID()), + email: normalizedEmail, + password: String(account.password || ''), + createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : now, + updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : now, + lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0, + }; + } + + function normalizePayPalAccounts(accounts) { + if (!Array.isArray(accounts)) return []; + + const deduped = new Map(); + for (const account of accounts) { + const normalized = normalizePayPalAccount(account); + if (!normalized.email) continue; + deduped.set(normalized.id, normalized); + } + return [...deduped.values()]; + } + + function findPayPalAccount(accounts = [], accountId = '') { + const normalizedId = String(accountId || '').trim(); + if (!normalizedId) return null; + return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null; + } + + function upsertPayPalAccountInList(accounts = [], nextAccount = null) { + if (!nextAccount) { + return normalizePayPalAccounts(accounts); + } + + const normalizedNext = normalizePayPalAccount(nextAccount); + const list = normalizePayPalAccounts(accounts); + const existingIndex = list.findIndex((account) => account.id === normalizedNext.id); + if (existingIndex >= 0) { + list[existingIndex] = normalizedNext; + return list; + } + return [...list, normalizedNext]; + } + + return { + findPayPalAccount, + normalizePayPalAccount, + normalizePayPalAccounts, + upsertPayPalAccountInList, + }; +}); diff --git a/sidepanel/form-dialog.js b/sidepanel/form-dialog.js new file mode 100644 index 0000000..cf3db7c --- /dev/null +++ b/sidepanel/form-dialog.js @@ -0,0 +1,233 @@ +(function attachSidepanelFormDialog(globalScope) { + function createFormDialog(context = {}) { + const { + overlay = null, + titleNode = null, + closeButton = null, + messageNode = null, + alertNode = null, + fieldsContainer = null, + cancelButton = null, + confirmButton = null, + documentRef = globalScope.document, + } = context; + + let resolver = null; + let currentConfig = null; + let currentInputs = []; + + function setHidden(node, hidden) { + if (!node) return; + node.hidden = Boolean(hidden); + } + + function resetAlert() { + if (!alertNode) return; + alertNode.textContent = ''; + alertNode.className = 'modal-alert modal-form-alert'; + alertNode.hidden = true; + } + + function setAlert(message = '', tone = 'danger') { + if (!alertNode) return; + const text = String(message || '').trim(); + if (!text) { + resetAlert(); + return; + } + alertNode.textContent = text; + alertNode.className = `modal-alert modal-form-alert${tone === 'danger' ? ' is-danger' : ''}`; + alertNode.hidden = false; + } + + function close(result = null) { + if (resolver) { + resolver(result); + resolver = null; + } + currentConfig = null; + currentInputs = []; + resetAlert(); + if (fieldsContainer) { + fieldsContainer.innerHTML = ''; + } + if (overlay) { + overlay.hidden = true; + } + } + + function buildFieldNode(field, values) { + const wrapper = documentRef.createElement('div'); + wrapper.className = 'modal-form-row'; + + const label = documentRef.createElement('label'); + label.className = 'modal-form-label'; + label.textContent = String(field.label || field.key || '').trim(); + wrapper.appendChild(label); + + let input = null; + if (field.type === 'textarea') { + input = documentRef.createElement('textarea'); + input.className = 'data-textarea'; + } else if (field.type === 'select') { + input = documentRef.createElement('select'); + input.className = 'data-select'; + const options = Array.isArray(field.options) ? field.options : []; + options.forEach((option) => { + const optionNode = documentRef.createElement('option'); + optionNode.value = String(option?.value || ''); + optionNode.textContent = String(option?.label || option?.value || ''); + input.appendChild(optionNode); + }); + } else { + input = documentRef.createElement('input'); + input.type = field.type === 'password' ? 'password' : 'text'; + input.className = 'data-input'; + } + + const normalizedValue = Object.prototype.hasOwnProperty.call(values, field.key) + ? values[field.key] + : field.value; + if (normalizedValue !== undefined && normalizedValue !== null) { + input.value = String(normalizedValue); + } + if (field.placeholder) { + input.placeholder = String(field.placeholder); + } + if (field.autocomplete) { + input.autocomplete = String(field.autocomplete); + } + if (field.inputMode) { + input.inputMode = String(field.inputMode); + } + if (field.rows && field.type === 'textarea') { + input.rows = Number(field.rows) || 3; + } + input.dataset.fieldKey = String(field.key || ''); + label.htmlFor = field.key; + input.id = field.key; + wrapper.appendChild(input); + + if (field.type !== 'textarea') { + input.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + void handleConfirm(); + }); + } + + currentInputs.push({ field, input }); + return wrapper; + } + + function collectValues() { + return currentInputs.reduce((result, item) => { + result[item.field.key] = item.input.value; + return result; + }, {}); + } + + async function handleConfirm() { + if (!currentConfig) { + close(null); + return; + } + + const values = collectValues(); + resetAlert(); + + for (const item of currentInputs) { + const { field, input } = item; + const rawValue = values[field.key]; + const textValue = String(rawValue || '').trim(); + if (field.required && !textValue) { + setAlert(field.requiredMessage || `${field.label || field.key}不能为空。`); + input.focus?.(); + return; + } + if (typeof field.validate === 'function') { + const validationMessage = await field.validate(rawValue, values); + if (validationMessage) { + setAlert(validationMessage); + input.focus?.(); + return; + } + } + } + + close(values); + } + + function bindEvents() { + overlay?.addEventListener('click', (event) => { + if (event.target === overlay) { + close(null); + } + }); + closeButton?.addEventListener('click', () => close(null)); + cancelButton?.addEventListener('click', () => close(null)); + confirmButton?.addEventListener('click', () => { + void handleConfirm(); + }); + } + + async function open(config = {}) { + if (!overlay || !titleNode || !fieldsContainer || !confirmButton) { + return null; + } + if (resolver) { + close(null); + } + + currentConfig = config || {}; + currentInputs = []; + titleNode.textContent = String(currentConfig.title || '填写表单'); + if (messageNode) { + const message = String(currentConfig.message || '').trim(); + messageNode.textContent = message; + setHidden(messageNode, !message); + } + resetAlert(); + if (currentConfig.alert?.text) { + setAlert(currentConfig.alert.text, currentConfig.alert.tone || 'danger'); + } + + confirmButton.textContent = String(currentConfig.confirmLabel || '确认'); + confirmButton.className = `btn ${currentConfig.confirmVariant || 'btn-primary'} btn-sm`; + fieldsContainer.innerHTML = ''; + + const initialValues = currentConfig.initialValues && typeof currentConfig.initialValues === 'object' + ? currentConfig.initialValues + : {}; + const fields = Array.isArray(currentConfig.fields) ? currentConfig.fields : []; + fields.forEach((field) => { + fieldsContainer.appendChild(buildFieldNode(field, initialValues)); + }); + + overlay.hidden = false; + const firstInput = currentInputs[0]?.input || null; + if (firstInput && typeof globalScope.requestAnimationFrame === 'function') { + globalScope.requestAnimationFrame(() => firstInput.focus?.()); + } else { + firstInput?.focus?.(); + } + + return new Promise((resolve) => { + resolver = resolve; + }); + } + + bindEvents(); + + return { + close, + open, + }; + } + + globalScope.SidepanelFormDialog = { + createFormDialog, + }; +})(window); diff --git a/sidepanel/paypal-manager.js b/sidepanel/paypal-manager.js new file mode 100644 index 0000000..97c1be0 --- /dev/null +++ b/sidepanel/paypal-manager.js @@ -0,0 +1,191 @@ +(function attachSidepanelPayPalManager(globalScope) { + function createPayPalManager(context = {}) { + const { + state, + dom, + helpers, + runtime, + paypalUtils = {}, + } = context; + + let actionInFlight = false; + + function getPayPalAccounts(currentState = state.getLatestState()) { + return helpers.getPayPalAccounts(currentState); + } + + function getCurrentPayPalAccountId(currentState = state.getLatestState()) { + return String(currentState?.currentPayPalAccountId || '').trim(); + } + + function buildSelectOptions(accounts = []) { + if (!accounts.length) { + return ''; + } + return accounts.map((account) => ( + `` + )).join(''); + } + + function applyPayPalAccountMutation(account) { + if (!account?.id) return; + const latestState = state.getLatestState(); + const nextAccounts = typeof paypalUtils.upsertPayPalAccountInList === 'function' + ? paypalUtils.upsertPayPalAccountInList(getPayPalAccounts(latestState), account) + : [...getPayPalAccounts(latestState), account]; + state.syncLatestState({ paypalAccounts: nextAccounts }); + renderPayPalAccounts(); + } + + function renderPayPalAccounts() { + if (!dom.selectPayPalAccount) return; + + const latestState = state.getLatestState(); + const accounts = getPayPalAccounts(latestState); + const currentId = getCurrentPayPalAccountId(latestState); + + dom.selectPayPalAccount.innerHTML = buildSelectOptions(accounts); + dom.selectPayPalAccount.disabled = accounts.length === 0; + dom.selectPayPalAccount.value = accounts.some((account) => account.id === currentId) ? currentId : ''; + } + + async function syncSelectedPayPalAccount(options = {}) { + const { silent = false } = options; + const accountId = String(dom.selectPayPalAccount?.value || '').trim(); + if (!accountId) { + state.syncLatestState({ + currentPayPalAccountId: null, + paypalEmail: '', + paypalPassword: '', + }); + renderPayPalAccounts(); + return null; + } + + const response = await runtime.sendMessage({ + type: 'SELECT_PAYPAL_ACCOUNT', + source: 'sidepanel', + payload: { accountId }, + }); + if (response?.error) { + throw new Error(response.error); + } + + state.syncLatestState({ + currentPayPalAccountId: response.account?.id || accountId, + paypalEmail: String(response.account?.email || '').trim(), + paypalPassword: String(response.account?.password || ''), + }); + renderPayPalAccounts(); + if (!silent) { + helpers.showToast(`已切换当前 PayPal 账号为 ${response.account?.email || accountId}`, 'success', 1800); + } + return response.account || null; + } + + async function openPayPalAccountDialog() { + if (typeof helpers.openFormDialog !== 'function') { + throw new Error('表单弹窗能力未加载,请刷新扩展后重试。'); + } + return helpers.openFormDialog({ + title: '添加 PayPal 账号', + confirmLabel: '保存账号', + confirmVariant: 'btn-primary', + fields: [ + { + key: 'email', + label: 'PayPal 账号', + type: 'text', + placeholder: '请输入 PayPal 登录邮箱', + autocomplete: 'username', + required: true, + requiredMessage: '请先填写 PayPal 账号。', + validate: (value) => { + const normalized = String(value || '').trim(); + if (!normalized.includes('@')) { + return 'PayPal 账号需填写邮箱格式。'; + } + return ''; + }, + }, + { + key: 'password', + label: 'PayPal 密码', + type: 'password', + placeholder: '请输入 PayPal 登录密码', + autocomplete: 'current-password', + required: true, + requiredMessage: '请先填写 PayPal 密码。', + }, + ], + }); + } + + async function handleAddPayPalAccount() { + if (actionInFlight) return; + + const formValues = await openPayPalAccountDialog(); + if (!formValues) { + return; + } + + actionInFlight = true; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = true; + } + + try { + const response = await runtime.sendMessage({ + type: 'UPSERT_PAYPAL_ACCOUNT', + source: 'sidepanel', + payload: { + email: String(formValues.email || '').trim(), + password: String(formValues.password || ''), + }, + }); + if (response?.error) { + throw new Error(response.error); + } + + applyPayPalAccountMutation(response.account); + if (response.account?.id) { + state.syncLatestState({ currentPayPalAccountId: response.account.id }); + renderPayPalAccounts(); + dom.selectPayPalAccount.value = response.account.id; + await syncSelectedPayPalAccount({ silent: true }); + } + helpers.showToast(`已保存 PayPal 账号 ${response.account?.email || ''}`, 'success', 2200); + } catch (error) { + helpers.showToast(`保存 PayPal 账号失败:${error.message}`, 'error'); + throw error; + } finally { + actionInFlight = false; + if (dom.btnAddPayPalAccount) { + dom.btnAddPayPalAccount.disabled = false; + } + } + } + + function bindPayPalEvents() { + dom.btnAddPayPalAccount?.addEventListener('click', () => { + void handleAddPayPalAccount(); + }); + dom.selectPayPalAccount?.addEventListener('change', () => { + void syncSelectedPayPalAccount().catch((error) => { + helpers.showToast(error.message, 'error'); + renderPayPalAccounts(); + }); + }); + } + + return { + bindPayPalEvents, + renderPayPalAccounts, + syncSelectedPayPalAccount, + }; + } + + globalScope.SidepanelPayPalManager = { + createPayPalManager, + }; +})(window); diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index df0dce6..f3f3d94 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -2722,6 +2722,36 @@ header { cursor: pointer; } +.modal-form-fields { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 14px; +} + +.modal-form-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.modal-form-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.modal-form-row .data-input, +.modal-form-row .data-select, +.modal-form-row .data-textarea { + width: 100%; +} + +.modal-form-alert { + margin-top: -4px; + margin-bottom: 14px; +} + .modal-actions { display: flex; justify-content: flex-end; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 40e9a1a..5f7d6ab 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -383,13 +383,14 @@ PayPal 订阅链路 - + +