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 cceb2073..c697f248 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', @@ -52,6 +54,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', @@ -82,6 +85,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', @@ -4108,6 +4119,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; } @@ -4228,6 +4242,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); @@ -13743,6 +13770,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, @@ -13757,6 +13789,7 @@ const mailRuleRegistry = self.MultiPageBackgroundMailRuleRegistry?.createMailRul defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, flowBuilders: { openai: openAiMailRules, + 'openai-reauth': openAiReauthMailRules, kiro: kiroMailRules, grok: grokMailRules, }, @@ -14351,6 +14384,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), @@ -14404,6 +14441,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 连接测试能力尚未接入。'); @@ -16294,6 +16332,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 3fa9d935..4e25ea7f 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -49,6 +49,7 @@ getCurrentPayPalAccount, getCurrentMail2925Account, getPendingAutoRunTimerPlan, + getReauthBatchRunner = () => null, getSourceLabel, getState, getNodeDefinitionForState, @@ -1375,6 +1376,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); @@ -1410,6 +1414,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, @@ -1430,6 +1437,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..1cfdc191 --- /dev/null +++ b/flows/openai-reauth/background/batch-runner.js @@ -0,0 +1,440 @@ +(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); + } + + /** + * 将原始文件 JSON 裁剪为“仅成功账号”的同结构 JSON。 + * - 顶层数组:只保留成功账号 entry。 + * - { accounts: [] }:保留顶层 metadata,只裁剪 accounts。 + * - 单账号对象:成功时输出合并后的对象;未匹配时输出成功账号兜底。 + * - 原始文本不可用/不可解析时退化为 success 数组。 + */ + function buildSuccessOnlyBatchFileJson(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 mergeSuccessfulEntry(entry) { + if (!entry || typeof entry !== 'object') return null; + const email = extractEmail(entry); + if (!email || !successByEmail.has(email)) { + return null; + } + 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(mergeSuccessfulEntry).filter(Boolean); + } else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.accounts)) { + updated = { + ...parsed, + accounts: parsed.accounts.map(mergeSuccessfulEntry).filter(Boolean), + }; + } else if (parsed && typeof parsed === 'object') { + updated = mergeSuccessfulEntry(parsed) || safeAccounts[0] || null; + } else { + updated = safeAccounts; + } + + 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), + successOnlyFileJson: buildSuccessOnlyBatchFileJson(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 successOnlyFileJson = buildSuccessOnlyBatchFileJson(originalFileText, success, getAccountEmail); + const finalResult = { + success: success.map((account) => ({ account, email: getAccountEmail(account) })), + failed, + updatedFileJson, + successOnlyFileJson, + 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, + buildSuccessOnlyBatchFileJson, + 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 c1d1ec93..1bc24989 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 dd77c232..0aba12ec 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 613dd007..b5f3d7ec 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -3705,3 +3705,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 2876411d..1818c0e5 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -668,6 +668,78 @@ 等待中... +
+ + + + + +