diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7b356920 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# 强制所有文本文件使用 LF,避免 Windows 下 CRLF 重写整文件造成 git diff stat 虚高。 +* text=auto eol=lf + +# 二进制资源不动。 +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.zip binary +*.crx binary +*.pdf binary diff --git a/.gitignore b/.gitignore index 9ed4eab3..c03f9f95 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ /data/account-run-history.json .npm-test.log .omx/ +.omc/ +/tmp/ /node_modules /.runtime /docs/新步骤顺序 diff --git a/background.js b/background.js index 02ef4c82..ae660d11 100644 --- a/background.js +++ b/background.js @@ -3,6 +3,8 @@ importScripts( 'flows/openai/index.js', 'flows/openai/workflow.js', + 'flows/openai-reauth/index.js', + 'flows/openai-reauth/workflow.js', 'flows/kiro/index.js', 'flows/kiro/workflow.js', 'flows/grok/index.js', @@ -49,6 +51,7 @@ importScripts( 'background/signup-flow-helpers.js', 'background/mail-rule-registry.js', 'flows/openai/mail-rules.js', + 'flows/openai-reauth/mail-rules.js', 'flows/kiro/mail-rules.js', 'flows/grok/mail-rules.js', 'background/flow-mail-polling.js', @@ -79,6 +82,14 @@ importScripts( 'flows/openai/background/steps/fetch-login-code.js', 'flows/openai/background/steps/confirm-oauth.js', 'flows/openai/background/steps/platform-verify.js', + 'flows/openai-reauth/reauth-account-validator.js', + 'flows/openai-reauth/background/oauth-client.js', + 'flows/openai-reauth/background/cookie-cleanup.js', + 'flows/openai-reauth/background/batch-runner.js', + 'flows/openai-reauth/background/steps/prepare-reauth.js', + 'flows/openai-reauth/background/steps/submit-reauth-email.js', + 'flows/openai-reauth/background/steps/fetch-reauth-code.js', + 'flows/openai-reauth/background/steps/capture-reauth-callback.js', 'data/names.js', 'hotmail-utils.js', 'microsoft-email.js', @@ -4021,6 +4032,9 @@ function buildFreshAutoRunKeepState(prevState = {}) { if (typeof grokStateHelpers?.buildFreshKeepState === 'function') { Object.assign(keepState, grokStateHelpers.buildFreshKeepState(sourceState)); } + if (activeFlowId === 'openai-reauth' && isPlainObjectValue(sourceState.reauthInputAccount)) { + keepState.reauthInputAccount = sourceState.reauthInputAccount; + } if (Object.prototype.hasOwnProperty.call(sourceState, 'settingsSchemaVersion')) { keepState.settingsSchemaVersion = Number(sourceState.settingsSchemaVersion) || 0; } @@ -4141,6 +4155,19 @@ async function setState(updates) { ...currentSessionState, }, updates); await chrome.storage.session.set(sessionUpdates); + + // 广播 STATE_PATCH:让 sidepanel 等监听方即时同步增量。 + // payload 用 sessionUpdates(真实生效的最终值,而非 raw updates)。 + // 无接收方时 sendMessage 会 reject,只吞「无接收方」错误,其余 warn 输出方便排查。 + try { + chrome.runtime.sendMessage({ type: 'STATE_PATCH', payload: sessionUpdates }).catch((err) => { + const msg = err?.message || ''; + if (!msg.includes('Could not establish connection') && !msg.includes('receiving end does not exist')) { + console.warn('[setState] STATE_PATCH broadcast failed:', msg); + } + }); + } catch (_) { } + const persistentAliasUpdates = {}; if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'manualAliasUsage')) { persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(sessionUpdates.manualAliasUsage); @@ -13416,6 +13443,11 @@ const openAiMailRules = self.MultiPageOpenAiMailRules?.createOpenAiMailRules({ MAIL_2925_VERIFICATION_INTERVAL_MS, MAIL_2925_VERIFICATION_MAX_ATTEMPTS, }); +const openAiReauthMailRules = self.MultiPageOpenAiReauthMailRules?.createOpenAiReauthMailRules({ + getHotmailVerificationRequestTimestamp, + MAIL_2925_VERIFICATION_INTERVAL_MS, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS, +}); const kiroMailRules = self.MultiPageKiroMailRules?.createKiroMailRules({ LUCKMAIL_PROVIDER, MAIL_2925_VERIFICATION_INTERVAL_MS, @@ -13430,6 +13462,7 @@ const mailRuleRegistry = self.MultiPageBackgroundMailRuleRegistry?.createMailRul defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, flowBuilders: { openai: openAiMailRules, + 'openai-reauth': openAiReauthMailRules, kiro: kiroMailRules, grok: grokMailRules, }, @@ -14017,6 +14050,10 @@ const stepExecutorsByKey = { 'post-bound-email-phone-verification': (state) => step8Executor.executeBoundEmailPostLoginPhoneVerification(state), 'confirm-oauth': (state) => step9Executor.executeStep9(state), 'platform-verify': (state) => executeStep10(state), + 'prepare-reauth': (state) => prepareReauthExecutor.executePrepareReauth(state), + 'submit-reauth-email': (state) => submitReauthEmailExecutor.executeSubmitReauthEmail(state), + 'fetch-reauth-code': (state) => fetchReauthCodeExecutor.executeFetchReauthCode(state), + 'capture-reauth-callback': (state) => captureReauthCallbackExecutor.executeCaptureReauthCallback(state), 'kiro-open-register-page': (state) => kiroRegisterRunner.executeKiroOpenRegisterPage(state), 'kiro-submit-email': (state) => kiroRegisterRunner.executeKiroSubmitEmail(state), 'kiro-submit-name': (state) => kiroRegisterRunner.executeKiroSubmitName(state), @@ -14070,6 +14107,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter fetchGeneratedEmail, refreshGpcCardBalance, finalizePhoneActivationAfterSuccessfulFlow, + getReauthBatchRunner: () => reauthBatchRunner, testKiroRsConnection: async (baseUrl, apiKey) => { if (typeof self.MultiPageBackgroundKiroPublisherKiroRs?.checkKiroRsConnection !== 'function') { throw new Error('kiro.rs 连接测试能力尚未接入。'); @@ -15952,6 +15990,77 @@ const step9Executor = self.MultiPageBackgroundStep9?.createStep9Executor({ waitForStep8Ready, }); +const openAiReauthOAuthClient = self.MultiPageOpenAiReauthOAuthClient || null; +const openAiReauthCookieCleanup = self.MultiPageOpenAiReauthCookieCleanup || null; + +const prepareReauthExecutor = self.MultiPageOpenAiReauthPrepareStep?.createPrepareReauthExecutor({ + addLog, + chrome, + clearOpenAiAuthCookies: openAiReauthCookieCleanup?.clearOpenAiAuthCookies, + completeNodeFromBackground, + generatePkcePair: openAiReauthOAuthClient?.generatePkcePair, + generateState: openAiReauthOAuthClient?.generateState, + buildAuthorizeUrl: openAiReauthOAuthClient?.buildAuthorizeUrl, + registerTab, + reuseOrCreateTab, + setState, +}); + +const submitReauthEmailExecutor = self.MultiPageOpenAiReauthSubmitEmailStep?.createSubmitReauthEmailExecutor({ + addLog, + completeNodeFromBackground, + reuseOrCreateTab, + sendToContentScriptResilient, + throwIfStopped, +}); + +const fetchReauthCodeExecutor = self.MultiPageOpenAiReauthFetchCodeStep?.createFetchReauthCodeExecutor({ + addLog, + completeNodeFromBackground, + pollFlowVerificationCode: flowMailPollingService?.pollFlowVerificationCode, + sendToContentScriptResilient, + sleepWithStop, + throwIfStopped, +}); + +const captureReauthCallbackExecutor = self.MultiPageOpenAiReauthCaptureCallbackStep?.createCaptureReauthCallbackExecutor({ + addLog, + chrome, + completeNodeFromBackground, + exchangeAuthorizationCode: openAiReauthOAuthClient?.exchangeAuthorizationCode, + parseCallbackUrl: openAiReauthOAuthClient?.parseCallbackUrl, + buildUpdatedAccount: openAiReauthOAuthClient?.buildUpdatedAccount, + setState, + // 复用注册流程 step9 的 OAuth 同意页点击编排 + getTabId, + isTabAlive, + ensureStep8SignupPageReady, + waitForStep8Ready, + prepareStep8DebuggerClick, + clickWithDebugger, + triggerStep8ContentStrategy, + waitForStep8ClickEffect, + getStep8EffectLabel, + reloadStep8ConsentPage, + sleepWithStop, + throwIfStopped, + STEP8_STRATEGIES, + STEP8_MAX_ROUNDS, + STEP8_CLICK_RETRY_DELAY_MS, + STEP8_READY_WAIT_TIMEOUT_MS, +}); + +const reauthBatchRunner = self.MultiPageOpenAiReauthBatchRunner?.createReauthBatchRunner({ + addLog, + executeNode, + getNodeIdsForState, + getState, + setState, + sleepWithStop, + throwIfStopped, + extractAccountEmail: self.MultiPageOpenAiReauthAccountValidator?.extractAccountEmail, +}); + async function executeStep9(state) { return step9Executor.executeStep9(state); } diff --git a/background/message-router.js b/background/message-router.js index 1fadb689..9f697b2d 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -49,6 +49,7 @@ getCurrentPayPalAccount, getCurrentMail2925Account, getPendingAutoRunTimerPlan, + getReauthBatchRunner = () => null, getSourceLabel, getState, getNodeDefinitionForState, @@ -1276,6 +1277,9 @@ await setPersistentSettings({ emailPrefix: message.payload.emailPrefix }); await setState({ emailPrefix: message.payload.emailPrefix }); } + if (message.payload.reauthInputAccount !== undefined) { + await setState({ reauthInputAccount: message.payload.reauthInputAccount }); + } const executionState = await getState(); if (doesNodeUseCompletionSignal(nodeId, executionState)) { const completionPayload = await executeNodeViaCompletionSignal(nodeId); @@ -1311,6 +1315,9 @@ if (Object.keys(autoRunFlowStateUpdates).length > 0 && typeof setState === 'function') { await setState(autoRunFlowStateUpdates); } + if (message.payload?.reauthInputAccount !== undefined && typeof setState === 'function') { + await setState({ reauthInputAccount: message.payload.reauthInputAccount }); + } const state = await getState(); const autoRunStartValidation = validateAutoRunStart(state, { activeFlowId: autoRunFlowStateUpdates.activeFlowId ?? state?.activeFlowId, @@ -1331,6 +1338,49 @@ return { ok: true }; } + case 'START_REAUTH_BATCH': { + clearStopRequest(); + if (message.source === 'sidepanel') { + await lockAutomationWindowFromMessage(message, sender); + } + const runner = getReauthBatchRunner(); + if (!runner || typeof runner.executeReauthBatch !== 'function') { + throw new Error('reauth 批量处理器尚未初始化。'); + } + const payload = message.payload || {}; + const accounts = Array.isArray(payload.accounts) ? payload.accounts : []; + if (accounts.length === 0) { + throw new Error('未选择任何待批量处理的账号。'); + } + const mailProvider = String(payload.mailProvider || '').trim(); + if (!mailProvider) { + throw new Error('未指定 mailProvider,请先在 sidepanel 选择邮箱来源。'); + } + if (typeof setState === 'function') { + await setState({ + activeFlowId: 'openai-reauth', + flowId: 'openai-reauth', + mailProvider, + }); + } + // fire-and-forget:后台执行,进度通过 setState 广播 + runner.executeReauthBatch({ + accounts, + mailProvider, + originalFileText: String(payload.originalFileText || ''), + skipOnFailure: payload.skipOnFailure !== false, + }).catch(async (error) => { + const message = error instanceof Error ? error.message : String(error || 'reauth 批量处理失败'); + console.warn('[MessageRouter] reauth batch failed:', message); + if (typeof addLog === 'function') { + try { + await addLog(`reauth 批量处理终止:${message}`, 'error', { stepKey: 'reauth-batch' }); + } catch (_) {} + } + }); + return { ok: true }; + } + case 'SKIP_AUTO_RUN_COUNTDOWN': { clearStopRequest(); if (message.source === 'sidepanel') { diff --git "a/docs/openai-reauth-release-\345\205\254\345\221\212\350\215\211\347\250\277.md" "b/docs/openai-reauth-release-\345\205\254\345\221\212\350\215\211\347\250\277.md" new file mode 100644 index 00000000..b55d6f38 --- /dev/null +++ "b/docs/openai-reauth-release-\345\205\254\345\221\212\350\215\211\347\250\277.md" @@ -0,0 +1,29 @@ +# 新增 OpenAI 重新授权流程(含新增浏览器权限) + +本次更新新增独立的 OpenAI Reauth flow,并因「批量结果一键下载整文件」能力新增 `downloads` 权限。**升级扩展时浏览器会弹出权限授予提示,属正常现象,授权后即可正常使用全部功能。** + +## 本次调整 + +- 新增独立流程 **「OpenAI 重新授权」**:针对 `refresh_token` 路径已被服务端 revoke、必须重新走完整 OAuth 才能拿到新 token 的 sub2api 账号 +- 支持单账号 / sub2api 整文件 / accounts 数组三种 JSON 输入 +- 支持「批量模式」:一次性对整文件内所有账号执行重新授权,自动累计成功 / 失败结果 +- 批量结果可一键下载为整文件 JSON(保留原文件结构,失败账号原样保留) +- 提供 2925 / Hotmail / iCloud / LuckMail / Cloud Mail / YYDS Mail / Cloudflare Temp Email 七种邮箱来源 + +## 影响范围 + +- **新增浏览器权限**:`downloads`(仅用于批量结果整文件下载) +- 升级后首次启用时,Chrome 会显示「此扩展需要新权限」提示,需要点击「启用扩展」/「授予」才会继续运行 +- 不影响现有 OpenAI 注册流程 / Kiro / Grok flow 的使用 + +## 用户需要做什么 + +- 升级后在 `chrome://extensions` 页面**确认授予新权限**(仅一次) +- 如果没有重新授权需求,可继续使用原 flow,无需任何操作 +- 需要重新授权时:sidepanel 切到「OpenAI 重新授权」→ 选 sub2api JSON 文件 → 选邮箱来源 → 单账号点「自动」/ 多账号开启「批量模式」→ 完成后下载更新后的整文件 JSON + +## 补充说明 + +- 本次新增的 `downloads` 权限仅在用户主动点击「下载完整 JSON 文件」时使用,不会主动写入任何本地文件 +- 所有 token / refresh_token 全程在本机处理,不外发任何第三方 +- 升级后如未弹权限提示但流程报错,请手动 disable / re-enable 扩展触发权限授予 diff --git a/flows/index.js b/flows/index.js index 3fa9b963..b9014a71 100644 --- a/flows/index.js +++ b/flows/index.js @@ -8,6 +8,10 @@ id: 'openai', path: 'flows/openai/', }, + 'openai-reauth': { + id: 'openai-reauth', + path: 'flows/openai-reauth/', + }, kiro: { id: 'kiro', path: 'flows/kiro/', @@ -32,18 +36,28 @@ if (!baseEntry) { return null; } + function pickDefinition() { + switch (normalized) { + case 'openai': return rootScope.MultiPageOpenAiFlowDefinition || null; + case 'openai-reauth': return rootScope.MultiPageOpenAiReauthFlowDefinition || null; + case 'kiro': return rootScope.MultiPageKiroFlowDefinition || null; + case 'grok': return rootScope.MultiPageGrokFlowDefinition || null; + default: return null; + } + } + function pickWorkflow() { + switch (normalized) { + case 'openai': return rootScope.MultiPageOpenAiWorkflow || null; + case 'openai-reauth': return rootScope.MultiPageOpenAiReauthWorkflow || null; + case 'kiro': return rootScope.MultiPageKiroWorkflow || null; + case 'grok': return rootScope.MultiPageGrokWorkflow || null; + default: return null; + } + } return { ...baseEntry, - definition: normalized === 'openai' - ? (rootScope.MultiPageOpenAiFlowDefinition || null) - : (normalized === 'kiro' - ? (rootScope.MultiPageKiroFlowDefinition || null) - : (rootScope.MultiPageGrokFlowDefinition || null)), - workflow: normalized === 'openai' - ? (rootScope.MultiPageOpenAiWorkflow || null) - : (normalized === 'kiro' - ? (rootScope.MultiPageKiroWorkflow || null) - : (rootScope.MultiPageGrokWorkflow || null)), + definition: pickDefinition(), + workflow: pickWorkflow(), }; } diff --git a/flows/openai-reauth/background/batch-runner.js b/flows/openai-reauth/background/batch-runner.js new file mode 100644 index 00000000..66464604 --- /dev/null +++ b/flows/openai-reauth/background/batch-runner.js @@ -0,0 +1,373 @@ +(function attachOpenAiReauthBatchRunner(root, factory) { + root.MultiPageOpenAiReauthBatchRunner = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBatchRunnerModule() { + const REAUTH_NODE_IDS = Object.freeze([ + 'prepare-reauth', + 'submit-reauth-email', + 'fetch-reauth-code', + 'capture-reauth-callback', + ]); + + const DEFAULT_INTER_ACCOUNT_DELAY_MS = 2000; + const BATCH_LOG_STEP_KEY = 'reauth-batch'; + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function extractAccountEmail(account = {}) { + // 优先走 validator 的统一实现,保持双端 email 提取逻辑一致。 + const validator = (typeof self !== 'undefined' ? self : globalThis) + .MultiPageOpenAiReauthAccountValidator; + const base = (validator && typeof validator.extractAccountEmail === 'function') + ? (validator.extractAccountEmail(account) || '') + : (function fallbackExtractAccountEmail() { + if (!account || typeof account !== 'object') return ''; + const credentials = isPlainObject(account.credentials) ? account.credentials : {}; + return cleanString(credentials.email || account.email || account.name); + })(); + return base.toLowerCase(); + } + + function isLikelyStopError(error) { + const message = String(error?.message || error || ''); + return /已被用户停止|user_stop|operation_aborted|stop signal|stopped by user/i.test(message); + } + + function isLikelyAccountFatalError(error) { + const message = String(error?.message || error || ''); + return /ACCOUNT_FATAL::/i.test(message); + } + + function buildResolvedAccountForState(account, mailProvider) { + if (!account || typeof account !== 'object') return account; + if (cleanString(account.mailProvider)) return account; + const provider = cleanString(mailProvider); + return provider ? { ...account, mailProvider: provider } : account; + } + + /** + * 将原始文件 JSON + 成功账号列表合并成新的整文件 JSON。 + * - 单账号对象 / accounts 数组 / 顶层数组 三种 schema 都支持。 + * - 成功账号按 email 匹配,merge 字段(保留原 metadata 如 priority/concurrency)。 + * - 失败账号保留原 entry,不丢数据。 + * - 原始文本不可用时退化为输出 success 数组。 + */ + function mergeBatchResultsIntoFile(originalFileText, successAccounts = [], extractEmail = extractAccountEmail) { + const safeAccounts = Array.isArray(successAccounts) ? successAccounts.filter(Boolean) : []; + const trimmedText = cleanString(originalFileText); + + if (!trimmedText) { + return JSON.stringify(safeAccounts, null, 2); + } + + let parsed; + try { + parsed = JSON.parse(trimmedText); + } catch { + return JSON.stringify(safeAccounts, null, 2); + } + + const successByEmail = new Map(); + for (const account of safeAccounts) { + const email = extractEmail(account); + if (email) { + successByEmail.set(email, account); + } + } + + function mergeEntry(entry) { + if (!entry || typeof entry !== 'object') return entry; + const email = extractEmail(entry); + if (!email || !successByEmail.has(email)) { + return entry; + } + const next = { ...entry }; + const updated = successByEmail.get(email); + if (updated && typeof updated === 'object') { + for (const [key, value] of Object.entries(updated)) { + next[key] = value; + } + } + return next; + } + + let updated; + if (Array.isArray(parsed)) { + updated = parsed.map(mergeEntry); + } else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.accounts)) { + updated = { ...parsed, accounts: parsed.accounts.map(mergeEntry) }; + } else if (parsed && typeof parsed === 'object') { + updated = mergeEntry(parsed); + } else { + updated = parsed; + } + + return JSON.stringify(updated, null, 2); + } + + function createReauthBatchRunner(deps = {}) { + const { + addLog = async () => {}, + executeNode, + getNodeIdsForState = null, + getState, + setState, + throwIfStopped = () => {}, + sleepWithStop = null, + interAccountDelayMs = DEFAULT_INTER_ACCOUNT_DELAY_MS, + extractAccountEmail: injectedExtractAccountEmail = null, + } = deps; + + if (typeof executeNode !== 'function') { + throw new Error('reauth-batch-runner 缺少 executeNode。'); + } + if (typeof getState !== 'function') { + throw new Error('reauth-batch-runner 缺少 getState。'); + } + if (typeof setState !== 'function') { + throw new Error('reauth-batch-runner 缺少 setState。'); + } + + async function log(message, level = 'info', options = {}) { + const normalized = options && typeof options === 'object' ? { ...options } : {}; + if (!normalized.stepKey) normalized.stepKey = BATCH_LOG_STEP_KEY; + return addLog(message, level, normalized); + } + + async function safeSleep(ms) { + const duration = Math.max(0, Math.floor(Number(ms) || 0)); + if (duration <= 0) return; + throwIfStopped(); + if (typeof sleepWithStop === 'function') { + await sleepWithStop(duration); + return; + } + await new Promise((resolve) => setTimeout(resolve, duration)); + throwIfStopped(); + } + + function getAccountEmail(account) { + if (typeof injectedExtractAccountEmail === 'function') { + try { + const injectedEmail = cleanString(injectedExtractAccountEmail(account)); + if (injectedEmail) return injectedEmail.toLowerCase(); + } catch { + // 注入的提取器异常时回退到模块内置逻辑,避免批量流程因日志/测试替身中断。 + } + } + return extractAccountEmail(account); + } + + async function resolveOrderedNodeIds() { + if (typeof getNodeIdsForState !== 'function') { + return [...REAUTH_NODE_IDS]; + } + try { + const state = await getState(); + const ids = (getNodeIdsForState(state) || []).filter(Boolean); + return ids.length > 0 ? ids : [...REAUTH_NODE_IDS]; + } catch { + return [...REAUTH_NODE_IDS]; + } + } + + async function runSingleAccount(account, options = {}) { + const email = getAccountEmail(account); + const accountForState = buildResolvedAccountForState(account, options.mailProvider); + + await setState({ + reauthInputAccount: accountForState, + reauthResultAccount: null, + reauthLastError: '', + reauthEmail: email, + nodeStatuses: {}, + }); + + const orderedNodeIds = await resolveOrderedNodeIds(); + + for (const nodeId of orderedNodeIds) { + throwIfStopped(); + await executeNode(nodeId); + } + + const finalState = await getState(); + return finalState?.reauthResultAccount || null; + } + + async function executeReauthBatch(options = {}) { + const accounts = Array.isArray(options.accounts) ? options.accounts.filter(Boolean) : []; + if (accounts.length === 0) { + throw new Error('reauth 批量队列为空,请先选择待处理的账号。'); + } + + const mailProvider = cleanString(options.mailProvider); + const originalFileText = String(options.originalFileText || ''); + const skipOnFailure = options.skipOnFailure !== false; + const total = accounts.length; + const success = []; + const failed = []; + const startedAt = Date.now(); + + await setState({ + reauthBatchRunning: true, + reauthBatchProgress: { + current: 0, + total, + currentEmail: '', + currentStatus: 'pending', + }, + reauthBatchResult: null, + }); + + await log(`开始 reauth 批量处理(共 ${total} 个账号)...`, 'info'); + + try { + for (let index = 0; index < total; index += 1) { + throwIfStopped(); + const account = accounts[index]; + const email = getAccountEmail(account) || `账号 #${index + 1}`; + const current = index + 1; + + await setState({ + reauthBatchProgress: { + current, + total, + currentEmail: email, + currentStatus: 'running', + }, + }); + await log(`[${current}/${total}] 开始处理 ${email}`, 'info'); + + try { + const updatedAccount = await runSingleAccount(account, { mailProvider }); + if (!updatedAccount || typeof updatedAccount !== 'object') { + throw new Error('reauth 完成但 reauthResultAccount 为空。'); + } + success.push(updatedAccount); + await log(`[${current}/${total}] ${email} 重新授权成功 ✓`, 'ok'); + await setState({ + reauthBatchProgress: { + current, + total, + currentEmail: email, + currentStatus: 'success', + }, + }); + } catch (error) { + if (isLikelyStopError(error)) { + throw error; + } + const message = getErrorMessage(error); + const fatal = isLikelyAccountFatalError(error); + failed.push({ account, email, error: message, fatal }); + const fatalLabel = fatal ? '(账号异常,已跳过)' : ''; + await log(`[${current}/${total}] ${email} ${fatal ? '账号不可用' : '失败'}:${message}${fatalLabel}`, fatal ? 'warn' : 'error'); + await setState({ + reauthBatchProgress: { + current, + total, + currentEmail: email, + currentStatus: 'failed', + }, + }); + if (!skipOnFailure) { + throw error; + } + } + + if (current < total) { + await safeSleep(interAccountDelayMs); + } + } + } catch (error) { + const stopped = isLikelyStopError(error); + try { + await setState({ + reauthBatchRunning: false, + reauthBatchProgress: { + current: success.length + failed.length, + total, + currentEmail: '', + currentStatus: stopped ? 'stopped' : 'aborted', + }, + reauthBatchResult: { + success: success.map((account) => ({ account, email: getAccountEmail(account) })), + failed, + updatedFileJson: mergeBatchResultsIntoFile(originalFileText, success, getAccountEmail), + successCount: success.length, + failedCount: failed.length, + total, + startedAt, + finalizedAt: Date.now(), + aborted: true, + stopReason: stopped ? 'user_stop' : getErrorMessage(error), + }, + }); + } catch (stateError) { + try { + await log(`批量终止状态写入失败:${getErrorMessage(stateError)}`, 'warn'); + } catch { + // 保留原始错误,状态写入/日志失败不应覆盖真正的批量终止原因。 + } + } + throw error; + } + + const updatedFileJson = mergeBatchResultsIntoFile(originalFileText, success, getAccountEmail); + const finalResult = { + success: success.map((account) => ({ account, email: getAccountEmail(account) })), + failed, + updatedFileJson, + successCount: success.length, + failedCount: failed.length, + total, + startedAt, + finalizedAt: Date.now(), + aborted: false, + }; + + await setState({ + reauthBatchRunning: false, + reauthBatchProgress: { + current: total, + total, + currentEmail: '', + currentStatus: 'completed', + }, + reauthBatchResult: finalResult, + }); + + await log( + `reauth 批量处理完成:${success.length}/${total} 成功,${failed.length} 失败。`, + failed.length > 0 ? 'warn' : 'ok' + ); + + return finalResult; + } + + return { + executeReauthBatch, + runSingleAccount, + }; + } + + return { + REAUTH_NODE_IDS, + DEFAULT_INTER_ACCOUNT_DELAY_MS, + BATCH_LOG_STEP_KEY, + extractAccountEmail, + mergeBatchResultsIntoFile, + isLikelyStopError, + isLikelyAccountFatalError, + createReauthBatchRunner, + }; +}); diff --git a/flows/openai-reauth/background/cookie-cleanup.js b/flows/openai-reauth/background/cookie-cleanup.js new file mode 100644 index 00000000..cd145bcf --- /dev/null +++ b/flows/openai-reauth/background/cookie-cleanup.js @@ -0,0 +1,124 @@ +(function attachOpenAiReauthCookieCleanup(root, factory) { + root.MultiPageOpenAiReauthCookieCleanup = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthCookieCleanupModule() { + const REAUTH_COOKIE_CLEAR_DOMAINS = Object.freeze([ + 'chatgpt.com', + 'chat.openai.com', + 'openai.com', + 'auth.openai.com', + 'auth0.openai.com', + 'accounts.openai.com', + ]); + + function normalizeCookieDomain(domain) { + return String(domain || '').trim().replace(/^\.+/, '').toLowerCase(); + } + + function shouldClearCookie(cookie) { + const domain = normalizeCookieDomain(cookie?.domain); + if (!domain) return false; + return REAUTH_COOKIE_CLEAR_DOMAINS.some((target) => ( + domain === target || domain.endsWith(`.${target}`) + )); + } + + function buildCookieKey(cookie, fallbackStoreId = '') { + return [ + cookie?.storeId || fallbackStoreId || '', + cookie?.domain || '', + cookie?.path || '', + cookie?.name || '', + cookie?.partitionKey ? JSON.stringify(cookie.partitionKey) : '', + ].join('|'); + } + + function buildCookieRemovalUrl(cookie) { + const host = normalizeCookieDomain(cookie?.domain); + const rawPath = String(cookie?.path || '/'); + const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; + return `https://${host}${path}`; + } + + function getErrorMessage(error) { + return error?.message || String(error || '未知错误'); + } + + async function collectCookies(chromeApi) { + if (!chromeApi?.cookies?.getAll) { + return []; + } + const stores = chromeApi.cookies.getAllCookieStores + ? await chromeApi.cookies.getAllCookieStores() + : [{ id: undefined }]; + const cookies = []; + const seen = new Set(); + const queryDomains = Array.from( + new Set(REAUTH_COOKIE_CLEAR_DOMAINS.map(normalizeCookieDomain).filter(Boolean)) + ); + + for (const store of stores) { + const storeId = store?.id; + for (const domain of queryDomains) { + let batch = []; + try { + batch = await chromeApi.cookies.getAll( + storeId ? { storeId, domain } : { domain } + ); + } catch (error) { + console.warn('[MultiPage:reauth-cookie-cleanup] query cookies failed', { + storeId: storeId || '', + domain, + message: getErrorMessage(error), + }); + continue; + } + for (const cookie of batch || []) { + if (!shouldClearCookie(cookie)) continue; + const key = buildCookieKey(cookie, storeId); + if (seen.has(key)) continue; + seen.add(key); + cookies.push(cookie); + } + } + } + return cookies; + } + + async function removeCookie(chromeApi, cookie) { + const details = { + url: buildCookieRemovalUrl(cookie), + name: cookie.name, + }; + if (cookie.storeId) details.storeId = cookie.storeId; + if (cookie.partitionKey) details.partitionKey = cookie.partitionKey; + + try { + const result = await chromeApi.cookies.remove(details); + return Boolean(result); + } catch (error) { + console.warn('[MultiPage:reauth-cookie-cleanup] remove cookie failed', { + domain: cookie?.domain, + name: cookie?.name, + message: getErrorMessage(error), + }); + return false; + } + } + + async function clearOpenAiAuthCookies({ chromeApi } = {}) { + if (!chromeApi?.cookies) { + return { collected: 0, removed: 0 }; + } + const cookies = await collectCookies(chromeApi); + let removed = 0; + for (const cookie of cookies) { + if (await removeCookie(chromeApi, cookie)) removed += 1; + } + return { collected: cookies.length, removed }; + } + + return { + REAUTH_COOKIE_CLEAR_DOMAINS, + clearOpenAiAuthCookies, + }; +}); diff --git a/flows/openai-reauth/background/oauth-client.js b/flows/openai-reauth/background/oauth-client.js new file mode 100644 index 00000000..c59e2b08 --- /dev/null +++ b/flows/openai-reauth/background/oauth-client.js @@ -0,0 +1,251 @@ +(function attachOpenAiReauthOAuthClient(root, factory) { + root.MultiPageOpenAiReauthOAuthClient = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthOAuthClientModule() { + const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; + const ISSUER = 'https://auth.openai.com'; + const AUTHORIZE_ENDPOINT = `${ISSUER}/oauth/authorize`; + const TOKEN_ENDPOINT = `${ISSUER}/oauth/token`; + const REDIRECT_PORT = 1455; + const REDIRECT_PATH = '/auth/callback'; + const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + const SCOPE = 'openid profile email offline_access'; + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function base64UrlEncode(bytes) { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } + + async function sha256Bytes(input) { + const encoder = new TextEncoder(); + return new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(input || '')))); + } + + function randomUrlSafeString(length = 64) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const size = Math.max(43, Math.min(128, Math.floor(Number(length) || 64))); + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + let output = ''; + for (let index = 0; index < size; index += 1) { + output += alphabet[bytes[index] % alphabet.length]; + } + return output; + } + + async function generatePkcePair() { + const codeVerifier = randomUrlSafeString(64); + const codeChallenge = base64UrlEncode(await sha256Bytes(codeVerifier)); + return { codeVerifier, codeChallenge }; + } + + function generateState() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } + + function buildAuthorizeUrl(params = {}) { + const codeChallenge = cleanString(params.codeChallenge); + const stateToken = cleanString(params.state); + const clientId = cleanString(params.clientId) || CLIENT_ID; + if (!codeChallenge) { + throw new Error('buildAuthorizeUrl 缺少 codeChallenge。'); + } + if (!stateToken) { + throw new Error('buildAuthorizeUrl 缺少 state。'); + } + const search = new URLSearchParams(); + search.set('client_id', clientId); + search.set('code_challenge', codeChallenge); + search.set('code_challenge_method', 'S256'); + search.set('codex_cli_simplified_flow', 'true'); + search.set('id_token_add_organizations', 'true'); + search.set('redirect_uri', REDIRECT_URI); + search.set('response_type', 'code'); + search.set('scope', SCOPE); + search.set('state', stateToken); + return `${AUTHORIZE_ENDPOINT}?${search.toString()}`; + } + + function parseCallbackUrl(rawUrl, expectedState = '') { + const normalizedUrl = cleanString(rawUrl); + if (!normalizedUrl) { + return null; + } + let parsed; + try { + parsed = new URL(normalizedUrl); + } catch (_error) { + return null; + } + if (!/^https?:$/.test(parsed.protocol)) { + return null; + } + if (!['127.0.0.1', 'localhost'].includes(parsed.hostname)) { + return null; + } + if (Number(parsed.port || 0) !== REDIRECT_PORT) { + return null; + } + if (parsed.pathname !== REDIRECT_PATH) { + return null; + } + const stateValue = cleanString(parsed.searchParams.get('state')); + const errorText = cleanString( + parsed.searchParams.get('error_description') || parsed.searchParams.get('error') + ); + const code = cleanString(parsed.searchParams.get('code')); + if (expectedState && stateValue && stateValue !== cleanString(expectedState)) { + return { + url: normalizedUrl, + state: stateValue, + error: `回调 state 不匹配:expected=${cleanString(expectedState)} actual=${stateValue}`, + }; + } + if (errorText) { + return { url: normalizedUrl, state: stateValue, error: errorText }; + } + if (!code) { + return null; + } + return { url: normalizedUrl, state: stateValue, code }; + } + + function decodeJwtPayload(jwt) { + const parts = String(jwt || '').split('.'); + if (parts.length < 2) { + return null; + } + try { + const segment = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = segment + '='.repeat((4 - segment.length % 4) % 4); + if (typeof atob !== 'function') { + return null; + } + const decoded = atob(padded); + const bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index += 1) { + bytes[index] = decoded.charCodeAt(index); + } + const text = new TextDecoder().decode(bytes); + return JSON.parse(text); + } catch (_error) { + return null; + } + } + + async function exchangeAuthorizationCode(params = {}) { + const fetchImpl = typeof params.fetchImpl === 'function' + ? params.fetchImpl + : (typeof fetch === 'function' ? fetch.bind(globalThis) : null); + if (typeof fetchImpl !== 'function') { + throw new Error('exchangeAuthorizationCode 需要 fetch 支持。'); + } + const code = cleanString(params.code); + const codeVerifier = cleanString(params.codeVerifier); + const clientId = cleanString(params.clientId) || CLIENT_ID; + if (!code) throw new Error('exchangeAuthorizationCode 缺少 code。'); + if (!codeVerifier) throw new Error('exchangeAuthorizationCode 缺少 codeVerifier。'); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + }); + const response = await fetchImpl(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch (_error) { + json = null; + } + if (!response.ok) { + const reason = json?.error_description || json?.error || text || `${response.status}`; + throw new Error(`换取 Token 失败:${cleanString(reason).slice(0, 400) || response.status}`); + } + const accessToken = cleanString(json?.access_token); + const refreshToken = cleanString(json?.refresh_token); + const idToken = cleanString(json?.id_token); + const expiresIn = Number(json?.expires_in) || 0; + if (!accessToken || !refreshToken) { + throw new Error('Token 响应缺少 access_token 或 refresh_token。'); + } + return { + accessToken, + refreshToken, + idToken, + expiresIn, + tokenType: cleanString(json?.token_type), + }; + } + + function buildUpdatedAccount(originalAccount = {}, tokens = {}) { + const idPayload = decodeJwtPayload(tokens.idToken) || {}; + const accessPayload = decodeJwtPayload(tokens.accessToken) || {}; + const authClaims = idPayload['https://api.openai.com/auth'] || {}; + const profileClaims = idPayload['https://api.openai.com/profile'] || {}; + const clientId = cleanString(tokens.clientId) || CLIENT_ID; + const expiresAt = tokens.expiresIn + ? Math.floor(Date.now() / 1000) + Number(tokens.expiresIn) + : Number(accessPayload.exp || 0) || 0; + const defaultOrgId = (Array.isArray(authClaims.organizations) + ? authClaims.organizations.find((org) => org?.is_default)?.id + : '') || ''; + + const baseCredentials = (originalAccount && typeof originalAccount.credentials === 'object') + ? originalAccount.credentials + : {}; + const nextCredentials = { + ...baseCredentials, + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + id_token: tokens.idToken || baseCredentials.id_token || '', + client_id: clientId, + expires_at: expiresAt, + email: cleanString(profileClaims.email || idPayload.email || baseCredentials.email), + chatgpt_account_id: cleanString(authClaims.chatgpt_account_id || baseCredentials.chatgpt_account_id), + chatgpt_user_id: cleanString(authClaims.chatgpt_user_id || baseCredentials.chatgpt_user_id), + organization_id: cleanString(defaultOrgId || baseCredentials.organization_id), + plan_type: cleanString(authClaims.chatgpt_plan_type || baseCredentials.plan_type) || 'free', + }; + return { + ...originalAccount, + credentials: nextCredentials, + }; + } + + return { + CLIENT_ID, + ISSUER, + AUTHORIZE_ENDPOINT, + TOKEN_ENDPOINT, + REDIRECT_PORT, + REDIRECT_PATH, + REDIRECT_URI, + SCOPE, + base64UrlEncode, + buildAuthorizeUrl, + buildUpdatedAccount, + decodeJwtPayload, + exchangeAuthorizationCode, + generatePkcePair, + generateState, + parseCallbackUrl, + randomUrlSafeString, + sha256Bytes, + }; +}); diff --git a/flows/openai-reauth/background/steps/capture-reauth-callback.js b/flows/openai-reauth/background/steps/capture-reauth-callback.js new file mode 100644 index 00000000..dbe27455 --- /dev/null +++ b/flows/openai-reauth/background/steps/capture-reauth-callback.js @@ -0,0 +1,543 @@ +(function attachOpenAiReauthCaptureCallbackStep(root, factory) { + root.MultiPageOpenAiReauthCaptureCallbackStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createCaptureCallbackStepModule() { + const NODE_ID = 'capture-reauth-callback'; + const VISIBLE_STEP = 4; + const STEP_KEY = NODE_ID; + const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; + const CALLBACK_CHECK_INTERVAL_MS = 1000; + const ACCOUNT_FATAL_PREFIX = 'ACCOUNT_FATAL::'; + const PHONE_VERIFICATION_TEXT_PATTERNS = Object.freeze([ + 'phone-verification', + 'phone verification', + 'verify your phone', + 'phone number verification', + 'add phone', + 'add a phone number', + '验证您的手机号码', + '验证手机号码', + '手机验证码页', + '手机验证', + '添加手机号页', + '添加手机号', + '手机号', + '一次性验证码', + 'whatsapp', + ]); + + const ACCOUNT_BANNED_TEXT_PATTERNS = Object.freeze([ + 'account_deactivated', + 'account suspended', + 'account deactivated', + 'account banned', + 'account has been', + 'account locked', + 'account disabled', + 'not authorized', + 'account compromised', + 'violation of our', + 'account flagged', + // 中文封禁/停用页面对应的关键词(覆盖 OpenAI 各种中文 UI) + '身份验证错误', + '你没有账户', + '已被删除', + '已停用', + '停用', + '已封禁', + '封禁', + '已被禁用', + '账号已被', + '账号异常', + 'your account', + ]); + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function isPhoneVerificationRequiredError(error) { + const message = getErrorMessage(error).toLowerCase(); + return PHONE_VERIFICATION_TEXT_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase())); + } + + function createCaptureReauthCallbackExecutor(deps = {}) { + const { + addLog = async () => {}, + chrome: chromeApi = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), + completeNodeFromBackground, + exchangeAuthorizationCode, + parseCallbackUrl, + buildUpdatedAccount, + fetchImpl = (typeof fetch === 'function' ? fetch.bind(globalThis) : null), + setState, + // step9 辅助函数(复用注册流程的 OAuth 同意页点击编排) + getTabId = null, + isTabAlive = null, + ensureStep8SignupPageReady = null, + waitForStep8Ready = null, + prepareStep8DebuggerClick = null, + clickWithDebugger = null, + triggerStep8ContentStrategy = null, + waitForStep8ClickEffect = null, + getStep8EffectLabel = null, + reloadStep8ConsentPage = null, + sleepWithStop = null, + throwIfStopped = () => {}, + STEP8_STRATEGIES = null, + STEP8_MAX_ROUNDS = 3, + STEP8_CLICK_RETRY_DELAY_MS = 1500, + STEP8_READY_WAIT_TIMEOUT_MS = 30000, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 completeNodeFromBackground。'); + } + if (typeof exchangeAuthorizationCode !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 exchangeAuthorizationCode。'); + } + if (typeof parseCallbackUrl !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 parseCallbackUrl。'); + } + if (typeof buildUpdatedAccount !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 buildUpdatedAccount。'); + } + if (typeof setState !== 'function') { + throw new Error('capture-reauth-callback executor 缺少 setState。'); + } + if (!chromeApi?.webNavigation || !chromeApi?.tabs) { + throw new Error('capture-reauth-callback executor 需要 chrome.webNavigation / chrome.tabs。'); + } + + const consentClickEnabled = ( + typeof getTabId === 'function' + && typeof isTabAlive === 'function' + && typeof ensureStep8SignupPageReady === 'function' + && typeof waitForStep8Ready === 'function' + && typeof prepareStep8DebuggerClick === 'function' + && typeof clickWithDebugger === 'function' + && typeof triggerStep8ContentStrategy === 'function' + && typeof waitForStep8ClickEffect === 'function' + && typeof getStep8EffectLabel === 'function' + && typeof reloadStep8ConsentPage === 'function' + && typeof sleepWithStop === 'function' + && Array.isArray(STEP8_STRATEGIES) && STEP8_STRATEGIES.length > 0 + ); + // 建设性日志:标记 consent 主动点击能力是否就绪,方便排查步骤 4 行为差异。 + if (!consentClickEnabled) { + logStep('OAuth 同意页主动点击能力未注入(部分 step9 辅助函数缺失),步骤 4 将仅依赖 localhost 回调监听。', 'warn') + .catch(() => {}); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + /** + * 检测认证页是否显示账号封禁/停用文案。 + * 主路径走 content script 的 DETECT_ACCOUNT_BANNED 消息(已注入到页面,最可靠); + * 降级路径走 chrome.scripting.executeScript 直接注入检测。 + */ + async function checkTabForBannedAccount(tabId) { + if (!Number.isInteger(tabId)) return false; + + const lowerPatterns = ACCOUNT_BANNED_TEXT_PATTERNS.map((p) => p.toLowerCase()); + + // 主路径:通过已注入的 content script 检测(不受 host_permissions 限制) + if (chromeApi?.tabs?.sendMessage) { + try { + const response = await chromeApi.tabs.sendMessage(tabId, { + type: 'DETECT_ACCOUNT_BANNED', + payload: { patterns: lowerPatterns }, + }); + if (response?.accountBanned) return true; + } catch { + // content script 可能未就绪,降级到 executeScript + } + } + + // 降级路径:chrome.scripting 直接注入 + if (chromeApi?.scripting?.executeScript) { + try { + const results = await chromeApi.scripting.executeScript({ + target: { tabId }, + func: (patterns) => { + const text = (document.body?.innerText || document.title || '').toLowerCase(); + return patterns.some((p) => text.includes(p)); + }, + args: [lowerPatterns], + }); + return results?.[0]?.result === true; + } catch { + return false; + } + } + + return false; + } + + function isPhoneVerificationState(pageState) { + if (!pageState || typeof pageState !== 'object') return false; + return Boolean(pageState.phoneVerificationPage) + || Boolean(pageState.addPhonePage) + || pageState.state === 'phone_verification_page' + || pageState.state === 'add_phone_page' + || isPhoneVerificationRequiredError(pageState.url || pageState.path || ''); + } + + function isPhoneVerificationSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== 'object') return false; + const combined = [ + snapshot.url, + snapshot.title, + snapshot.text, + ] + .filter(Boolean) + .join('\n'); + return isPhoneVerificationRequiredError(combined); + } + + /** + * 步骤 3 邮箱验证码通过后,OpenAI 可能不会进入 OAuth 同意页,而是立即要求手机验证。 + * 这里在步骤 4 刚开始就做一次轻量预检,避免等待 OAuth ready/点击超时才跳过账号。 + */ + async function checkTabForPhoneVerificationRequired(tabId) { + if (!Number.isInteger(tabId)) return false; + + if (chromeApi?.tabs?.get) { + try { + const tab = await chromeApi.tabs.get(tabId); + if (isPhoneVerificationSnapshot(tab)) return true; + } catch { + // 标签页可能正在跳转,继续尝试 content / executeScript 路径。 + } + } + + if (chromeApi?.tabs?.sendMessage) { + try { + const response = await chromeApi.tabs.sendMessage(tabId, { + type: 'GET_LOGIN_AUTH_STATE', + source: 'background', + payload: {}, + }); + if (isPhoneVerificationState(response)) return true; + } catch { + // content script 可能未就绪,降级到 executeScript。 + } + } + + if (chromeApi?.scripting?.executeScript) { + try { + const results = await chromeApi.scripting.executeScript({ + target: { tabId }, + func: () => ({ + url: String(location.href || ''), + title: String(document.title || ''), + text: String(document.body?.innerText || document.documentElement?.innerText || '').trim(), + }), + }); + return isPhoneVerificationSnapshot(results?.[0]?.result); + } catch { + return false; + } + } + + return false; + } + + function buildAccountBannedError() { + return new Error(`${ACCOUNT_FATAL_PREFIX}account_banned::该账号已被 OpenAI 封禁/停用,无法继续重新授权。`); + } + + function buildPhoneVerificationRequiredError(error) { + const reason = getErrorMessage(error); + return new Error( + `${ACCOUNT_FATAL_PREFIX}phone_verification_required::该账号重新授权触发手机验证,当前 reauth 流程不处理手机验证,已跳过该账号。` + + (reason ? ` 原因:${reason}` : '') + ); + } + + function executeCaptureReauthCallback(state = {}) { + const nodeId = String(state?.nodeId || NODE_ID).trim(); + const expectedState = String(state?.reauthState || '').trim(); + const codeVerifier = String(state?.reauthCodeVerifier || '').trim(); + const originalAccount = state?.reauthInputAccount; + + return new Promise((resolve, reject) => { + if (!expectedState) { + reject(new Error('缺少 OAuth state,请先执行步骤 1。')); + return; + } + if (!codeVerifier) { + reject(new Error('缺少 PKCE code_verifier,请先执行步骤 1。')); + return; + } + if (!originalAccount || typeof originalAccount !== 'object') { + reject(new Error('缺少待重新授权的账号 JSON。')); + return; + } + + let resolved = false; + const startedAt = Date.now(); + let timeoutTimer = null; + let onBeforeNavigate = null; + let onCommitted = null; + let onTabUpdated = null; + + function cleanup() { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + if (onBeforeNavigate) { + chromeApi.webNavigation.onBeforeNavigate.removeListener?.(onBeforeNavigate); + onBeforeNavigate = null; + } + if (onCommitted) { + chromeApi.webNavigation.onCommitted.removeListener?.(onCommitted); + onCommitted = null; + } + if (onTabUpdated) { + chromeApi.tabs.onUpdated.removeListener?.(onTabUpdated); + onTabUpdated = null; + } + } + + function rejectStep(error) { + if (resolved) return; + resolved = true; + cleanup(); + reject(error); + } + + async function finalize(parsed) { + if (resolved || !parsed) return; + if (parsed.error) { + rejectStep(new Error(`OAuth 回调错误:${parsed.error}`)); + return; + } + const code = String(parsed.code || '').trim(); + if (!code) return; + + resolved = true; + cleanup(); + + try { + await logStep(`已捕获 localhost 回调,正在向 OAuth 服务端换取新 Token...`); + const tokens = await exchangeAuthorizationCode({ + code, + codeVerifier, + fetchImpl, + }); + const updatedAccount = buildUpdatedAccount(originalAccount, tokens); + await setState({ + reauthResultAccount: updatedAccount, + reauthCodeVerifier: '', + reauthState: '', + reauthLastError: '', + }); + await logStep('Token 换取成功,新 access_token / refresh_token / id_token 已写入会话状态。', 'ok'); + await completeNodeFromBackground(nodeId, { reauthResultAccount: updatedAccount }); + resolve(); + } catch (error) { + const message = getErrorMessage(error); + await setState({ reauthLastError: message }).catch(() => {}); + await logStep(`步骤 4 失败:${message}`, 'error'); + reject(error); + } + } + + function handleNavigation(details = {}) { + const url = String(details?.url || '').trim(); + if (!url) return; + const parsed = parseCallbackUrl(url, expectedState); + if (parsed) { + finalize(parsed); + const tabId = Number(details?.tabId); + if (Number.isInteger(tabId) && chromeApi.tabs?.remove) { + chromeApi.tabs.remove(tabId).catch(() => {}); + } + } + } + + function handleTabUpdated(_tabId, _changeInfo, tab) { + const url = String(tab?.url || _changeInfo?.url || '').trim(); + if (!url) return; + const parsed = parseCallbackUrl(url, expectedState); + if (parsed) { + finalize(parsed); + const tabIdToClose = Number(_tabId); + if (Number.isInteger(tabIdToClose) && chromeApi.tabs?.remove) { + chromeApi.tabs.remove(tabIdToClose).catch(() => {}); + } + } + } + + // 同一 localhost 回调可能被 onBeforeNavigate / onCommitted 同时观察到; + // finalize 内部用 resolved guard 保证幂等,保留双监听以提高捕获率。 + onBeforeNavigate = handleNavigation; + onCommitted = handleNavigation; + onTabUpdated = handleTabUpdated; + chromeApi.webNavigation.onBeforeNavigate.addListener(onBeforeNavigate); + chromeApi.webNavigation.onCommitted.addListener(onCommitted); + chromeApi.tabs.onUpdated.addListener(onTabUpdated); + + function isResolved() { + return resolved; + } + + async function drivePrimaryContinueClick() { + if (!consentClickEnabled) { + await logStep('OAuth 同意页主动点击能力未注入,仅依赖 localhost 监听等待回调。', 'warn'); + return; + } + let tabId = null; + try { + tabId = await getTabId('openai-auth'); + if (!Number.isInteger(tabId) || !(await isTabAlive('openai-auth'))) { + await logStep('OAuth 认证页 tab 不存在或已关闭,跳过主动点击「继续」按钮。', 'warn'); + return; + } + + try { + await chromeApi.tabs.update(tabId, { active: true }); + } catch (_focusError) {} + + // 步骤 3 验证码通过后先立即预检账号级阻断:封禁/停用或手机验证都直接跳过当前账号。 + if (await checkTabForBannedAccount(tabId)) { + throw buildAccountBannedError(); + } + if (await checkTabForPhoneVerificationRequired(tabId)) { + throw buildPhoneVerificationRequiredError('认证页要求手机验证。'); + } + + await ensureStep8SignupPageReady(tabId, { + timeoutMs: 15000, + visibleStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + logMessage: '认证页内容脚本尚未就绪,正在等待页面恢复...', + }); + + if (await checkTabForBannedAccount(tabId)) { + throw buildAccountBannedError(); + } + if (await checkTabForPhoneVerificationRequired(tabId)) { + throw buildPhoneVerificationRequiredError('认证页要求手机验证。'); + } + + for (let round = 1; round <= STEP8_MAX_ROUNDS && !isResolved(); round++) { + throwIfStopped(); + + // 每轮先快速检测账号级阻断页面,避免 waitForStep8Ready 的 30s 超时白等。 + if (await checkTabForBannedAccount(tabId)) { + throw buildAccountBannedError(); + } + if (await checkTabForPhoneVerificationRequired(tabId)) { + throw buildPhoneVerificationRequiredError('认证页要求手机验证。'); + } + + const pageState = await waitForStep8Ready( + tabId, + STEP8_READY_WAIT_TIMEOUT_MS, + { visibleStep: VISIBLE_STEP } + ); + if (isResolved()) return; + if (pageState?.phoneVerificationPage || pageState?.addPhonePage) { + throw buildPhoneVerificationRequiredError(pageState?.url || '认证页要求手机验证。'); + } + if (!pageState?.consentReady) { + await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS); + continue; + } + + const strategy = STEP8_STRATEGIES[Math.min(round - 1, STEP8_STRATEGIES.length - 1)]; + await logStep(`第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击 OAuth 同意页「继续」(${strategy.label})...`); + + if (strategy.mode === 'debugger') { + const clickTarget = await prepareStep8DebuggerClick(tabId, { + timeoutMs: 15000, + responseTimeoutMs: 15000, + visibleStep: VISIBLE_STEP, + }); + if (isResolved()) return; + await clickWithDebugger(tabId, clickTarget?.rect, { visibleStep: VISIBLE_STEP }); + } else { + await triggerStep8ContentStrategy(tabId, strategy.strategy, { + timeoutMs: 15000, + responseTimeoutMs: 15000, + visibleStep: VISIBLE_STEP, + }); + } + if (isResolved()) return; + + const effect = await waitForStep8ClickEffect( + tabId, + pageState.url, + 15000, + { visibleStep: VISIBLE_STEP } + ); + if (isResolved()) return; + + if (effect.progressed) { + await logStep(`已点击「继续」,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'ok'); + return; + } + + if (round >= STEP8_MAX_ROUNDS) { + throw new Error(`连续 ${STEP8_MAX_ROUNDS} 轮点击「继续」后页面仍无反应。`); + } + + await logStep(`${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn'); + await reloadStep8ConsentPage(tabId, 30000, { visibleStep: VISIBLE_STEP }); + await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS); + } + } catch (clickError) { + if (isResolved()) return; + + // 同意页点击失败后做一次封号页面检测:若确认是封禁/停用,立即抛出 fatal 错误终止等待。 + const banned = await checkTabForBannedAccount(tabId); + if (banned) { + rejectStep(buildAccountBannedError()); + return; + } + + if (isPhoneVerificationRequiredError(clickError)) { + rejectStep(buildPhoneVerificationRequiredError(clickError)); + return; + } + + const message = getErrorMessage(clickError); + await logStep(`主动点击 OAuth 同意页失败:${message}(继续等待 localhost 回调,可能由用户手动完成同意)`, 'warn'); + } + } + + // 封号等 fatal 错误需穿透静默 catch 传播给 rejectStep,避免被吞掉后继续干等 callback timeout。 + drivePrimaryContinueClick().catch((err) => { + if (resolved) return; + if (/ACCOUNT_FATAL::/i.test(String(err?.message || ''))) { + rejectStep(err); + } + }); + + function checkTimeout() { + if (resolved) return; + if (Date.now() - startedAt >= CALLBACK_TIMEOUT_MS) { + rejectStep(new Error(`${Math.round(CALLBACK_TIMEOUT_MS / 1000)} 秒内未捕获到 localhost 回调,OAuth 同意点击可能被拦截。`)); + return; + } + timeoutTimer = setTimeout(checkTimeout, CALLBACK_CHECK_INTERVAL_MS); + } + timeoutTimer = setTimeout(checkTimeout, CALLBACK_CHECK_INTERVAL_MS); + + logStep('正在监听 localhost:1455 回调...').catch(() => {}); + }); + } + + return { executeCaptureReauthCallback }; + } + + return { + NODE_ID, + VISIBLE_STEP, + createCaptureReauthCallbackExecutor, + }; +}); diff --git a/flows/openai-reauth/background/steps/fetch-reauth-code.js b/flows/openai-reauth/background/steps/fetch-reauth-code.js new file mode 100644 index 00000000..2f2de12f --- /dev/null +++ b/flows/openai-reauth/background/steps/fetch-reauth-code.js @@ -0,0 +1,267 @@ +(function attachOpenAiReauthFetchCodeStep(root, factory) { + root.MultiPageOpenAiReauthFetchCodeStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createFetchCodeStepModule() { + const NODE_ID = 'fetch-reauth-code'; + const VISIBLE_STEP = 3; + const STEP_KEY = NODE_ID; + const FILL_CODE_TIMEOUT_MS = 60000; + const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000; + const DEFAULT_MAX_RESEND_REQUESTS = 3; + const DEFAULT_RESEND_INTERVAL_MS = 5000; + const RESEND_REQUEST_TIMEOUT_MS = 45000; + const RESEND_FAILURE_BACKOFF_MS = 2000; + // 2925 默认 15 attempts × 15s = 225s 单轮太长,缩到 6 让 resend 能早点介入。 + const MAIL_2925_POLL_MAX_ATTEMPTS = 6; + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function isLikelyStopError(error) { + const message = String(error?.message || error || ''); + return /已被用户停止|user_stop|operation_aborted|stop signal|stopped by user/i.test(message); + } + + function createFetchReauthCodeExecutor(deps = {}) { + const { + addLog = async () => {}, + completeNodeFromBackground, + pollFlowVerificationCode, + sendToContentScriptResilient, + throwIfStopped = () => {}, + sleepWithStop = null, + maxResendRequests = DEFAULT_MAX_RESEND_REQUESTS, + resendIntervalMs = DEFAULT_RESEND_INTERVAL_MS, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('fetch-reauth-code executor 缺少 completeNodeFromBackground。'); + } + if (typeof pollFlowVerificationCode !== 'function') { + throw new Error('fetch-reauth-code executor 缺少 pollFlowVerificationCode。'); + } + if (typeof sendToContentScriptResilient !== 'function') { + throw new Error('fetch-reauth-code executor 缺少 sendToContentScriptResilient。'); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + async function safeSleep(ms) { + const duration = Math.max(0, Math.floor(Number(ms) || 0)); + if (duration <= 0) return; + if (typeof sleepWithStop === 'function') { + await sleepWithStop(duration); + return; + } + const deadline = Date.now() + duration; + const tick = Math.min(250, duration); + while (Date.now() < deadline) { + throwIfStopped(); + const waitMs = Math.min(tick, deadline - Date.now()); + if (waitMs <= 0) break; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + throwIfStopped(); + } + + function resolveFilterAfterTimestamp(state = {}, fallbackTimestamp = 0) { + const numericFallback = Number(fallbackTimestamp) || 0; + const candidates = [ + numericFallback, + Number(state?.loginVerificationRequestedAt) || 0, + Number(state?.reauthStartedAt) || 0, + ]; + const requestedAt = candidates.reduce((max, value) => (value > max ? value : max), 0) + || Date.now(); + const provider = String(state?.mailProvider || '').trim().toLowerCase(); + if (provider === '2925') { + return Math.max(0, requestedAt - MAIL_2925_FILTER_LOOKBACK_MS); + } + return Math.max(0, requestedAt); + } + + function buildPollPayloadOverrides(state = {}) { + const provider = String(state?.mailProvider || '').trim().toLowerCase(); + if (provider === '2925') { + return { maxAttempts: MAIL_2925_POLL_MAX_ATTEMPTS }; + } + return {}; + } + + async function requestVerificationCodeResend() { + const result = await sendToContentScriptResilient( + 'openai-auth', + { + type: 'RESEND_VERIFICATION_CODE', + step: VISIBLE_STEP, + source: 'background', + payload: {}, + }, + { + timeoutMs: RESEND_REQUEST_TIMEOUT_MS, + responseTimeoutMs: RESEND_REQUEST_TIMEOUT_MS, + retryDelayMs: 700, + logMessage: '认证页正在切换,等待页面重新就绪后继续点击「重新发送」...', + logStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + } + ); + if (result?.error) { + throw new Error(result.error); + } + return Date.now(); + } + + async function pollVerificationCodeOnce(state, filterAfterTimestamp) { + return pollFlowVerificationCode({ + actionLabel: 'OAuth 重新授权验证码', + flowId: 'openai-reauth', + logStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + missingCapabilityMessage: '当前重新授权步骤缺少邮件轮询能力,无法继续执行。', + nodeId: NODE_ID, + notFoundMessage: `步骤 ${VISIBLE_STEP}:邮箱轮询结束,但未获取到 OAuth 验证码。`, + payloadOverrides: buildPollPayloadOverrides(state), + state: { + ...state, + activeFlowId: 'openai-reauth', + flowId: 'openai-reauth', + visibleStep: VISIBLE_STEP, + }, + step: VISIBLE_STEP, + filterAfterTimestamp, + }); + } + + async function fillCodeIntoAuthPage(code) { + const fillResult = await sendToContentScriptResilient( + 'openai-auth', + { + type: 'FILL_CODE', + step: VISIBLE_STEP, + source: 'background', + payload: { code, visibleStep: VISIBLE_STEP }, + }, + { + timeoutMs: FILL_CODE_TIMEOUT_MS, + responseTimeoutMs: FILL_CODE_TIMEOUT_MS, + retryDelayMs: 700, + logMessage: '认证页正在切换,等待页面重新就绪后继续填写验证码...', + logStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + } + ); + if (fillResult?.error) { + throw new Error(fillResult.error); + } + return fillResult || {}; + } + + async function executeFetchReauthCode(state = {}) { + const nodeId = String(state?.nodeId || NODE_ID).trim(); + const email = String(state?.reauthEmail || state?.email || '').trim(); + if (!email) { + throw new Error('缺少邮箱地址,请先执行步骤 1。'); + } + + if (state?.skipReauthVerificationStep) { + await logStep('OAuth 授权页未要求验证码,跳过本步骤。', 'ok'); + await completeNodeFromBackground(nodeId, { skipReauthVerificationStep: true }); + return; + } + + const normalizedMaxResend = Math.max(0, Math.floor(Number(maxResendRequests) || 0)); + // 第 1 轮直接轮询首封邮件;第 2 轮起每轮先点「重新发送」再轮询,因此 totalRounds = maxResend + 1。 + const totalRounds = normalizedMaxResend + 1; + const cooldownMs = Math.max(0, Number(resendIntervalMs) || 0); + let filterAfterTimestamp = resolveFilterAfterTimestamp(state); + let lastError = null; + let usedResendRequests = 0; + + for (let round = 1; round <= totalRounds; round += 1) { + throwIfStopped(); + + if (round > 1) { + await logStep( + `未取到验证码,准备点击 OpenAI 「重新发送邮件」(第 ${usedResendRequests + 1}/${normalizedMaxResend} 次)...`, + 'warn' + ); + try { + const requestedAt = await requestVerificationCodeResend(); + filterAfterTimestamp = resolveFilterAfterTimestamp(state, requestedAt); + usedResendRequests += 1; + await logStep('已请求 OpenAI 重新发送验证码邮件。', 'warn'); + } catch (resendError) { + if (isLikelyStopError(resendError)) { + throw resendError; + } + await logStep( + `请求重新发送验证码失败:${getErrorMessage(resendError)},将继续刷新邮箱后重试。`, + 'warn' + ); + await safeSleep(RESEND_FAILURE_BACKOFF_MS); + } + } + + try { + await logStep( + `正在轮询邮箱 ${email} 的 OAuth 验证码...(第 ${round}/${totalRounds} 轮)` + ); + const codeResult = await pollVerificationCodeOnce(state, filterAfterTimestamp); + const code = String(codeResult?.code || '').trim(); + if (!code) { + throw new Error('邮箱轮询完成,但未取到 OAuth 验证码。'); + } + + await logStep(`已收到验证码 ${code},正在填回 OAuth 授权页...`); + throwIfStopped(); + await fillCodeIntoAuthPage(code); + await logStep('验证码已填回,等待 OAuth 服务端跳转 localhost 回调。', 'ok'); + await completeNodeFromBackground(nodeId, { reauthVerificationCode: code }); + return; + } catch (error) { + if (isLikelyStopError(error)) { + throw error; + } + lastError = error; + await logStep( + `步骤 ${VISIBLE_STEP} 第 ${round}/${totalRounds} 轮失败:${getErrorMessage(error)}`, + 'warn' + ); + if (round >= totalRounds) { + break; + } + if (cooldownMs > 0) { + await logStep( + `等待 ${Math.ceil(cooldownMs / 1000)} 秒后点击「重新发送」并继续轮询...`, + 'info' + ); + await safeSleep(cooldownMs); + } + } + } + + const finalMessage = lastError ? getErrorMessage(lastError) : '验证码获取失败。'; + await logStep( + `步骤 ${VISIBLE_STEP} 已用完 ${totalRounds} 轮轮询,仍未拿到 OAuth 验证码:${finalMessage}`, + 'error' + ); + throw lastError || new Error( + `步骤 ${VISIBLE_STEP}:已用完 ${totalRounds} 轮轮询,仍未拿到 OAuth 验证码。` + ); + } + + return { executeFetchReauthCode }; + } + + return { + NODE_ID, + VISIBLE_STEP, + DEFAULT_MAX_RESEND_REQUESTS, + DEFAULT_RESEND_INTERVAL_MS, + MAIL_2925_POLL_MAX_ATTEMPTS, + createFetchReauthCodeExecutor, + }; +}); diff --git a/flows/openai-reauth/background/steps/prepare-reauth.js b/flows/openai-reauth/background/steps/prepare-reauth.js new file mode 100644 index 00000000..cd28292c --- /dev/null +++ b/flows/openai-reauth/background/steps/prepare-reauth.js @@ -0,0 +1,130 @@ +(function attachOpenAiReauthPrepareStep(root, factory) { + root.MultiPageOpenAiReauthPrepareStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createPrepareStepModule() { + const NODE_ID = 'prepare-reauth'; + const VISIBLE_STEP = 1; + const STEP_KEY = NODE_ID; + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function createPrepareReauthExecutor(deps = {}) { + const { + addLog = async () => {}, + chrome: chromeApi = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), + clearOpenAiAuthCookies, + completeNodeFromBackground, + generatePkcePair, + generateState, + buildAuthorizeUrl, + reuseOrCreateTab, + registerTab, + setState, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('prepare-reauth executor 缺少 completeNodeFromBackground。'); + } + if (typeof clearOpenAiAuthCookies !== 'function') { + throw new Error('prepare-reauth executor 缺少 clearOpenAiAuthCookies。'); + } + if (typeof generatePkcePair !== 'function' || typeof generateState !== 'function' || typeof buildAuthorizeUrl !== 'function') { + throw new Error('prepare-reauth executor 缺少 oauth-client 依赖。'); + } + if (typeof reuseOrCreateTab !== 'function') { + throw new Error('prepare-reauth executor 缺少 reuseOrCreateTab。'); + } + if (typeof setState !== 'function') { + throw new Error('prepare-reauth executor 缺少 setState。'); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + function readReauthInputAccount(state = {}) { + const account = state?.reauthInputAccount; + if (!account || typeof account !== 'object') { + throw new Error('缺少待重新授权的账号 JSON,请在 sidepanel 粘贴账号对象后再启动。'); + } + const credentials = account.credentials && typeof account.credentials === 'object' + ? account.credentials + : {}; + const email = cleanString(credentials.email || account.email || account.name); + if (!email) { + throw new Error('账号 JSON 中缺少 email 字段。'); + } + const mailProvider = cleanString(account.mailProvider || credentials.mailProvider); + if (!mailProvider) { + throw new Error('账号 JSON 中缺少 mailProvider 字段(必须显式声明邮箱来源)。'); + } + return { email, mailProvider }; + } + + async function executePrepareReauth(state = {}) { + const nodeId = cleanString(state?.nodeId) || NODE_ID; + try { + const { email, mailProvider } = readReauthInputAccount(state); + + await logStep(`正在为 ${email} 准备重新授权...`); + + if (chromeApi?.cookies) { + const result = await clearOpenAiAuthCookies({ chromeApi }); + await logStep(`已清理 ${result.removed}/${result.collected} 个 OpenAI/ChatGPT cookies。`, 'ok'); + } else { + await logStep('当前环境无 chrome.cookies API,跳过 cookie 清理。', 'warn'); + } + + const pkce = await generatePkcePair(); + const stateToken = generateState(); + const oauthUrl = buildAuthorizeUrl({ + codeChallenge: pkce.codeChallenge, + state: stateToken, + }); + + await setState({ + reauthEmail: email, + email, + reauthMailProvider: mailProvider, + mailProvider, + reauthCodeVerifier: pkce.codeVerifier, + reauthState: stateToken, + reauthAuthorizeUrl: oauthUrl, + oauthUrl, + reauthStartedAt: Date.now(), + reauthResultAccount: null, + reauthLastError: '', + }); + + const tabId = await reuseOrCreateTab('openai-auth', oauthUrl, { forceNew: true }); + if (typeof registerTab === 'function' && Number.isInteger(tabId)) { + await registerTab('openai-auth', tabId); + } + await logStep('已打开 OAuth 授权页,准备进入下一步。', 'ok'); + + await completeNodeFromBackground(nodeId, { + reauthEmail: email, + reauthMailProvider: mailProvider, + }); + } catch (error) { + const message = getErrorMessage(error); + await setState({ reauthLastError: message }); + await logStep(`步骤 1 失败:${message}`, 'error'); + throw error; + } + } + + return { executePrepareReauth }; + } + + return { + NODE_ID, + VISIBLE_STEP, + createPrepareReauthExecutor, + }; +}); diff --git a/flows/openai-reauth/background/steps/submit-reauth-email.js b/flows/openai-reauth/background/steps/submit-reauth-email.js new file mode 100644 index 00000000..2b529d2e --- /dev/null +++ b/flows/openai-reauth/background/steps/submit-reauth-email.js @@ -0,0 +1,110 @@ +(function attachOpenAiReauthSubmitEmailStep(root, factory) { + root.MultiPageOpenAiReauthSubmitEmailStep = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createSubmitEmailStepModule() { + const NODE_ID = 'submit-reauth-email'; + const VISIBLE_STEP = 2; + const STEP_KEY = NODE_ID; + const SUBMIT_EMAIL_TIMEOUT_MS = 90000; + + function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error ?? '未知错误'); + } + + function createSubmitReauthEmailExecutor(deps = {}) { + const { + addLog = async () => {}, + completeNodeFromBackground, + reuseOrCreateTab, + sendToContentScriptResilient, + throwIfStopped = () => {}, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('submit-reauth-email executor 缺少 completeNodeFromBackground。'); + } + if (typeof sendToContentScriptResilient !== 'function') { + throw new Error('submit-reauth-email executor 缺少 sendToContentScriptResilient。'); + } + if (typeof reuseOrCreateTab !== 'function') { + throw new Error('submit-reauth-email executor 缺少 reuseOrCreateTab。'); + } + + function logStep(message, level = 'info') { + return addLog(message, level, { step: VISIBLE_STEP, stepKey: STEP_KEY }); + } + + async function executeSubmitReauthEmail(state = {}) { + const nodeId = String(state?.nodeId || NODE_ID).trim(); + const email = String(state?.reauthEmail || state?.email || '').trim(); + const oauthUrl = String(state?.reauthAuthorizeUrl || state?.oauthUrl || '').trim(); + if (!email) { + throw new Error('缺少邮箱地址,请先执行步骤 1。'); + } + if (!oauthUrl) { + throw new Error('缺少 OAuth 授权 URL,请先执行步骤 1。'); + } + + try { + throwIfStopped(); + await logStep(`正在向 OAuth 授权页提交邮箱 ${email}...`); + + await reuseOrCreateTab('openai-auth', oauthUrl); + + const result = await sendToContentScriptResilient( + 'openai-auth', + { + type: 'EXECUTE_NODE', + nodeId: 'oauth-login', + step: VISIBLE_STEP, + source: 'background', + payload: { + email, + accountIdentifier: email, + loginIdentifierType: 'email', + password: '', + visibleStep: VISIBLE_STEP, + }, + }, + { + timeoutMs: SUBMIT_EMAIL_TIMEOUT_MS, + responseTimeoutMs: SUBMIT_EMAIL_TIMEOUT_MS, + retryDelayMs: 700, + logMessage: '认证页正在切换,等待页面重新就绪后继续提交邮箱...', + logStep: VISIBLE_STEP, + logStepKey: STEP_KEY, + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + if (result?.directOAuthConsentPage || result?.skipLoginVerificationStep) { + await logStep('OAuth 授权页未要求验证码,直接进入回调阶段。', 'ok'); + await completeNodeFromBackground(nodeId, { + skipReauthVerificationStep: true, + loginVerificationRequestedAt: result?.loginVerificationRequestedAt || null, + }); + return; + } + + await logStep('已提交邮箱,等待邮箱验证码到达。', 'ok'); + await completeNodeFromBackground(nodeId, { + loginVerificationRequestedAt: result?.loginVerificationRequestedAt || Date.now(), + }); + } catch (error) { + const message = getErrorMessage(error); + await logStep(`步骤 2 失败:${message}`, 'error'); + throw error; + } + } + + return { executeSubmitReauthEmail }; + } + + return { + NODE_ID, + VISIBLE_STEP, + createSubmitReauthEmailExecutor, + }; +}); diff --git a/flows/openai-reauth/index.js b/flows/openai-reauth/index.js new file mode 100644 index 00000000..e0833993 --- /dev/null +++ b/flows/openai-reauth/index.js @@ -0,0 +1,99 @@ +(function attachMultiPageOpenAiReauthFlowDefinition(root, factory) { + root.MultiPageOpenAiReauthFlowDefinition = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createMultiPageOpenAiReauthFlowDefinition() { + function freezeDeep(entry) { + if (!entry || typeof entry !== 'object' || Object.isFrozen(entry)) { + return entry; + } + Object.getOwnPropertyNames(entry).forEach((key) => { + freezeDeep(entry[key]); + }); + return Object.freeze(entry); + } + + const VALUE = freezeDeep({ + id: 'openai-reauth', + label: 'OpenAI 重新授权', + services: [], + capabilities: { + stepDefinitionMode: 'openai-reauth-static', + canSwitchFlow: false, + supportsEmailSignup: false, + supportsPhoneSignup: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsAccountContribution: false, + supportedTargetIds: [], + }, + baseGroups: ['openai-oauth', 'reauth-input'], + targets: {}, + defaultTargetId: null, + settingsDefaults: {}, + settingsGroups: { + 'reauth-input': { + id: 'reauth-input', + label: 'OAuth 重新授权', + rowIds: [ + 'row-reauth-account-json', + 'row-reauth-mode-picker', + 'row-reauth-provider-picker', + 'row-reauth-batch-actions', + 'row-reauth-batch-progress', + 'row-reauth-result', + ], + }, + }, + targetCapabilities: {}, + runtimeSources: { + 'openai-auth': { + flowId: 'openai-reauth', + kind: 'flow-page', + label: '认证页', + readyPolicy: 'allow-child-frame', + family: 'openai-auth-family', + driverId: 'flows/openai/content/openai-auth', + cleanupScopes: ['oauth-localhost-callback'], + detectionMatchers: [ + { + hostnames: ['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com'], + }, + ], + familyMatchers: [ + { + hostnames: ['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com'], + }, + ], + }, + }, + driverDefinitions: { + 'flows/openai/content/openai-auth': { + sourceId: 'openai-auth', + commands: ['oauth-login', 'submit-verification-code', 'detect-auth-state'], + }, + }, + nodes: [ + { + id: 'prepare-reauth', + step: 1, + label: '准备授权(清 cookie / 生成 PKCE / 打开认证页)', + }, + { + id: 'submit-reauth-email', + step: 2, + label: '提交邮箱并等待验证码页', + }, + { + id: 'fetch-reauth-code', + step: 3, + label: '收取邮箱验证码并填回', + }, + { + id: 'capture-reauth-callback', + step: 4, + label: '抓取 localhost 回调并换取新 Token', + }, + ], + }); + + return VALUE; +}); diff --git a/flows/openai-reauth/mail-rules.js b/flows/openai-reauth/mail-rules.js new file mode 100644 index 00000000..06326aa6 --- /dev/null +++ b/flows/openai-reauth/mail-rules.js @@ -0,0 +1,133 @@ +(function attachOpenAiReauthMailRules(root, factory) { + root.MultiPageOpenAiReauthMailRules = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthMailRulesModule() { + const REAUTH_CODE_RULE_ID = 'openai-reauth-code'; + const REAUTH_CODE_NODE_ID = 'fetch-reauth-code'; + const REAUTH_VISIBLE_STEP = 3; + + const OPENAI_CODE_PATTERNS = Object.freeze([ + Object.freeze({ + source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', + flags: 'i', + }), + Object.freeze({ + source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})', + flags: 'i', + }), + Object.freeze({ + source: '(?:verification\\s+code|temporary\\s+verification\\s+code|your\\s+chatgpt\\s+code|code(?:\\s+is)?)[^0-9]{0,16}(\\d{6})', + flags: 'i', + }), + ]); + const OPENAI_REQUIRED_KEYWORDS = Object.freeze([ + 'openai', + 'chatgpt', + 'verify', + 'verification', + 'confirm', + 'login', + '验证码', + '代码', + ]); + const OPENAI_SENDER_FILTERS = Object.freeze([ + 'openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward', + ]); + const OPENAI_SUBJECT_FILTERS = Object.freeze([ + 'verify', 'verification', 'code', '验证码', 'confirm', 'login', + ]); + + function buildTargetEmailHints(targetEmail = '') { + const normalized = String(targetEmail || '').trim().toLowerCase(); + if (!normalized) return []; + const hints = [normalized]; + const atIndex = normalized.indexOf('@'); + if (atIndex > 0) { + hints.push(`${normalized.slice(0, atIndex)}=${normalized.slice(atIndex + 1)}`); + } + return [...new Set(hints)]; + } + + function createOpenAiReauthMailRules(deps = {}) { + const { + getHotmailVerificationRequestTimestamp = () => 0, + MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, + } = deps; + + function isMail2925Provider(state = {}) { + return String(state?.mailProvider || '').trim().toLowerCase() === '2925'; + } + + function shouldMatchMail2925TargetEmail(state = {}) { + return isMail2925Provider(state) + && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; + } + + function resolveTargetEmail(state = {}) { + return String(state?.reauthEmail || state?.email || '').trim(); + } + + function getVisibleStepForNode(_nodeId, _state = {}) { + return REAUTH_VISIBLE_STEP; + } + + function getRuleDefinition(_input, state = {}) { + const mail2925Provider = isMail2925Provider(state); + const targetEmail = resolveTargetEmail(state); + return { + flowId: 'openai-reauth', + ruleId: REAUTH_CODE_RULE_ID, + nodeId: REAUTH_CODE_NODE_ID, + step: REAUTH_VISIBLE_STEP, + artifactType: 'code', + codePatterns: OPENAI_CODE_PATTERNS, + filterAfterTimestamp: mail2925Provider + ? 0 + : getHotmailVerificationRequestTimestamp(REAUTH_VISIBLE_STEP, state), + requiredKeywords: OPENAI_REQUIRED_KEYWORDS, + senderFilters: OPENAI_SENDER_FILTERS, + subjectFilters: OPENAI_SUBJECT_FILTERS, + targetEmail, + targetEmailHints: buildTargetEmailHints(targetEmail), + mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), + maxAttempts: mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, + intervalMs: mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, + }; + } + + function getRuleDefinitionForNode(nodeId, state = {}) { + if (String(nodeId || '').trim() !== REAUTH_CODE_NODE_ID) { + return null; + } + return getRuleDefinition({ nodeId }, state); + } + + function buildVerificationPollPayload(input, state = {}, overrides = {}) { + return { + ...getRuleDefinition(input, state), + ...(overrides || {}), + }; + } + + function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) { + const rule = getRuleDefinitionForNode(nodeId, state); + if (!rule) return null; + return { ...rule, ...(overrides || {}) }; + } + + return { + buildVerificationPollPayload, + buildVerificationPollPayloadForNode, + getRuleDefinition, + getRuleDefinitionForNode, + getVisibleStepForNode, + }; + } + + return { + REAUTH_CODE_RULE_ID, + REAUTH_CODE_NODE_ID, + REAUTH_VISIBLE_STEP, + createOpenAiReauthMailRules, + }; +}); diff --git a/flows/openai-reauth/reauth-account-validator.js b/flows/openai-reauth/reauth-account-validator.js new file mode 100644 index 00000000..5f14e209 --- /dev/null +++ b/flows/openai-reauth/reauth-account-validator.js @@ -0,0 +1,112 @@ +(function attachOpenAiReauthAccountValidator(root, factory) { + root.MultiPageOpenAiReauthAccountValidator = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiReauthAccountValidatorModule() { + const SUPPORTED_MAIL_PROVIDERS = Object.freeze([ + '2925', + 'hotmail-api', + 'icloud', + 'luckmail-api', + 'cloudmail', + 'yyds-mail', + 'cloudflare-temp-email', + ]); + + const MAIL_PROVIDER_OPTIONS = Object.freeze([ + Object.freeze({ value: '2925', label: '2925' }), + Object.freeze({ value: 'hotmail-api', label: 'Hotmail (API)' }), + Object.freeze({ value: 'icloud', label: 'iCloud' }), + Object.freeze({ value: 'luckmail-api', label: 'LuckMail (API)' }), + Object.freeze({ value: 'cloudmail', label: 'Cloud Mail' }), + Object.freeze({ value: 'yyds-mail', label: 'YYDS Mail' }), + Object.freeze({ value: 'cloudflare-temp-email', label: 'Cloudflare Temp Email' }), + ]); + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cleanString(value) { + return String(value ?? '').trim(); + } + + function extractAccountEmail(account) { + const credentials = isPlainObject(account?.credentials) ? account.credentials : {}; + return cleanString(credentials.email) + || cleanString(account?.email) + || cleanString(account?.name); + } + + function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + } + + function parseAccountsFromJson(rawText) { + const trimmed = cleanString(rawText); + if (!trimmed) { + return { ok: false, error: '请粘贴账号 JSON(单账号对象或 sub2api 导出整文件)。' }; + } + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (error) { + return { ok: false, error: `JSON 解析失败:${error.message}` }; + } + + let accounts = null; + if (Array.isArray(parsed?.accounts)) { + accounts = parsed.accounts; + } else if (Array.isArray(parsed)) { + accounts = parsed; + } else if (isPlainObject(parsed)) { + accounts = [parsed]; + } else { + return { ok: false, error: 'JSON 必须是单账号对象、accounts 数组,或含 accounts 字段的对象。' }; + } + + if (!accounts.length) { + return { ok: false, error: 'accounts 列表为空。' }; + } + + const normalized = []; + for (let index = 0; index < accounts.length; index += 1) { + const account = accounts[index]; + if (!isPlainObject(account)) { + return { ok: false, error: `accounts[${index}] 不是对象。` }; + } + const email = extractAccountEmail(account); + if (!email) { + return { ok: false, error: `accounts[${index}] 缺少 email(credentials.email / email / name)。` }; + } + if (!isValidEmail(email)) { + return { ok: false, error: `accounts[${index}] 的 email 格式无效:${email}` }; + } + normalized.push({ index, email, account }); + } + + return { + ok: true, + accounts: normalized, + }; + } + + function buildResolvedAccount(account, mailProvider) { + const normalizedProvider = cleanString(mailProvider); + if (!normalizedProvider) { + throw new Error('未提供 mailProvider,无法注入到账号对象。'); + } + return { + ...account, + mailProvider: normalizedProvider, + }; + } + + return { + SUPPORTED_MAIL_PROVIDERS, + MAIL_PROVIDER_OPTIONS, + parseAccountsFromJson, + buildResolvedAccount, + extractAccountEmail, + isValidEmail, + }; +}); diff --git a/flows/openai-reauth/workflow.js b/flows/openai-reauth/workflow.js new file mode 100644 index 00000000..99786e30 --- /dev/null +++ b/flows/openai-reauth/workflow.js @@ -0,0 +1,88 @@ +(function attachMultiPageOpenAiReauthWorkflow(root, factory) { + root.MultiPageOpenAiReauthWorkflow = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createMultiPageOpenAiReauthWorkflow() { + function freezeDeep(entry) { + if (!entry || typeof entry !== 'object' || Object.isFrozen(entry)) { + return entry; + } + Object.getOwnPropertyNames(entry).forEach((key) => { + freezeDeep(entry[key]); + }); + return Object.freeze(entry); + } + + const STEP_VARIANTS = freezeDeep({ + default: [ + { + id: 1, + order: 10, + key: 'prepare-reauth', + title: '准备授权(清 cookie / 生成 PKCE / 打开认证页)', + sourceId: 'openai-auth', + driverId: null, + command: 'prepare-reauth', + flowId: 'openai-reauth', + }, + { + id: 2, + order: 20, + key: 'submit-reauth-email', + title: '提交邮箱并等待验证码页', + sourceId: 'openai-auth', + driverId: 'flows/openai/content/openai-auth', + command: 'oauth-login', + flowId: 'openai-reauth', + }, + { + id: 3, + order: 30, + key: 'fetch-reauth-code', + title: '收取邮箱验证码并填回', + sourceId: 'openai-auth', + driverId: 'flows/openai/content/openai-auth', + command: 'submit-verification-code', + mailRuleId: 'openai-reauth-code', + flowId: 'openai-reauth', + }, + { + id: 4, + order: 40, + key: 'capture-reauth-callback', + title: '抓取 localhost 回调并换取新 Token', + sourceId: 'openai-auth', + driverId: null, + command: 'capture-reauth-callback', + flowId: 'openai-reauth', + }, + ], + }); + + function getVariantStepDefinitions(variantKey = 'default') { + return Array.isArray(STEP_VARIANTS[variantKey]) ? STEP_VARIANTS[variantKey] : STEP_VARIANTS.default; + } + + function getModeStepDefinitions() { + return getVariantStepDefinitions('default'); + } + + function getAllSteps() { + return getVariantStepDefinitions('default'); + } + + function getPlusPaymentStepTitle() { + return ''; + } + + function resolveStepTitle(step = {}) { + return step?.title || ''; + } + + return { + flowId: 'openai-reauth', + getAllSteps, + getModeStepDefinitions, + getPlusPaymentStepTitle, + getVariantStepDefinitions, + resolveStepTitle, + }; +}); diff --git a/flows/openai/content/openai-auth.js b/flows/openai/content/openai-auth.js index cde7b69b..e9c2d3d1 100644 --- a/flows/openai/content/openai-auth.js +++ b/flows/openai/content/openai-auth.js @@ -40,6 +40,7 @@ if (document.documentElement.getAttribute(OPENAI_AUTH_LISTENER_SENTINEL) !== '1' || message.type === 'ENSURE_SIGNUP_ENTRY_READY' || message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY' || message.type === 'ENSURE_SIGNUP_PASSWORD_PAGE_READY' + || message.type === 'DETECT_ACCOUNT_BANNED' ) { resetStopState(); handleCommand(message).then((result) => { @@ -165,6 +166,12 @@ async function handleCommand(message) { return await ensureSignupPhoneEntryReady(); case 'ENSURE_SIGNUP_PASSWORD_PAGE_READY': return await ensureSignupPasswordPageReady(); + case 'DETECT_ACCOUNT_BANNED': { + const text = (document.body?.innerText || document.title || '').toLowerCase(); + const patterns = (message.payload?.patterns || []).map((p) => String(p || '').toLowerCase()).filter(Boolean); + const matched = patterns.some((p) => text.includes(p)); + return { accountBanned: matched }; + } case 'STEP8_FIND_AND_CLICK': return await step8_findAndClick(message.payload); case 'STEP8_GET_STATE': diff --git a/manifest.json b/manifest.json index 5c18b6b6..121e6c39 100644 --- a/manifest.json +++ b/manifest.json @@ -16,6 +16,7 @@ "debugger", "browsingData", "cookies", + "downloads", "storage", "scripting", "activeTab" @@ -50,6 +51,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -72,6 +74,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -94,6 +97,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -113,6 +117,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", @@ -131,6 +136,7 @@ "js": [ "content/activation-utils.js", "flows/openai/index.js", + "flows/openai-reauth/index.js", "flows/kiro/index.js", "flows/grok/index.js", "flows/index.js", diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index ae20c106..ec6942ce 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -3636,3 +3636,236 @@ header { transition-duration: 0.01ms !important; } } + +.data-row-stack { + flex-direction: column; + align-items: stretch; + gap: 6px; + width: 100%; +} +.data-textarea-reauth { + width: 100%; + min-height: 140px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + resize: vertical; +} +.data-readonly-reauth { + width: 100%; + min-height: 160px; + max-height: 360px; + margin: 0; + padding: 8px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + background: rgba(0, 0, 0, 0.04); + border-radius: 4px; + font-size: 12px; +} + +/* ============================================================ + Reauth flow UI — 文件 / 模式 / 进度 / 结果 + ============================================================ */ + +.reauth-status-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + padding: 2px 8px; + border-radius: 12px; + background: var(--bg-elevated); + color: var(--text-muted); + white-space: nowrap; +} +.reauth-status-chip:empty { display: none; } +.reauth-status-chip.ok { + background: var(--green-soft); + color: var(--green); +} +.reauth-status-chip.error { + background: var(--red-soft); + color: var(--red); +} +.reauth-status-chip.warn { + background: var(--amber-soft); + color: var(--amber); +} + +.reauth-file-zone, +.reauth-mode-zone, +.reauth-actions-zone { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 0; +} + +.reauth-mode-toggle { + display: inline-flex; + align-items: center; + gap: 6px; +} +.reauth-mode-toggle-text { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; +} + +.reauth-account-picker { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1 1 auto; + min-width: 0; +} +.reauth-account-picker label { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; +} +.reauth-account-picker select { + flex: 1 1 auto; + min-width: 0; +} + +/* 进度条 */ +.reauth-progress-zone { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + flex: 1 1 auto; +} +.reauth-progress-bar { + width: 100%; + height: 8px; + background: var(--bg-elevated); + border-radius: 4px; + overflow: hidden; + position: relative; +} +.reauth-progress-fill { + height: 100%; + width: 0; + background: linear-gradient(90deg, var(--blue), var(--cyan)); + border-radius: 4px; + transition: width 0.35s ease; +} +.reauth-progress-fill.is-success { + background: linear-gradient(90deg, var(--green), var(--cyan)); +} +.reauth-progress-fill.is-stopped, +.reauth-progress-fill.is-aborted { + background: linear-gradient(90deg, var(--amber), var(--orange)); +} +.reauth-progress-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 12px; +} +.reauth-progress-counter { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--text-primary); + font-weight: 600; +} +.reauth-progress-current { + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.reauth-progress-current:empty { display: none; } +.reauth-progress-text { + font-size: 11px; + color: var(--text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* 状态徽标 */ +.reauth-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} +.reauth-status-badge:empty { display: none; } +.reauth-status-badge.pending { + background: var(--bg-elevated); + color: var(--text-muted); +} +.reauth-status-badge.running { + background: var(--blue-soft); + color: var(--blue); +} +.reauth-status-badge.success { + background: var(--green-soft); + color: var(--green); +} +.reauth-status-badge.failed { + background: var(--red-soft); + color: var(--red); +} +.reauth-status-badge.completed { + background: var(--green-soft); + color: var(--green); +} +.reauth-status-badge.stopped, +.reauth-status-badge.aborted { + background: var(--amber-soft); + color: var(--amber); +} + +/* 结果区 */ +.reauth-result-zone { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + flex: 1 1 auto; +} +.reauth-result-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--bg-elevated); + border-radius: 6px; + border-left: 3px solid var(--border); + font-size: 13px; + color: var(--text-secondary); +} +.reauth-result-summary.has-failure { + border-left-color: var(--amber); +} +.reauth-result-summary.all-success { + border-left-color: var(--green); +} +.reauth-result-summary.aborted { + border-left-color: var(--red); +} +.reauth-result-summary-text { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} +.reauth-result-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 401a97f2..ded8be8e 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -758,6 +758,71 @@ 等待中... + + + + + +
设置
@@ -1891,6 +1956,9 @@ + + + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 837d9767..bf6eb515 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -51,6 +51,31 @@ const btnOpenContributionUpload = document.getElementById('btn-open-contribution const btnExitContributionMode = document.getElementById('btn-exit-contribution-mode'); const displayOauthUrl = document.getElementById('display-oauth-url'); const displayLocalhostUrl = document.getElementById('display-localhost-url'); +const inputReauthAccountFile = document.getElementById('input-reauth-account-file'); +const reauthJsonStatus = document.getElementById('reauth-json-status'); +const rowReauthAccountPicker = document.getElementById('row-reauth-account-picker'); +const selectReauthAccount = document.getElementById('select-reauth-account'); +const selectReauthMailProvider = document.getElementById('select-reauth-mail-provider'); +const displayReauthResultAccount = document.getElementById('display-reauth-result-account'); +const btnReauthCopyResult = document.getElementById('btn-reauth-copy-result'); +const reauthCopyStatus = document.getElementById('reauth-copy-status'); +const rowReauthBatchToggle = document.getElementById('row-reauth-batch-toggle'); +const inputReauthBatchMode = document.getElementById('input-reauth-batch-mode'); +const rowReauthBatchActions = document.getElementById('row-reauth-batch-actions'); +const btnReauthStartBatch = document.getElementById('btn-reauth-start-batch'); +const btnReauthStopBatch = document.getElementById('btn-reauth-stop-batch'); +const rowReauthBatchProgress = document.getElementById('row-reauth-batch-progress'); +const displayReauthBatchProgress = document.getElementById('display-reauth-batch-progress'); +const displayReauthBatchSummary = document.getElementById('display-reauth-batch-summary'); +const btnReauthDownloadResult = document.getElementById('btn-reauth-download-result'); +const rowReauthModePicker = document.getElementById('row-reauth-mode-picker'); +const rowReauthProviderPicker = document.getElementById('row-reauth-provider-picker'); +const reauthProgressFill = document.getElementById('reauth-progress-fill'); +const reauthProgressCounter = document.getElementById('reauth-progress-counter'); +const reauthProgressCurrent = document.getElementById('reauth-progress-current'); +const reauthProgressStatusBadge = document.getElementById('reauth-progress-status-badge'); +const reauthResultSummary = document.getElementById('reauth-result-summary'); +const btnReauthToggleDetails = document.getElementById('btn-reauth-toggle-details'); const displayStatus = document.getElementById('display-status'); const statusBar = document.getElementById('status-bar'); const inputEmail = document.getElementById('input-email'); @@ -11629,6 +11654,9 @@ async function restoreState() { displayLocalhostUrl.textContent = state.localhostUrl; displayLocalhostUrl.classList.add('has-value'); } + renderReauthResultAccount(state.reauthResultAccount); + applyReauthBatchProgress(state.reauthBatchProgress || null); + applyReauthBatchResult(state.reauthBatchResult || null); if (state.nodeStatuses) { for (const [nodeId, status] of Object.entries(state.nodeStatuses)) { updateNodeUI(nodeId, status); @@ -13539,6 +13567,35 @@ function updatePanelModeUI() { ? 'SUB2API 回调验证' : (useCodex2Api ? 'Codex2API 回调验证' : 'CPA 回调验证'); } + + applyOpenAiReauthRowVisibility(activeFlowId); +} + +const REAUTH_HIDDEN_ROW_IDS = [ + 'row-source-selector', + 'row-custom-password', + 'row-mail-provider', + 'row-email-generator', + 'row-auto-run-controls', +]; + +function applyOpenAiReauthRowVisibility(activeFlowId) { + const isReauthFlow = String(activeFlowId || '').trim().toLowerCase() === 'openai-reauth'; + REAUTH_HIDDEN_ROW_IDS.forEach((rowId) => { + const element = document.getElementById(rowId); + if (!element) return; + if (isReauthFlow) { + element.dataset.reauthHidden = '1'; + element.style.display = 'none'; + } else if (element.dataset.reauthHidden === '1') { + element.style.display = ''; + delete element.dataset.reauthHidden; + } + }); + if (isReauthFlow && typeof syncReauthMailProviderToGlobal === 'function') { + // 仅同步显示,不写 storage —— 否则与 background SAVE_SETTING → STATE_CHANGED 广播形成死循环 + syncReauthMailProviderToGlobal({ persist: false }); + } } // ============================================================ @@ -14817,7 +14874,13 @@ stepsList?.addEventListener('click', async (event) => { } } } else { - const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: { nodeId } }); + const reauthPayload = { nodeId }; + if (nodeId === 'prepare-reauth') { + const account = ensurePendingReauthAccount(); + if (!account) return; + reauthPayload.reauthInputAccount = account; + } + const response = await sendSidepanelMessage({ type: 'EXECUTE_NODE', source: 'sidepanel', payload: reauthPayload }); if (response?.error) { throw new Error(response.error); } @@ -15124,20 +15187,32 @@ async function startAutoRunFromCurrentSettings() { ? getSelectedTargetId(activeFlowId) : normalizeTargetIdForFlow(activeFlowId, latestState?.targetId || '', getDefaultTargetIdForFlow(activeFlowId)); btnAutoRun.innerHTML = ' 运行中...'; + const autoRunPayload = { + totalRuns, + activeFlowId, + targetId, + autoRunSkipFailures, + accountContributionEnabled: Boolean(latestState?.accountContributionEnabled), + contributionAdapterId: latestState?.contributionAdapterId || '', + contributionNickname, + contributionQq, + mode, + }; + if (activeFlowId === 'openai-reauth') { + const reauthAccount = ensurePendingReauthAccount(); + if (!reauthAccount) { + btnAutoRun.disabled = false; + inputRunCount.disabled = false; + btnAutoRun.innerHTML = ' 自动'; + clearPendingAutoRunStartRunCount(); + return false; + } + autoRunPayload.reauthInputAccount = reauthAccount; + } const response = await sendSidepanelMessage({ type: 'AUTO_RUN', source: 'sidepanel', - payload: { - totalRuns, - activeFlowId, - targetId, - autoRunSkipFailures, - accountContributionEnabled: Boolean(latestState?.accountContributionEnabled), - contributionAdapterId: latestState?.contributionAdapterId || '', - contributionNickname, - contributionQq, - mode, - }, + payload: autoRunPayload, }); if (response?.error) { clearPendingAutoRunStartRunCount(); @@ -17320,6 +17395,22 @@ btnPhoneSmsProviderOrderReset?.addEventListener('click', () => { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { switch (message.type) { + case 'STATE_PATCH': { + // background setState 写完 chrome.storage.session 后会广播此消息, + // payload 为 sessionUpdates(实际生效的增量)。这里按需把关心字段 + // dispatch 到对应渲染函数;其它字段交给原有 NODE_STATUS_CHANGED / GET_STATE + // 通道(不在此处覆盖既有渲染逻辑)。 + const patch = message?.payload; + if (!patch || typeof patch !== 'object') break; + if (Object.prototype.hasOwnProperty.call(patch, 'reauthBatchProgress')) { + try { applyReauthBatchProgress(patch.reauthBatchProgress || null); } catch (_) { } + } + if (Object.prototype.hasOwnProperty.call(patch, 'reauthBatchResult')) { + try { applyReauthBatchResult(patch.reauthBatchResult || null); } catch (_) { } + } + break; + } + case 'REQUEST_CUSTOM_VERIFICATION_BYPASS_CONFIRMATION': { (async () => { const step = Number(message.payload?.step); @@ -17378,6 +17469,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { displayLocalhostUrl.textContent = state.localhostUrl; displayLocalhostUrl.classList.add('has-value'); } + renderReauthResultAccount(state.reauthResultAccount); + applyReauthBatchProgress(state.reauthBatchProgress || null); + applyReauthBatchResult(state.reauthBatchResult || null); } } ).catch(() => { }); @@ -18343,3 +18437,552 @@ Promise.allSettled([ console.error('Failed to initialize sidepanel state:', err); }); }); + +let pendingReauthAccounts = null; +let lastReauthFileText = ''; + +function setReauthJsonStatus(text, level = '') { + if (!reauthJsonStatus) return; + reauthJsonStatus.textContent = text || ''; + reauthJsonStatus.classList.remove('ok', 'error', 'warn'); + if (['ok', 'error', 'warn'].includes(level)) reauthJsonStatus.classList.add(level); +} + +function setReauthCopyStatus(text, level = '') { + if (!reauthCopyStatus) return; + reauthCopyStatus.textContent = text || ''; + reauthCopyStatus.classList.remove('ok', 'error', 'warn'); + if (['ok', 'error', 'warn'].includes(level)) reauthCopyStatus.classList.add(level); +} + +function getReauthValidatorApi() { + return (typeof self !== 'undefined' ? self : window).MultiPageOpenAiReauthAccountValidator || null; +} + +function populateReauthMailProviderOptions() { + if (!selectReauthMailProvider) return; + const validator = getReauthValidatorApi(); + const options = Array.isArray(validator?.MAIL_PROVIDER_OPTIONS) ? validator.MAIL_PROVIDER_OPTIONS : []; + if (!options.length) return; + const previousValue = selectReauthMailProvider.value; + selectReauthMailProvider.innerHTML = ''; + for (const entry of options) { + const option = document.createElement('option'); + option.value = String(entry?.value || ''); + option.textContent = String(entry?.label || entry?.value || ''); + selectReauthMailProvider.appendChild(option); + } + if (previousValue && options.some((entry) => entry?.value === previousValue)) { + selectReauthMailProvider.value = previousValue; + } +} +populateReauthMailProviderOptions(); + +function renderReauthAccountPicker(accounts) { + if (!selectReauthAccount || !rowReauthAccountPicker) return; + selectReauthAccount.innerHTML = ''; + accounts.forEach((entry) => { + const option = document.createElement('option'); + option.value = String(entry.index); + option.textContent = `${entry.index + 1}. ${entry.email}`; + selectReauthAccount.appendChild(option); + }); + selectReauthAccount.value = '0'; + rowReauthAccountPicker.style.display = accounts.length > 1 ? '' : 'none'; +} + +function clearReauthAccountPicker() { + if (selectReauthAccount) selectReauthAccount.innerHTML = ''; + if (rowReauthAccountPicker) rowReauthAccountPicker.style.display = 'none'; +} + +function parsePendingReauthAccounts(rawText, fileLabel = '') { + const validator = getReauthValidatorApi(); + if (!validator || typeof validator.parseAccountsFromJson !== 'function') { + setReauthJsonStatus('校验器未加载,请检查扩展安装。', 'error'); + pendingReauthAccounts = null; + clearReauthAccountPicker(); + applyReauthBatchUiVisibility(); + return null; + } + const result = validator.parseAccountsFromJson(rawText || ''); + if (!result.ok) { + setReauthJsonStatus(result.error, 'error'); + if (typeof showToast === 'function') { + showToast(`账号 JSON 校验失败:${result.error}`, 'error'); + } + pendingReauthAccounts = null; + clearReauthAccountPicker(); + applyReauthBatchUiVisibility(); + return null; + } + pendingReauthAccounts = result.accounts; + renderReauthAccountPicker(result.accounts); + const prefix = fileLabel ? `从「${fileLabel}」` : ''; + setReauthJsonStatus( + result.accounts.length === 1 + ? `${prefix}已识别 1 个账号:${result.accounts[0].email}` + : `${prefix}已识别 ${result.accounts.length} 个账号,可选单个账号或开启「批量模式」一次性处理全部`, + 'ok' + ); + applyReauthBatchUiVisibility(); + return result.accounts; +} + +// ============================================================ +// Reauth 批量模式 UI 控制 +// ============================================================ + +let lastReauthBatchProgress = null; +let lastReauthBatchResult = null; +let reauthBatchRunningLocal = false; + +function isReauthBatchModeEnabled() { + return Boolean(inputReauthBatchMode?.checked); +} + +function hasMultipleReauthAccounts() { + return Array.isArray(pendingReauthAccounts) && pendingReauthAccounts.length > 1; +} + +function applyReauthBatchUiVisibility() { + const accountsCount = Array.isArray(pendingReauthAccounts) ? pendingReauthAccounts.length : 0; + const hasAccounts = accountsCount > 0; + const showBatchToggle = accountsCount > 1; + + if (rowReauthBatchToggle) { + rowReauthBatchToggle.style.display = showBatchToggle ? '' : 'none'; + } + if (!showBatchToggle && inputReauthBatchMode) { + inputReauthBatchMode.checked = false; + } + + const batchMode = showBatchToggle && isReauthBatchModeEnabled(); + + // 模式行:账号数 ≥ 2 时显示(用于切换 toggle 和单账号下拉) + if (rowReauthModePicker) { + rowReauthModePicker.style.display = showBatchToggle ? '' : 'none'; + } + // 单账号下拉:账号数 ≥ 2 且非批量模式时显示 + if (rowReauthAccountPicker) { + rowReauthAccountPicker.style.display = (showBatchToggle && !batchMode) ? '' : 'none'; + } + // 邮箱来源:有账号时显示 + if (rowReauthProviderPicker) { + rowReauthProviderPicker.style.display = hasAccounts ? '' : 'none'; + } + // 批量操作行:批量模式时显示 + if (rowReauthBatchActions) { + rowReauthBatchActions.style.display = batchMode ? '' : 'none'; + } + // 进度行:批量模式时显示 + if (rowReauthBatchProgress) { + rowReauthBatchProgress.style.display = batchMode ? '' : 'none'; + } + // 下载文件按钮:只要有批量结果就显示(不依赖 batchMode toggle),保证 sidepanel 重开后仍可下载 + if (btnReauthDownloadResult) { + const hasBatchJson = Boolean(lastReauthBatchResult?.updatedFileJson); + btnReauthDownloadResult.style.display = (batchMode || hasBatchJson) ? '' : 'none'; + } + // 启动按钮:批量模式且未运行时可点 + if (btnReauthStartBatch) { + btnReauthStartBatch.disabled = !batchMode || reauthBatchRunningLocal; + } + // 停止按钮:批量模式且运行中时显示 + if (btnReauthStopBatch) { + btnReauthStopBatch.style.display = (batchMode && reauthBatchRunningLocal) ? '' : 'none'; + } + // 折叠详情按钮:有批量结果 JSON 时显示(同样不依赖 toggle) + if (btnReauthToggleDetails) { + const hasJson = Boolean(lastReauthBatchResult?.updatedFileJson); + btnReauthToggleDetails.style.display = hasJson ? '' : 'none'; + } +} + +function getProgressStatusLabel(status) { + return ({ + pending: '准备中', + running: '处理中', + success: '成功', + failed: '失败', + completed: '已完成', + stopped: '已停止', + aborted: '已中断', + })[status] || ''; +} + +function formatBatchProgressText(progress) { + if (!progress || typeof progress !== 'object') return '尚未开始'; + const { current = 0, total = 0, currentEmail = '', currentStatus = '' } = progress; + if (currentStatus === 'completed') return `批量已完成(共 ${total} 个账号)`; + if (currentStatus === 'stopped') return `已被用户停止(${current}/${total})`; + if (currentStatus === 'aborted') return `批量中断(${current}/${total})`; + if (currentStatus === 'pending') return total > 0 ? `准备中,共 ${total} 个账号` : '尚未开始'; + return `当前:${currentEmail || `账号 #${current}`}`; +} + +function applyReauthBatchProgress(progress) { + lastReauthBatchProgress = progress || null; + const running = Boolean(progress) && ['pending', 'running'].includes(String(progress?.currentStatus || '')); + reauthBatchRunningLocal = running; + + const total = Math.max(0, Number(progress?.total) || 0); + const current = Math.max(0, Math.min(total, Number(progress?.current) || 0)); + const status = String(progress?.currentStatus || ''); + const percent = total > 0 ? Math.round((current / total) * 100) : 0; + + if (reauthProgressFill) { + reauthProgressFill.style.width = `${percent}%`; + reauthProgressFill.classList.remove('is-success', 'is-stopped', 'is-aborted'); + if (status === 'completed') reauthProgressFill.classList.add('is-success'); + if (status === 'stopped') reauthProgressFill.classList.add('is-stopped'); + if (status === 'aborted') reauthProgressFill.classList.add('is-aborted'); + } + if (reauthProgressCounter) { + reauthProgressCounter.textContent = total > 0 ? `${current} / ${total}` : '0 / 0'; + } + if (reauthProgressCurrent) { + reauthProgressCurrent.textContent = progress?.currentEmail || ''; + } + if (reauthProgressStatusBadge) { + const label = getProgressStatusLabel(status); + reauthProgressStatusBadge.textContent = label; + reauthProgressStatusBadge.className = `reauth-status-badge ${status || ''}`.trim(); + } + if (displayReauthBatchProgress) { + displayReauthBatchProgress.textContent = formatBatchProgressText(progress); + } + applyReauthBatchUiVisibility(); +} + +function applyReauthBatchResult(result) { + lastReauthBatchResult = result || null; + reauthBatchRunningLocal = false; + + if (displayReauthBatchSummary && reauthResultSummary) { + reauthResultSummary.classList.remove('all-success', 'has-failure', 'aborted'); + if (!result || typeof result !== 'object') { + displayReauthBatchSummary.textContent = '等待重新授权完成...'; + } else { + const { successCount = 0, failedCount = 0, total = 0, aborted = false } = result; + const failedEmails = Array.isArray(result.failed) + ? result.failed.map((f) => f?.email).filter(Boolean) + : []; + const parts = [`成功 ${successCount} / 总计 ${total}`]; + if (failedCount > 0) { + const preview = failedEmails.slice(0, 3).join(', '); + const more = failedEmails.length > 3 ? ` … +${failedEmails.length - 3}` : ''; + parts.push(`失败 ${failedCount}(${preview}${more})`); + } + if (aborted) { + parts.push('已中断'); + reauthResultSummary.classList.add('aborted'); + } else if (failedCount > 0) { + reauthResultSummary.classList.add('has-failure'); + } else if (successCount === total && total > 0) { + reauthResultSummary.classList.add('all-success'); + } + displayReauthBatchSummary.textContent = parts.join(' · '); + } + } + + if (displayReauthResultAccount && result?.updatedFileJson) { + displayReauthResultAccount.textContent = result.updatedFileJson; + displayReauthResultAccount.classList.add('has-value'); + } + if (btnReauthCopyResult) { + btnReauthCopyResult.disabled = !result?.updatedFileJson; + } + if (btnReauthDownloadResult) { + btnReauthDownloadResult.disabled = !result?.updatedFileJson; + } + // 重置折叠状态:每次新结果默认折叠 + if (displayReauthResultAccount && result?.updatedFileJson) { + displayReauthResultAccount.style.display = 'none'; + } + if (btnReauthToggleDetails) { + btnReauthToggleDetails.textContent = '▼ 展开详情'; + btnReauthToggleDetails.setAttribute('aria-expanded', 'false'); + } + applyReauthBatchUiVisibility(); +} + +function buildReauthBatchAccounts(provider) { + const accounts = Array.isArray(pendingReauthAccounts) ? pendingReauthAccounts : []; + if (!accounts.length) return []; + const validator = getReauthValidatorApi(); + if (!validator || typeof validator.buildResolvedAccount !== 'function') return []; + return accounts + .map((entry) => { + try { + return validator.buildResolvedAccount(entry.account, provider); + } catch { + return null; + } + }) + .filter(Boolean); +} + +async function handleReauthBatchStart() { + if (!hasMultipleReauthAccounts()) { + setReauthJsonStatus('批量模式至少需要 2 个账号。', 'error'); + return; + } + const provider = String(selectReauthMailProvider?.value || '').trim(); + if (!provider) { + setReauthJsonStatus('请先选择「邮箱来源」。', 'error'); + return; + } + const accounts = buildReauthBatchAccounts(provider); + if (accounts.length === 0) { + setReauthJsonStatus('未能构造可用账号列表。', 'error'); + return; + } + + reauthBatchRunningLocal = true; + applyReauthBatchProgress({ current: 0, total: accounts.length, currentEmail: '', currentStatus: 'pending' }); + + try { + const response = await chrome.runtime.sendMessage({ + type: 'START_REAUTH_BATCH', + source: 'sidepanel', + payload: { + accounts, + mailProvider: provider, + originalFileText: lastReauthFileText || '', + skipOnFailure: true, + }, + }); + if (response?.error) { + throw new Error(response.error); + } + setReauthJsonStatus(`已启动批量授权(${accounts.length} 个账号)。`, 'ok'); + } catch (error) { + reauthBatchRunningLocal = false; + applyReauthBatchUiVisibility(); + const message = error?.message || String(error || ''); + setReauthJsonStatus(`批量启动失败:${message}`, 'error'); + if (typeof showToast === 'function') { + showToast(`批量启动失败:${message}`, 'error'); + } + } +} + +async function handleReauthBatchStop() { + try { + await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} }); + setReauthJsonStatus('已请求停止批量授权...', 'warn'); + } catch (error) { + const message = error?.message || String(error || ''); + setReauthJsonStatus(`停止请求失败:${message}`, 'error'); + } +} + +function generateBatchDownloadFileName() { + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `sub2api-reauth-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.json`; +} + +async function handleReauthBatchDownload() { + const text = lastReauthBatchResult?.updatedFileJson + || (displayReauthResultAccount?.textContent || ''); + if (!text) { + setReauthCopyStatus('暂无可下载的批量结果。', 'error'); + return; + } + const filename = generateBatchDownloadFileName(); + try { + if (chrome?.downloads?.download) { + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + let revoked = false; + const revokeUrl = () => { + if (revoked) return; + revoked = true; + try { URL.revokeObjectURL(url); } catch {} + }; + let downloadId; + try { + downloadId = await chrome.downloads.download({ url, filename, saveAs: true }); + } catch (downloadError) { + revokeUrl(); + throw downloadError; + } + let cleanupDownloadListener = () => {}; + if (Number.isInteger(downloadId) && chrome.downloads?.onChanged?.addListener) { + const onDownloadChanged = (delta = {}) => { + if (delta.id !== downloadId) return; + const state = delta.state?.current; + if (state === 'complete' || state === 'interrupted') { + cleanupDownloadListener(); + revokeUrl(); + } + }; + cleanupDownloadListener = () => { + try { chrome.downloads.onChanged.removeListener(onDownloadChanged); } catch {} + }; + chrome.downloads.onChanged.addListener(onDownloadChanged); + } + // saveAs 对话框可能停留较久;监听不到最终状态时以长兜底释放 object URL。 + setTimeout(() => { + cleanupDownloadListener(); + revokeUrl(); + }, 10 * 60 * 1000); + setReauthCopyStatus(`已触发下载:${filename}`, 'ok'); + return; + } + } catch (error) { + console.warn('[reauth] download failed, fallback to clipboard:', error); + } + try { + await navigator.clipboard.writeText(text); + setReauthCopyStatus('下载不可用,已复制到剪贴板', 'ok'); + } catch (error) { + setReauthCopyStatus(`下载与复制均失败:${error?.message || error}`, 'error'); + } +} + +inputReauthBatchMode?.addEventListener('change', () => { + applyReauthBatchUiVisibility(); +}); +btnReauthStartBatch?.addEventListener('click', () => { + handleReauthBatchStart(); +}); +btnReauthStopBatch?.addEventListener('click', () => { + handleReauthBatchStop(); +}); +btnReauthDownloadResult?.addEventListener('click', () => { + handleReauthBatchDownload(); +}); +btnReauthToggleDetails?.addEventListener('click', () => { + if (!displayReauthResultAccount) return; + const isHidden = displayReauthResultAccount.style.display === 'none'; + displayReauthResultAccount.style.display = isHidden ? '' : 'none'; + if (btnReauthToggleDetails) { + btnReauthToggleDetails.textContent = isHidden ? '▲ 收起详情' : '▼ 展开详情'; + btnReauthToggleDetails.setAttribute('aria-expanded', isHidden ? 'true' : 'false'); + } +}); + +function ensurePendingReauthAccount() { + if (!pendingReauthAccounts && lastReauthFileText) { + parsePendingReauthAccounts(lastReauthFileText); + } + const accounts = pendingReauthAccounts; + if (!accounts || !accounts.length) { + setReauthJsonStatus('请先选择账号 JSON 文件。', 'error'); + if (typeof showToast === 'function') { + showToast('请先选择账号 JSON 文件。', 'error'); + } + return null; + } + + const selectedIndex = selectReauthAccount && accounts.length > 1 + ? Number(selectReauthAccount.value) + : 0; + const entry = accounts[Number.isInteger(selectedIndex) ? selectedIndex : 0] || accounts[0]; + if (!entry) { + setReauthJsonStatus('未选择账号。', 'error'); + return null; + } + + const provider = String(selectReauthMailProvider?.value || '').trim(); + if (!provider) { + setReauthJsonStatus('请先选择「邮箱来源」。', 'error'); + if (typeof showToast === 'function') { + showToast('请先在「邮箱来源」下拉框选择 mailProvider。', 'error'); + } + return null; + } + + const validator = getReauthValidatorApi(); + if (!validator || typeof validator.buildResolvedAccount !== 'function') { + setReauthJsonStatus('校验器未加载。', 'error'); + return null; + } + return validator.buildResolvedAccount(entry.account, provider); +} + +function renderReauthResultAccount(account) { + if (!displayReauthResultAccount || !btnReauthCopyResult) return; + const batchMode = isReauthBatchModeEnabled(); + if (account && typeof account === 'object') { + displayReauthResultAccount.textContent = JSON.stringify(account, null, 2); + displayReauthResultAccount.classList.add('has-value'); + btnReauthCopyResult.disabled = false; + // 单账号模式下直接展示完整 JSON;批量模式下默认折叠(由 applyReauthBatchResult 控制) + if (!batchMode) { + displayReauthResultAccount.style.display = ''; + } + // 单账号模式下也填充 summary 区,给师兄一个简短确认 + if (!batchMode && reauthResultSummary && displayReauthBatchSummary) { + const email = account?.credentials?.email || account?.email || account?.name || ''; + reauthResultSummary.classList.remove('has-failure', 'aborted'); + reauthResultSummary.classList.add('all-success'); + displayReauthBatchSummary.textContent = `已重新授权:${email}`; + } + } else { + displayReauthResultAccount.textContent = '等待重新授权完成...'; + displayReauthResultAccount.classList.remove('has-value'); + btnReauthCopyResult.disabled = true; + if (!batchMode) { + displayReauthResultAccount.style.display = ''; + if (reauthResultSummary && displayReauthBatchSummary) { + reauthResultSummary.classList.remove('has-failure', 'aborted', 'all-success'); + displayReauthBatchSummary.textContent = '等待重新授权完成...'; + } + } + } +} + +inputReauthAccountFile?.addEventListener('change', async (event) => { + const file = event.target?.files?.[0]; + pendingReauthAccounts = null; + lastReauthFileText = ''; + clearReauthAccountPicker(); + applyReauthBatchUiVisibility(); + if (!file) { + setReauthJsonStatus('未选择文件'); + return; + } + try { + const text = await file.text(); + lastReauthFileText = text; + parsePendingReauthAccounts(text, file.name); + } catch (error) { + setReauthJsonStatus(`文件读取失败:${error?.message || error}`, 'error'); + } +}); + +btnReauthCopyResult?.addEventListener('click', async () => { + const text = displayReauthResultAccount?.textContent || ''; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setReauthCopyStatus('已复制', 'ok'); + } catch (error) { + setReauthCopyStatus(`复制失败:${error?.message || error}`, 'error'); + } +}); + +// reauth 流程:让「邮箱来源」选择同步到全局 mailProvider, +// 复用注册流程的 provider section 可见性逻辑(cloudflare / icloud / hotmail 等配置卡片) +// 参数 persist 控制是否写回 storage: +// - 用户主动 change 时 persist=true,会触发 saveSettings +// - UI 刷新(如 applyOpenAiReauthRowVisibility)触发时 persist=false,仅同步显示,避免 SAVE_SETTING ↔ STATE_CHANGED 死循环 +function syncReauthMailProviderToGlobal({ persist = false } = {}) { + const provider = String(selectReauthMailProvider?.value || '').trim(); + if (!provider) return; + if (selectMailProvider && selectMailProvider.value !== provider) { + selectMailProvider.value = provider; + } + if (typeof updateMailProviderUI === 'function') updateMailProviderUI(); + if (!persist) return; + if (typeof markSettingsDirty === 'function') markSettingsDirty(true); + if (typeof saveSettings === 'function') saveSettings({ silent: true }).catch(() => { }); +} + +selectReauthMailProvider?.addEventListener('change', () => { + syncReauthMailProviderToGlobal({ persist: true }); +}); diff --git a/tests/openai-reauth-batch-runner.test.js b/tests/openai-reauth-batch-runner.test.js new file mode 100644 index 00000000..173a8c53 --- /dev/null +++ b/tests/openai-reauth-batch-runner.test.js @@ -0,0 +1,480 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const RUNNER_PATH = path.join( + __dirname, + '..', + 'flows', + 'openai-reauth', + 'background', + 'batch-runner.js' +); + +function loadRunnerModule() { + const source = fs.readFileSync(RUNNER_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {}, console, setTimeout, clearTimeout }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthBatchRunner; +} + +function buildDeps(overrides = {}) { + const calls = { + log: [], + setState: [], + executeNode: [], + sleep: [], + }; + + let mockState = {}; + let stopFlag = false; + let pollIndex = 0; + + function getMockState() { + return JSON.parse(JSON.stringify(mockState)); + } + + const deps = { + addLog: async (message, level, options) => { + calls.log.push({ message, level, options }); + }, + setState: async (updates) => { + calls.setState.push(JSON.parse(JSON.stringify(updates || {}))); + mockState = { ...mockState, ...(updates || {}) }; + }, + getState: async () => getMockState(), + executeNode: async (nodeId) => { + calls.executeNode.push(nodeId); + if (typeof overrides.executeNodeHandler === 'function') { + const result = await overrides.executeNodeHandler(nodeId, pollIndex, mockState); + if (result && typeof result === 'object') { + mockState = { ...mockState, ...result }; + } + return; + } + // 默认 stub:在最后一个 node(capture-reauth-callback)执行后写入成功 result + if (nodeId === 'capture-reauth-callback') { + pollIndex += 1; + const successAccount = { + ...(mockState.reauthInputAccount || {}), + credentials: { + ...(mockState.reauthInputAccount?.credentials || {}), + access_token: `new_access_${pollIndex}`, + refresh_token: `new_refresh_${pollIndex}`, + }, + }; + mockState = { ...mockState, reauthResultAccount: successAccount }; + } + }, + getNodeIdsForState: overrides.getNodeIdsForState || (() => [ + 'prepare-reauth', + 'submit-reauth-email', + 'fetch-reauth-code', + 'capture-reauth-callback', + ]), + throwIfStopped: () => { + if (stopFlag) { + const err = new Error('已被用户停止'); + throw err; + } + }, + sleepWithStop: async (ms) => { + calls.sleep.push(ms); + if (typeof overrides.onSleep === 'function') { + await overrides.onSleep(ms, calls.sleep.length); + } + }, + interAccountDelayMs: overrides.interAccountDelayMs ?? 0, + }; + + return { + deps, + calls, + setStopped: (value) => { stopFlag = Boolean(value); }, + getMockState, + }; +} + +// ============================================================ +// 纯函数:extractAccountEmail +// ============================================================ + +test('extractAccountEmail 优先 credentials.email > email > name', () => { + const mod = loadRunnerModule(); + assert.equal( + mod.extractAccountEmail({ credentials: { email: 'A@x.com' }, email: 'B@y.com', name: 'C@z.com' }), + 'a@x.com' + ); + assert.equal( + mod.extractAccountEmail({ email: 'B@Y.com', name: 'C@z.com' }), + 'b@y.com' + ); + assert.equal(mod.extractAccountEmail({ name: 'C@Z.com' }), 'c@z.com'); + assert.equal(mod.extractAccountEmail(null), ''); + assert.equal(mod.extractAccountEmail({}), ''); +}); + +// ============================================================ +// 纯函数:mergeBatchResultsIntoFile +// ============================================================ + +test('mergeBatchResultsIntoFile:sub2api accounts 数组按 email 替换成功账号', () => { + const mod = loadRunnerModule(); + const original = JSON.stringify({ + accounts: [ + { name: 'A@2925.com', credentials: { email: 'A@2925.com', access_token: 'old_a' }, priority: 1 }, + { name: 'B@2925.com', credentials: { email: 'B@2925.com', access_token: 'old_b' }, priority: 2 }, + ], + }); + const success = [ + { name: 'A@2925.com', credentials: { email: 'a@2925.com', access_token: 'new_a' } }, + ]; + + const merged = JSON.parse(mod.mergeBatchResultsIntoFile(original, success)); + assert.equal(merged.accounts[0].credentials.access_token, 'new_a'); + assert.equal(merged.accounts[0].priority, 1, '原 priority 必须保留'); + assert.equal(merged.accounts[1].credentials.access_token, 'old_b', '未在 success 列表的账号保留原状'); +}); + +test('mergeBatchResultsIntoFile:顶层数组形式正常 merge', () => { + const mod = loadRunnerModule(); + const original = JSON.stringify([ + { email: 'a@x.com', credentials: { email: 'a@x.com', access_token: 'old' } }, + ]); + const success = [{ email: 'a@x.com', credentials: { email: 'a@x.com', access_token: 'new' } }]; + + const merged = JSON.parse(mod.mergeBatchResultsIntoFile(original, success)); + assert.equal(merged[0].credentials.access_token, 'new'); +}); + +test('mergeBatchResultsIntoFile:单账号对象形式正常 merge', () => { + const mod = loadRunnerModule(); + const original = JSON.stringify({ + email: 'a@x.com', + credentials: { email: 'a@x.com', access_token: 'old' }, + extra: { keep: true }, + }); + const success = [{ email: 'a@x.com', credentials: { email: 'a@x.com', access_token: 'new' } }]; + + const merged = JSON.parse(mod.mergeBatchResultsIntoFile(original, success)); + assert.equal(merged.credentials.access_token, 'new'); + assert.deepEqual(merged.extra, { keep: true }); +}); + +test('mergeBatchResultsIntoFile:原始文本不可解析时退化为 success 数组', () => { + const mod = loadRunnerModule(); + const merged = JSON.parse(mod.mergeBatchResultsIntoFile('not a json', [{ email: 'a@x.com' }])); + assert.deepEqual(merged, [{ email: 'a@x.com' }]); +}); + +test('mergeBatchResultsIntoFile:空 originalFileText 退化为 success 数组', () => { + const mod = loadRunnerModule(); + const merged = JSON.parse(mod.mergeBatchResultsIntoFile('', [{ email: 'a@x.com' }])); + assert.deepEqual(merged, [{ email: 'a@x.com' }]); +}); + +// ============================================================ +// executeReauthBatch 主流程 +// ============================================================ + +test('executeReauthBatch:空 accounts 抛错', async () => { + const mod = loadRunnerModule(); + const { deps } = buildDeps(); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + await assert.rejects( + () => executeReauthBatch({ accounts: [] }), + /批量队列为空/ + ); +}); + +test('executeReauthBatch:顺序跑完 3 个账号,全部成功', async () => { + const mod = loadRunnerModule(); + const { deps, calls } = buildDeps(); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + const accounts = [ + { email: 'a@2925.com', credentials: { email: 'a@2925.com', access_token: 'old_a' } }, + { email: 'b@2925.com', credentials: { email: 'b@2925.com', access_token: 'old_b' } }, + { email: 'c@2925.com', credentials: { email: 'c@2925.com', access_token: 'old_c' } }, + ]; + + const result = await executeReauthBatch({ + accounts, + mailProvider: '2925', + originalFileText: JSON.stringify({ accounts }), + }); + + assert.equal(result.successCount, 3); + assert.equal(result.failedCount, 0); + assert.equal(result.total, 3); + assert.equal(result.aborted, false); + // 每个账号 4 个 node,共 12 次 executeNode + assert.equal(calls.executeNode.length, 12); + + const merged = JSON.parse(result.updatedFileJson); + assert.equal(merged.accounts.length, 3); + assert.equal(merged.accounts[0].credentials.access_token, 'new_access_1'); + assert.equal(merged.accounts[2].credentials.access_token, 'new_access_3'); +}); + +test('executeReauthBatch:中间账号失败时 skip 继续,最终统计正确', async () => { + const mod = loadRunnerModule(); + let nodeRun = 0; + const { deps } = buildDeps({ + executeNodeHandler: async (nodeId, currentSuccess, state) => { + nodeRun += 1; + const targetEmail = state?.reauthInputAccount?.credentials?.email; + if (nodeId === 'fetch-reauth-code' && targetEmail === 'b@2925.com') { + throw new Error('mock 第二个账号 fetch-code 失败'); + } + if (nodeId === 'capture-reauth-callback') { + return { + reauthResultAccount: { + ...(state.reauthInputAccount || {}), + credentials: { + ...(state.reauthInputAccount?.credentials || {}), + access_token: `new_for_${targetEmail}`, + }, + }, + }; + } + }, + }); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + const accounts = [ + { credentials: { email: 'a@2925.com' } }, + { credentials: { email: 'b@2925.com' } }, + { credentials: { email: 'c@2925.com' } }, + ]; + + const result = await executeReauthBatch({ + accounts, + mailProvider: '2925', + }); + + assert.equal(result.successCount, 2); + assert.equal(result.failedCount, 1); + assert.equal(result.failed[0].email, 'b@2925.com'); + assert.match(result.failed[0].error, /fetch-code 失败/); + assert.equal(result.aborted, false); +}); + +test('executeReauthBatch:skipOnFailure=false 时失败立即终止', async () => { + const mod = loadRunnerModule(); + const { deps, calls, getMockState } = buildDeps({ + executeNodeHandler: async (nodeId, _, state) => { + const email = state?.reauthInputAccount?.credentials?.email; + if (email === 'b@2925.com' && nodeId === 'prepare-reauth') { + throw new Error('mock 第二账号一上来就 fail'); + } + if (nodeId === 'capture-reauth-callback') { + return { + reauthResultAccount: { + ...(state.reauthInputAccount || {}), + credentials: { + ...(state.reauthInputAccount?.credentials || {}), + access_token: 'new', + }, + }, + }; + } + }, + }); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + const accounts = [ + { credentials: { email: 'a@2925.com' } }, + { credentials: { email: 'b@2925.com' } }, + { credentials: { email: 'c@2925.com' } }, + ]; + + await assert.rejects( + () => executeReauthBatch({ + accounts, + mailProvider: '2925', + skipOnFailure: false, + }), + /mock 第二账号一上来就 fail/ + ); + + // 第三个账号不应被处理 + const lastStateUpdate = calls.setState[calls.setState.length - 1]; + assert.equal(lastStateUpdate.reauthBatchResult.aborted, true); + assert.equal(lastStateUpdate.reauthBatchResult.successCount, 1); + assert.equal(lastStateUpdate.reauthBatchResult.failedCount, 1); + assert.match(lastStateUpdate.reauthBatchResult.stopReason, /第二账号一上来就 fail/); +}); + +test('executeReauthBatch:stop 信号 → 立即终止,aborted=true & stopReason=user_stop', async () => { + const mod = loadRunnerModule(); + let nodeRun = 0; + const { deps, calls } = buildDeps({ + executeNodeHandler: async (nodeId, _, state) => { + nodeRun += 1; + if (nodeRun === 5) { + throw new Error('已被用户停止'); + } + if (nodeId === 'capture-reauth-callback') { + return { + reauthResultAccount: { + ...(state.reauthInputAccount || {}), + credentials: { + ...(state.reauthInputAccount?.credentials || {}), + access_token: 'new', + }, + }, + }; + } + }, + }); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + const accounts = [ + { credentials: { email: 'a@2925.com' } }, + { credentials: { email: 'b@2925.com' } }, + { credentials: { email: 'c@2925.com' } }, + ]; + + await assert.rejects( + () => executeReauthBatch({ accounts, mailProvider: '2925' }), + /已被用户停止/ + ); + + const lastStateUpdate = calls.setState[calls.setState.length - 1]; + assert.equal(lastStateUpdate.reauthBatchResult.aborted, true); + assert.equal(lastStateUpdate.reauthBatchResult.stopReason, 'user_stop'); + assert.equal(lastStateUpdate.reauthBatchProgress.currentStatus, 'stopped'); +}); + +test('executeReauthBatch:progress 每轮 currentStatus 流转 running → success / failed', async () => { + const mod = loadRunnerModule(); + const { deps, calls } = buildDeps(); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + await executeReauthBatch({ + accounts: [{ credentials: { email: 'a@2925.com' } }], + mailProvider: '2925', + originalFileText: JSON.stringify({ accounts: [{ credentials: { email: 'a@2925.com' } }] }), + }); + + const progressUpdates = calls.setState + .filter((u) => u.reauthBatchProgress) + .map((u) => u.reauthBatchProgress.currentStatus); + + assert.ok(progressUpdates.includes('pending')); + assert.ok(progressUpdates.includes('running')); + assert.ok(progressUpdates.includes('success')); + assert.ok(progressUpdates.includes('completed')); +}); + +test('executeReauthBatch:runSingleAccount 把 mailProvider 注入到 state.reauthInputAccount', async () => { + const mod = loadRunnerModule(); + const { deps, calls } = buildDeps(); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + await executeReauthBatch({ + accounts: [{ credentials: { email: 'a@2925.com' } }], + mailProvider: '2925', + }); + + const inputAccountUpdate = calls.setState + .find((u) => u.reauthInputAccount && u.reauthInputAccount.credentials?.email === 'a@2925.com'); + assert.ok(inputAccountUpdate); + assert.equal(inputAccountUpdate.reauthInputAccount.mailProvider, '2925'); +}); + +test('executeReauthBatch:优先使用注入的 extractAccountEmail,避免依赖全局 validator', async () => { + const mod = loadRunnerModule(); + const { deps } = buildDeps(); + deps.extractAccountEmail = (account) => account?.aliasEmail || ''; + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + const accounts = [ + { aliasEmail: 'Injected@Example.com', credentials: { access_token: 'old' }, priority: 5 }, + ]; + const result = await executeReauthBatch({ + accounts, + mailProvider: '2925', + originalFileText: JSON.stringify({ accounts }), + }); + + assert.equal(result.success[0].email, 'injected@example.com'); + const merged = JSON.parse(result.updatedFileJson); + assert.equal(merged.accounts[0].credentials.access_token, 'new_access_1'); + assert.equal(merged.accounts[0].priority, 5); +}); + +test('executeReauthBatch:终止状态写入失败时仍抛出原始错误', async () => { + const mod = loadRunnerModule(); + const { deps, calls } = buildDeps({ + executeNodeHandler: async () => { + throw new Error('original batch failure'); + }, + }); + const originalSetState = deps.setState; + deps.setState = async (updates) => { + if (updates?.reauthBatchResult) { + throw new Error('secondary state failure'); + } + return originalSetState(updates); + }; + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + await assert.rejects( + () => executeReauthBatch({ + accounts: [{ credentials: { email: 'a@2925.com' } }], + mailProvider: '2925', + skipOnFailure: false, + }), + /original batch failure/ + ); + assert.ok(calls.log.some((entry) => /状态写入失败/.test(entry.message))); +}); + +test('executeReauthBatch:每账号开始前清空 reauthResultAccount / nodeStatuses 避免污染', async () => { + const mod = loadRunnerModule(); + const { deps, calls } = buildDeps(); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + await executeReauthBatch({ + accounts: [ + { credentials: { email: 'a@2925.com' } }, + { credentials: { email: 'b@2925.com' } }, + ], + mailProvider: '2925', + }); + + const inputResets = calls.setState.filter( + (u) => u.reauthInputAccount && u.reauthResultAccount === null + ); + assert.equal(inputResets.length, 2, '每个账号开始时都应 reset reauthResultAccount'); + inputResets.forEach((reset) => { + assert.deepEqual(reset.nodeStatuses, {}, '应重置 nodeStatuses'); + }); +}); + +test('executeReauthBatch:interAccountDelayMs > 0 时账号之间会 sleep(最后一个账号不 sleep)', async () => { + const mod = loadRunnerModule(); + const { deps, calls } = buildDeps({ interAccountDelayMs: 1000 }); + const { executeReauthBatch } = mod.createReauthBatchRunner(deps); + + await executeReauthBatch({ + accounts: [ + { credentials: { email: 'a@2925.com' } }, + { credentials: { email: 'b@2925.com' } }, + { credentials: { email: 'c@2925.com' } }, + ], + mailProvider: '2925', + }); + + // 3 个账号之间 sleep 2 次 + assert.equal(calls.sleep.length, 2); + assert.equal(calls.sleep[0], 1000); +}); diff --git a/tests/openai-reauth-capture-callback.test.js b/tests/openai-reauth-capture-callback.test.js new file mode 100644 index 00000000..f16f79b3 --- /dev/null +++ b/tests/openai-reauth-capture-callback.test.js @@ -0,0 +1,417 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const STEP_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'background', 'steps', 'capture-reauth-callback.js'); + +function loadStepModule() { + const source = fs.readFileSync(STEP_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {}, console, setTimeout, clearTimeout }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthCaptureCallbackStep; +} + +function buildMockChromeApi() { + const navListeners = []; + const committedListeners = []; + const tabUpdatedListeners = []; + const removedTabs = []; + const sentMessages = []; + return { + navListeners, + committedListeners, + tabUpdatedListeners, + removedTabs, + sentMessages, + api: { + webNavigation: { + onBeforeNavigate: { + addListener: (fn) => navListeners.push(fn), + removeListener: (fn) => { + const idx = navListeners.indexOf(fn); + if (idx >= 0) navListeners.splice(idx, 1); + }, + }, + onCommitted: { + addListener: (fn) => committedListeners.push(fn), + removeListener: (fn) => { + const idx = committedListeners.indexOf(fn); + if (idx >= 0) committedListeners.splice(idx, 1); + }, + }, + }, + tabs: { + onUpdated: { + addListener: (fn) => tabUpdatedListeners.push(fn), + removeListener: (fn) => { + const idx = tabUpdatedListeners.indexOf(fn); + if (idx >= 0) tabUpdatedListeners.splice(idx, 1); + }, + }, + remove: async (tabId) => { removedTabs.push(tabId); }, + sendMessage: async (tabId, message) => { + sentMessages.push({ tabId, message }); + return null; + }, + }, + }, + }; +} + +function buildBaseDeps(overrides = {}) { + const completeCalls = []; + const setStateCalls = []; + const logCalls = []; + return { + completeCalls, + setStateCalls, + logCalls, + deps: { + addLog: async (message, level, options) => { logCalls.push({ message, level, options }); }, + completeNodeFromBackground: async (nodeId, payload) => { completeCalls.push({ nodeId, payload }); }, + exchangeAuthorizationCode: async ({ code, codeVerifier }) => ({ + accessToken: `access_for_${code}_${codeVerifier}`, + refreshToken: 'refresh_x', + idToken: 'id_x', + expiresIn: 3600, + }), + parseCallbackUrl: (url, expected) => { + if (!url.includes('localhost:1455/auth/callback')) return null; + const parsed = new URL(url); + const code = parsed.searchParams.get('code'); + const stateParam = parsed.searchParams.get('state'); + if (expected && stateParam !== expected) return { error: 'state mismatch' }; + return code ? { code, state: stateParam } : null; + }, + buildUpdatedAccount: (original, tokens) => ({ + ...original, + credentials: { + ...(original.credentials || {}), + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + }, + }), + setState: async (patch) => { setStateCalls.push(patch); }, + ...overrides, + }, + }; +} + +test('收到合法 localhost 回调时换 token、更新 account 并 complete', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + nodeId: 'capture-reauth-callback', + reauthState: 'STATE_TOKEN', + reauthCodeVerifier: 'VERIFIER_TOKEN', + reauthInputAccount: { name: 'a@b.com', credentials: { email: 'a@b.com' } }, + }); + + assert.equal(chromeMock.navListeners.length, 1); + assert.equal(chromeMock.committedListeners.length, 1); + assert.equal(chromeMock.tabUpdatedListeners.length, 1); + + chromeMock.navListeners[0]({ + url: 'http://localhost:1455/auth/callback?code=ABC&state=STATE_TOKEN', + tabId: 99, + }); + + await promise; + + assert.equal(chromeMock.navListeners.length, 0, '应已清理监听器'); + assert.equal(chromeMock.removedTabs[0], 99, '应关闭回调 tab'); + assert.equal(harness.completeCalls.length, 1); + assert.equal(harness.completeCalls[0].payload.reauthResultAccount.credentials.access_token, + 'access_for_ABC_VERIFIER_TOKEN'); + const lastSetState = harness.setStateCalls[harness.setStateCalls.length - 1]; + assert.ok(lastSetState.reauthResultAccount); + assert.equal(lastSetState.reauthCodeVerifier, ''); + assert.equal(lastSetState.reauthState, ''); +}); + +test('state 不匹配时拒绝并清理监听器', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'STATE_GOOD', + reauthCodeVerifier: 'VERIFIER', + reauthInputAccount: { name: 'x@y.com' }, + }); + + chromeMock.navListeners[0]({ + url: 'http://localhost:1455/auth/callback?code=Z&state=BAD', + tabId: 1, + }); + + await assert.rejects(promise, /state mismatch/); + assert.equal(chromeMock.navListeners.length, 0); +}); + +test('缺少 reauthState 时立刻拒绝', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + await assert.rejects( + executeCaptureReauthCallback({ + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'a' }, + }), + /OAuth state/ + ); +}); + +test('缺少 codeVerifier 时立刻拒绝', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + await assert.rejects( + executeCaptureReauthCallback({ + reauthState: 'S', + reauthInputAccount: { name: 'a' }, + }), + /code_verifier/ + ); +}); + +test('缺少 reauthInputAccount 时立刻拒绝', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + await assert.rejects( + executeCaptureReauthCallback({ + reauthState: 'S', + reauthCodeVerifier: 'V', + }), + /账号 JSON/ + ); +}); + +test('Token 交换失败时拒绝并写入 reauthLastError', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps({ + exchangeAuthorizationCode: async () => { throw new Error('upstream rejected'); }, + }); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'STATE', + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'a' }, + }); + + chromeMock.navListeners[0]({ + url: 'http://localhost:1455/auth/callback?code=C&state=STATE', + tabId: 7, + }); + + await assert.rejects(promise, /upstream rejected/); + const errorPatch = harness.setStateCalls.find((p) => p.reauthLastError); + assert.ok(errorPatch, '失败时应写入 reauthLastError'); + assert.match(errorPatch.reauthLastError, /upstream rejected/); +}); + +test('tabUpdated 路径同样能捕获回调', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps(); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'S', + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'x' }, + }); + + chromeMock.tabUpdatedListeners[0]( + 42, + {}, + { url: 'http://localhost:1455/auth/callback?code=K&state=S' } + ); + + await promise; + assert.equal(harness.completeCalls.length, 1); + assert.equal(chromeMock.removedTabs[0], 42); +}); + +test('进入手机验证页时抛出账号级 fatal,批量可跳过当前账号', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const harness = buildBaseDeps({ + getTabId: async () => 99, + isTabAlive: async () => true, + ensureStep8SignupPageReady: async () => {}, + waitForStep8Ready: async () => { + throw new Error('步骤 4:自动确认 OAuth 只处理 OAuth 授权页,当前仍在手机验证码页。 URL: https://auth.openai.com/phone-verification'); + }, + prepareStep8DebuggerClick: async () => {}, + clickWithDebugger: async () => {}, + triggerStep8ContentStrategy: async () => {}, + waitForStep8ClickEffect: async () => ({}), + getStep8EffectLabel: () => '无跳转', + reloadStep8ConsentPage: async () => {}, + sleepWithStop: async () => {}, + STEP8_STRATEGIES: [{ id: 'primary', label: '主按钮' }], + }); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'S', + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'phone-check@example.com' }, + }); + + await assert.rejects( + promise, + /ACCOUNT_FATAL::phone_verification_required::.*手机验证/ + ); + assert.equal(harness.completeCalls.length, 0); + assert.equal(chromeMock.navListeners.length, 0, 'fatal 后应清理 onBeforeNavigate'); + assert.equal(chromeMock.committedListeners.length, 0, 'fatal 后应清理 onCommitted'); + assert.equal(chromeMock.tabUpdatedListeners.length, 0, 'fatal 后应清理 tabs.onUpdated'); +}); + +test('步骤4启动后立即预检手机验证页,不等待 OAuth ready 超时', async () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + chromeMock.api.tabs.get = async () => ({ + id: 88, + url: 'https://auth.openai.com/phone-verification', + title: '验证您的手机号码', + }); + let waitForReadyCalled = false; + const harness = buildBaseDeps({ + getTabId: async () => 88, + isTabAlive: async () => true, + ensureStep8SignupPageReady: async () => {}, + waitForStep8Ready: async () => { + waitForReadyCalled = true; + return { consentReady: true }; + }, + prepareStep8DebuggerClick: async () => {}, + clickWithDebugger: async () => {}, + triggerStep8ContentStrategy: async () => {}, + waitForStep8ClickEffect: async () => ({}), + getStep8EffectLabel: () => '无跳转', + reloadStep8ConsentPage: async () => {}, + sleepWithStop: async () => {}, + STEP8_STRATEGIES: [{ id: 'primary', label: '主按钮' }], + }); + const { executeCaptureReauthCallback } = mod.createCaptureReauthCallbackExecutor({ + ...harness.deps, + chrome: chromeMock.api, + }); + + const promise = executeCaptureReauthCallback({ + reauthState: 'S', + reauthCodeVerifier: 'V', + reauthInputAccount: { name: 'phone-preflight@example.com' }, + }); + + await assert.rejects( + promise, + /ACCOUNT_FATAL::phone_verification_required::.*手机验证/ + ); + assert.equal(waitForReadyCalled, false, '应在等待 OAuth ready 前直接跳过'); + assert.equal(harness.completeCalls.length, 0); + assert.equal(chromeMock.navListeners.length, 0); + assert.equal(chromeMock.committedListeners.length, 0); + assert.equal(chromeMock.tabUpdatedListeners.length, 0); +}); + +test('createExecutor 在 deps 缺失时直接抛错(不允许半成品)', () => { + const mod = loadStepModule(); + const chromeMock = buildMockChromeApi(); + const baseDeps = { + ...buildBaseDeps().deps, + chrome: chromeMock.api, + }; + const cases = [ + { + name: 'completeNodeFromBackground', + expected: /completeNodeFromBackground/, + mutate: (deps) => { delete deps.completeNodeFromBackground; }, + }, + { + name: 'exchangeAuthorizationCode', + expected: /exchangeAuthorizationCode/, + mutate: (deps) => { delete deps.exchangeAuthorizationCode; }, + }, + { + name: 'parseCallbackUrl', + expected: /parseCallbackUrl/, + mutate: (deps) => { delete deps.parseCallbackUrl; }, + }, + { + name: 'buildUpdatedAccount', + expected: /buildUpdatedAccount/, + mutate: (deps) => { delete deps.buildUpdatedAccount; }, + }, + { + name: 'setState', + expected: /setState/, + mutate: (deps) => { delete deps.setState; }, + }, + { + name: 'chrome.webNavigation', + expected: /webNavigation \/ chrome\.tabs/, + mutate: (deps) => { deps.chrome = { tabs: chromeMock.api.tabs }; }, + }, + { + name: 'chrome.tabs', + expected: /webNavigation \/ chrome\.tabs/, + mutate: (deps) => { deps.chrome = { webNavigation: chromeMock.api.webNavigation }; }, + }, + ]; + + for (const entry of cases) { + const deps = { ...baseDeps }; + entry.mutate(deps); + assert.throws( + () => mod.createCaptureReauthCallbackExecutor(deps), + entry.expected, + `missing ${entry.name} should throw` + ); + } +}); diff --git a/tests/openai-reauth-cookie-cleanup.test.js b/tests/openai-reauth-cookie-cleanup.test.js new file mode 100644 index 00000000..0f292949 --- /dev/null +++ b/tests/openai-reauth-cookie-cleanup.test.js @@ -0,0 +1,116 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const COOKIE_CLEANUP_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'background', 'cookie-cleanup.js'); + +function loadCookieCleanupModule() { + const source = fs.readFileSync(COOKIE_CLEANUP_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {}, console }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthCookieCleanup; +} + +function buildFakeChromeApi(cookies, options = {}) { + const removeCalls = []; + return { + removeCalls, + chromeApi: { + cookies: { + getAllCookieStores: async () => options.stores || [{ id: '0' }], + getAll: async (query) => cookies.filter((c) => + (!query.storeId || c.storeId === query.storeId) + && (!query.domain || c.domain === query.domain || c.domain === `.${query.domain}`) + ), + remove: async (details) => { + removeCalls.push(details); + if (options.removeFails && options.removeFails.has(details.name)) { + throw new Error(`mock remove failure for ${details.name}`); + } + return { name: details.name, url: details.url }; + }, + }, + }, + }; +} + +test('REAUTH_COOKIE_CLEAR_DOMAINS 包含 6 个目标 domain', () => { + const mod = loadCookieCleanupModule(); + assert.deepEqual( + [...mod.REAUTH_COOKIE_CLEAR_DOMAINS].sort(), + [ + 'accounts.openai.com', + 'auth.openai.com', + 'auth0.openai.com', + 'chat.openai.com', + 'chatgpt.com', + 'openai.com', + ] + ); +}); + +test('clearOpenAiAuthCookies 收集并删除目标 domain 的 cookie', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi([ + { name: 'session', domain: '.openai.com', path: '/', storeId: '0' }, + { name: 'auth_token', domain: 'auth.openai.com', path: '/', storeId: '0' }, + { name: 'cf_token', domain: 'chatgpt.com', path: '/', storeId: '0' }, + { name: 'unrelated', domain: 'google.com', path: '/', storeId: '0' }, + ]); + const result = await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.equal(result.collected, 3); + assert.equal(result.removed, 3); + assert.equal(fake.removeCalls.length, 3); + for (const call of fake.removeCalls) { + assert.match(call.url, /^https:\/\/(chatgpt\.com|auth\.openai\.com|openai\.com)\/$/); + } + assert.equal(fake.removeCalls.find((c) => c.name === 'unrelated'), undefined); +}); + +test('clearOpenAiAuthCookies 跨 storeId 不重复', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi( + [ + { name: 'a', domain: 'auth.openai.com', path: '/', storeId: '0' }, + { name: 'a', domain: 'auth.openai.com', path: '/', storeId: '1' }, + ], + { stores: [{ id: '0' }, { id: '1' }] } + ); + const result = await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.equal(result.collected, 2); + assert.equal(result.removed, 2); +}); + +test('clearOpenAiAuthCookies 部分失败时不影响其他 cookie', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi( + [ + { name: 'good', domain: 'auth.openai.com', path: '/', storeId: '0' }, + { name: 'bad', domain: 'auth.openai.com', path: '/', storeId: '0' }, + ], + { removeFails: new Set(['bad']) } + ); + const result = await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.equal(result.collected, 2); + assert.equal(result.removed, 1); +}); + +test('clearOpenAiAuthCookies 在 chromeApi 缺失时安全返回', async () => { + const mod = loadCookieCleanupModule(); + const result = await mod.clearOpenAiAuthCookies({}); + assert.deepEqual(result, { collected: 0, removed: 0 }); +}); + +test('clearOpenAiAuthCookies 保留 partitionKey', async () => { + const mod = loadCookieCleanupModule(); + const fake = buildFakeChromeApi([ + { name: 'p', domain: 'auth.openai.com', path: '/', storeId: '0', partitionKey: { topLevelSite: 'https://chatgpt.com' } }, + ]); + await mod.clearOpenAiAuthCookies({ chromeApi: fake.chromeApi }); + assert.deepEqual(fake.removeCalls[0].partitionKey, { topLevelSite: 'https://chatgpt.com' }); +}); diff --git a/tests/openai-reauth-fetch-code.test.js b/tests/openai-reauth-fetch-code.test.js new file mode 100644 index 00000000..4c470e3c --- /dev/null +++ b/tests/openai-reauth-fetch-code.test.js @@ -0,0 +1,343 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const STEP_PATH = path.join( + __dirname, + '..', + 'flows', + 'openai-reauth', + 'background', + 'steps', + 'fetch-reauth-code.js' +); + +function loadStepModule() { + const source = fs.readFileSync(STEP_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {}, console, setTimeout, clearTimeout }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthFetchCodeStep; +} + +function buildDeps(overrides = {}) { + const calls = { + log: [], + poll: [], + resilient: [], + complete: [], + sleep: [], + }; + + let stopped = false; + const stopAfter = overrides.stopAfter || null; + let pollInvokeCount = 0; + + const deps = { + addLog: async (message, level, options) => { + calls.log.push({ message, level, options }); + }, + completeNodeFromBackground: async (nodeId, payload) => { + calls.complete.push({ nodeId, payload }); + }, + pollFlowVerificationCode: async (options) => { + pollInvokeCount += 1; + calls.poll.push(options); + const handler = overrides.pollHandler; + if (typeof handler === 'function') { + return handler(options, pollInvokeCount); + } + return { code: '654321' }; + }, + sendToContentScriptResilient: async (target, message, options) => { + calls.resilient.push({ target, message, options }); + const handler = overrides.resilientHandler; + if (typeof handler === 'function') { + return handler(target, message, options); + } + return {}; + }, + throwIfStopped: () => { + if (stopped) { + const err = new Error('已被用户停止'); + throw err; + } + }, + sleepWithStop: overrides.sleepWithStop || (async (ms) => { + calls.sleep.push(ms); + if (stopAfter && calls.sleep.length >= stopAfter) { + stopped = true; + const err = new Error('已被用户停止'); + throw err; + } + }), + maxResendRequests: overrides.maxResendRequests ?? 2, + resendIntervalMs: overrides.resendIntervalMs ?? 10, + }; + + return { deps, calls, setStopped: (value) => { stopped = Boolean(value); } }; +} + +test('executeFetchReauthCode 缺少邮箱抛错', async () => { + const mod = loadStepModule(); + const { deps } = buildDeps(); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await assert.rejects( + () => executeFetchReauthCode({}), + /缺少邮箱地址/ + ); +}); + +test('executeFetchReauthCode skipReauthVerificationStep 跳过轮询直接 complete', async () => { + const mod = loadStepModule(); + const { deps, calls } = buildDeps(); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + skipReauthVerificationStep: true, + }); + + assert.equal(calls.poll.length, 0); + assert.equal(calls.resilient.length, 0); + assert.equal(calls.complete.length, 1); + assert.equal(calls.complete[0].nodeId, 'fetch-reauth-code'); + assert.equal(calls.complete[0].payload.skipReauthVerificationStep, true); +}); + +test('executeFetchReauthCode 首轮 poll 成功 → 不触发 resend,直接 FILL_CODE + complete', async () => { + const mod = loadStepModule(); + const { deps, calls } = buildDeps({ + pollHandler: async () => ({ code: '123456' }), + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }); + + assert.equal(calls.poll.length, 1); + // 首轮不应有 resend + const resendCalls = calls.resilient.filter( + (c) => c.message?.type === 'RESEND_VERIFICATION_CODE' + ); + assert.equal(resendCalls.length, 0); + // FILL_CODE 调用一次 + const fillCalls = calls.resilient.filter((c) => c.message?.type === 'FILL_CODE'); + assert.equal(fillCalls.length, 1); + assert.equal(fillCalls[0].message.payload.code, '123456'); + assert.equal(calls.complete.length, 1); + assert.equal(calls.complete[0].payload.reauthVerificationCode, '123456'); +}); + +test('executeFetchReauthCode 第一轮失败 → 调 RESEND_VERIFICATION_CODE → 第二轮成功', async () => { + const mod = loadStepModule(); + let pollAttempt = 0; + const { deps, calls } = buildDeps({ + pollHandler: async () => { + pollAttempt += 1; + if (pollAttempt === 1) { + throw new Error('邮箱轮询完成,但未取到 OAuth 验证码。'); + } + return { code: '999000' }; + }, + maxResendRequests: 3, + resendIntervalMs: 5, + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }); + + assert.equal(calls.poll.length, 2); + const resendCalls = calls.resilient.filter( + (c) => c.message?.type === 'RESEND_VERIFICATION_CODE' + ); + assert.equal(resendCalls.length, 1); + assert.equal(resendCalls[0].message.step, 3); + + const fillCalls = calls.resilient.filter((c) => c.message?.type === 'FILL_CODE'); + assert.equal(fillCalls.length, 1); + assert.equal(fillCalls[0].message.payload.code, '999000'); + assert.equal(calls.complete.length, 1); +}); + +test('executeFetchReauthCode 所有轮 poll 均失败 → 抛出最后一个 error', async () => { + const mod = loadStepModule(); + const { deps, calls } = buildDeps({ + pollHandler: async () => { + throw new Error('邮箱轮询完成,但未取到 OAuth 验证码。'); + }, + maxResendRequests: 2, + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await assert.rejects( + () => executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }), + /未取到/ + ); + + // 总轮数 = maxResendRequests + 1 = 3 + assert.equal(calls.poll.length, 3); + const resendCalls = calls.resilient.filter( + (c) => c.message?.type === 'RESEND_VERIFICATION_CODE' + ); + assert.equal(resendCalls.length, 2); + assert.equal(calls.complete.length, 0); +}); + +test('executeFetchReauthCode RESEND 调用本身失败时不中断主轮询', async () => { + const mod = loadStepModule(); + let pollAttempt = 0; + let resendAttempt = 0; + const { deps, calls } = buildDeps({ + pollHandler: async () => { + pollAttempt += 1; + if (pollAttempt === 1) { + throw new Error('邮箱轮询完成,但未取到 OAuth 验证码。'); + } + return { code: '777888' }; + }, + resilientHandler: async (target, message) => { + if (message?.type === 'RESEND_VERIFICATION_CODE') { + resendAttempt += 1; + return { error: 'mock resend failed' }; + } + return {}; + }, + maxResendRequests: 2, + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }); + + assert.equal(resendAttempt, 1); + assert.equal(calls.poll.length, 2); + const fillCalls = calls.resilient.filter((c) => c.message?.type === 'FILL_CODE'); + assert.equal(fillCalls.length, 1); +}); + +test('executeFetchReauthCode 在 stop 信号下立刻终止,不继续 resend / poll', async () => { + const mod = loadStepModule(); + let pollAttempt = 0; + const { deps, calls } = buildDeps({ + pollHandler: async () => { + pollAttempt += 1; + throw new Error('邮箱轮询完成,但未取到 OAuth 验证码。'); + }, + // sleepWithStop 第一次就抛 stop + sleepWithStop: async () => { + const err = new Error('已被用户停止'); + throw err; + }, + maxResendRequests: 3, + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await assert.rejects( + () => executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }), + /已被用户停止/ + ); + + // 仅第一轮 poll,之后 sleep 抛 stop,第二轮 poll 不会被调用 + assert.equal(pollAttempt, 1); +}); + +test('executeFetchReauthCode 2925 时 payloadOverrides.maxAttempts = 6(缩短单轮 poll)', async () => { + const mod = loadStepModule(); + const { deps, calls } = buildDeps({ + pollHandler: async () => ({ code: '111222' }), + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }); + + assert.equal(calls.poll.length, 1); + const pollOptions = calls.poll[0]; + assert.equal(pollOptions.payloadOverrides?.maxAttempts, 6); + assert.equal(mod.MAIL_2925_POLL_MAX_ATTEMPTS, 6); +}); + +test('executeFetchReauthCode 非 2925 provider 不强制覆盖 maxAttempts', async () => { + const mod = loadStepModule(); + const { deps, calls } = buildDeps({ + pollHandler: async () => ({ code: '333444' }), + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await executeFetchReauthCode({ + reauthEmail: 'demo@hotmail.com', + mailProvider: 'hotmail-api', + }); + + assert.equal(calls.poll.length, 1); + const pollOptions = calls.poll[0]; + assert.equal(pollOptions.payloadOverrides?.maxAttempts, undefined); +}); + +test('executeFetchReauthCode FILL_CODE 返回 error → throw 并不调 completeNode', async () => { + const mod = loadStepModule(); + const { deps, calls } = buildDeps({ + pollHandler: async () => ({ code: '555666' }), + resilientHandler: async (target, message) => { + if (message?.type === 'FILL_CODE') { + return { error: '未找到验证码输入框。' }; + } + return {}; + }, + maxResendRequests: 0, + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await assert.rejects( + () => executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }), + /未找到验证码输入框/ + ); + + assert.equal(calls.complete.length, 0); +}); + +test('executeFetchReauthCode RESEND 中收到 stop 信号 → 立刻抛 stop', async () => { + const mod = loadStepModule(); + let pollAttempt = 0; + const { deps } = buildDeps({ + pollHandler: async () => { + pollAttempt += 1; + if (pollAttempt === 1) { + throw new Error('邮箱轮询完成,但未取到 OAuth 验证码。'); + } + return { code: '111111' }; + }, + resilientHandler: async (target, message) => { + if (message?.type === 'RESEND_VERIFICATION_CODE') { + throw new Error('已被用户停止'); + } + return {}; + }, + maxResendRequests: 2, + }); + const { executeFetchReauthCode } = mod.createFetchReauthCodeExecutor(deps); + await assert.rejects( + () => executeFetchReauthCode({ + reauthEmail: 'demo@2925.com', + mailProvider: '2925', + }), + /已被用户停止/ + ); + + // 只跑一次 poll,resend 抛 stop 后直接外抛,不进入第二轮 poll + assert.equal(pollAttempt, 1); +}); diff --git a/tests/openai-reauth-mail-rules.test.js b/tests/openai-reauth-mail-rules.test.js new file mode 100644 index 00000000..9e11dd90 --- /dev/null +++ b/tests/openai-reauth-mail-rules.test.js @@ -0,0 +1,128 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const MAIL_RULES_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'mail-rules.js'); + +function loadMailRulesModule() { + const source = fs.readFileSync(MAIL_RULES_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {} }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthMailRules; +} + +test('createOpenAiReauthMailRules 暴露统一接口', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + assert.equal(typeof rules.buildVerificationPollPayload, 'function'); + assert.equal(typeof rules.buildVerificationPollPayloadForNode, 'function'); + assert.equal(typeof rules.getRuleDefinition, 'function'); + assert.equal(typeof rules.getRuleDefinitionForNode, 'function'); +}); + +test('getRuleDefinitionForNode 仅识别 fetch-reauth-code', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const ok = rules.getRuleDefinitionForNode('fetch-reauth-code', { reauthEmail: 'a@b.com' }); + assert.ok(ok); + assert.equal(ok.flowId, 'openai-reauth'); + assert.equal(ok.ruleId, 'openai-reauth-code'); + assert.equal(ok.nodeId, 'fetch-reauth-code'); + assert.equal(ok.step, 3); + assert.equal(ok.targetEmail, 'a@b.com'); + + assert.equal(rules.getRuleDefinitionForNode('fetch-signup-code', { email: 'x@y.com' }), null); + assert.equal(rules.getRuleDefinitionForNode('fetch-login-code', { email: 'x@y.com' }), null); + assert.equal(rules.getRuleDefinitionForNode('', { email: 'x@y.com' }), null); +}); + +test('targetEmail 优先 reauthEmail,缺失时 fallback 到 email', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const a = rules.getRuleDefinitionForNode('fetch-reauth-code', { reauthEmail: 'r@x.com', email: 'fallback@x.com' }); + assert.equal(a.targetEmail, 'r@x.com'); + const b = rules.getRuleDefinitionForNode('fetch-reauth-code', { email: 'only@x.com' }); + assert.equal(b.targetEmail, 'only@x.com'); +}); + +test('targetEmailHints 包含原始邮箱与 username=domain 形式', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { reauthEmail: 'foo@2925.com' }); + assert.deepEqual(result.targetEmailHints, ['foo@2925.com', 'foo=2925.com']); +}); + +test('mail2925 provider 切换到长间隔与多次重试', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({ + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + }); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { + reauthEmail: 'r@2925.com', + mailProvider: '2925', + mail2925Mode: 'receive', + }); + assert.equal(result.maxAttempts, 15); + assert.equal(result.intervalMs, 15000); + assert.equal(result.mail2925MatchTargetEmail, true); + assert.equal(result.filterAfterTimestamp, 0); +}); + +test('非 2925 provider 使用默认短间隔与 5 次重试', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { + reauthEmail: 'r@hotmail.com', + mailProvider: 'hotmail-api', + }); + assert.equal(result.maxAttempts, 5); + assert.equal(result.intervalMs, 3000); + assert.equal(result.mail2925MatchTargetEmail, false); +}); + +test('hotmail provider 使用 timestamp dep 提供的 filter', () => { + const mod = loadMailRulesModule(); + let receivedStep = null; + let receivedState = null; + const rules = mod.createOpenAiReauthMailRules({ + getHotmailVerificationRequestTimestamp: (step, state) => { + receivedStep = step; + receivedState = state; + return 1779999999999; + }, + }); + const result = rules.getRuleDefinitionForNode('fetch-reauth-code', { + reauthEmail: 'r@hotmail.com', + mailProvider: 'hotmail-api', + }); + assert.equal(receivedStep, 3); + assert.equal(receivedState.reauthEmail, 'r@hotmail.com'); + assert.equal(result.filterAfterTimestamp, 1779999999999); +}); + +test('buildVerificationPollPayloadForNode 合并 overrides', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + const result = rules.buildVerificationPollPayloadForNode( + 'fetch-reauth-code', + { reauthEmail: 'a@b.com' }, + { filterAfterTimestamp: 12345 } + ); + assert.equal(result.filterAfterTimestamp, 12345); + assert.equal(result.targetEmail, 'a@b.com'); +}); + +test('buildVerificationPollPayloadForNode 拒绝未知 nodeId', () => { + const mod = loadMailRulesModule(); + const rules = mod.createOpenAiReauthMailRules({}); + assert.equal( + rules.buildVerificationPollPayloadForNode('fetch-login-code', { email: 'x@y.com' }), + null + ); +}); diff --git a/tests/openai-reauth-oauth-client.test.js b/tests/openai-reauth-oauth-client.test.js new file mode 100644 index 00000000..780ab22f --- /dev/null +++ b/tests/openai-reauth-oauth-client.test.js @@ -0,0 +1,231 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { webcrypto } = require('node:crypto'); + +const OAUTH_CLIENT_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'background', 'oauth-client.js'); + +function loadOAuthClientModule() { + const source = fs.readFileSync(OAUTH_CLIENT_PATH, 'utf-8'); + const sandbox = { + self: {}, + globalThis: {}, + crypto: webcrypto, + TextEncoder, + TextDecoder, + fetch: globalThis.fetch, + URL, + URLSearchParams, + Uint8Array, + Buffer, + btoa: (str) => Buffer.from(str, 'binary').toString('base64'), + atob: (str) => Buffer.from(str, 'base64').toString('binary'), + console, + }; + sandbox.self.crypto = webcrypto; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthOAuthClient; +} + +test('PKCE RFC 7636 已知向量验证', async () => { + const mod = loadOAuthClientModule(); + const knownVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + const bytes = await mod.sha256Bytes(knownVerifier); + const actual = mod.base64UrlEncode(bytes); + assert.equal(actual, expected, '已知向量应通过 RFC 7636 验证'); +}); + +test('generatePkcePair 返回合规的 verifier 和 challenge', async () => { + const mod = loadOAuthClientModule(); + const { codeVerifier, codeChallenge } = await mod.generatePkcePair(); + assert.match(codeVerifier, /^[A-Za-z0-9\-._~]{43,128}$/, 'verifier 字符集和长度合规'); + assert.match(codeChallenge, /^[A-Za-z0-9_-]{43}$/, 'challenge 是 43 字符 base64url 无填充'); +}); + +test('generateState 返回 64 字符 hex', () => { + const mod = loadOAuthClientModule(); + const s = mod.generateState(); + assert.match(s, /^[0-9a-f]{64}$/); +}); + +test('buildAuthorizeUrl 重建用户给的示例链接', () => { + const mod = loadOAuthClientModule(); + const url = mod.buildAuthorizeUrl({ + codeChallenge: 'xu7USQwZr4TDQWbZPOmqmkzwB5bbuTzHK0Z3AToSF9Y', + state: '17cd69f25d8fc9253b6850031c465cc57dc6badd7943f6306444b37c3ce565b7', + }); + const expected = 'https://auth.openai.com/oauth/authorize?client_id=app_EMoamEEZ73f0CkXaXp7hrann&code_challenge=xu7USQwZr4TDQWbZPOmqmkzwB5bbuTzHK0Z3AToSF9Y&code_challenge_method=S256&codex_cli_simplified_flow=true&id_token_add_organizations=true&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email+offline_access&state=17cd69f25d8fc9253b6850031c465cc57dc6badd7943f6306444b37c3ce565b7'; + assert.equal(url, expected); +}); + +test('buildAuthorizeUrl 缺少参数时抛错', () => { + const mod = loadOAuthClientModule(); + assert.throws(() => mod.buildAuthorizeUrl({ codeChallenge: 'x' }), /state/); + assert.throws(() => mod.buildAuthorizeUrl({ state: 'y' }), /codeChallenge/); +}); + +test('parseCallbackUrl 成功路径', () => { + const mod = loadOAuthClientModule(); + const result = mod.parseCallbackUrl( + 'http://localhost:1455/auth/callback?code=abc123&state=xyz', + 'xyz' + ); + assert.equal(result.code, 'abc123'); + assert.equal(result.state, 'xyz'); + assert.equal(result.error, undefined); +}); + +test('parseCallbackUrl state 不匹配时返回 error', () => { + const mod = loadOAuthClientModule(); + const result = mod.parseCallbackUrl( + 'http://localhost:1455/auth/callback?code=abc&state=actual', + 'expected' + ); + assert.ok(result.error.includes('state')); +}); + +test('parseCallbackUrl 拒绝非法 URL 路径与端口', () => { + const mod = loadOAuthClientModule(); + assert.equal(mod.parseCallbackUrl('http://localhost:1455/other?code=x&state=y', 'y'), null); + assert.equal(mod.parseCallbackUrl('http://localhost:9999/auth/callback?code=x&state=y', 'y'), null); + assert.equal(mod.parseCallbackUrl('http://evil.com/auth/callback?code=x&state=y', 'y'), null); +}); + +test('parseCallbackUrl error_description 优先', () => { + const mod = loadOAuthClientModule(); + const result = mod.parseCallbackUrl( + 'http://localhost:1455/auth/callback?error=access_denied&error_description=user_canceled&state=y', + 'y' + ); + assert.equal(result.error, 'user_canceled'); + assert.equal(result.code, undefined); +}); + +test('exchangeAuthorizationCode 用 fake fetch 验证请求体与字段抽取', async () => { + const mod = loadOAuthClientModule(); + let capturedUrl = ''; + let capturedBody = ''; + let capturedHeaders = null; + const fakeFetch = async (url, options) => { + capturedUrl = url; + capturedBody = options.body; + capturedHeaders = options.headers; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + access_token: 'tok_access', + refresh_token: 'tok_refresh', + id_token: 'tok_id', + expires_in: 3600, + token_type: 'Bearer', + }), + }; + }; + const result = await mod.exchangeAuthorizationCode({ + code: 'code_value', + codeVerifier: 'verifier_value', + fetchImpl: fakeFetch, + }); + assert.equal(capturedUrl, 'https://auth.openai.com/oauth/token'); + assert.equal(capturedHeaders['Content-Type'], 'application/x-www-form-urlencoded'); + const params = new URLSearchParams(capturedBody); + assert.equal(params.get('grant_type'), 'authorization_code'); + assert.equal(params.get('client_id'), 'app_EMoamEEZ73f0CkXaXp7hrann'); + assert.equal(params.get('code'), 'code_value'); + assert.equal(params.get('code_verifier'), 'verifier_value'); + assert.equal(params.get('redirect_uri'), 'http://localhost:1455/auth/callback'); + assert.equal(result.accessToken, 'tok_access'); + assert.equal(result.refreshToken, 'tok_refresh'); + assert.equal(result.idToken, 'tok_id'); + assert.equal(result.expiresIn, 3600); +}); + +test('exchangeAuthorizationCode 失败响应抛出详细错误', async () => { + const mod = loadOAuthClientModule(); + const fakeFetch = async () => ({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: 'invalid_grant', error_description: 'code expired' }), + }); + await assert.rejects( + () => mod.exchangeAuthorizationCode({ code: 'c', codeVerifier: 'v', fetchImpl: fakeFetch }), + /code expired|invalid_grant/ + ); +}); + +test('exchangeAuthorizationCode 缺少 access_token 时抛错', async () => { + const mod = loadOAuthClientModule(); + const fakeFetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ refresh_token: 'r' }), + }); + await assert.rejects( + () => mod.exchangeAuthorizationCode({ code: 'c', codeVerifier: 'v', fetchImpl: fakeFetch }), + /access_token|refresh_token/ + ); +}); + +test('decodeJwtPayload 能解码示例 id_token', () => { + const mod = loadOAuthClientModule(); + const samplePayload = { email: 'foo@bar.com', exp: 1779903020 }; + const headerB64 = Buffer.from(JSON.stringify({ alg: 'none' }), 'utf-8').toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(samplePayload), 'utf-8').toString('base64url'); + const jwt = `${headerB64}.${payloadB64}.sig`; + const decoded = mod.decodeJwtPayload(jwt); + assert.deepEqual(decoded, samplePayload); +}); + +test('buildUpdatedAccount 保留原账号外层字段,仅替换 credentials', () => { + const mod = loadOAuthClientModule(); + const idPayload = { + email: 'ranger@2925.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_123', + chatgpt_user_id: 'user_456', + chatgpt_plan_type: 'plus', + organizations: [ + { id: 'org-x', is_default: false }, + { id: 'org-default', is_default: true }, + ], + }, + 'https://api.openai.com/profile': { email: 'ranger@2925.com', email_verified: true }, + }; + const idTokenJwt = [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify(idPayload)).toString('base64url'), + 'sig', + ].join('.'); + const original = { + name: 'ranger@2925.com', + platform: 'openai', + type: 'oauth', + credentials: { email: 'ranger@2925.com', client_id: 'old', refresh_token: 'old-refresh' }, + extra: { email: 'ranger@2925.com' }, + concurrency: 3, + priority: 3, + }; + const updated = mod.buildUpdatedAccount(original, { + accessToken: 'new-access', + refreshToken: 'new-refresh', + idToken: idTokenJwt, + expiresIn: 3600, + }); + assert.equal(updated.name, 'ranger@2925.com'); + assert.equal(updated.concurrency, 3); + assert.equal(updated.credentials.access_token, 'new-access'); + assert.equal(updated.credentials.refresh_token, 'new-refresh'); + assert.equal(updated.credentials.id_token, idTokenJwt); + assert.equal(updated.credentials.chatgpt_account_id, 'acct_123'); + assert.equal(updated.credentials.organization_id, 'org-default'); + assert.equal(updated.credentials.plan_type, 'plus'); + assert.equal(updated.credentials.client_id, 'app_EMoamEEZ73f0CkXaXp7hrann'); + assert.ok(updated.credentials.expires_at > Math.floor(Date.now() / 1000)); +}); diff --git a/tests/openai-reauth-validate-account-json.test.js b/tests/openai-reauth-validate-account-json.test.js new file mode 100644 index 00000000..a95a25a1 --- /dev/null +++ b/tests/openai-reauth-validate-account-json.test.js @@ -0,0 +1,162 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const VALIDATOR_PATH = path.join(__dirname, '..', 'flows', 'openai-reauth', 'reauth-account-validator.js'); + +function loadValidatorModule() { + const source = fs.readFileSync(VALIDATOR_PATH, 'utf-8'); + const sandbox = { self: {}, globalThis: {} }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.self.MultiPageOpenAiReauthAccountValidator; +} + +test('SUPPORTED_MAIL_PROVIDERS 暴露 7 个 provider', () => { + const mod = loadValidatorModule(); + assert.deepEqual([...mod.SUPPORTED_MAIL_PROVIDERS].sort(), [ + '2925', + 'cloudflare-temp-email', + 'cloudmail', + 'hotmail-api', + 'icloud', + 'luckmail-api', + 'yyds-mail', + ]); +}); + +test('parseAccountsFromJson 空文本返回错误', () => { + const mod = loadValidatorModule(); + const r = mod.parseAccountsFromJson(''); + assert.equal(r.ok, false); + assert.match(r.error, /请粘贴/); +}); + +test('parseAccountsFromJson 非法 JSON', () => { + const mod = loadValidatorModule(); + assert.equal(mod.parseAccountsFromJson('{not json').ok, false); +}); + +test('parseAccountsFromJson 非对象/数组拒绝', () => { + const mod = loadValidatorModule(); + assert.equal(mod.parseAccountsFromJson('"str"').ok, false); + assert.equal(mod.parseAccountsFromJson('42').ok, false); + assert.equal(mod.parseAccountsFromJson('null').ok, false); +}); + +test('parseAccountsFromJson 单账号对象', () => { + const mod = loadValidatorModule(); + const r = mod.parseAccountsFromJson(JSON.stringify({ + credentials: { email: 'a@b.com' }, + })); + assert.equal(r.ok, true); + assert.equal(r.accounts.length, 1); + assert.equal(r.accounts[0].email, 'a@b.com'); + assert.equal(r.accounts[0].index, 0); +}); + +test('parseAccountsFromJson sub2api 整文件 - 3 个账号', () => { + const mod = loadValidatorModule(); + const r = mod.parseAccountsFromJson(JSON.stringify({ + exported_at: '2026-05-28T07:52:21Z', + proxies: [], + accounts: [ + { name: 'a@2925.com', credentials: { email: 'a@2925.com' } }, + { name: 'b@2925.com', credentials: { email: 'b@2925.com' } }, + { name: 'c@2925.com', credentials: { email: 'c@2925.com' } }, + ], + })); + assert.equal(r.ok, true); + assert.equal(r.accounts.length, 3); + assert.equal(r.accounts[0].email, 'a@2925.com'); + assert.equal(r.accounts[1].email, 'b@2925.com'); + assert.equal(r.accounts[2].email, 'c@2925.com'); +}); + +test('parseAccountsFromJson accounts 是数组(无 wrapper)', () => { + const mod = loadValidatorModule(); + const r = mod.parseAccountsFromJson(JSON.stringify([ + { credentials: { email: 'x@y.com' } }, + ])); + assert.equal(r.ok, true); + assert.equal(r.accounts.length, 1); +}); + +test('parseAccountsFromJson accounts 数组为空时拒绝', () => { + const mod = loadValidatorModule(); + const r = mod.parseAccountsFromJson(JSON.stringify({ accounts: [] })); + assert.equal(r.ok, false); + assert.match(r.error, /列表为空/); +}); + +test('parseAccountsFromJson 某个 account 缺 email 时报错并指明 index', () => { + const mod = loadValidatorModule(); + const r = mod.parseAccountsFromJson(JSON.stringify({ + accounts: [ + { credentials: { email: 'a@b.com' } }, + { credentials: {} }, + ], + })); + assert.equal(r.ok, false); + assert.match(r.error, /accounts\[1\]/); + assert.match(r.error, /email/); +}); + +test('parseAccountsFromJson email 格式非法时报错', () => { + const mod = loadValidatorModule(); + const r = mod.parseAccountsFromJson(JSON.stringify({ + accounts: [{ credentials: { email: 'not-email' } }], + })); + assert.equal(r.ok, false); + assert.match(r.error, /格式无效/); +}); + +test('parseAccountsFromJson 兼容 email 在顶层 / name 字段', () => { + const mod = loadValidatorModule(); + const r1 = mod.parseAccountsFromJson(JSON.stringify({ accounts: [{ email: 'top@x.com' }] })); + assert.equal(r1.accounts[0].email, 'top@x.com'); + const r2 = mod.parseAccountsFromJson(JSON.stringify({ accounts: [{ name: 'name@x.com' }] })); + assert.equal(r2.accounts[0].email, 'name@x.com'); +}); + +test('buildResolvedAccount 注入 mailProvider', () => { + const mod = loadValidatorModule(); + const original = { + name: 'a@2925.com', + platform: 'openai', + credentials: { email: 'a@2925.com', refresh_token: 'old' }, + concurrency: 3, + }; + const resolved = mod.buildResolvedAccount(original, '2925'); + assert.equal(resolved.mailProvider, '2925'); + assert.equal(resolved.concurrency, 3); + assert.equal(resolved.credentials.refresh_token, 'old'); +}); + +test('buildResolvedAccount 拒绝空 provider', () => { + const mod = loadValidatorModule(); + assert.throws( + () => mod.buildResolvedAccount({ credentials: { email: 'a@b.com' } }, ''), + /mailProvider/ + ); +}); + +test('buildResolvedAccount 不修改原对象(不可变)', () => { + const mod = loadValidatorModule(); + const original = { credentials: { email: 'a@b.com' } }; + const resolved = mod.buildResolvedAccount(original, 'hotmail-api'); + assert.equal(original.mailProvider, undefined); + assert.equal(resolved.mailProvider, 'hotmail-api'); +}); + +test('extractAccountEmail 优先级:credentials.email > email > name', () => { + const mod = loadValidatorModule(); + assert.equal(mod.extractAccountEmail({ credentials: { email: 'c@x.com' }, email: 'e@x.com', name: 'n@x.com' }), 'c@x.com'); + assert.equal(mod.extractAccountEmail({ email: 'e@x.com', name: 'n@x.com' }), 'e@x.com'); + assert.equal(mod.extractAccountEmail({ name: 'n@x.com' }), 'n@x.com'); + assert.equal(mod.extractAccountEmail({}), ''); +}); diff --git a/tests/sidepanel-flow-source-registry.test.js b/tests/sidepanel-flow-source-registry.test.js index 804cb743..f52c1ddc 100644 --- a/tests/sidepanel-flow-source-registry.test.js +++ b/tests/sidepanel-flow-source-registry.test.js @@ -322,6 +322,9 @@ function updatePlusModeUI() { function updatePhoneVerificationSettingsUI() { calls.push({ type: 'phone' }); } +function applyOpenAiReauthRowVisibility(activeFlowId) { + calls.push({ type: 'reauth-visibility', flowId: activeFlowId }); +} function resolveCurrentSidepanelCapabilities() { return { visibleGroupIds: ['service-account', 'openai-plus', 'openai-phone'], @@ -346,7 +349,7 @@ return { assert.deepEqual( api.calls.map((entry) => entry.type), - ['render-flow', 'render-target', 'groups', 'plus', 'phone'] + ['render-flow', 'render-target', 'groups', 'plus', 'phone', 'reauth-visibility'] ); assert.equal(api.selectFlow.value, 'openai'); assert.equal(api.selectPanelMode.value, 'cpa');