diff --git a/background.js b/background.js index c899566..94530fd 100644 --- a/background.js +++ b/background.js @@ -1,6 +1,8 @@ // background.js — Service Worker: orchestration, state, tab management, message routing importScripts( + 'shared/source-registry.js', + 'shared/flow-capabilities.js', 'managed-alias-utils.js', 'mail2925-utils.js', 'paypal-utils.js', @@ -15,10 +17,14 @@ importScripts( 'background/paypal-account-store.js', 'background/ip-proxy-provider-711proxy.js', 'background/ip-proxy-core.js', + 'background/sub2api-api.js', 'background/panel-bridge.js', 'background/registration-email-state.js', + 'background/runtime-state.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', + 'background/mail-rule-registry.js', + 'flows/openai/mail-rules.js', 'background/message-router.js', 'background/verification-flow.js', 'background/auto-run-controller.js', @@ -56,21 +62,30 @@ importScripts( 'content/activation-utils.js' ); -const NORMAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled: false }) || []; +const DEFAULT_ACTIVE_FLOW_ID = 'openai'; +const NORMAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + plusModeEnabled: false, +}) || []; const PLUS_PAYPAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, plusPaymentMethod: 'paypal', }) || NORMAL_STEP_DEFINITIONS; const PLUS_GOPAY_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, plusPaymentMethod: 'gopay', }) || PLUS_PAYPAL_STEP_DEFINITIONS; const PLUS_GPC_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', }) || PLUS_GOPAY_STEP_DEFINITIONS; const PLUS_STEP_DEFINITIONS = PLUS_PAYPAL_STEP_DEFINITIONS; -const ALL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getAllSteps?.() || [ +const ALL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getAllSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, +}) || [ ...NORMAL_STEP_DEFINITIONS, ...PLUS_PAYPAL_STEP_DEFINITIONS, ...PLUS_GOPAY_STEP_DEFINITIONS, @@ -80,6 +95,7 @@ const STEP_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite))) .sort((left, right) => left - right); +const DEFAULT_STEP_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite) @@ -205,6 +221,11 @@ const { isRecoverableStep9AuthFailure, } = self.MultiPageActivationUtils; const registrationEmailStateHelpers = self.MultiPageRegistrationEmailState?.createRegistrationEmailStateHelpers?.() || null; +const runtimeStateHelpers = self.MultiPageBackgroundRuntimeState?.createRuntimeStateHelpers?.({ + DEFAULT_ACTIVE_FLOW_ID, + defaultStepStatuses: DEFAULT_STEP_STATUSES, + getStepDefinitionForState: (step, state) => getStepDefinitionForState(step, state), +}) || null; const DEFAULT_REGISTRATION_EMAIL_STATE = registrationEmailStateHelpers?.DEFAULT_REGISTRATION_EMAIL_STATE || { current: '', previous: '', @@ -269,6 +290,20 @@ function getPreservedPhoneIdentity(state = {}) { return null; } +function buildStateViewWithRuntimeState(state = {}) { + if (runtimeStateHelpers?.buildStateView) { + return runtimeStateHelpers.buildStateView(state); + } + return state; +} + +function buildStatePatchWithRuntimeState(currentState = {}, updates = {}) { + if (runtimeStateHelpers?.buildSessionStatePatch) { + return runtimeStateHelpers.buildSessionStatePatch(currentState, updates); + } + return updates; +} + function statePatchHasChanges(state = {}, patch = {}) { return Object.keys(patch).some((key) => JSON.stringify(state?.[key] ?? null) !== JSON.stringify(patch[key] ?? null)); } @@ -571,11 +606,21 @@ function getSignupMethodForStepDefinitions(state = {}) { function getStepDefinitionsForState(state = {}) { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.MultiPageStepDefinitions?.getSteps) { - return rootScope.MultiPageStepDefinitions.getSteps({ + const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai'; + const activeFlowId = String(state?.activeFlowId || '').trim().toLowerCase() || defaultFlowId; + const definitions = rootScope.MultiPageStepDefinitions.getSteps({ + activeFlowId, plusModeEnabled: isPlusModeState(state), plusPaymentMethod: normalizePlusPaymentMethod(state?.plusPaymentMethod), signupMethod: getSignupMethodForStepDefinitions(state), }); + if (Array.isArray(definitions)) { + return definitions; + } + } + const activeFlowId = String(state?.activeFlowId || '').trim().toLowerCase(); + if (activeFlowId && activeFlowId !== DEFAULT_ACTIVE_FLOW_ID) { + return []; } if (!isPlusModeState(state)) { return NORMAL_STEP_DEFINITIONS; @@ -607,10 +652,19 @@ function getStepIdsForState(state = {}) { function getLastStepIdForState(state = {}) { const ids = getStepIdsForState(state); - return ids[ids.length - 1] || 10; + if (ids.length) { + return ids[ids.length - 1]; + } + return String(state?.activeFlowId || '').trim().toLowerCase() === DEFAULT_ACTIVE_FLOW_ID ? 10 : 0; } function getAuthChainStartStepId(state = {}) { + const authStepId = typeof getStepIdByKeyForState === 'function' + ? getStepIdByKeyForState('oauth-login', state) + : null; + if (Number.isInteger(authStepId) && authStepId > 0) { + return authStepId; + } return isPlusModeState(state) ? 10 : FINAL_OAUTH_CHAIN_START_STEP; } @@ -844,8 +898,13 @@ const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings'; const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 20000; const DEFAULT_STATE = { + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + activeRunId: '', + currentNodeId: '', + nodeStatuses: {}, currentStep: 0, // 当前流程执行到的步骤编号。 - stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])), + stepStatuses: { ...DEFAULT_STEP_STATUSES }, + runtimeState: runtimeStateHelpers?.buildDefaultRuntimeState?.() || null, ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 resolvedSignupMethod: null, // 当前自动轮次冻结后的实际注册方式。 @@ -869,6 +928,7 @@ const DEFAULT_STATE = { sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。 sub2apiOAuthState: null, // SUB2API OpenAI Auth state。 sub2apiGroupId: null, // SUB2API 目标分组 ID。 + sub2apiGroupIds: [], // SUB2API 多目标分组 ID。 sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。 sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。 codex2apiSessionId: null, // Codex2API OAuth 会话 ID。 @@ -1320,7 +1380,81 @@ function normalizeSignupMethod(value = '') { : 'email'; } +function getFlowCapabilityRegistry() { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (typeof flowCapabilityRegistry !== 'undefined' && flowCapabilityRegistry) { + return flowCapabilityRegistry; + } + return rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; +} + +function resolveCurrentFlowCapabilities(state = {}, options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.resolveSidepanelCapabilities) { + return null; + } + return registry.resolveSidepanelCapabilities({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + +function validateAutoRunStartState(state = {}, options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.validateAutoRunStart) { + return { ok: true, errors: [] }; + } + return registry.validateAutoRunStart({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + +function validateModeSwitchState(state = {}, options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.validateModeSwitch) { + return { + ok: true, + changedKeys: Array.isArray(options?.changedKeys) ? options.changedKeys : [], + errors: [], + normalizedUpdates: {}, + }; + } + return registry.validateModeSwitch({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + changedKeys: options?.changedKeys, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + function canUsePhoneSignup(state = {}) { + const capabilityState = typeof resolveCurrentFlowCapabilities === 'function' + ? resolveCurrentFlowCapabilities(state) + : (() => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId, + panelMode: state?.panelMode, + signupMethod: state?.signupMethod, + state, + }) + : null; + })(); + if (capabilityState && typeof capabilityState.canUsePhoneSignup === 'boolean') { + return capabilityState.canUsePhoneSignup; + } return Boolean(state?.phoneVerificationEnabled) && !Boolean(state?.plusModeEnabled) && !Boolean(state?.contributionMode); @@ -1332,9 +1466,26 @@ function resolveSignupMethod(state = {}) { return normalizeSignupMethod(frozenMethod); } const method = normalizeSignupMethod(state?.signupMethod); - return method === SIGNUP_METHOD_PHONE && canUsePhoneSignup(state) - ? SIGNUP_METHOD_PHONE - : SIGNUP_METHOD_EMAIL; + const capabilityState = typeof resolveCurrentFlowCapabilities === 'function' + ? resolveCurrentFlowCapabilities(state, { signupMethod: method }) + : (() => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId, + panelMode: state?.panelMode, + signupMethod: method, + state, + }) + : null; + })(); + if (capabilityState?.effectiveSignupMethod) { + return normalizeSignupMethod(capabilityState.effectiveSignupMethod); + } + return method === SIGNUP_METHOD_PHONE && canUsePhoneSignup(state) ? SIGNUP_METHOD_PHONE : SIGNUP_METHOD_EMAIL; } async function ensureResolvedSignupMethodForRun(options = {}) { @@ -2762,7 +2913,9 @@ function buildPersistentSettingsPayload(input = {}, options = {}) { }; if (Object.prototype.hasOwnProperty.call(payload, 'phoneVerificationEnabled') || Object.prototype.hasOwnProperty.call(payload, 'plusModeEnabled') - || Object.prototype.hasOwnProperty.call(payload, 'signupMethod')) { + || Object.prototype.hasOwnProperty.call(payload, 'signupMethod') + || Object.prototype.hasOwnProperty.call(payload, 'panelMode') + || Object.prototype.hasOwnProperty.call(payload, 'activeFlowId')) { payload.signupMethod = resolveSignupMethod(nextSignupConstraintState); } if (payload.ipProxyServiceProfiles) { @@ -2838,7 +2991,13 @@ async function getState() { getPersistedAliasState(), accountRunHistoryHelpers?.getPersistedAccountRunHistory?.() || [], ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state, accountRunHistory }; + return buildStateViewWithRuntimeState({ + ...DEFAULT_STATE, + ...persistedSettings, + ...persistedAliasState, + ...state, + accountRunHistory, + }); } async function initializeSessionStorageAccess() { @@ -2857,19 +3016,24 @@ async function initializeSessionStorageAccess() { async function setState(updates) { console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200)); if (Object.keys(updates || {}).length > 0) { - await chrome.storage.session.set(updates); + const currentSessionState = await chrome.storage.session.get(null); + const sessionUpdates = buildStatePatchWithRuntimeState({ + ...DEFAULT_STATE, + ...currentSessionState, + }, updates); + await chrome.storage.session.set(sessionUpdates); const persistentAliasUpdates = {}; - if (Object.prototype.hasOwnProperty.call(updates, 'manualAliasUsage')) { - persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(updates.manualAliasUsage); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'manualAliasUsage')) { + persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(sessionUpdates.manualAliasUsage); } - if (Object.prototype.hasOwnProperty.call(updates, 'preservedAliases')) { - persistentAliasUpdates.preservedAliases = normalizeBooleanMap(updates.preservedAliases); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'preservedAliases')) { + persistentAliasUpdates.preservedAliases = normalizeBooleanMap(sessionUpdates.preservedAliases); } - if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCache')) { - persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(updates.icloudAliasCache); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'icloudAliasCache')) { + persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(sessionUpdates.icloudAliasCache); } - if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCacheAt')) { - persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(updates.icloudAliasCacheAt) || 0); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'icloudAliasCacheAt')) { + persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(sessionUpdates.icloudAliasCacheAt) || 0); } if (Object.keys(persistentAliasUpdates).length > 0) { await chrome.storage.local.set(persistentAliasUpdates); @@ -2926,6 +3090,30 @@ async function importSettingsBundle(configBundle) { fillDefaults: true, requireKnownKeys: true, }); + const importModeValidation = validateModeSwitchState({ + ...state, + ...importedSettings, + resolvedSignupMethod: null, + }, { + changedKeys: Object.keys(importedSettings), + }); + if (importModeValidation?.normalizedUpdates && Object.keys(importModeValidation.normalizedUpdates).length > 0) { + Object.assign(importedSettings, importModeValidation.normalizedUpdates); + } + if ( + Object.prototype.hasOwnProperty.call(importedSettings, 'phoneVerificationEnabled') + || Object.prototype.hasOwnProperty.call(importedSettings, 'plusModeEnabled') + || Object.prototype.hasOwnProperty.call(importedSettings, 'signupMethod') + || Object.prototype.hasOwnProperty.call(importedSettings, 'panelMode') + || Object.prototype.hasOwnProperty.call(importedSettings, 'activeFlowId') + || Object.prototype.hasOwnProperty.call(importedSettings, 'contributionMode') + ) { + importedSettings.signupMethod = resolveSignupMethod({ + ...state, + ...importedSettings, + resolvedSignupMethod: null, + }); + } await setPersistentSettings(importedSettings); @@ -3463,7 +3651,7 @@ async function resetState() { ? prev.freeReusablePhoneActivation : null; await chrome.storage.session.clear(); - await chrome.storage.session.set({ + const resetPayload = buildStatePatchWithRuntimeState({}, { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, @@ -3493,6 +3681,7 @@ async function resetState() { ? Number(prev.automationWindowId) : null, }); + await chrome.storage.session.set(resetPayload); } /** @@ -4055,6 +4244,8 @@ async function requestHotmailLocalCode(account, pollPayload = {}) { top: 5, senderFilters: pollPayload.senderFilters || [], subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], excludeCodes: pollPayload.excludeCodes || [], filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0, }), @@ -4331,6 +4522,8 @@ async function pollHotmailVerificationCode(step, state, pollPayload = {}) { afterTimestamp: pollPayload.filterAfterTimestamp || 0, senderFilters: pollPayload.senderFilters || [], subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], excludeCodes: pollPayload.excludeCodes || [], }); const match = matchResult.match; @@ -5513,6 +5706,8 @@ async function pollCloudflareTempEmailVerificationCode(step, state, pollPayload afterTimestamp: pollPayload.filterAfterTimestamp || 0, senderFilters: pollPayload.senderFilters || [], subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], excludeCodes: pollPayload.excludeCodes || [], }); const match = matchResult.match; @@ -7218,7 +7413,7 @@ function isSignupEntryHost(hostname = '') { if (typeof navigationUtils !== 'undefined' && navigationUtils?.isSignupEntryHost) { return navigationUtils.isSignupEntryHost(hostname); } - return ['chatgpt.com', 'chat.openai.com'].includes(hostname); + return ['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(hostname); } function isLikelyLoggedInChatgptHomeUrl(rawUrl) { @@ -7302,6 +7497,7 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { if (!candidate) return false; const reference = parseUrlSafely(referenceUrl); switch (source) { + case 'openai-auth': case 'signup-page': return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); case 'duck-mail': @@ -7312,6 +7508,8 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { return is163MailHost(candidate.hostname); case 'gmail-mail': return candidate.hostname === 'mail.google.com'; + case 'icloud-mail': + return candidate.hostname === 'www.icloud.com' || candidate.hostname === 'www.icloud.com.cn'; case 'inbucket-mail': return Boolean(reference) && candidate.origin === reference.origin && candidate.pathname.startsWith('/m/'); case 'mail-2925': @@ -7322,11 +7520,24 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { return Boolean(reference) && candidate.origin === reference.origin && (candidate.pathname.startsWith('/admin/accounts') || candidate.pathname.startsWith('/login') || candidate.pathname === '/'); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && (candidate.pathname.startsWith('/admin/accounts') || candidate.pathname === '/admin' || candidate.pathname === '/'); default: return false; } } +function sourcesMatch(leftSource, rightSource) { + if (sourceRegistry?.resolveCanonicalSource) { + const left = sourceRegistry.resolveCanonicalSource(leftSource); + const right = sourceRegistry.resolveCanonicalSource(rightSource); + return Boolean(left && right && left === right); + } + return String(leftSource || '').trim() === String(rightSource || '').trim(); +} + async function rememberSourceLastUrl(source, url) { return tabRuntime.rememberSourceLastUrl(source, url); } @@ -7407,7 +7618,7 @@ async function ensureContentScriptReadyOnTab(source, tabId, options = {}) { function isContentScriptReadyPong(source, pong) { if (!pong?.ok) return false; - if (pong.source && pong.source !== source) return false; + if (pong.source && !sourcesMatch(pong.source, source)) return false; if (source === 'plus-checkout') { return Boolean(pong.plusCheckoutReady); } @@ -7598,11 +7809,13 @@ function getSourceLabel(source) { return loggingStatus.getSourceLabel(source); } const labels = { + 'openai-auth': '认证页', 'gmail-mail': 'Gmail 邮箱', 'sidepanel': '侧边栏', 'signup-page': '认证页', 'vps-panel': 'CPA 面板', 'sub2api-panel': 'SUB2API 后台', + 'codex2api-panel': 'Codex2API 后台', 'qq-mail': 'QQ 邮箱', 'mail-163': '163 邮箱', 'mail-2925': '2925 邮箱', @@ -7614,6 +7827,8 @@ function getSourceLabel(source) { 'cloudmail': 'Cloud Mail', 'plus-checkout': 'Plus Checkout', 'paypal-flow': 'PayPal 授权页', + 'gopay-flow': 'GoPay 授权页', + 'unknown-source': '未知来源', }; return labels[source] || source || '未知来源'; } @@ -7667,10 +7882,16 @@ function getStepFetchNetworkRetryPolicy(step) { }; } +const sourceRegistry = self.MultiPageSourceRegistry?.createSourceRegistry?.() || null; +const flowCapabilityRegistry = self.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, +}) || null; + const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({ DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, + sourceRegistry, }); const loggingStatus = self.MultiPageBackgroundLoggingStatus?.createLoggingStatus({ @@ -7680,6 +7901,7 @@ const loggingStatus = self.MultiPageBackgroundLoggingStatus?.createLoggingStatus isRecoverableStep9AuthFailure, LOG_PREFIX, setState, + sourceRegistry, STOP_ERROR_MESSAGE, }); @@ -7692,6 +7914,7 @@ const tabRuntime = self.MultiPageBackgroundTabRuntime?.createTabRuntime({ isRetryableContentScriptTransportError, LOG_PREFIX, matchesSourceUrlFamily, + sourceRegistry, setState, sleepWithStop, STOP_ERROR_MESSAGE, @@ -8057,7 +8280,9 @@ function getDownstreamStateResets(step, state = {}) { sub2apiSessionId: null, sub2apiOAuthState: null, sub2apiGroupId: null, + sub2apiGroupIds: [], sub2apiDraftName: null, + sub2apiProxyId: null, codex2apiSessionId: null, codex2apiOAuthState: null, flowStartTime: null, @@ -8556,6 +8781,30 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) { return false; } + if (plan.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START) { + const autoRunStartValidation = typeof validateAutoRunStartState === 'function' + ? validateAutoRunStartState(state, { state }) + : { ok: true, errors: [] }; + if (autoRunStartValidation?.ok === false) { + const validationMessage = autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'; + await clearAutoRunTimerAlarm(); + await broadcastAutoRunStatus('idle', { + currentRun: 0, + totalRuns: 1, + attemptRun: 0, + }, { + autoRunRoundSummaries: [], + autoRunTimerPlan: null, + scheduledAutoRunPlan: null, + }); + await addLog(`自动运行计划已取消:${validationMessage}`, 'error'); + if (trigger === 'manual') { + throw new Error(validationMessage); + } + return false; + } + } + await clearAutoRunTimerAlarm(); if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) { return false; @@ -8962,6 +9211,9 @@ async function handleStepData(step, payload) { if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; + if (payload.sub2apiGroupIds !== undefined) updates.sub2apiGroupIds = Array.isArray(payload.sub2apiGroupIds) + ? payload.sub2apiGroupIds + : []; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; @@ -11296,8 +11548,20 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe waitForTabStableComplete, waitForTabUrlMatch, }); +const openAiMailRules = self.MultiPageOpenAiMailRules?.createOpenAiMailRules({ + getHotmailVerificationRequestTimestamp, + MAIL_2925_VERIFICATION_INTERVAL_MS, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS, +}); +const mailRuleRegistry = self.MultiPageBackgroundMailRuleRegistry?.createMailRuleRegistry({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, + flowBuilders: { + openai: openAiMailRules, + }, +}); const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.createVerificationFlowHelpers({ addLog, + buildVerificationPollPayload: mailRuleRegistry?.buildVerificationPollPayload, chrome, closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, @@ -11638,6 +11902,7 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ sendToContentScript, sendToContentScriptResilient, shouldBypassStep9ForLocalCpa, + DEFAULT_SUB2API_GROUP_NAME, SUB2API_STEP9_RESPONSE_TIMEOUT_MS, }); const stepExecutorsByKey = { @@ -11716,6 +11981,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter normalizeSignupMethod, canUsePhoneSignup, resolveSignupMethod, + validateAutoRunStart: validateAutoRunStartState, getTabId, getStopRequested: () => stopRequested, handleCloudflareSecurityBlocked, @@ -11805,6 +12071,10 @@ const plusGoPayStepRegistry = buildStepRegistry(PLUS_GOPAY_STEP_DEFINITIONS); const plusGpcStepRegistry = buildStepRegistry(PLUS_GPC_STEP_DEFINITIONS); function getStepRegistryForState(state = {}) { + const activeFlowId = String(state?.activeFlowId || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID; + if (activeFlowId !== DEFAULT_ACTIVE_FLOW_ID) { + throw new Error(`当前尚未注册 flow=${activeFlowId} 的步骤执行器。`); + } if (!isPlusModeState(state)) { return normalStepRegistry; } diff --git a/background/logging-status.js b/background/logging-status.js index b86afa8..f92cd39 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -9,16 +9,22 @@ isRecoverableStep9AuthFailure, LOG_PREFIX, setState, + sourceRegistry = null, STOP_ERROR_MESSAGE, } = deps; function getSourceLabel(source) { + if (sourceRegistry?.getSourceLabel) { + return sourceRegistry.getSourceLabel(source); + } const labels = { + 'openai-auth': '认证页', 'gmail-mail': 'Gmail 邮箱', 'sidepanel': '侧边栏', 'signup-page': '认证页', 'vps-panel': 'CPA 面板', 'sub2api-panel': 'SUB2API 后台', + 'codex2api-panel': 'Codex2API 后台', 'qq-mail': 'QQ 邮箱', 'mail-163': '163 邮箱', 'mail-2925': '2925 邮箱', @@ -28,6 +34,10 @@ 'luckmail-api': 'LuckMail(API 购邮)', 'cloudflare-temp-email': 'Cloudflare Temp Email', 'cloudmail': 'Cloud Mail', + 'plus-checkout': 'Plus Checkout', + 'paypal-flow': 'PayPal 授权页', + 'gopay-flow': 'GoPay 授权页', + 'unknown-source': '未知来源', }; return labels[source] || source || '未知来源'; } diff --git a/background/mail-rule-registry.js b/background/mail-rule-registry.js new file mode 100644 index 0000000..be9848f --- /dev/null +++ b/background/mail-rule-registry.js @@ -0,0 +1,48 @@ +(function attachBackgroundMailRuleRegistry(root, factory) { + root.MultiPageBackgroundMailRuleRegistry = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundMailRuleRegistryModule() { + function createMailRuleRegistry(deps = {}) { + const { + defaultFlowId = 'openai', + flowBuilders = {}, + } = deps; + + function resolveFlowId(state = {}) { + const activeFlowId = String(state?.activeFlowId || '').trim(); + return activeFlowId || String(defaultFlowId || '').trim() || 'openai'; + } + + function getFlowBuilder(flowId) { + const normalizedFlowId = String(flowId || '').trim(); + return normalizedFlowId ? flowBuilders[normalizedFlowId] || null : null; + } + + function getVerificationMailRule(step, state = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder || typeof flowBuilder.getRuleDefinition !== 'function') { + throw new Error(`未找到 flow=${flowId} 的邮件规则定义。`); + } + return flowBuilder.getRuleDefinition(step, state); + } + + function buildVerificationPollPayload(step, state = {}, overrides = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder || typeof flowBuilder.buildVerificationPollPayload !== 'function') { + throw new Error(`未找到 flow=${flowId} 的邮件轮询规则构造器。`); + } + return flowBuilder.buildVerificationPollPayload(step, state, overrides); + } + + return { + buildVerificationPollPayload, + getVerificationMailRule, + resolveFlowId, + }; + } + + return { + createMailRuleRegistry, + }; +}); diff --git a/background/message-router.js b/background/message-router.js index b7d5737..4b160b4 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -51,13 +51,67 @@ getStepIdsForState, getLastStepIdForState, normalizeSignupMethod = (value = '') => String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email', - canUsePhoneSignup = (state = {}) => Boolean(state?.phoneVerificationEnabled) - && !Boolean(state?.plusModeEnabled) - && !Boolean(state?.contributionMode), + canUsePhoneSignup = (state = {}) => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (capabilityRegistry?.canUsePhoneSignup) { + return capabilityRegistry.canUsePhoneSignup(state); + } + return Boolean(state?.phoneVerificationEnabled) + && !Boolean(state?.plusModeEnabled) + && !Boolean(state?.contributionMode); + }, resolveSignupMethod = (state = {}) => { const method = normalizeSignupMethod(state?.signupMethod); + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (capabilityRegistry?.resolveSignupMethod) { + return capabilityRegistry.resolveSignupMethod(state, method); + } return method === 'phone' && canUsePhoneSignup(state) ? 'phone' : 'email'; }, + validateAutoRunStart = (state = {}, options = {}) => { + const validationState = options?.state || state; + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (!capabilityRegistry?.validateAutoRunStart) { + return { ok: true, errors: [] }; + } + return capabilityRegistry.validateAutoRunStart({ + activeFlowId: options?.activeFlowId ?? validationState?.activeFlowId, + panelMode: options?.panelMode ?? validationState?.panelMode, + signupMethod: options?.signupMethod ?? validationState?.signupMethod, + state: validationState, + }); + }, + validateModeSwitch = (state = {}, options = {}) => { + const validationState = options?.state || state; + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (!capabilityRegistry?.validateModeSwitch) { + return { + ok: true, + changedKeys: Array.isArray(options?.changedKeys) ? options.changedKeys : [], + errors: [], + normalizedUpdates: {}, + }; + } + return capabilityRegistry.validateModeSwitch({ + activeFlowId: options?.activeFlowId ?? validationState?.activeFlowId, + changedKeys: options?.changedKeys, + panelMode: options?.panelMode ?? validationState?.panelMode, + signupMethod: options?.signupMethod ?? validationState?.signupMethod, + state: validationState, + }); + }, getTabId, getStopRequested, handleAutoRunLoopUnhandledError, @@ -421,6 +475,9 @@ if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; + if (payload.sub2apiGroupIds !== undefined) updates.sub2apiGroupIds = Array.isArray(payload.sub2apiGroupIds) + ? payload.sub2apiGroupIds + : []; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; @@ -437,6 +494,7 @@ const stepKey = getStepKeyForState(step, stateForStep); if (stepKey === 'oauth-login') { + await syncStepAccountIdentityFromPayload(payload); if (payload.skipLoginVerificationStep) { await setState({ loginVerificationRequestedAt: null }); const latestState = await getState(); @@ -938,6 +996,10 @@ } } const state = await getState(); + const autoRunStartValidation = validateAutoRunStart(state, { state }); + if (autoRunStartValidation?.ok === false) { + throw new Error(autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'); + } if (getPendingAutoRunTimerPlan(state)) { throw new Error('已有自动运行倒计时计划,请先取消或立即开始。'); } @@ -965,6 +1027,11 @@ }); } } + const state = await getState(); + const autoRunStartValidation = validateAutoRunStart(state, { state }); + if (autoRunStartValidation?.ok === false) { + throw new Error(autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'); + } const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1); return await scheduleAutoRun(totalRuns, { delayMinutes: message.payload?.delayMinutes, @@ -1036,6 +1103,16 @@ const currentState = await getState(); const updates = buildPersistentSettingsPayload(message.payload || {}); const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {}); + const modeValidation = validateModeSwitch({ + ...currentState, + ...updates, + resolvedSignupMethod: null, + }, { + changedKeys: Object.keys(updates), + }); + if (modeValidation?.normalizedUpdates && Object.keys(modeValidation.normalizedUpdates).length > 0) { + Object.assign(updates, modeValidation.normalizedUpdates); + } const nextSignupState = { ...currentState, ...updates, @@ -1045,6 +1122,9 @@ Object.prototype.hasOwnProperty.call(updates, 'phoneVerificationEnabled') || Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled') || Object.prototype.hasOwnProperty.call(updates, 'signupMethod') + || Object.prototype.hasOwnProperty.call(updates, 'panelMode') + || Object.prototype.hasOwnProperty.call(updates, 'activeFlowId') + || Object.prototype.hasOwnProperty.call(updates, 'contributionMode') ) { updates.signupMethod = resolveSignupMethod(nextSignupState); } @@ -1145,7 +1225,12 @@ ); await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info'); } - return { ok: true, state: await getState(), proxyRouting }; + return { + ok: true, + modeValidation, + proxyRouting, + state: await getState(), + }; } case 'REFRESH_GPC_CARD_BALANCE': { diff --git a/background/navigation-utils.js b/background/navigation-utils.js index 9077502..2353973 100644 --- a/background/navigation-utils.js +++ b/background/navigation-utils.js @@ -6,6 +6,7 @@ DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, + sourceRegistry = null, } = deps; function parseUrlSafely(rawUrl) { @@ -66,7 +67,7 @@ } function isSignupEntryHost(hostname = '') { - return ['chatgpt.com', 'chat.openai.com'].includes(hostname); + return ['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(hostname); } function isSignupPasswordPageUrl(rawUrl) { @@ -117,12 +118,16 @@ } function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { + if (sourceRegistry?.matchesSourceUrlFamily) { + return sourceRegistry.matchesSourceUrlFamily(source, candidateUrl, referenceUrl); + } const candidate = parseUrlSafely(candidateUrl); if (!candidate) return false; const reference = parseUrlSafely(referenceUrl); switch (source) { + case 'openai-auth': case 'signup-page': return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); case 'duck-mail': diff --git a/background/panel-bridge.js b/background/panel-bridge.js index 03b5bc9..dd553fa 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -19,6 +19,25 @@ SUB2API_STEP1_RESPONSE_TIMEOUT_MS, } = deps; + let sub2ApiApi = null; + + function getSub2ApiApi() { + if (sub2ApiApi) { + return sub2ApiApi; + } + const factory = deps.createSub2ApiApi + || self.MultiPageBackgroundSub2ApiApi?.createSub2ApiApi; + if (typeof factory !== 'function') { + throw new Error('SUB2API 直连接口模块未加载,无法生成 OAuth 链接。'); + } + sub2ApiApi = factory({ + addLog, + normalizeSub2ApiUrl, + DEFAULT_SUB2API_GROUP_NAME, + }); + return sub2ApiApi; + } + function normalizeAdminKey(value = '') { return String(value || '').trim(); } @@ -250,7 +269,6 @@ async function requestSub2ApiOAuthUrl(state, options = {}) { const { logLabel = 'OAuth 刷新' } = options; const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); - const groupName = (state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME).trim() || DEFAULT_SUB2API_GROUP_NAME; if (!sub2apiUrl) { throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.'); @@ -262,54 +280,14 @@ throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。'); } - await addLog(`${logLabel}:正在打开 SUB2API 后台...`); - - const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; - await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl); - - const tab = typeof createAutomationTab === 'function' - ? await createAutomationTab({ url: sub2apiUrl, active: true }) - : await chrome.tabs.create({ url: sub2apiUrl, active: true }); - const tabId = tab.id; - await rememberSourceLastUrl('sub2api-panel', sub2apiUrl); - - await addLog(`${logLabel}:SUB2API 页面已打开,正在等待页面进入目标地址...`); - const matchedTab = await waitForTabUrlFamily('sub2api-panel', tabId, sub2apiUrl, { - timeoutMs: 15000, - retryDelayMs: 400, - }); - if (!matchedTab) { - await addLog(`${logLabel}:SUB2API 页面尚未稳定,继续尝试连接内容脚本...`, 'warn'); - } - - await ensureContentScriptReadyOnTab('sub2api-panel', tabId, { - inject: injectFiles, - injectSource: 'sub2api-panel', - timeoutMs: 45000, - retryDelayMs: 900, - logMessage: `${logLabel}:SUB2API 页面仍在加载,正在重试连接内容脚本...`, - }); - - const result = await sendToContentScript('sub2api-panel', { - type: 'REQUEST_OAUTH_URL', - source: 'background', - payload: { + const api = getSub2ApiApi(); + return api.generateOpenAiAuthUrl({ + ...state, sub2apiUrl, - sub2apiEmail: state.sub2apiEmail, - sub2apiPassword: state.sub2apiPassword, - sub2apiGroupName: groupName, - sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, - sub2apiAccountPriority: state.sub2apiAccountPriority, - logStep: 7, - }, }, { - responseTimeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS, + logLabel, + timeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS, }); - - if (result?.error) { - throw new Error(result.error); - } - return result || {}; } return { diff --git a/background/runtime-state.js b/background/runtime-state.js new file mode 100644 index 0000000..cfacddd --- /dev/null +++ b/background/runtime-state.js @@ -0,0 +1,484 @@ +(function attachBackgroundRuntimeState(root, factory) { + root.MultiPageBackgroundRuntimeState = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundRuntimeStateModule() { + function createRuntimeStateHelpers(deps = {}) { + const { + DEFAULT_ACTIVE_FLOW_ID = 'openai', + defaultStepStatuses = {}, + getStepDefinitionForState = null, + } = deps; + + const RUNTIME_SHARED_FIELDS = Object.freeze([ + 'automationWindowId', + 'tabRegistry', + 'sourceLastUrls', + 'flowStartTime', + ]); + const RUNTIME_PROXY_FIELDS = Object.freeze([ + 'ipProxyApiPool', + 'ipProxyApiCurrentIndex', + 'ipProxyApiCurrent', + 'ipProxyAccountPool', + 'ipProxyAccountCurrentIndex', + 'ipProxyAccountCurrent', + 'ipProxyPool', + 'ipProxyCurrentIndex', + 'ipProxyCurrent', + ]); + const OPENAI_FLOW_FIELD_GROUPS = Object.freeze({ + auth: Object.freeze([ + 'oauthUrl', + 'localhostUrl', + ]), + platformBinding: Object.freeze([ + 'cpaOAuthState', + 'cpaManagementOrigin', + 'sub2apiSessionId', + 'sub2apiOAuthState', + 'sub2apiGroupId', + 'sub2apiDraftName', + 'sub2apiProxyId', + 'sub2apiGroupIds', + 'codex2apiSessionId', + 'codex2apiOAuthState', + ]), + plus: Object.freeze([ + 'plusCheckoutTabId', + 'plusCheckoutUrl', + 'plusCheckoutCountry', + 'plusCheckoutCurrency', + 'plusCheckoutSource', + 'plusBillingCountryText', + 'plusBillingAddress', + 'plusPaypalApprovedAt', + 'plusGoPayApprovedAt', + 'plusReturnUrl', + 'plusManualConfirmationPending', + 'plusManualConfirmationRequestId', + 'plusManualConfirmationStep', + 'plusManualConfirmationMethod', + 'plusManualConfirmationTitle', + 'plusManualConfirmationMessage', + 'gopayHelperReferenceId', + 'gopayHelperGoPayGuid', + 'gopayHelperRedirectUrl', + 'gopayHelperNextAction', + 'gopayHelperFlowId', + 'gopayHelperChallengeId', + 'gopayHelperStartPayload', + 'gopayHelperTaskId', + 'gopayHelperTaskStatus', + 'gopayHelperStatusText', + 'gopayHelperRemoteStage', + 'gopayHelperApiWaitingFor', + 'gopayHelperApiInputDeadlineAt', + 'gopayHelperApiInputWaitSeconds', + 'gopayHelperLastInputError', + 'gopayHelperOtpInvalidCount', + 'gopayHelperFailureStage', + 'gopayHelperFailureDetail', + 'gopayHelperTaskPayload', + 'gopayHelperOrderCreatedAt', + 'gopayHelperTaskProgressSignature', + 'gopayHelperTaskProgressAt', + 'gopayHelperTaskProgressTaskId', + 'gopayHelperPinPayload', + 'gopayHelperResolvedOtp', + 'gopayHelperOtpRequestId', + 'gopayHelperOtpReferenceId', + ]), + phoneVerification: Object.freeze([ + 'currentPhoneActivation', + 'phoneNumber', + 'currentPhoneVerificationCode', + 'currentPhoneVerificationCountdownEndsAt', + 'currentPhoneVerificationCountdownWindowIndex', + 'currentPhoneVerificationCountdownWindowTotal', + 'reusablePhoneActivation', + 'freeReusablePhoneActivation', + 'phoneReusableActivationPool', + 'signupPhoneNumber', + 'signupPhoneActivation', + 'signupPhoneCompletedActivation', + 'signupPhoneVerificationRequestedAt', + 'signupPhoneVerificationPurpose', + ]), + luckmail: Object.freeze([ + 'currentLuckmailPurchase', + 'currentLuckmailMailCursor', + ]), + identity: Object.freeze([ + 'resolvedSignupMethod', + 'accountIdentifierType', + 'accountIdentifier', + 'registrationEmailState', + 'email', + 'password', + 'lastEmailTimestamp', + 'lastSignupCode', + 'lastLoginCode', + 'step8VerificationTargetEmail', + ]), + }); + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cloneValue(value) { + if (Array.isArray(value)) { + return value.map((item) => cloneValue(item)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, cloneValue(entryValue)]) + ); + } + return value; + } + + function normalizePlainObject(value) { + return isPlainObject(value) ? value : {}; + } + + function normalizeFlowId(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized || DEFAULT_ACTIVE_FLOW_ID; + } + + function normalizeRunId(value = '') { + return String(value || '').trim(); + } + + function normalizeStepNumber(value) { + const step = Math.floor(Number(value) || 0); + return step > 0 ? step : 0; + } + + function normalizeNodeStatus(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return 'pending'; + } + return normalized; + } + + function buildDefaultStepStatuses() { + return Object.fromEntries( + Object.entries(normalizePlainObject(defaultStepStatuses)).map(([key, value]) => [ + String(key), + normalizeNodeStatus(value), + ]) + ); + } + + function normalizeStepStatuses(value) { + const base = buildDefaultStepStatuses(); + if (!isPlainObject(value)) { + return base; + } + + const next = { ...base }; + for (const [key, status] of Object.entries(value)) { + const step = normalizeStepNumber(key); + if (!step) continue; + next[String(step)] = normalizeNodeStatus(status); + } + return next; + } + + function normalizeLegacyStepCompat(value = {}, state = {}) { + const candidate = normalizePlainObject(value); + const currentStep = normalizeStepNumber( + Object.prototype.hasOwnProperty.call(state, 'currentStep') + ? state.currentStep + : candidate.currentStep + ); + const stepStatuses = normalizeStepStatuses( + Object.prototype.hasOwnProperty.call(state, 'stepStatuses') + ? state.stepStatuses + : candidate.stepStatuses + ); + + return { + currentStep, + stepStatuses, + }; + } + + function resolveStepKey(step, state = {}) { + const numericStep = normalizeStepNumber(step); + if (!numericStep || typeof getStepDefinitionForState !== 'function') { + return ''; + } + return String(getStepDefinitionForState(numericStep, state)?.key || '').trim(); + } + + function normalizeNodeStatuses(value, state = {}, legacyStepCompat = null) { + if (isPlainObject(value) && Object.keys(value).length > 0) { + return Object.fromEntries( + Object.entries(value) + .map(([key, status]) => [String(key || '').trim(), normalizeNodeStatus(status)]) + .filter(([key]) => Boolean(key)) + ); + } + + const compat = legacyStepCompat || normalizeLegacyStepCompat({}, state); + const next = {}; + for (const [stepKey, status] of Object.entries(compat.stepStatuses || {})) { + const step = normalizeStepNumber(stepKey); + if (!step) continue; + const nodeKey = resolveStepKey(step, state) || `step-${step}`; + next[nodeKey] = normalizeNodeStatus(status); + } + return next; + } + + function pickDefinedFields(state = {}, fields = []) { + const next = {}; + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(state, field)) { + next[field] = cloneValue(state[field]); + } + } + return next; + } + + function buildSharedState(baseValue = {}, state = {}) { + return { + ...cloneValue(normalizePlainObject(baseValue)), + ...pickDefinedFields(state, RUNTIME_SHARED_FIELDS), + }; + } + + function buildServiceState(baseValue = {}, state = {}) { + const base = cloneValue(normalizePlainObject(baseValue)); + return { + ...base, + proxy: { + ...cloneValue(normalizePlainObject(base.proxy)), + ...pickDefinedFields(state, RUNTIME_PROXY_FIELDS), + }, + }; + } + + function flattenOpenAiFlowState(flowState = {}) { + const openaiState = normalizePlainObject(flowState.openai); + const next = {}; + for (const [groupKey, fields] of Object.entries(OPENAI_FLOW_FIELD_GROUPS)) { + const group = normalizePlainObject(openaiState[groupKey]); + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(group, field)) { + next[field] = cloneValue(group[field]); + } + } + } + return next; + } + + function buildOpenAiFlowState(baseValue = {}, state = {}) { + const baseFlowState = cloneValue(normalizePlainObject(baseValue)); + const baseOpenAi = cloneValue(normalizePlainObject(baseFlowState.openai)); + const openaiState = { + ...baseOpenAi, + }; + + for (const [groupKey, fields] of Object.entries(OPENAI_FLOW_FIELD_GROUPS)) { + openaiState[groupKey] = { + ...cloneValue(normalizePlainObject(baseOpenAi[groupKey])), + ...pickDefinedFields(state, fields), + }; + } + + return { + ...baseFlowState, + openai: openaiState, + }; + } + + function buildRuntimeStateDefault() { + return { + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + activeRunId: '', + currentNodeId: '', + nodeStatuses: {}, + sharedState: {}, + serviceState: { + proxy: {}, + }, + flowState: { + openai: { + auth: {}, + platformBinding: {}, + plus: {}, + phoneVerification: {}, + luckmail: {}, + identity: {}, + }, + }, + legacyStepCompat: { + currentStep: 0, + stepStatuses: buildDefaultStepStatuses(), + }, + }; + } + + function ensureRuntimeState(state = {}) { + const baseRuntimeState = { + ...buildRuntimeStateDefault(), + ...cloneValue(normalizePlainObject(state.runtimeState)), + }; + const activeFlowId = normalizeFlowId( + Object.prototype.hasOwnProperty.call(state, 'activeFlowId') + ? state.activeFlowId + : baseRuntimeState.activeFlowId + ); + const legacyStepCompat = normalizeLegacyStepCompat(baseRuntimeState.legacyStepCompat, state); + const currentNodeId = String( + Object.prototype.hasOwnProperty.call(state, 'currentNodeId') + ? state.currentNodeId + : (baseRuntimeState.currentNodeId || resolveStepKey(legacyStepCompat.currentStep, state)) + ).trim(); + const nodeStatuses = normalizeNodeStatuses( + Object.prototype.hasOwnProperty.call(state, 'nodeStatuses') + ? state.nodeStatuses + : baseRuntimeState.nodeStatuses, + state, + legacyStepCompat + ); + + return { + ...baseRuntimeState, + activeFlowId, + activeRunId: normalizeRunId( + Object.prototype.hasOwnProperty.call(state, 'activeRunId') + ? state.activeRunId + : baseRuntimeState.activeRunId + ), + currentNodeId, + nodeStatuses, + sharedState: buildSharedState(baseRuntimeState.sharedState, state), + serviceState: buildServiceState(baseRuntimeState.serviceState, state), + flowState: buildOpenAiFlowState(baseRuntimeState.flowState, state), + legacyStepCompat, + }; + } + + function buildFlattenedUpdates(updates = {}) { + const next = { + ...updates, + }; + const runtimeState = normalizePlainObject(updates.runtimeState); + const legacyStepCompat = normalizePlainObject(updates.legacyStepCompat); + const sharedState = normalizePlainObject(updates.sharedState); + const serviceState = normalizePlainObject(updates.serviceState); + const flowState = normalizePlainObject(updates.flowState); + + if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeFlowId')) { + next.activeFlowId = runtimeState.activeFlowId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeRunId')) { + next.activeRunId = runtimeState.activeRunId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'currentNodeId')) { + next.currentNodeId = runtimeState.currentNodeId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'nodeStatuses')) { + next.nodeStatuses = cloneValue(runtimeState.nodeStatuses); + } + if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'currentStep')) { + next.currentStep = legacyStepCompat.currentStep; + } + if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'stepStatuses')) { + next.stepStatuses = cloneValue(legacyStepCompat.stepStatuses); + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'legacyStepCompat')) { + const compatCandidate = normalizePlainObject(runtimeState.legacyStepCompat); + if (Object.prototype.hasOwnProperty.call(compatCandidate, 'currentStep')) { + next.currentStep = compatCandidate.currentStep; + } + if (Object.prototype.hasOwnProperty.call(compatCandidate, 'stepStatuses')) { + next.stepStatuses = cloneValue(compatCandidate.stepStatuses); + } + } + + Object.assign(next, pickDefinedFields(sharedState, RUNTIME_SHARED_FIELDS)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'sharedState')) { + Object.assign( + next, + pickDefinedFields(normalizePlainObject(runtimeState.sharedState), RUNTIME_SHARED_FIELDS) + ); + } + + const serviceProxy = normalizePlainObject(serviceState.proxy); + Object.assign(next, pickDefinedFields(serviceProxy, RUNTIME_PROXY_FIELDS)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'serviceState')) { + const runtimeServiceState = normalizePlainObject(runtimeState.serviceState); + Object.assign( + next, + pickDefinedFields(normalizePlainObject(runtimeServiceState.proxy), RUNTIME_PROXY_FIELDS) + ); + } + + Object.assign(next, flattenOpenAiFlowState(flowState)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'flowState')) { + Object.assign(next, flattenOpenAiFlowState(normalizePlainObject(runtimeState.flowState))); + } + + return next; + } + + function buildStateView(state = {}) { + const runtimeState = ensureRuntimeState(state); + return { + ...state, + activeFlowId: runtimeState.activeFlowId, + activeRunId: runtimeState.activeRunId, + currentNodeId: runtimeState.currentNodeId, + nodeStatuses: cloneValue(runtimeState.nodeStatuses), + flowState: cloneValue(runtimeState.flowState), + sharedState: cloneValue(runtimeState.sharedState), + serviceState: cloneValue(runtimeState.serviceState), + legacyStepCompat: cloneValue(runtimeState.legacyStepCompat), + currentStep: runtimeState.legacyStepCompat.currentStep, + stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), + runtimeState, + }; + } + + function buildSessionStatePatch(currentState = {}, updates = {}) { + const flattenedUpdates = buildFlattenedUpdates(updates); + const nextState = { + ...currentState, + ...flattenedUpdates, + }; + const runtimeState = ensureRuntimeState(nextState); + + return { + ...flattenedUpdates, + activeFlowId: runtimeState.activeFlowId, + activeRunId: runtimeState.activeRunId, + currentNodeId: runtimeState.currentNodeId, + nodeStatuses: cloneValue(runtimeState.nodeStatuses), + currentStep: runtimeState.legacyStepCompat.currentStep, + stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), + runtimeState, + }; + } + + return { + DEFAULT_ACTIVE_FLOW_ID, + OPENAI_FLOW_FIELD_GROUPS, + RUNTIME_PROXY_FIELDS, + RUNTIME_SHARED_FIELDS, + buildDefaultRuntimeState: buildRuntimeStateDefault, + buildSessionStatePatch, + buildStateView, + ensureRuntimeState, + }; + } + + return { + createRuntimeStateHelpers, + }; +}); diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index 2d39999..6654265 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -19,9 +19,29 @@ sendToContentScript, sendToContentScriptResilient, shouldBypassStep9ForLocalCpa, + DEFAULT_SUB2API_GROUP_NAME = 'codex', SUB2API_STEP9_RESPONSE_TIMEOUT_MS, } = deps; + let sub2ApiApi = null; + + function getSub2ApiApi() { + if (sub2ApiApi) { + return sub2ApiApi; + } + const factory = deps.createSub2ApiApi + || self.MultiPageBackgroundSub2ApiApi?.createSub2ApiApi; + if (typeof factory !== 'function') { + throw new Error('SUB2API 直连接口模块未加载,无法提交回调。'); + } + sub2ApiApi = factory({ + addLog, + normalizeSub2ApiUrl, + DEFAULT_SUB2API_GROUP_NAME, + }); + return sub2ApiApi; + } + function normalizeString(value = '') { return String(value || '').trim(); } @@ -346,62 +366,22 @@ if (!sub2apiUrl) { throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.'); } - const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; - - await addStepLog(visibleStep, '正在打开 SUB2API 后台...'); - - let tabId = await getTabId('sub2api-panel'); - const alive = tabId && await isTabAlive('sub2api-panel'); - - if (!alive) { - tabId = await reuseOrCreateTab('sub2api-panel', sub2apiUrl, { - inject: injectFiles, - injectSource: 'sub2api-panel', - reloadIfSameUrl: true, - }); - } else { - await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl, { excludeTabIds: [tabId] }); - await chrome.tabs.update(tabId, { active: true }); - await rememberSourceLastUrl('sub2api-panel', sub2apiUrl); - } - - await ensureContentScriptReadyOnTab('sub2api-panel', tabId, { - inject: injectFiles, - injectSource: 'sub2api-panel', - }); - - await addStepLog(visibleStep, '正在向 SUB2API 提交回调并创建账号...'); - const requestMessage = { - type: 'EXECUTE_STEP', - step: platformVerifyStep, - source: 'background', - payload: { - localhostUrl: state.localhostUrl, - visibleStep, - sub2apiUrl, - sub2apiEmail: state.sub2apiEmail, - sub2apiPassword: state.sub2apiPassword, - sub2apiGroupName: state.sub2apiGroupName, - sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, - sub2apiAccountPriority: state.sub2apiAccountPriority, - sub2apiProxyId: state.sub2apiProxyId, - sub2apiSessionId: state.sub2apiSessionId, - sub2apiOAuthState: state.sub2apiOAuthState, - sub2apiGroupId: state.sub2apiGroupId, - sub2apiGroupIds: state.sub2apiGroupIds, - sub2apiDraftName: state.sub2apiDraftName, - }, - }; + const api = getSub2ApiApi(); const maxExchangeAttempts = 3; let lastError = null; for (let attempt = 1; attempt <= maxExchangeAttempts; attempt += 1) { try { - const result = await sendToContentScript('sub2api-panel', requestMessage, { - responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, + const result = await api.submitOpenAiCallback({ + ...state, + visibleStep, + sub2apiUrl, + }, { + visibleStep, + logLabel: `步骤 ${visibleStep}`, + logOptions: { step: visibleStep, stepKey: 'platform-verify' }, + timeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, }); - if (result?.error) { - throw new Error(result.error); - } + await completeStepFromBackground(platformVerifyStep, result); return; } catch (error) { lastError = error; diff --git a/background/sub2api-api.js b/background/sub2api-api.js new file mode 100644 index 0000000..b23ade1 --- /dev/null +++ b/background/sub2api-api.js @@ -0,0 +1,606 @@ +(function attachBackgroundSub2ApiApi(root, factory) { + root.MultiPageBackgroundSub2ApiApi = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundSub2ApiApiModule() { + function createSub2ApiApi(deps = {}) { + const { + addLog = async () => {}, + normalizeSub2ApiUrl = (value) => value, + DEFAULT_SUB2API_GROUP_NAME = 'codex', + fetchImpl = (...args) => fetch(...args), + } = deps; + + const DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback'; + const DEFAULT_PROXY_NAME = ''; + const DEFAULT_CONCURRENCY = 10; + const DEFAULT_PRIORITY = 1; + const DEFAULT_RATE_MULTIPLIER = 1; + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function extractStateFromAuthUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + function normalizeRedirectUri(input = DEFAULT_REDIRECT_URI) { + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/') { + parsed.pathname = '/auth/callback'; + } + if (parsed.pathname !== '/auth/callback') { + throw new Error('SUB2API 回调地址必须是 /auth/callback,例如 http://localhost:1455/auth/callback'); + } + return parsed.toString(); + } + + function getSub2ApiOrigin(rawUrl = '') { + const sub2apiUrl = normalizeSub2ApiUrl(rawUrl); + if (!sub2apiUrl) { + throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.'); + } + try { + return new URL(sub2apiUrl).origin; + } catch { + throw new Error('SUB2API URL 格式无效,请先在侧边栏检查。'); + } + } + + function getSub2ApiErrorMessage(payload, responseStatus = 500, path = '') { + const candidates = [ + payload?.message, + payload?.detail, + payload?.error, + payload?.reason, + ]; + const message = candidates.map(normalizeString).find(Boolean); + return message || `SUB2API 请求失败(HTTP ${responseStatus}):${path}`; + } + + async function requestJson(origin, path, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const token = normalizeString(options.token); + const response = await fetchImpl(`${origin}${path}`, { + method: options.method || 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + + if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'code')) { + if (Number(payload.code) === 0) { + return payload.data; + } + throw new Error(getSub2ApiErrorMessage(payload, response.status, path)); + } + + if (!response.ok) { + throw new Error(getSub2ApiErrorMessage(payload, response.status, path)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`SUB2API 请求超时:${path}`); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + async function loginSub2Api(state = {}, options = {}) { + const email = normalizeString(state.sub2apiEmail); + const password = String(state.sub2apiPassword || ''); + const origin = getSub2ApiOrigin(state.sub2apiUrl); + + if (!email) { + throw new Error('尚未配置 SUB2API 登录邮箱,请先在侧边栏填写。'); + } + if (!password) { + throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。'); + } + + const loginData = await requestJson(origin, '/api/v1/auth/login', { + method: 'POST', + timeoutMs: options.timeoutMs, + body: { email, password }, + }); + + const token = normalizeString(loginData?.access_token || loginData?.accessToken); + if (!token) { + throw new Error('SUB2API 登录返回缺少 access_token。'); + } + + return { + origin, + token, + user: loginData?.user || null, + }; + } + + function normalizeSub2ApiGroupNames(value) { + const source = Array.isArray(value) + ? value + : String(value || '').split(/[\r\n,,;;]+/); + const seen = new Set(); + const names = []; + for (const item of source) { + const name = normalizeString(item); + const key = name.toLowerCase(); + if (!name || seen.has(key)) continue; + seen.add(key); + names.push(name); + } + return names.length ? names : [DEFAULT_SUB2API_GROUP_NAME]; + } + + async function getGroupsByNames(origin, token, groupNames, options = {}) { + const targetNames = normalizeSub2ApiGroupNames(groupNames); + const groups = await requestJson(origin, '/api/v1/admin/groups/all', { + method: 'GET', + token, + timeoutMs: options.timeoutMs, + }); + const matched = []; + const missing = []; + + for (const targetName of targetNames) { + const normalized = targetName.toLowerCase(); + const group = (Array.isArray(groups) ? groups : []).find((item) => { + const itemName = normalizeString(item?.name).toLowerCase(); + if (!itemName || itemName !== normalized) return false; + return !item.platform || item.platform === 'openai'; + }); + if (group) { + matched.push(group); + } else { + missing.push(targetName); + } + } + + if (missing.length) { + throw new Error(`SUB2API 中未找到以下 openai 分组:${missing.join('、')}。`); + } + + return matched; + } + + function normalizeSub2ApiProxyPreference(value) { + return normalizeString(value); + } + + function resolveSub2ApiProxyPreference(state = {}) { + if (state.sub2apiDefaultProxyName !== undefined) { + return normalizeSub2ApiProxyPreference(state.sub2apiDefaultProxyName); + } + return DEFAULT_PROXY_NAME; + } + + function resolveSub2ApiAccountPriority(state = {}) { + const rawValue = normalizeString(state.sub2apiAccountPriority); + if (!rawValue) { + return DEFAULT_PRIORITY; + } + const numeric = Number(rawValue); + if (!Number.isSafeInteger(numeric) || numeric < 1) { + throw new Error('SUB2API 账号优先级必须是大于等于 1 的整数。'); + } + return numeric; + } + + function normalizeProxyId(value) { + if (value === undefined || value === null || value === '') { + return null; + } + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) { + return null; + } + return normalized; + } + + function buildProxyDisplayName(proxy = {}) { + const id = normalizeProxyId(proxy.id); + const name = normalizeString(proxy.name); + const protocol = normalizeString(proxy.protocol); + const host = normalizeString(proxy.host); + const port = proxy.port === undefined || proxy.port === null ? '' : normalizeString(proxy.port); + const address = protocol && host && port ? `${protocol}://${host}:${port}` : ''; + return [ + name || '(未命名代理)', + id ? `#${id}` : '', + address, + ].filter(Boolean).join(' '); + } + + function buildProxySearchText(proxy = {}) { + return [ + proxy.id, + proxy.name, + proxy.protocol, + proxy.host, + proxy.port, + buildProxyDisplayName(proxy), + ] + .filter((value) => value !== undefined && value !== null && value !== '') + .map((value) => normalizeString(value).toLowerCase()) + .filter(Boolean) + .join(' '); + } + + function isActiveProxy(proxy = {}) { + const status = normalizeString(proxy.status).toLowerCase(); + return !status || status === 'active'; + } + + function findSub2ApiProxy(proxies = [], preference = '') { + const activeProxies = (Array.isArray(proxies) ? proxies : []) + .filter(isActiveProxy) + .filter((proxy) => normalizeProxyId(proxy.id)); + const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase(); + const preferredId = normalizeProxyId(normalizedPreference); + + if (preferredId) { + const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId); + return { + proxy: matchedById || null, + reason: matchedById ? 'id' : 'missing-id', + candidates: activeProxies, + }; + } + + if (normalizedPreference) { + const exactMatches = activeProxies.filter((proxy) => normalizeString(proxy.name).toLowerCase() === normalizedPreference); + if (exactMatches.length === 1) { + return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies }; + } + if (exactMatches.length > 1) { + return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches }; + } + + const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference)); + if (fuzzyMatches.length === 1) { + return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies }; + } + if (fuzzyMatches.length > 1) { + return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches }; + } + + return { proxy: null, reason: 'missing-name', candidates: activeProxies }; + } + + if (activeProxies.length === 1) { + return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies }; + } + return { + proxy: null, + reason: activeProxies.length ? 'no-preference' : 'none-active', + candidates: activeProxies, + }; + } + + async function resolveSub2ApiProxy(origin, token, preference = '', options = {}) { + const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', { + method: 'GET', + token, + timeoutMs: options.timeoutMs, + }); + if (!Array.isArray(proxies)) { + throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。'); + } + + const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference); + if (proxy) { + return proxy; + } + + const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)'; + const available = (candidates || []) + .slice(0, 8) + .map(buildProxyDisplayName) + .join(';') || '无可用代理'; + if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') { + throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`); + } + if (reason === 'missing-id') { + throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`); + } + if (reason === 'missing-name') { + throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`); + } + if (reason === 'no-preference') { + throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID;留空则不使用代理。可用代理:${available}`); + } + throw new Error('SUB2API 没有可用代理;请检查默认代理配置,或将其留空以禁用代理。'); + } + + function buildDraftAccountName(groupName) { + const prefix = normalizeString(groupName || DEFAULT_SUB2API_GROUP_NAME) + .replace(/[^\w\u4e00-\u9fa5-]+/g, '-') + .replace(/^-+|-+$/g, '') || DEFAULT_SUB2API_GROUP_NAME; + const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14); + const random = Math.floor(Math.random() * 9000 + 1000); + return `${prefix}-${stamp}-${random}`; + } + + function parseLocalhostCallback(rawUrl, visibleStep = 10) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效。`); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('回调 URL 协议不正确。'); + } + if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) { + throw new Error(`步骤 ${visibleStep} 只接受 localhost / 127.0.0.1 回调地址。`); + } + if (parsed.pathname !== '/auth/callback') { + throw new Error('回调 URL 路径必须是 /auth/callback。'); + } + + const code = normalizeString(parsed.searchParams.get('code')); + const state = normalizeString(parsed.searchParams.get('state')); + if (!code || !state) { + throw new Error('回调 URL 中缺少 code 或 state。'); + } + + return { + url: parsed.toString(), + code, + state, + }; + } + + function buildOpenAiCredentials(exchangeData) { + const credentials = {}; + const allowedKeys = [ + 'access_token', + 'refresh_token', + 'id_token', + 'expires_at', + 'email', + 'chatgpt_account_id', + 'chatgpt_user_id', + 'organization_id', + 'plan_type', + 'client_id', + ]; + + for (const key of allowedKeys) { + if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') { + credentials[key] = exchangeData[key]; + } + } + + if (!credentials.access_token) { + throw new Error('SUB2API 交换授权码后未返回 access_token。'); + } + + return credentials; + } + + function buildOpenAiExtra(exchangeData) { + const extra = {}; + const allowedKeys = ['email', 'name', 'privacy_mode']; + + for (const key of allowedKeys) { + if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') { + extra[key] = exchangeData[key]; + } + } + + return Object.keys(extra).length ? extra : undefined; + } + + async function logWithOptions(message, level = 'info', options = {}) { + await addLog(message, level, options.logOptions || {}); + } + + async function generateOpenAiAuthUrl(state = {}, options = {}) { + const logLabel = normalizeString(options.logLabel) || 'OAuth 刷新'; + const redirectUri = normalizeRedirectUri(options.redirectUri || DEFAULT_REDIRECT_URI); + const groupNames = normalizeSub2ApiGroupNames(state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME); + const groupName = groupNames[0] || DEFAULT_SUB2API_GROUP_NAME; + + await logWithOptions(`${logLabel}:正在通过 SUB2API 管理接口登录并生成 OpenAI Auth 链接...`, 'info', options); + const { origin, token } = await loginSub2Api(state, options); + const groups = await getGroupsByNames(origin, token, groupNames, options); + const group = groups[0]; + const proxyPreference = resolveSub2ApiProxyPreference(state); + const proxy = proxyPreference ? await resolveSub2ApiProxy(origin, token, proxyPreference, options) : null; + const proxyId = normalizeProxyId(proxy?.id); + const draftName = buildDraftAccountName(group.name || groupName); + const groupLabel = groups.map((item) => `${item.name}(#${item.id})`).join('、'); + + await logWithOptions(`${logLabel}:已登录 SUB2API,使用分组 ${groupLabel}。`, 'info', options); + if (proxy) { + await logWithOptions(`${logLabel}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', options); + } else { + await logWithOptions(`${logLabel}:未配置 SUB2API 默认代理,本次将不使用代理。`, 'info', options); + } + + const authRequestBody = { redirect_uri: redirectUri }; + if (proxyId) { + authRequestBody.proxy_id = proxyId; + } + + const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', { + method: 'POST', + token, + timeoutMs: options.timeoutMs, + body: authRequestBody, + }); + + const oauthUrl = normalizeString(authData?.auth_url || authData?.authUrl); + const sessionId = normalizeString(authData?.session_id || authData?.sessionId); + const oauthState = normalizeString(authData?.state || extractStateFromAuthUrl(oauthUrl)); + + if (!oauthUrl || !sessionId) { + throw new Error('SUB2API 未返回完整的 auth_url / session_id。'); + } + + await logWithOptions(`${logLabel}:已获取 SUB2API OAuth 链接:${oauthUrl.slice(0, 96)}...`, 'ok', options); + return { + oauthUrl, + sub2apiSessionId: sessionId, + sub2apiOAuthState: oauthState, + sub2apiGroupId: group.id, + sub2apiGroupIds: groups.map((item) => item.id), + sub2apiDraftName: draftName, + sub2apiProxyId: proxyId, + }; + } + + async function submitOpenAiCallback(state = {}, options = {}) { + const visibleStep = Number(options.visibleStep || state.visibleStep) || 10; + const callback = parseLocalhostCallback(state.localhostUrl || '', visibleStep); + const flowEmail = normalizeString(state.email); + const sessionId = normalizeString(state.sub2apiSessionId); + const expectedState = normalizeString(state.sub2apiOAuthState); + const logLabel = normalizeString(options.logLabel) || `步骤 ${visibleStep}`; + + if (!sessionId) { + throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。'); + } + if (expectedState && expectedState !== callback.state) { + throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。'); + } + + const { origin, token } = await loginSub2Api(state, options); + const proxyPreference = resolveSub2ApiProxyPreference(state); + const preferredProxyId = normalizeProxyId(state.sub2apiProxyId); + const proxySelector = preferredProxyId || proxyPreference; + const proxy = proxySelector ? await resolveSub2ApiProxy(origin, token, proxySelector, options) : null; + const proxyId = normalizeProxyId(proxy?.id); + const accountPriority = resolveSub2ApiAccountPriority(state); + const storedGroupIds = Array.isArray(state.sub2apiGroupIds) ? state.sub2apiGroupIds : []; + const groupIdsFromState = storedGroupIds + .map((id) => Number(id)) + .filter((id) => Number.isFinite(id) && id > 0); + const groups = groupIdsFromState.length + ? groupIdsFromState.map((id) => ({ id })) + : (state.sub2apiGroupId + ? [{ id: state.sub2apiGroupId, name: state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME }] + : await getGroupsByNames(origin, token, state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME, options)); + + await logWithOptions(`${logLabel}:正在通过 SUB2API 管理接口交换 OpenAI 授权码...`, 'info', options); + if (proxy) { + await logWithOptions(`${logLabel}:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', options); + } else { + await logWithOptions(`${logLabel}:未配置 SUB2API 默认代理,本次将不使用代理。`, 'info', options); + } + + const exchangeRequestBody = { + session_id: sessionId, + code: callback.code, + state: callback.state, + }; + if (proxyId) { + exchangeRequestBody.proxy_id = proxyId; + } + + const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', { + method: 'POST', + token, + timeoutMs: options.timeoutMs, + body: exchangeRequestBody, + }); + + const credentials = buildOpenAiCredentials(exchangeData); + const extra = buildOpenAiExtra(exchangeData); + const resolvedEmail = normalizeString(exchangeData?.email || credentials?.email); + const groupIds = groups + .map((group) => Number(group.id)) + .filter((id) => Number.isFinite(id) && id > 0); + if (!groupIds.length) { + throw new Error('SUB2API 返回的目标分组 ID 无效。'); + } + + const accountName = resolvedEmail + || flowEmail + || normalizeString(state.sub2apiDraftName) + || buildDraftAccountName(state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME); + const createPayload = { + name: accountName, + notes: '', + platform: 'openai', + type: 'oauth', + credentials, + concurrency: DEFAULT_CONCURRENCY, + priority: accountPriority, + rate_multiplier: DEFAULT_RATE_MULTIPLIER, + group_ids: groupIds, + auto_pause_on_expired: true, + }; + if (proxyId) { + createPayload.proxy_id = proxyId; + } + if (extra) { + createPayload.extra = extra; + } + + await logWithOptions(`${logLabel}:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`, 'info', options); + const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', { + method: 'POST', + token, + timeoutMs: options.createTimeoutMs, + body: createPayload, + }); + + const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`; + await logWithOptions(verifiedStatus, 'ok', options); + return { + localhostUrl: callback.url, + verifiedStatus, + }; + } + + return { + buildDraftAccountName, + buildOpenAiCredentials, + buildOpenAiExtra, + buildProxyDisplayName, + extractStateFromAuthUrl, + generateOpenAiAuthUrl, + getGroupsByNames, + loginSub2Api, + normalizeProxyId, + normalizeRedirectUri, + normalizeSub2ApiGroupNames, + parseLocalhostCallback, + requestJson, + resolveSub2ApiAccountPriority, + resolveSub2ApiProxy, + submitOpenAiCallback, + }; + } + + return { + createSub2ApiApi, + }; +}); diff --git a/background/tab-runtime.js b/background/tab-runtime.js index f08f1a3..b689715 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -11,6 +11,7 @@ isRetryableContentScriptTransportError, LOG_PREFIX, matchesSourceUrlFamily, + sourceRegistry = null, setState, sleepWithStop, STOP_ERROR_MESSAGE, @@ -19,6 +20,65 @@ const pendingCommands = new Map(); + function resolveCanonicalSource(source) { + if (sourceRegistry?.resolveCanonicalSource) { + return sourceRegistry.resolveCanonicalSource(source); + } + return String(source || '').trim(); + } + + function getSourceKeys(source) { + if (sourceRegistry?.getSourceKeys) { + const registryKeys = sourceRegistry.getSourceKeys(source); + if (Array.isArray(registryKeys) && registryKeys.length) { + return registryKeys; + } + } + const normalized = String(source || '').trim(); + return normalized ? [normalized] : []; + } + + function getSourceCommandKey(source) { + const keys = getSourceKeys(source); + return keys[0] || String(source || '').trim(); + } + + function sourcesMatch(leftSource, rightSource) { + const left = resolveCanonicalSource(leftSource); + const right = resolveCanonicalSource(rightSource); + return Boolean(left && right && left === right); + } + + function getSourceMapValue(record, source) { + const map = record && typeof record === 'object' ? record : {}; + for (const key of getSourceKeys(source)) { + if (Object.prototype.hasOwnProperty.call(map, key)) { + return map[key]; + } + } + return undefined; + } + + function setSourceMapValue(record, source, value) { + const nextRecord = { ...(record || {}) }; + const keys = getSourceKeys(source); + const canonicalKey = keys[0] || String(source || '').trim(); + for (const key of keys.slice(1)) { + delete nextRecord[key]; + } + if (canonicalKey) { + nextRecord[canonicalKey] = value; + } + return nextRecord; + } + + function getCleanupOwnerSource(cleanupScope) { + if (sourceRegistry?.getCleanupOwnerSource) { + return sourceRegistry.getCleanupOwnerSource(cleanupScope); + } + return cleanupScope === 'oauth-localhost-callback' ? 'signup-page' : ''; + } + function normalizeAutomationWindowId(value) { if (value === null || value === undefined || value === '') { return null; @@ -170,7 +230,7 @@ } async function registerTab(source, tabId) { - const registry = await getTabRegistry(); + let registry = await getTabRegistry(); let windowId = null; if (chrome?.tabs?.get && Number.isInteger(tabId)) { const tab = await chrome.tabs.get(tabId).catch(() => null); @@ -180,42 +240,42 @@ } windowId = normalizeAutomationWindowId(tab?.windowId); } - registry[source] = { + registry = setSourceMapValue(registry, source, { tabId, ready: true, ...(windowId !== null ? { windowId } : {}), - }; + }); await setState({ tabRegistry: registry }); console.log(LOG_PREFIX, `Tab registered: ${source} -> ${tabId}`); } async function isTabAlive(source) { - const registry = await getTabRegistry(); - const entry = registry[source]; + let registry = await getTabRegistry(); + const entry = getSourceMapValue(registry, source); if (!entry) return false; try { const tab = await chrome.tabs.get(entry.tabId); if (!(await isTabInAutomationWindow(tab))) { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return false; } return true; } catch { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return false; } } async function getTabId(source) { - const registry = await getTabRegistry(); - const tabId = registry[source]?.tabId || null; + let registry = await getTabRegistry(); + const tabId = getSourceMapValue(registry, source)?.tabId || null; if (!Number.isInteger(tabId)) { return null; } if (!(await isTabInAutomationWindow(tabId))) { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return null; } @@ -225,8 +285,7 @@ async function rememberSourceLastUrl(source, url) { if (!source || !url) return; const state = await getState(); - const sourceLastUrls = { ...(state.sourceLastUrls || {}) }; - sourceLastUrls[source] = url; + const sourceLastUrls = setSourceMapValue(state.sourceLastUrls, source, url); await setState({ sourceLastUrls }); } @@ -234,7 +293,7 @@ const { excludeTabIds = [] } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const state = await getState(); - const lastUrl = state.sourceLastUrls?.[source]; + const lastUrl = getSourceMapValue(state.sourceLastUrls, source); const referenceUrls = [currentUrl, lastUrl].filter(Boolean); if (!referenceUrls.length) return; @@ -249,9 +308,10 @@ await chrome.tabs.remove(matchedIds).catch(() => { }); - const registry = await getTabRegistry(); - if (registry[source]?.tabId && matchedIds.includes(registry[source].tabId)) { - registry[source] = null; + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); + if (sourceEntry?.tabId && matchedIds.includes(sourceEntry.tabId)) { + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); } @@ -274,7 +334,7 @@ async function closeLocalhostCallbackTabs(callbackUrl, options = {}) { if (!isLocalhostOAuthCallbackUrl(callbackUrl)) return 0; - const { excludeTabIds = [] } = options; + const { excludeTabIds = [], ownerSource = getCleanupOwnerSource('oauth-localhost-callback') } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const tabs = await queryTabsInAutomationWindow({}); const matchedIds = tabs @@ -286,9 +346,10 @@ await chrome.tabs.remove(matchedIds).catch(() => { }); - const registry = await getTabRegistry(); - if (registry['signup-page']?.tabId && matchedIds.includes(registry['signup-page'].tabId)) { - registry['signup-page'] = null; + let registry = await getTabRegistry(); + const ownerEntry = getSourceMapValue(registry, ownerSource); + if (ownerEntry?.tabId && matchedIds.includes(ownerEntry.tabId)) { + registry = setSourceMapValue(registry, ownerSource, null); await setState({ tabRegistry: registry }); } @@ -468,7 +529,7 @@ while (Date.now() - start < timeoutMs) { attempt += 1; const pong = await pingContentScriptOnTab(tabId); - if (pong?.ok && (!pong.source || pong.source === source)) { + if (pong?.ok && (!pong.source || sourcesMatch(pong.source, source))) { console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`); await registerTab(source, tabId); return; @@ -478,9 +539,13 @@ throw new Error(`${getSourceLabel(source)} 内容脚本未就绪,且未提供可用的注入文件。`); } - const registry = await getTabRegistry(); - if (registry[source]) { - registry[source].ready = false; + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); await setState({ tabRegistry: registry }); } @@ -505,7 +570,7 @@ } const pongAfterInject = await pingContentScriptOnTab(tabId); - if (pongAfterInject?.ok && (!pongAfterInject.source || pongAfterInject.source === source)) { + if (pongAfterInject?.ok && (!pongAfterInject.source || sourcesMatch(pongAfterInject.source, source))) { console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready after inject ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`); await registerTab(source, tabId); return; @@ -609,14 +674,16 @@ function queueCommand(source, message, timeout = 15000) { return new Promise((resolve, reject) => { + const commandKey = getSourceCommandKey(source); const timer = setTimeout(() => { - pendingCommands.delete(source); + pendingCommands.delete(commandKey); reject(new Error(`Content script on ${source} did not respond in ${timeout / 1000}s. Try refreshing the tab and retry.`)); }, timeout); - pendingCommands.set(source, { + pendingCommands.set(commandKey, { message, resolve, reject, + source, timer, responseTimeoutMs: timeout, }); @@ -625,11 +692,16 @@ } function flushCommand(source, tabId) { - const pending = pendingCommands.get(source); + const pending = pendingCommands.get(getSourceCommandKey(source)); if (pending) { clearTimeout(pending.timer); - pendingCommands.delete(source); - sendTabMessageWithTimeout(tabId, source, pending.message, pending.responseTimeoutMs).then(pending.resolve).catch(pending.reject); + pendingCommands.delete(getSourceCommandKey(source)); + sendTabMessageWithTimeout( + tabId, + pending.source || source, + pending.message, + pending.responseTimeoutMs + ).then(pending.resolve).catch(pending.reject); console.log(LOG_PREFIX, `Flushed queued command to ${source} (tab ${tabId})`); } } @@ -677,18 +749,29 @@ const sameUrl = currentTab.url === url; const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl; - const registry = await getTabRegistry(); + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); if (sameUrl) { await chrome.tabs.update(tabId, { active: true }); if (shouldReloadOnReuse) { - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); await chrome.tabs.reload(tabId); await waitForTabUpdateComplete(tabId); } if (options.inject) { - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); if (options.injectSource) { await chrome.scripting.executeScript({ @@ -710,7 +793,12 @@ return tabId; } - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); await chrome.tabs.update(tabId, { url, active: true }); @@ -765,7 +853,7 @@ throwIfStopped(); const { responseTimeoutMs = getContentScriptResponseTimeoutMs(message) } = options; const registry = await getTabRegistry(); - const entry = registry[source]; + const entry = getSourceMapValue(registry, source); if (!entry || !entry.ready) { throwIfStopped(); diff --git a/background/verification-flow.js b/background/verification-flow.js index c6a0ea6..ad73d80 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -7,6 +7,7 @@ function createVerificationFlowHelpers(deps = {}) { const { addLog: rawAddLog = async () => {}, + buildVerificationPollPayload: externalBuildVerificationPollPayload = null, chrome, closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, @@ -408,27 +409,25 @@ } function getVerificationPollPayload(step, state, overrides = {}) { + if (typeof externalBuildVerificationPollPayload === 'function') { + return externalBuildVerificationPollPayload(step, state, overrides); + } + const normalizedStep = Number(step) === 4 ? 4 : 8; const is2925Provider = state?.mailProvider === '2925'; const mail2925MatchTargetEmail = is2925Provider && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; - if (step === 4) { - return { - filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(4, state), - senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], - subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], - targetEmail: state.email, - mail2925MatchTargetEmail, - maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, - intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, - ...overrides, - }; - } - return { - filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(8, state), - senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], - subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], - targetEmail: String(state?.step8VerificationTargetEmail || '').trim() || state.email, + flowId: String(state?.activeFlowId || '').trim(), + step: normalizedStep, + filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(normalizedStep, state), + senderFilters: [], + subjectFilters: [], + requiredKeywords: [], + codePatterns: [], + targetEmail: normalizedStep === 4 + ? state.email + : (String(state?.step8VerificationTargetEmail || '').trim() || state.email), + targetEmailHints: [], mail2925MatchTargetEmail, maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, diff --git a/content/gmail-mail.js b/content/gmail-mail.js index 3444d6e..f9eb84c 100644 --- a/content/gmail-mail.js +++ b/content/gmail-mail.js @@ -98,6 +98,39 @@ function getTargetEmailMatchState(text, targetEmail) { return { matches: false, hasExplicitEmail: false }; } +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + const MONTH_INDEX_MAP = { jan: 0, feb: 1, @@ -165,16 +198,17 @@ function parseGmailTimestampText(rawText) { return null; } -function extractVerificationCode(text) { +function extractVerificationCode(text, options = {}) { const normalized = String(text || ''); + const matchedByRule = extractCodeByRulePatterns(normalized, options?.codePatterns); + if (matchedByRule) { + return matchedByRule; + } const cnMatch = normalized.match(/(?:验证码|代码)[^0-9]{0,16}(\d{6})/i); if (cnMatch) return cnMatch[1]; - const openAiLoginMatch = normalized.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (openAiLoginMatch) return openAiLoginMatch[1]; - - const enMatch = normalized.match(/(?:verification\s+code|temporary\s+verification\s+code|your\s+chatgpt\s+code|code(?:\s+is)?)[^0-9]{0,16}(\d{6})/i); + const enMatch = normalized.match(/(?:verification\s+code|temporary\s+verification\s+code|log-?in\s+code|enter\s+this\s+code|code(?:\s+is)?)[^0-9]{0,24}(\d{6})/i); if (enMatch) return enMatch[1]; const plainMatch = normalized.match(/\b(\d{6})\b/); @@ -357,7 +391,7 @@ function collectThreadRows() { if ( row.matches('tr.zA') || row.querySelector('.bog, .y6, .y2, .afn, [data-thread-id], [data-legacy-thread-id], [data-legacy-last-message-id]') - || /openai|chatgpt|verify|verification|code|验证码/i.test(text) + || /verify|verification|code|验证码|log-?in/i.test(text) ) { rows.push(row); } @@ -545,6 +579,7 @@ async function openRowAndGetMessageText(row) { async function handlePollEmail(step, payload) { const { + codePatterns = [], senderFilters = [], subjectFilters = [], maxAttempts = 5, @@ -618,7 +653,9 @@ async function handlePollEmail(step, payload) { } const previewTargetState = getTargetEmailMatchState(preview.combinedText, targetEmail); - const previewCode = extractVerificationCode(preview.combinedText); + const previewCode = extractVerificationCode(preview.combinedText, { + codePatterns, + }); if (previewCode) { if (excludedCodeSet.has(previewCode)) { log(`步骤 ${step}:跳过排除的验证码:${previewCode}`, 'info'); @@ -644,7 +681,9 @@ async function handlePollEmail(step, payload) { const openedText = await openRowAndGetMessageText(row); const openedTargetState = getTargetEmailMatchState(openedText, targetEmail); - const bodyCode = extractVerificationCode(openedText); + const bodyCode = extractVerificationCode(openedText, { + codePatterns, + }); if (!bodyCode) { continue; } diff --git a/content/icloud-mail.js b/content/icloud-mail.js index 8f434ad..a3e5ab3 100644 --- a/content/icloud-mail.js +++ b/content/icloud-mail.js @@ -43,6 +43,39 @@ if (shouldHandlePollEmailInCurrentFrame) { return String(value || '').replace(/\s+/g, ' ').trim(); } + function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; + } + + function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; + } + function isVisibleElement(node) { return Boolean(node instanceof HTMLElement) && (Boolean(node.offsetParent) || getComputedStyle(node).position === 'fixed'); @@ -82,12 +115,15 @@ if (shouldHandlePollEmailInCurrentFrame) { ].join('::')).slice(0, 240); } - function extractVerificationCode(text) { + function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) return matchedByRule; + const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; @@ -276,7 +312,14 @@ if (shouldHandlePollEmailInCurrentFrame) { } async function handlePollEmail(step, payload) { - const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload; + const { + codePatterns = [], + senderFilters, + subjectFilters, + maxAttempts, + intervalMs, + excludeCodes = [], + } = payload; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); const FALLBACK_AFTER = 3; const pollSessionKey = normalizePollSessionKey(payload); @@ -327,7 +370,7 @@ if (shouldHandlePollEmailInCurrentFrame) { continue; } - let code = extractVerificationCode(meta.combinedText); + let code = extractVerificationCode(meta.combinedText, { codePatterns }); let opened = null; if (!code) { @@ -339,7 +382,7 @@ if (shouldHandlePollEmailInCurrentFrame) { if (!openedSenderMatch && !openedSubjectMatch && !senderMatch && !subjectMatch) { continue; } - code = extractVerificationCode(opened.combinedText); + code = extractVerificationCode(opened.combinedText, { codePatterns }); } if (!code) { diff --git a/content/inbucket-mail.js b/content/inbucket-mail.js index ade97f4..accdc5b 100644 --- a/content/inbucket-mail.js +++ b/content/inbucket-mail.js @@ -60,12 +60,55 @@ function normalizeText(value) { return (value || '').replace(/\s+/g, ' ').trim().toLowerCase(); } -function extractVerificationCode(text) { +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + +function matchesKeywordHints(text, keywords = []) { + const normalizedText = normalizeText(text); + const normalizedKeywords = Array.isArray(keywords) ? keywords : []; + return normalizedKeywords.some((keyword) => normalizedText.includes(normalizeText(keyword))); +} + +function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) { + return matchedByRule; + } const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; @@ -76,7 +119,7 @@ function extractVerificationCode(text) { return null; } -function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) { +function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail, options = {}) { const sender = normalizeText(mail.sender); const subject = normalizeText(mail.subject); const mailbox = normalizeText(mail.mailbox); @@ -87,8 +130,12 @@ function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) { const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase())); const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal); const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText); - const code = extractVerificationCode(mail.combinedText); - const keywordMatch = /openai|chatgpt|verify|verification|confirm|log-?in|验证码|代码/.test(combined); + const code = extractVerificationCode(mail.combinedText, { + codePatterns: options?.codePatterns, + }); + const keywordMatch = options?.requiredKeywords?.length + ? matchesKeywordHints(combined, options.requiredKeywords) + : /verify|verification|confirm|log-?in|验证码|代码/.test(combined); if (mailboxMatch) return { matched: true, mailboxMatch, code }; if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code }; @@ -170,6 +217,8 @@ async function deleteCurrentMailboxMessage(step) { async function handleMailboxPollEmail(step, payload) { const { + codePatterns = [], + requiredKeywords = [], senderFilters = [], subjectFilters = [], maxAttempts = 20, @@ -208,14 +257,19 @@ async function handleMailboxPollEmail(step, payload) { if (seenMailIds.has(mail.mailId)) continue; if (!useFallback && existingMailIds.has(mail.mailId)) continue; - const match = rowMatchesFilters(mail, senderFilters, subjectFilters, ''); + const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '', { + codePatterns, + requiredKeywords, + }); if (!match.matched) continue; candidates.push({ ...mail, code: match.code }); } for (const mail of candidates) { - const code = mail.code || extractVerificationCode(mail.combinedText); + const code = mail.code || extractVerificationCode(mail.combinedText, { + codePatterns, + }); if (!code) continue; if (excludedCodeSet.has(code)) { log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info'); diff --git a/content/mail-163.js b/content/mail-163.js index 3756a87..95a4bf4 100644 --- a/content/mail-163.js +++ b/content/mail-163.js @@ -73,6 +73,39 @@ function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + function getNetEaseMailLabel(hostname) { const currentHostname = String( hostname || (typeof location !== 'undefined' ? location.hostname : '') || '' @@ -129,7 +162,7 @@ function isLikelyMailItemNode(node) { return false; } - return /发件人|验证码|verification|chatgpt|openai|code|log-?in/i.test(summaryText); + return /发件人|验证码|verification|code|log-?in/i.test(summaryText); } function findMailItems() { @@ -406,7 +439,7 @@ function selectOpenedMailTextCandidate(item, candidates = [], options = {}) { if (sender && lower.includes(sender)) { return true; } - return Boolean(extractVerificationCode(candidate) && /chatgpt|openai|verification|验证码|log-?in\s+code/i.test(lower)); + return Boolean(extractVerificationCode(candidate, { codePatterns: options.codePatterns }) && /verification|验证码|log-?in\s+code|enter\s+this\s+code|code/i.test(lower)); }) || source[0] || ''; const filteredCandidates = candidates.filter((candidate) => !excludedSet.has(normalizeText(candidate))); @@ -443,9 +476,11 @@ async function returnToInbox() { return false; } -async function openMailAndGetMessageText(item) { +async function openMailAndGetMessageText(item, options = {}) { const beforeCandidates = collectOpenedMailTextCandidates(); - const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates); + const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates, { + codePatterns: options.codePatterns, + }); if (typeof simulateClick === 'function') { simulateClick(item); } else { @@ -456,6 +491,7 @@ async function openMailAndGetMessageText(item) { for (let i = 0; i < 24; i += 1) { await sleep(250); const candidate = readOpenedMailText(item, { + codePatterns: options.codePatterns, excludedTexts: beforeCandidates, allowExcludedFallback: false, }); @@ -463,7 +499,7 @@ async function openMailAndGetMessageText(item) { continue; } openedText = candidate; - if (extractVerificationCode(candidate)) { + if (extractVerificationCode(candidate, { codePatterns: options.codePatterns })) { break; } if (candidate !== beforeText && candidate.length > beforeText.length + 24) { @@ -488,7 +524,15 @@ function scheduleEmailCleanup(item, step) { // ============================================================ async function handlePollEmail(step, payload) { - const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [], filterAfterTimestamp = 0 } = payload; + const { + codePatterns = [], + senderFilters, + subjectFilters, + maxAttempts, + intervalMs, + excludeCodes = [], + filterAfterTimestamp = 0, + } = payload; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0); const mailLabel = getNetEaseMailLabel(); @@ -581,12 +625,12 @@ async function handlePollEmail(step, payload) { }); if (senderMatch || subjectMatch) { - let code = extractVerificationCode(combinedText); + let code = extractVerificationCode(combinedText, { codePatterns }); let codeSource = '邮件列表'; if (!code) { - const openedText = await openMailAndGetMessageText(item); - code = extractVerificationCode(openedText); + const openedText = await openMailAndGetMessageText(item, { codePatterns }); + code = extractVerificationCode(openedText, { codePatterns }); if (code) { codeSource = '邮件正文'; } @@ -716,12 +760,16 @@ async function refreshInbox() { // Verification Code Extraction // ============================================================ -function extractVerificationCode(text) { +function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) { + return matchedByRule; + } const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; diff --git a/content/mail-2925.js b/content/mail-2925.js index 9f683ec..8d481fa 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -712,7 +712,56 @@ function matchesMailFilters(text, senderFilters, subjectFilters) { return senderMatch || subjectMatch; } -function extractStrictChatGPTVerificationCode(text) { +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + +function normalizeTargetEmailHints(hints = [], targetEmail = '') { + const normalizedHints = Array.isArray(hints) ? hints : []; + const collected = normalizedHints + .map((item) => String(item || '').trim().toLowerCase()) + .filter(Boolean); + const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); + if (normalizedTarget) { + collected.push(normalizedTarget); + const atIndex = normalizedTarget.indexOf('@'); + if (atIndex > 0) { + collected.push(`${normalizedTarget.slice(0, atIndex)}=${normalizedTarget.slice(atIndex + 1)}`); + } + } + return [...new Set(collected)]; +} + +function extractLegacyStrictVerificationCode(text) { const normalized = String(text || ''); const patterns = [ /your\s+(?:temporary\s+)?chatgpt\s+(?:(?:log-?in|login)\s+)?code\s+is[\s\S]{0,80}?(\d{6})/i, @@ -784,11 +833,16 @@ function findSafeStandaloneSixDigitCode(text) { return null; } -function extractVerificationCode(text, strictChatGPTCodeOnly = false) { - const strictCode = extractStrictChatGPTVerificationCode(text); - if (strictChatGPTCodeOnly) { - return strictCode; +function extractVerificationCode(text, options = {}) { + const legacyStrictMode = typeof options === 'boolean' ? options : false; + const strictMode = legacyStrictMode || Boolean(options?.strictMode); + const codePatterns = legacyStrictMode ? [] : options?.codePatterns; + const strictCode = extractLegacyStrictVerificationCode(text); + const matchedByRule = extractCodeByRulePatterns(text, codePatterns); + if (strictMode) { + return matchedByRule || strictCode; } + if (matchedByRule) return matchedByRule; if (strictCode) return strictCode; const normalized = String(text || ''); @@ -796,11 +850,8 @@ function extractVerificationCode(text, strictChatGPTCodeOnly = false) { const matchCn = normalized.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = normalized.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; - - const matchChatGPT = normalized.match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i); - if (matchChatGPT) return matchChatGPT[1]; + const matchLoginCode = normalized.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = normalized.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; @@ -813,9 +864,9 @@ function extractEmails(text = '') { return [...new Set(matches.map((item) => item.toLowerCase()))]; } -function extractForwardedTargetEmails(text = '') { +function extractForwardedTargetEmails(text = '', targetEmailHints = []) { const normalizedText = String(text || '').toLowerCase(); - const matches = normalizedText.match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@(?:tm\d*\.openai\.com|em\d+\.tm\.openai\.com)/gi) || []; + const matches = normalizedText.match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@[a-z0-9.-]+\.[a-z]{2,}/gi) || []; const decoded = matches .map((candidate) => { const match = String(candidate || '').match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@/i); @@ -825,7 +876,19 @@ function extractForwardedTargetEmails(text = '') { return `${match[1].toLowerCase()}@${match[2].toLowerCase()}`; }) .filter(Boolean); - return [...new Set(decoded)]; + const hinted = normalizeTargetEmailHints(targetEmailHints) + .filter((hint) => hint.includes('@') || hint.includes('=')) + .flatMap((hint) => { + if (hint.includes('@')) { + return normalizedText.includes(hint) ? [hint] : []; + } + const match = hint.match(/^([^=]+)=([^=]+)$/); + if (!match || !normalizedText.includes(hint)) { + return []; + } + return [`${match[1]}@${match[2]}`]; + }); + return [...new Set([...decoded, ...hinted])]; } function emailMatchesTarget(candidate, targetEmail) { @@ -838,19 +901,20 @@ function emailMatchesTarget(candidate, targetEmail) { return normalizedCandidate === normalizedTarget; } -function getTargetEmailMatchState(text, targetEmail) { +function getTargetEmailMatchState(text, targetEmail, options = {}) { const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); if (!normalizedTarget) { return { matches: true, hasExplicitEmail: false }; } const normalizedText = String(text || '').toLowerCase(); - if (normalizedText.includes(normalizedTarget)) { + const targetEmailHints = normalizeTargetEmailHints(options?.targetEmailHints, normalizedTarget); + if (targetEmailHints.some((hint) => normalizedText.includes(hint))) { return { matches: true, hasExplicitEmail: true }; } const extractedEmails = extractEmails(normalizedText); - const forwardedTargetEmails = extractForwardedTargetEmails(normalizedText); + const forwardedTargetEmails = extractForwardedTargetEmails(normalizedText, targetEmailHints); if (!extractedEmails.length) { return forwardedTargetEmails.length ? { @@ -1219,14 +1283,15 @@ async function ensureMail2925Session(payload = {}) { async function handlePollEmail(step, payload) { await ensureSeenCodesSession(step, payload); const { + codePatterns = [], senderFilters, subjectFilters, maxAttempts, intervalMs, filterAfterTimestamp = 0, excludeCodes = [], - strictChatGPTCodeOnly = false, targetEmail = '', + targetEmailHints = [], mail2925MatchTargetEmail = false, } = payload || {}; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); @@ -1290,21 +1355,25 @@ async function handlePollEmail(step, payload) { continue; } const previewTargetState = mail2925MatchTargetEmail - ? getTargetEmailMatchState(previewText, targetEmail) + ? getTargetEmailMatchState(previewText, targetEmail, { targetEmailHints }) : { matches: true, hasExplicitEmail: false }; if (mail2925MatchTargetEmail && previewTargetState.hasExplicitEmail && !previewTargetState.matches) { continue; } - const previewCode = extractVerificationCode(previewText, strictChatGPTCodeOnly); + const previewCode = extractVerificationCode(previewText, { + codePatterns, + }); const openedText = await openMailAndDeleteAfterRead(item, step); const openedTargetState = mail2925MatchTargetEmail - ? getTargetEmailMatchState(openedText, targetEmail) + ? getTargetEmailMatchState(openedText, targetEmail, { targetEmailHints }) : { matches: true, hasExplicitEmail: false }; if (mail2925MatchTargetEmail && openedTargetState.hasExplicitEmail && !openedTargetState.matches) { continue; } - const bodyCode = extractVerificationCode(openedText, strictChatGPTCodeOnly); + const bodyCode = extractVerificationCode(openedText, { + codePatterns, + }); const candidateCode = bodyCode || previewCode; if (!candidateCode) { diff --git a/content/qq-mail.js b/content/qq-mail.js index dc72ca1..73b9eba 100644 --- a/content/qq-mail.js +++ b/content/qq-mail.js @@ -50,12 +50,52 @@ function getCurrentMailIds() { return ids; } +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + // ============================================================ // Email Polling // ============================================================ async function handlePollEmail(step, payload) { - const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload; + const { + codePatterns = [], + senderFilters, + subjectFilters, + maxAttempts, + intervalMs, + excludeCodes = [], + } = payload; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); log(`步骤 ${step}:开始轮询邮箱(最多 ${maxAttempts} 次,每 ${intervalMs / 1000} 秒一次)`); @@ -103,7 +143,9 @@ async function handlePollEmail(step, payload) { const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase())); if (senderMatch || subjectMatch) { - const code = extractVerificationCode(subject + ' ' + digest); + const code = extractVerificationCode(subject + ' ' + digest, { + codePatterns, + }); if (code) { if (excludedCodeSet.has(code)) { log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info'); @@ -169,13 +211,16 @@ async function refreshInbox() { // Verification Code Extraction // ============================================================ -function extractVerificationCode(text) { +function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) return matchedByRule; + // Pattern 1: Chinese format "代码为 370794" or "验证码...370794" const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; // Pattern 2: English format "code is 370794" or "code: 370794" const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); diff --git a/content/utils.js b/content/utils.js index 4a35f3e..8e749c3 100644 --- a/content/utils.js +++ b/content/utils.js @@ -7,8 +7,16 @@ function detectScriptSource({ url = '', hostname = '', } = {}) { + const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.(); + if (sourceRegistry?.detectSourceFromLocation) { + return sourceRegistry.detectSourceFromLocation({ + injectedSource, + url, + hostname, + }); + } if (injectedSource) return injectedSource; - if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page'; + if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'openai-auth'; if (hostname === 'mail.qq.com' || hostname === 'wx.mail.qq.com') return 'qq-mail'; if ( hostname === 'mail.163.com' @@ -22,8 +30,7 @@ function detectScriptSource({ if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail'; if (url.includes('chatgpt.com')) return 'chatgpt'; if (url.includes("2925.com")) return "mail-2925"; - // VPS panel — detected dynamically since URL is configurable - return 'vps-panel'; + return 'unknown-source'; } const SCRIPT_SOURCE = (() => { @@ -294,6 +301,10 @@ function log(message, level = 'info', options = {}) { * Report that this content script is loaded and ready. */ function reportReady() { + if (getRuntimeScriptSource() === 'unknown-source') { + console.warn(LOG_PREFIX, 'skip CONTENT_SCRIPT_READY for unknown source'); + return; + } console.log(LOG_PREFIX, '内容脚本已就绪'); const message = { type: 'CONTENT_SCRIPT_READY', @@ -439,6 +450,10 @@ async function humanPause(min = 250, max = 850) { } function shouldReportReadyForFrame(source, isChildFrame) { + const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.(); + if (sourceRegistry?.shouldReportReadyForFrame) { + return sourceRegistry.shouldReportReadyForFrame(source, isChildFrame); + } if (!isChildFrame) return true; return ![ 'qq-mail', @@ -447,6 +462,7 @@ function shouldReportReadyForFrame(source, isChildFrame) { 'mail-2925', 'inbucket-mail', 'plus-checkout', + 'unknown-source', ].includes(source); } diff --git a/data/step-definitions.js b/data/step-definitions.js index dd87867..27584ff 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -1,6 +1,7 @@ (function attachStepDefinitions(root, factory) { root.MultiPageStepDefinitions = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createStepDefinitionsModule() { + const DEFAULT_ACTIVE_FLOW_ID = 'openai'; const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; @@ -88,11 +89,20 @@ : SIGNUP_METHOD_EMAIL; } + function normalizeActiveFlowId(value = '', fallback = DEFAULT_ACTIVE_FLOW_ID) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return fallbackValue || DEFAULT_ACTIVE_FLOW_ID; + } + function getResolvedSignupMethod(options = {}) { return normalizeSignupMethod(options?.resolvedSignupMethod || options?.signupMethod); } - function getModeStepDefinitions(options = {}) { + function getOpenAiModeStepDefinitions(options = {}) { if (!isPlusModeEnabled(options)) { return NORMAL_STEP_DEFINITIONS; } @@ -103,20 +113,20 @@ return paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_GOPAY_STEP_DEFINITIONS : PLUS_PAYPAL_STEP_DEFINITIONS; } - function getPlusPaymentStepTitle(options = {}) { + function getOpenAiPlusPaymentStepTitle(options = {}) { if (!isPlusModeEnabled(options)) { return ''; } - const paymentStep = getModeStepDefinitions({ + const paymentStep = getOpenAiModeStepDefinitions({ ...options, plusModeEnabled: true, }).find((step) => step.key === PLUS_PAYMENT_STEP_KEY); return paymentStep?.title || ''; } - function getResolvedStepTitle(step = {}, options = {}) { + function getOpenAiResolvedStepTitle(step = {}, options = {}) { if (isPlusModeEnabled(options) && step.key === PLUS_PAYMENT_STEP_KEY) { - return getPlusPaymentStepTitle(options) || step.title; + return getOpenAiPlusPaymentStepTitle(options) || step.title; } const signupMethod = getResolvedSignupMethod(options); if (signupMethod === SIGNUP_METHOD_PHONE && PHONE_SIGNUP_TITLE_OVERRIDES[step.key]) { @@ -125,37 +135,83 @@ return step.title; } - function cloneSteps(steps = [], options = {}) { + const FLOW_DEFINITION_BUILDERS = Object.freeze({ + openai: { + getAllSteps() { + const keyed = new Map(); + for (const step of [ + ...NORMAL_STEP_DEFINITIONS, + ...PLUS_PAYPAL_STEP_DEFINITIONS, + ...PLUS_GOPAY_STEP_DEFINITIONS, + ...PLUS_GPC_STEP_DEFINITIONS, + ]) { + keyed.set(`${step.id}:${step.key}`, step); + } + return Array.from(keyed.values()).sort((left, right) => { + const leftOrder = Number.isFinite(left.order) ? left.order : left.id; + const rightOrder = Number.isFinite(right.order) ? right.order : right.id; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return left.id - right.id; + }); + }, + getModeStepDefinitions: getOpenAiModeStepDefinitions, + getPlusPaymentStepTitle: getOpenAiPlusPaymentStepTitle, + resolveStepTitle: getOpenAiResolvedStepTitle, + }, + }); + + function hasFlow(flowId) { + const normalizedFlowId = normalizeActiveFlowId(flowId, ''); + return Boolean(normalizedFlowId && FLOW_DEFINITION_BUILDERS[normalizedFlowId]); + } + + function getRegisteredFlowIds() { + return Object.keys(FLOW_DEFINITION_BUILDERS); + } + + function getFlowDefinitionBuilder(options = {}) { + const flowId = normalizeActiveFlowId(options?.activeFlowId, DEFAULT_ACTIVE_FLOW_ID); + return { + flowId, + builder: FLOW_DEFINITION_BUILDERS[flowId] || null, + }; + } + + function cloneSteps(steps = [], options = {}, flowId = DEFAULT_ACTIVE_FLOW_ID) { + const { builder } = getFlowDefinitionBuilder({ activeFlowId: flowId }); return steps.map((step) => ({ ...step, - title: getResolvedStepTitle(step, options), + flowId, + title: builder?.resolveStepTitle ? builder.resolveStepTitle(step, options) : step.title, })); } function getSteps(options = {}) { - return cloneSteps(getModeStepDefinitions(options), options); + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getModeStepDefinitions) { + return []; + } + return cloneSteps(builder.getModeStepDefinitions(options), options, flowId); } - function getAllSteps() { - const keyed = new Map(); - for (const step of [ - ...NORMAL_STEP_DEFINITIONS, - ...PLUS_PAYPAL_STEP_DEFINITIONS, - ...PLUS_GOPAY_STEP_DEFINITIONS, - ...PLUS_GPC_STEP_DEFINITIONS, - ]) { - keyed.set(`${step.id}:${step.key}`, step); + function getAllSteps(options = {}) { + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getAllSteps) { + return []; } - return cloneSteps(Array.from(keyed.values()).sort((left, right) => { - const leftOrder = Number.isFinite(left.order) ? left.order : left.id; - const rightOrder = Number.isFinite(right.order) ? right.order : right.id; - if (leftOrder !== rightOrder) return leftOrder - rightOrder; - return left.id - right.id; - })); + return cloneSteps(builder.getAllSteps(options), options, flowId); + } + + function getPlusPaymentStepTitle(options = {}) { + const { builder } = getFlowDefinitionBuilder(options); + if (!builder?.getPlusPaymentStepTitle) { + return ''; + } + return builder.getPlusPaymentStepTitle(options); } function getStepIds(options = {}) { - return getModeStepDefinitions(options) + return getSteps(options) .map((step) => Number(step.id)) .filter(Number.isFinite) .sort((left, right) => left - right); @@ -168,11 +224,16 @@ function getStepById(id, options = {}) { const numericId = Number(id); - const match = getModeStepDefinitions(options).find((step) => step.id === numericId); - return match ? cloneSteps([match], options)[0] : null; + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getModeStepDefinitions) { + return null; + } + const match = builder.getModeStepDefinitions(options).find((step) => step.id === numericId); + return match ? cloneSteps([match], options, flowId)[0] : null; } return { + DEFAULT_ACTIVE_FLOW_ID, STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS, NORMAL_STEP_DEFINITIONS, PLUS_STEP_DEFINITIONS: PLUS_PAYPAL_STEP_DEFINITIONS, @@ -184,10 +245,13 @@ getAllSteps, getLastStepId, getPlusPaymentStepTitle, + getRegisteredFlowIds, getStepById, getStepIds, getSteps, + hasFlow, isPlusModeEnabled, + normalizeActiveFlowId, normalizePlusPaymentMethod, normalizeSignupMethod, }; diff --git a/docs/local-customizations-maintenance.md b/docs/local-customizations-maintenance.md index 927bde6..9273bf3 100644 --- a/docs/local-customizations-maintenance.md +++ b/docs/local-customizations-maintenance.md @@ -338,6 +338,8 @@ OAuth 登录流程打开新标签页,避免复用旧页面导致状态污染 ## 8. 接码平台多平台适配:HeroSMS + 5sim +边界说明:接码平台多平台适配当前只属于 OpenAI 注册 / OAuth 链路,不作为多注册 flow 架构的 core 通用服务。后续新增的注册项目默认不接入接码;只有出现第二个真实项目也需要短信生命周期时,再按实际复用点抽象 provider 层。 + ### 目标 手机号验证 Step 9 支持按平台切换接码能力: diff --git a/docs/多注册流程侧边栏能力矩阵.md b/docs/多注册流程侧边栏能力矩阵.md new file mode 100644 index 0000000..088d8e6 --- /dev/null +++ b/docs/多注册流程侧边栏能力矩阵.md @@ -0,0 +1,258 @@ +# 多注册流程侧边栏能力矩阵 + +本文解决的是一个很容易被低估的问题:sidepanel 现在不只是“渲染 UI”,它还承担了大量业务约束。如果未来要支持多个 flow,不能只做动态渲染,还必须把“哪些能力允许显示、允许切换、允许启动”做成统一能力矩阵。 + +## 1. 当前源码里的真实问题 + +当前手机号注册能力至少同时受这几组条件约束: + +- `phoneVerificationEnabled` +- `plusModeEnabled` +- `contributionMode` +- `panelMode === 'cpa'` 时还会触发额外提醒 + +而这些约束现在分散在多个地方: + +- `background.js`:`canUsePhoneSignup` +- `background/message-router.js`:同类判断 +- `sidepanel/sidepanel.js`:`canSelectPhoneSignupMethod` +- `sidepanel/sidepanel.js`:`shouldWarnCpaPhoneSignup` + +这说明 sidepanel 当前既在做展示,也在做策略判定,而且判定逻辑有重复。 + +## 2. 设计目标 + +后续 sidepanel 至少要拆成三层能力判断: + +1. `flowCapabilities` +2. `panelCapabilities` +3. `runtimeLocks` + +最终所有显示 / 禁用 / 启动校验都只读这三层组合结果。 + +## 3. 三层能力定义 + +### 3.1 `flowCapabilities` + +表示“当前 flow 业务上支持什么”。 + +示例: + +```js +flowCapabilities.openai = { + supportsEmailSignup: true, + supportsPhoneSignup: true, + supportsPhoneVerificationSettings: true, + supportsPlusMode: true, + supportsContributionMode: true, + supportsPlatformBinding: ['cpa', 'sub2api', 'codex2api'], + supportsLuckmail: true, + supportsOauthTimeoutBudget: true, + stepDefinitionMode: 'openai-dynamic', +}; + +flowCapabilities.siteA = { + supportsEmailSignup: true, + supportsPhoneSignup: false, + supportsPhoneVerificationSettings: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsPlatformBinding: ['siteA-panel'], + supportsLuckmail: false, + supportsOauthTimeoutBudget: false, + stepDefinitionMode: 'siteA', +}; +``` + +原则: + +- 没有真实需求,不要默认给新 flow 打开手机号、Plus、LuckMail +- 新 flow 默认从最小能力集合开始 + +### 3.2 `panelCapabilities` + +表示“当前面板来源允许什么交互”。 + +示例: + +```js +panelCapabilities = { + cpa: { + supportsPhoneSignup: true, + requiresPhoneSignupWarning: true, + }, + sub2api: { + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }, + codex2api: { + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }, +}; +``` + +注意:`contributionMode` 不是新的 `panelMode`,它是独立运行态锁。 + +### 3.3 `runtimeLocks` + +表示“当前这一次运行或当前 UI 状态是否允许操作”。 + +示例: + +```js +runtimeLocks = { + autoRunLocked: false, + contributionMode: false, + plusModeEnabled: false, + phoneVerificationEnabled: true, + settingsMenuLocked: false, +}; +``` + +它决定的是: + +- 现在能不能切换注册方式 +- 能不能改 flow +- 能不能导入导出配置 +- 能不能打开某些配置区 + +## 4. 统一选择器 + +建议提供一个中心 selector: + +```js +resolveSidepanelCapabilities({ + activeFlowId, + panelMode, + state, +}); +``` + +输出: + +```js +{ + effectiveSignupMethods: ['email', 'phone'], + canShowPhoneSettings: true, + canSelectPhoneSignup: true, + shouldWarnCpaPhoneSignup: true, + canShowPlusSettings: true, + canShowLuckmail: true, + canExportSettings: true, + canSwitchFlow: false, + stepDefinitionOptions: {}, +} +``` + +以后 sidepanel 与 background 都只消费这份结果,不再各写一套判断。 + +## 5. OpenAI 当前矩阵 + +### 5.1 仅看 flow 能力 + +OpenAI 当前业务上支持: + +- 邮箱注册 +- 手机号注册 +- OAuth 登录 +- 平台回调绑定 +- Plus +- LuckMail +- 接码配置 + +### 5.2 加上 runtime lock 后的真实结果 + +OpenAI 的手机号注册只有在以下条件同时成立时才可选: + +- `flowCapabilities.openai.supportsPhoneSignup === true` +- `phoneVerificationEnabled === true` +- `plusModeEnabled === false` +- `contributionMode === false` + +也就是说,手机号注册不是一个“单纯 UI 开关”,而是能力矩阵结果。 + +### 5.3 加上 panel 约束后的额外行为 + +当满足手机号注册可用,且当前 `panelMode === 'cpa'` 时: + +- 不阻止 +- 但要提示风险 + +这类“允许但要提醒”的逻辑也必须归到能力矩阵里,而不是散落在点击事件中。 + +## 6. 步骤列表也属于能力矩阵的一部分 + +当前步骤定义主要受: + +- `plusModeEnabled` +- `signupMethod` + +影响,并通过 `data/step-definitions.js` 动态切换。 + +多 flow 后必须升级成: + +```js +getStepDefinitions({ + activeFlowId, + signupVariant, + panelMode, + plusModeEnabled, + contributionMode, +}); +``` + +也就是说,步骤列表不再是全局 `10 步 / 13 步` 二选一,而是 flow-aware 的定义。 + +## 7. Sidepanel 分层职责 + +### 7.1 展示层 + +只负责: + +- show / hide +- enabled / disabled +- 文案与提示 + +### 7.2 能力判定层 + +只负责: + +- `resolveSidepanelCapabilities()` +- `validateAutoRunStart()` +- `validateModeSwitch()` + +### 7.3 后台复核层 + +后台在收到启动、切换注册方式、切换 flow 等消息时,仍要复核一遍同一套能力判断,避免 sidepanel 以外的入口绕过约束。 + +## 8. 新 flow 的默认能力策略 + +后续新增 flow 时,默认按以下最小集合开始: + +- 仅邮箱注册 +- 无手机号注册 +- 无 Plus +- 无 LuckMail +- 无贡献模式 +- 无 OpenAI 平台回调面板 + +只有真实需求出现时,再逐项打开 capability。 + +## 9. 迁移顺序 + +1. 提炼 `flowCapabilitiesRegistry` +2. 提炼 `panelCapabilitiesRegistry` +3. 新增 `resolveSidepanelCapabilities()` 与 `validateAutoRunStart()` +4. 让 `sidepanel.js`、`background.js`、`message-router.js` 共用同一套 selector +5. 再把步骤定义切换也改成 flow-aware + +## 10. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- sidepanel 不只是渲染层,还承接业务约束 +- 手机号注册能力由多个开关共同决定,当前逻辑分散且重复 +- `contributionMode` 与 `panelMode` 容易混淆 +- 多 flow 之后,步骤列表也必须纳入能力矩阵,而不是继续全局写死 + diff --git a/docs/多注册流程来源与驱动注册设计.md b/docs/多注册流程来源与驱动注册设计.md new file mode 100644 index 0000000..82a5f4c --- /dev/null +++ b/docs/多注册流程来源与驱动注册设计.md @@ -0,0 +1,246 @@ +# 多注册流程来源与驱动注册设计 + +本文解决的是另一个会在第二个 flow 接入时立刻爆炸的问题:当前项目虽然有 `tab-runtime`,但“来源 source 是谁、该注入什么脚本、URL family 怎么判定、localhost callback 应该清谁”仍然带着明显的 OpenAI 单流程假设。 + +## 1. 当前源码里的真实耦合 + +### 1.1 `signup-page` 其实不是抽象 source,而是 OpenAI Auth 标签页别名 + +目前这些地方都把 OpenAI Auth / Consent / Add-phone 页面统称为 `signup-page`: + +- `content/utils.js` 的 `detectScriptSource()` +- `background/navigation-utils.js` 的 `matchesSourceUrlFamily()` +- `background/tab-runtime.js` 的注册表、冲突清理、callback 清理 + +这说明当前的 source 体系不是“可扩展的来源注册表”,而是“OpenAI 时代遗留的 source 命名”。 + +### 1.2 `content/utils.js` 的默认回退不安全 + +当前 `detectScriptSource()` 在无法识别页面时会回退成 `vps-panel`。这对单流程时期问题不大,但多 flow 下会出现两个风险: + +1. 新 flow 页面被误识别成 `vps-panel` +2. `PING / READY / COMPLETE` 日志和标签注册打到错误 source + +### 1.3 `tab-runtime` 的 localhost cleanup 直接写死 `registry['signup-page']` + +`closeLocalhostCallbackTabs()` 现在默认把匹配 callback 的残留 tab 从 `signup-page` 名下清掉。将来只要第二个 flow 也有 callback,或者 callback 不是来自 OpenAI Auth 页,这里就会清错或漏清。 + +### 1.4 manifest 的静态注入列表目前基本围绕 OpenAI + +`manifest.json` 里的静态内容脚本匹配域名,目前只有 OpenAI Auth 页是明确的 flow 页面入口。第二个 flow 如果沿用这套做法,后续只会不断在 manifest 里继续加分散规则,缺少统一 source 定义中心。 + +## 2. 设计目标 + +后续必须拆成两层注册表: + +1. `sourceRegistry`:管理“页面来源与标签页生命周期” +2. `driverRegistry`:管理“这个来源页面由哪个 content driver 负责,支持哪些命令” + +这两层不要再混成一个 `signup-page` 字符串。 + +## 3. `sourceRegistry` 设计 + +建议 source 用稳定 ID,而不是模糊业务词。 + +```js +sourceRegistry = { + 'openai-auth': { + flowId: 'openai', + kind: 'flow-page', + label: 'OpenAI 认证页', + hostPatterns: [ + 'https://auth.openai.com/*', + 'https://auth0.openai.com/*', + 'https://accounts.openai.com/*', + ], + familyMatcher: 'openai-auth-family', + injectFiles: [ + 'content/activation-utils.js', + 'content/utils.js', + 'content/operation-delay.js', + 'content/auth-page-recovery.js', + 'content/phone-country-utils.js', + 'content/phone-auth.js', + 'content/signup-page.js', + ], + cleanupScopes: ['oauth-localhost-callback'], + }, + + 'mail-gmail': { + flowId: null, + kind: 'mail-provider', + label: 'Gmail', + hostPatterns: ['https://mail.google.com/*'], + familyMatcher: 'gmail-family', + injectFiles: ['content/activation-utils.js', 'content/utils.js', 'content/gmail-mail.js'], + }, + + 'panel-sub2api': { + flowId: 'openai', + kind: 'panel-page', + label: 'SUB2API 后台', + dynamicOnly: true, + familyMatcher: 'sub2api-panel-family', + injectFiles: ['content/utils.js', 'content/sub2api-panel.js'], + }, +}; +``` + +### 3.1 source 里至少要有这些字段 + +- `id` +- `flowId` +- `kind` +- `label` +- `hostPatterns` +- `familyMatcher` +- `injectFiles` +- `readyPolicy` +- `cleanupScopes` +- `tabReusePolicy` + +### 3.2 source 不等于 flow + +一个 flow 会有多个 source: + +- `openai-entry` +- `openai-auth` +- `openai-plus-checkout` +- `openai-paypal` +- `openai-gopay` +- `panel-sub2api` + +同样,一个 source 也可能不属于具体 flow,例如: + +- `mail-gmail` +- `mail-163` +- `mail-2925` +- `mail-inbucket` + +## 4. `driverRegistry` 设计 + +`driverRegistry` 解决的是“页面上能做什么”,不是“标签页如何复用”。 + +```js +driverRegistry = { + 'openai-auth': { + sourceId: 'openai-auth', + driverId: 'content/signup-page', + commands: [ + 'OPEN_SIGNUP', + 'SUBMIT_SIGNUP_IDENTIFIER', + 'SUBMIT_PASSWORD', + 'SUBMIT_PROFILE', + 'SUBMIT_LOGIN_CODE', + 'SUBMIT_PHONE_CODE', + 'DETECT_AUTH_STATE', + ], + }, + + 'mail-gmail': { + sourceId: 'mail-gmail', + driverId: 'content/gmail-mail', + commands: ['POLL_MAILBOX', 'OPEN_MESSAGE', 'DELETE_MESSAGE'], + }, +}; +``` + +这样做的好处是: + +- `tab-runtime` 只关心 source +- workflow / mail service 只关心 driver capability +- 后续换内容脚本实现时,不需要改 tab 生命周期代码 + +## 5. `signup-page` 的迁移策略 + +`signup-page` 不能直接继续作为未来通用 source 名字。 + +建议迁移方式: + +1. 新增正式 source:`openai-auth` +2. 在 `sourceRegistry.aliases` 里临时保留: + - `signup-page -> openai-auth` +3. `getSourceLabel()`、`matchesSourceUrlFamily()`、`ensureContentScriptReadyOnTab()` 全部优先读注册表 +4. 等 OpenAI 全链路迁完后,再删除 `signup-page` 别名 + +## 6. localhost callback cleanup 怎么改 + +当前 callback 清理逻辑最大的问题是“只知道 callback URL,不知道 callback 属于哪个 flow source”。 + +建议改成: + +```js +callbackRegistry = { + 'oauth-localhost-callback': { + ownerSourceId: 'openai-auth', + matcher: isLocalhostOAuthCallbackUrl, + clearOwnerTabOnClose: true, + }, +}; +``` + +`closeLocalhostCallbackTabs()` 的输入要升级为: + +- `cleanupScope` +- `callbackUrl` +- `ownerSourceId` + +而不是默认拿 `signup-page`。 + +## 7. `content/utils.js` 的改法 + +### 7.1 禁止默认回退成 `vps-panel` + +新的规则应该是: + +- 有 `window.__MULTIPAGE_SOURCE` 时,以注入 source 为准 +- 没有显式 source 时,只在静态 host map 里做白名单匹配 +- 仍无法识别时返回 `unknown-source` + +`unknown-source` 不参与 tab 注册,不自动 `reportReady()`,只记录诊断日志。 + +### 7.2 child frame ready 规则也要转到注册表 + +当前 `shouldReportReadyForFrame()` 里写死了多个 source 名称。后续应改成 source metadata: + +- `readyPolicy: 'top-frame-only'` +- `readyPolicy: 'allow-child-frame'` + +## 8. manifest / 动态注入策略 + +原则上以后由 `sourceRegistry` 作为单一事实来源,manifest 只是实现载体。 + +建议约定: + +- 稳定公共页面可继续走静态 `content_scripts` +- 变动大、域名多、只在运行期打开的页面改走 `chrome.scripting.executeScript` +- 每个 source 明确自己的 `hostPatterns` 和 `injectFiles` +- 新增 flow 时,不允许直接在业务代码里散写 `injectFiles = [...]` + +## 9. 与网络策略的关系 + +`sourceRegistry` 只负责页面来源,不负责代理策略;但每个 flow 应同时提供自己的 `networkProfile`,至少包含: + +- `guardDomains` +- `probeEndpoints` +- `failCloseDomains` + +否则 `ip-proxy-core` 仍会继续写死 `chatgpt.com / openai.com`。 + +## 10. 第一阶段迁移顺序 + +1. 引入 `sourceRegistry / driverRegistry` 与旧 source alias。 +2. 把 `getSourceLabel()`、`matchesSourceUrlFamily()`、`ensureContentScriptReadyOnTab()` 改成读注册表。 +3. 去掉 `detectScriptSource()` 的 `vps-panel` 默认回退。 +4. 改 `closeLocalhostCallbackTabs()`,不再写死 `signup-page`。 +5. 第二个 flow 接入时,严格要求先注册 source 和 driver,再写步骤逻辑。 + +## 11. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- `signup-page` 实际是 OpenAI source,不是通用注册页 +- source URL family / content ready / localhost callback cleanup 没有统一注册表 +- manifest 与动态注入没有统一入口 +- `content/utils.js` 的默认 source 回退会污染未来 flow + diff --git a/docs/多注册流程架构边界.md b/docs/多注册流程架构边界.md new file mode 100644 index 0000000..894b606 --- /dev/null +++ b/docs/多注册流程架构边界.md @@ -0,0 +1,124 @@ +# 多注册流程架构边界 + +本文记录后续从“OpenAI 注册扩展”升级到“多注册流程扩展”时的模块边界。当前结论是:需要引入多 flow 架构,但不要把所有能力都提前抽成通用服务。 + +## 1. 总体判断 + +后续新增注册项目时,不应继续在现有 OpenAI 步骤里堆 `if site === ...`。不同网站的注册入口、验证码、资料页、风控页、回调页和成功判定都可能完全不同,应按独立 flow 处理。 + +目标形态: + +```txt +core/ + flow-registry + workflow-engine + runtime-state + tab-runtime + logging + account-artifacts + email + mail-code-polling + network-proxy + +flows/ + openai/ + workflow.js + content-driver.js + mail-rules.js + network-profile.js + phone-verification-flow.js + phone-sms-providers/ + + site-a/ + workflow.js + content-driver.js + mail-rules.js + network-profile.js +``` + +第一阶段不需要立刻调整成上述目录,只需要按这个边界重构,避免新增项目继续污染 OpenAI 专用逻辑。 + +## 2. 必须通用化的部分 + +- `activeFlowId / runId / nodeId` 状态模型:替代只适合单流程的 `currentStep / stepStatuses`,同时避免与现有外部接口里的远端 `flow_id` 语义冲突。 +- Workflow engine:负责节点执行、跳转、停止、恢复、重试和超时,不理解具体网站页面。 +- Flow registry:负责注册 OpenAI、后续网站 A、网站 B 等独立流程。 +- Tab runtime:标签页注册、自动化窗口锁定、脚本注入、消息超时、页面 ready 检查。 +- Logging / status:结构化日志、节点状态、运行进度、停止和失败展示。 +- Account artifact store:记录账号产物、身份、凭据、回调结果和失败信息。 +- Email identity:邮箱生成、邮箱池、别名、转发收件目标等身份能力。 +- Mail polling:邮件验证码、magic link、激活链接的轮询框架;具体过滤和提取规则由 flow 提供。 +- IP proxy / network policy:代理应用、切换、出口检测、防泄漏;目标域名和探测 URL 由 flow 提供。 +- Sidepanel dynamic renderer:按 flow capability 显示配置区、步骤列表和运行状态。 +- Settings schema:通用配置与 flow 私有配置分层导入导出。 +- Error taxonomy / recovery policy:通用层只处理执行框架错误,业务错误由 flow 自己分类。 + +## 3. 暂不通用化的部分 + +以下能力当前只属于 OpenAI flow,不进入 core: + +- 手机接码:HeroSMS / 5sim / NexSMS、取号、复用、轮询、换号、释放、手机号注册和后置 add-phone。 +- LuckMail:当前 `project_code = openai` 的购邮、复用、已用/保留/禁用管理。 +- OpenAI / ChatGPT 页面 DOM 操作。 +- OpenAI OAuth 授权、localhost callback、CPA / SUB2API / Codex2API 绑定。 +- ChatGPT Plus、PayPal、GoPay、GPC 相关支付流程。 +- OpenAI 的验证码邮件过滤规则、登录状态判断、add-phone fatal 判断。 +- OpenAI 专属账号产物字段,如 Plus checkout、OAuth state、平台验证字段。 + +原因:当前除了 OpenAI,新项目暂不需要手机接码。过早把接码抽成通用服务会让新 flow 被迫理解手机号订单、短信平台、号码复用和页面 resend 细节,增加维护成本。 + +## 4. 手机接码边界 + +手机接码先保持为 OpenAI 专属能力。 + +当前这些模块在逻辑上归属 `flows/openai`,即使物理路径暂时还在旧目录: + +- `background/phone-verification-flow.js` +- `phone-sms/providers/hero-sms.js` +- `phone-sms/providers/five-sim.js` +- `phone-sms/providers/registry.js` +- `content/phone-auth.js` +- `content/phone-country-utils.js` +- sidepanel 中的接码配置区 +- 相关测试:`tests/phone-verification-flow.test.js`、`tests/five-sim-provider.test.js`、`tests/sidepanel-phone-verification-settings.test.js` + +后续新增 flow 默认 `sms: false`,不显示接码配置,不加载接码步骤,也不要求 workflow engine 理解短信订单。 + +如果未来第二个真实 flow 也需要接码,再按事实抽象,优先抽 provider API,而不是直接抽完整手机号验证编排: + +```js +flow.phoneVerification = { + acquirePolicy, + submitPhoneNumber, + submitCode, + classifyPhoneError, + recoverAfterTimeout, +} +``` + +只有两个以上 flow 共享相同短信供应商生命周期时,才考虑把 `phone-sms/providers/*` 下沉到 `core/services/sms`。 + +## 5. 迁移顺序 + +1. 引入 `activeFlowId / runId / currentNodeId / nodeStatuses / flowContext`,先兼容 OpenAI 旧步骤。 +2. 把邮箱、邮件轮询、代理、tab runtime、日志和账号记录逐步参数化,去掉 OpenAI 常量。 +3. 建立 `flows/openai` 边界,把 OpenAI 步骤、content driver、OAuth、Plus 和接码逻辑收拢进去。 +4. 用第二个真实注册项目验证 flow registry 和 workflow engine,而不是靠假想接口设计过度抽象。 +5. 稳定后清理旧 `step` 数字兼容层。 + +## 6. 新 flow 接入原则 + +新增注册项目时,只允许新增该 flow 的 workflow、content driver、mail rules、network profile 和必要的私有配置。不要直接修改 OpenAI 步骤,也不要把 OpenAI 的手机号验证、OAuth、Plus 支付逻辑提升为通用能力。 + +通用层只负责“怎么运行一个 flow”,具体 flow 负责“这个网站怎么注册”。 + +## 7. 配套设计文档 + +本边界文档只回答“什么该进 core、什么暂时不要抽”。要真正开工,还需要同时遵守下面四份配套设计: + +- `docs/多注册流程状态迁移设计.md`:解决 `DEFAULT_STATE` 扁平模型、旧 step 兼容、自动运行/日志/账号记录如何迁移。 +- `docs/多注册流程来源与驱动注册设计.md`:解决 `signup-page`、source family、内容脚本注入和 localhost callback cleanup 的注册表设计。 +- `docs/多注册流程邮件分层设计.md`:解决 provider driver 与 flow mail rules 的边界,以及 LuckMail 暂不通用化的问题。 +- `docs/多注册流程侧边栏能力矩阵.md`:解决 sidepanel 展示层与业务约束层混在一起的问题。 + +后续新 flow 设计如果与这四份文档冲突,以“先修正文档边界,再动代码”为准,不允许直接在现有 OpenAI 逻辑上叠条件分支。 diff --git a/docs/多注册流程状态迁移设计.md b/docs/多注册流程状态迁移设计.md new file mode 100644 index 0000000..fbce6e4 --- /dev/null +++ b/docs/多注册流程状态迁移设计.md @@ -0,0 +1,248 @@ +# 多注册流程状态迁移设计 + +本文是 `docs/多注册流程架构边界.md` 的落地补充,专门解决“现有扁平状态怎么迁到多 flow 架构”这个最容易把旧逻辑拖垮的问题。 + +## 1. 先说结论 + +当前项目不能直接在 `DEFAULT_STATE` 上继续堆字段。要支持多个完全不同的注册流程,必须先把“运行态”“持久配置”“共享服务状态”“flow 私有状态”分层,再保留一层 OpenAI 旧步骤兼容视图。 + +这次迁移的目标不是一步把全部字段搬完,而是先建立新的状态主模型,让旧代码通过 adapter 继续运行。 + +## 2. 当前源码里的真实问题 + +### 2.1 `DEFAULT_STATE` 仍然是单流程扁平模型 + +`background.js` 里的 `DEFAULT_STATE` 目前把这些不同层次的状态混在一起: + +- 流程进度:`currentStep`、`stepStatuses` +- 通用运行态:`automationWindowId`、`tabRegistry`、`logs` +- OpenAI OAuth / 平台回调:`oauthUrl`、`localhostUrl`、`sub2apiSessionId`、`codex2apiSessionId` +- OpenAI Plus:`plusCheckoutTabId`、`plusCheckoutUrl`、`plusBillingAddress` +- OpenAI 接码:`currentPhoneActivation`、`signupPhoneActivation`、`reusablePhoneActivation` +- OpenAI LuckMail:`currentLuckmailPurchase` +- 共享邮箱运行态:`registrationEmailState` + +这会带来两个直接后果: + +1. 新 flow 一接进来,就只能继续往全局 state 加字段。 +2. 任何 reset / retry / stop 都容易误清理别的业务域状态。 + +### 2.2 旧步骤数字模型已经渗透到多个模块 + +当前不只是 `background.js` 依赖 `currentStep / stepStatuses`,下列模块也在按“Step 1~13”理解流程: + +- `background/auto-run-controller.js` +- `background/logging-status.js` +- `background/account-run-history.js` + +所以新状态模型不能只改 `background.js`,必须给自动运行、日志、账号记录保留兼容出口。 + +### 2.3 `registrationEmailState` 名字看起来通用,内部却仍带 OpenAI 身份假设 + +`background/registration-email-state.js` 已经把邮箱运行态单独抽出来了,这是好事;但它的 `preserveAccountIdentity` 现在默认保留的是“手机号身份”,也就是: + +- `accountIdentifierType = phone` +- `signupPhoneNumber` +- `signupPhoneActivation` +- `signupPhoneCompletedActivation` + +这对 OpenAI 的 `add-email` 分支是对的,但对未来可能出现的“用户名主身份”“邀请码主身份”“钱包地址主身份”并不通用。 + +## 3. 目标状态分层 + +建议先建立“概念上的标准状态模型”,物理存储可以分阶段迁移。 + +```js +chrome.storage.session.runtimeState = { + activeFlowId: 'openai', + activeRunId: 'run_20260512_xxx', + currentNodeId: 'submit-signup-email', + nodeStatuses: { + 'open-chatgpt': 'completed', + 'submit-signup-email': 'running', + }, + + shared: { + automationWindowId: 123, + tabRegistry: {}, + sourceLastUrls: {}, + logs: [], + autoRun: {}, + accountArtifacts: {}, + }, + + services: { + mail: {}, + proxy: {}, + accountPools: {}, + }, + + flows: { + openai: { + auth: {}, + platformBinding: {}, + plus: {}, + phoneVerification: {}, + luckmail: {}, + identity: {}, + }, + }, + + legacyStepCompat: { + currentStep: 2, + stepStatuses: { 1: 'completed', 2: 'running' }, + }, +}; + +chrome.storage.local.settings = { + schemaVersion: 2, + + shared: { + autoRunDefaults: {}, + logging: {}, + }, + + services: { + mailProviders: {}, + ipProxy: {}, + hotmailPool: {}, + mail2925Pool: {}, + icloud: {}, + customEmailPool: {}, + }, + + flows: { + openai: { + signupMethod: 'email', + phoneVerificationEnabled: false, + plusModeEnabled: false, + plusPaymentMethod: 'paypal', + sub2api: {}, + codex2api: {}, + luckmail: {}, + smsProviders: {}, + }, + }, +}; +``` + +## 4. 四层状态边界 + +### 4.1 共享运行态 `runtimeState.shared` + +只放“运行任何 flow 都需要”的内容: + +- `activeFlowId / activeRunId / currentNodeId / nodeStatuses` +- `automationWindowId` +- `tabRegistry` +- `sourceLastUrls` +- `logs` +- 自动运行轮次、暂停、计时器计划 +- 账号产物总览,如最终身份、最终邮箱、最终手机号、完成时间 + +这里不要再放 OpenAI 特有概念,例如 `plusCheckoutTabId`、`currentPhoneActivation`。 + +### 4.2 共享服务状态 `runtimeState.services` + +放“可被多个 flow 复用的服务运行态”,而不是具体网站流程状态: + +- 邮箱 provider 当前会话 +- 2925 / Hotmail / iCloud / Cloud Mail 等共享邮箱服务上下文 +- IP 代理当前应用结果、探测结果、池游标 +- 可复用的共享账号池游标 + +注意:`LuckMail` 目前不进这一层,它仍属于 OpenAI flow 私有能力。 + +### 4.3 flow 私有运行态 `runtimeState.flows[flowId]` + +每个 flow 自己保管自己的业务运行态。当前 OpenAI 至少应拆成: + +- `auth`:`oauthUrl`、`localhostUrl` +- `platformBinding`:`sub2api*`、`codex2api*` +- `plus`:`plusCheckoutTabId`、`plusCheckoutUrl`、`plusBillingAddress` +- `phoneVerification`:`currentPhoneActivation`、`signupPhoneActivation`、`reusablePhoneActivation` +- `luckmail`:`currentLuckmailPurchase`、`currentLuckmailMailCursor` +- `identity`:OpenAI 特有的登录方式冻结结果、`step8VerificationTargetEmail` 等 + +### 4.4 持久配置 `settings` + +持久配置必须按“共享 / 服务 / flow 私有”拆开,不再把所有配置都平铺到同一个 namespace。 + +特别注意: + +- `signupMethod` 先保留在 `flows.openai`,不要急着抽成全局通用枚举。 +- `plusModeEnabled`、`plusPaymentMethod`、`gopay*`、`paypal*` 都属于 OpenAI flow 私有配置。 +- `phoneVerificationEnabled` 当前也属于 OpenAI flow 私有配置。 + +## 5. 旧步骤兼容层怎么做 + +迁移阶段仍要保留 `currentStep / stepStatuses`,但它们不再是主数据,只是 `legacyStepCompat` 的派生视图。 + +建议规则: + +1. 新的 canonical 状态只写 `activeFlowId / currentNodeId / nodeStatuses` +2. OpenAI flow 通过 node-to-step 映射生成 `legacyStepCompat` +3. `getState()` 对旧模块仍返回: + - `currentStep` + - `stepStatuses` + - OpenAI 旧字段镜像 +4. `setStepStatus(step, status)` 内部先更新 node,再回写 legacy step 视图 + +这样可以保证: + +- `background/auto-run-controller.js` 先不重写也能跑 +- `background/logging-status.js` 先不重写也能继续渲染 +- `background/account-run-history.js` 仍能按旧语义落盘 + +## 6. 账号记录与自动运行的兼容策略 + +### 6.1 自动运行 + +`background/auto-run-controller.js` 当前按数字步骤推断进度、失败点和恢复点。迁移阶段要增加一层统一 selector: + +- `getActiveRunProgress(state)` +- `getLegacyStepCompat(state)` +- `inferResumeNode(state, flowId)` + +第一阶段可以继续让 OpenAI flow 通过旧 step 恢复;第二个 flow 接入时再真正改成 node-based resume。 + +### 6.2 账号记录 + +`background/account-run-history.js` 不能只记“失败在第几步”,还要开始记录: + +- `activeFlowId` +- `activeRunId` +- `currentNodeId` +- `nodeStatuses` 摘要 +- `legacyFailedStep`(仅 OpenAI 兼容) + +否则未来多 flow 下,单看 `step7_failed` 已经没有意义。 + +## 7. 命名约束 + +仓库里已经存在别的 `flow_id` 语义,用在 GPC / GoPay 远端任务里。为了避免冲突: + +- 扩展内部运行态统一使用 `activeFlowId` +- 当前轮标识统一使用 `activeRunId` +- 节点标识统一使用 `currentNodeId` +- 不新增裸字段 `flowId` + +`flow_id` 只保留给外部接口 payload 兼容使用。 + +## 8. 第一阶段迁移顺序 + +1. 新增状态 selector / patch helper,不改业务步骤行为。 +2. 给 OpenAI flow 建立 `flowState.openai` 命名空间,同时保留旧字段镜像。 +3. 把日志、自动运行、账号记录改为优先读 selector,不直接拼 `stepStatuses`。 +4. 把新加字段一律放进 `shared / services / flows.openai`,禁止继续向 `DEFAULT_STATE` 顶层加 OpenAI 字段。 +5. 第二个 flow 接入前,再清理旧 step 镜像的写路径。 + +## 9. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- `DEFAULT_STATE` 扁平模型没有命名空间 +- 旧步骤状态与新 node 状态如何并存 +- 自动运行 / 日志 / 账号记录如何不被状态迁移打断 +- `registrationEmailState` 当前仍隐含“手机号是唯一可保留主身份”的假设 + diff --git a/docs/多注册流程邮件分层设计.md b/docs/多注册流程邮件分层设计.md new file mode 100644 index 0000000..2dc28c3 --- /dev/null +++ b/docs/多注册流程邮件分层设计.md @@ -0,0 +1,222 @@ +# 多注册流程邮件分层设计 + +本文专门解决“邮件轮询虽然看起来像公共能力,但实际 OpenAI 规则已经写进 provider 脚本和后台轮询里”这个问题。 + +## 1. 先说结论 + +邮件相关能力必须拆成两层,不能再只写一句“mail polling 通用化”: + +1. `mail provider driver` +2. `flow mail rules` + +前者负责“怎么从 Gmail / 163 / 2925 / Inbucket / API 邮箱里拿到邮件”,后者负责“哪封邮件算当前 flow 需要的验证码 / magic link / 激活链接”。 + +## 2. 当前源码里的真实耦合 + +### 2.1 后台轮询 payload 已经带着 OpenAI 过滤条件 + +`background/verification-flow.js` 当前在 `getVerificationPollPayload()` 里直接写死: + +- `senderFilters: ['openai', 'noreply', 'verify', 'auth', ...]` +- `subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', ...]` + +这不是 provider 通用逻辑,而是 OpenAI flow 规则。 + +### 2.2 内容脚本也已经写死 OpenAI / ChatGPT 正则 + +当前多个 provider 脚本里直接内嵌了 OpenAI / ChatGPT 识别: + +- `content/gmail-mail.js` +- `content/mail-163.js` +- `content/mail-2925.js` +- `content/inbucket-mail.js` + +例如: + +- `openai` +- `chatgpt` +- `verification` +- `log-in code` +- `strictChatGPTCodeOnly` + +这意味着现在不是“通用 provider + OpenAI 规则”,而是“OpenAI 规则渗透进 provider 实现本身”。 + +### 2.3 LuckMail 不能直接算进共享邮件层 + +LuckMail 当前还有一个额外边界:它不只是“邮箱提供商”,还绑定了 OpenAI 项目购邮与复用语义。 + +当前事实包括: + +- `DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'` +- sidepanel 明确只展示 `openai` 项目的 LuckMail 邮箱 + +所以 LuckMail 当前应留在 OpenAI flow 内,不直接并入通用 `mailProviders`。 + +## 3. 目标分层 + +## 3.1 第一层:`mail provider driver` + +这一层只负责 provider 访问与消息归一化,不理解具体网站业务。 + +职责包括: + +- 打开 provider 页面或请求 provider API +- 等待页面 ready / 会话可用 +- 列邮件、开邮件、取正文、删邮件 +- 会话级去重 +- 输出统一格式的消息对象 + +建议输出结构: + +```js +{ + providerId: 'gmail', + messageId: 'abc123', + receivedAt: 1710000000000, + sender: 'OpenAI ', + subject: 'Your code is 123456', + previewText: 'Your code is 123456', + normalizedText: 'your code is 123456', + html: '...', + links: ['https://...'], + targetHints: ['user@example.com'], + metadata: {}, +} +``` + +这一层不要再写: + +- “这是不是 OpenAI 邮件” +- “是不是 ChatGPT 登录码” +- “这个 code 是否必须 6 位” + +## 3.2 第二层:`flow mail rules` + +这一层由具体 flow 定义: + +- 要找的邮件类型 +- 过滤条件 +- 选择规则 +- 提取规则 +- 命中后的消费动作 + +建议接口: + +```js +flow.mailRules = { + signupCode: { + buildQuery(state) {}, + matchMessage(message, state) {}, + extractArtifact(message, state) {}, + afterConsume(message, provider, state) {}, + }, + loginCode: { + buildQuery(state) {}, + matchMessage(message, state) {}, + extractArtifact(message, state) {}, + }, +}; +``` + +其中: + +- `buildQuery` 负责时间窗、目标邮箱、允许的发件人线索 +- `matchMessage` 负责判定这是不是当前 flow 的邮件 +- `extractArtifact` 负责提取 `code / magic-link / activation-link` +- `afterConsume` 负责删除邮件、记录 cursor、保存已试 code + +## 4. 建议的共享 mail service 接口 + +```js +mailService.poll({ + providerId: 'gmail', + rule: flow.mailRules.signupCode, + state, + maxAttempts: 5, + intervalMs: 3000, +}); +``` + +内部流程: + +1. provider driver 按 query 拉取候选消息 +2. rule 决定哪封命中 +3. rule 提取 artifact +4. service 返回统一结果 + +返回值示例: + +```js +{ + ok: true, + artifact: { + type: 'code', + value: '123456', + }, + message: { ...normalizedMessage }, +} +``` + +## 5. OpenAI flow 当前应该拆出的规则 + +OpenAI 至少需要单独拆出这些规则文件: + +- `flows/openai/mail-rules/signup-code.js` +- `flows/openai/mail-rules/login-code.js` +- `flows/openai/mail-rules/add-email-code.js` + +它们负责承接现在散落在这些地方的规则: + +- `verification-flow.js` 中的 sender / subject filters +- `gmail-mail.js` / `mail-163.js` / `mail-2925.js` / `inbucket-mail.js` 中的 OpenAI / ChatGPT 正则 +- `strictChatGPTCodeOnly` +- Step 4 与 Step 8 不同的时间窗和目标邮箱逻辑 + +## 6. provider driver 的拆分建议 + +### 6.1 可以进入共享层的 provider + +这些 provider 的访问方式本身有共享价值,可以做成共享 mail driver: + +- Gmail +- 163 / 126 / 163 VIP +- 2925 +- Inbucket +- iCloud Mail +- Hotmail helper / Graph Mail +- Cloud Mail / SkyMail + +### 6.2 暂时不进入共享层的 provider + +- LuckMail:当前仍是 OpenAI 项目购邮与验证码轮询能力的一部分 + +如果未来第二个 flow 也明确复用 LuckMail,且复用的是“同一套 project / token / 生命周期”,再考虑把它抽出来。 + +## 7. `registrationEmailState` 与邮件层的关系 + +`registrationEmailState` 负责“当前流程邮箱身份”,不是“provider 拉信规则”。 + +所以后续边界应是: + +- `registrationEmailState`:运行态身份记录 +- `mail provider driver`:拉信能力 +- `flow mail rules`:判定与提取 + +不要再在 `registrationEmailState` 或 provider driver 里夹带 OpenAI `add-email` 业务判断。 + +## 8. 迁移顺序 + +1. 先把 `background/verification-flow.js` 里的查询构造迁到 `flows/openai/mail-rules/*` +2. 再把各 provider 脚本里的 OpenAI / ChatGPT 正则搬出到 rule 层 +3. 保留 provider 里的“消息抓取 / 页面操作 / 删除策略” +4. 等第二个 flow 接入时,只新增它自己的 `mail-rules`,不改 Gmail / 163 / 2925 driver + +## 9. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- “Mail polling 通用化”表述过于粗糙,没有说明 provider 与 rule 两层 +- provider 内容脚本里已经嵌入 OpenAI 规则 +- `strictChatGPTCodeOnly` 这类开关的归属边界不清 +- LuckMail 当前其实还是 OpenAI flow 私有邮件能力,不是共享 provider + diff --git a/flows/openai/mail-rules.js b/flows/openai/mail-rules.js new file mode 100644 index 0000000..44dc4ca --- /dev/null +++ b/flows/openai/mail-rules.js @@ -0,0 +1,111 @@ +(function attachOpenAiMailRules(root, factory) { + root.MultiPageOpenAiMailRules = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiMailRulesModule() { + const SIGNUP_CODE_RULE_ID = 'openai-signup-code'; + const LOGIN_CODE_RULE_ID = 'openai-login-code'; + 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', + '验证码', + '代码', + ]); + + function buildTargetEmailHints(targetEmail = '') { + const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); + if (!normalizedTarget) { + return []; + } + const hints = [normalizedTarget]; + const atIndex = normalizedTarget.indexOf('@'); + if (atIndex > 0) { + hints.push(`${normalizedTarget.slice(0, atIndex)}=${normalizedTarget.slice(atIndex + 1)}`); + } + return [...new Set(hints)]; + } + + function createOpenAiMailRules(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 getRuleDefinition(step, state = {}) { + const normalizedStep = Number(step) === 4 ? 4 : 8; + const mail2925Provider = isMail2925Provider(state); + const signupStep = normalizedStep === 4; + const targetEmail = signupStep + ? state?.email + : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email); + + return { + flowId: 'openai', + ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID, + step: normalizedStep, + artifactType: 'code', + codePatterns: OPENAI_CODE_PATTERNS, + filterAfterTimestamp: mail2925Provider + ? 0 + : getHotmailVerificationRequestTimestamp(normalizedStep, state), + requiredKeywords: signupStep + ? OPENAI_REQUIRED_KEYWORDS + : [...OPENAI_REQUIRED_KEYWORDS, 'login'], + senderFilters: signupStep + ? ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'] + : ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], + subjectFilters: signupStep + ? ['verify', 'verification', 'code', '验证码', 'confirm'] + : ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], + targetEmail, + targetEmailHints: buildTargetEmailHints(targetEmail), + mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), + maxAttempts: mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, + intervalMs: mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, + }; + } + + function buildVerificationPollPayload(step, state = {}, overrides = {}) { + return { + ...getRuleDefinition(step, state), + ...(overrides || {}), + }; + } + + return { + buildVerificationPollPayload, + getRuleDefinition, + }; + } + + return { + LOGIN_CODE_RULE_ID, + SIGNUP_CODE_RULE_ID, + createOpenAiMailRules, + }; +}); diff --git a/hotmail-utils.js b/hotmail-utils.js index 3ff4778..aa93a99 100644 --- a/hotmail-utils.js +++ b/hotmail-utils.js @@ -32,13 +32,49 @@ : HOTMAIL_SERVICE_MODE_LOCAL; } - function extractVerificationCode(text) { + function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; + } + + function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; + } + + function extractVerificationCode(text, options = {}) { const source = String(text || ''); + const matchedByRule = extractCodeByRulePatterns(source, options?.codePatterns); + if (matchedByRule) return matchedByRule; + const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = source.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = source.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i); if (matchEn) return matchEn[1]; @@ -47,7 +83,7 @@ return matchStandalone ? matchStandalone[1] : null; } - function extractVerificationCodeFromMessage(message = {}) { + function extractVerificationCodeFromMessage(message = {}, options = {}) { const sender = firstNonEmptyString([ message?.from?.emailAddress?.address, message?.sender, @@ -55,7 +91,9 @@ ]); const subject = firstNonEmptyString([message?.subject]); const preview = firstNonEmptyString([message?.bodyPreview, message?.preview, message?.text]); - return extractVerificationCode([subject, preview, sender].filter(Boolean).join(' ')); + return extractVerificationCode([subject, preview, sender].filter(Boolean).join(' '), { + codePatterns: options?.codePatterns, + }); } function getLatestHotmailMessage(messages) { @@ -138,6 +176,10 @@ function messageMatchesFilters(message, filters = {}) { const senderFilters = (filters.senderFilters || []).map(normalizeText).filter(Boolean); const subjectFilters = (filters.subjectFilters || []).map(normalizeText).filter(Boolean); + const requiredKeywords = (filters.requiredKeywords || []).map(normalizeText).filter(Boolean); + const hasSenderFilters = senderFilters.length > 0; + const hasSubjectFilters = subjectFilters.length > 0; + const hasKeywordHints = requiredKeywords.length > 0; const afterTimestamp = normalizeTimestamp(filters.afterTimestamp); const receivedAt = normalizeTimestamp(message?.receivedDateTime); if (afterTimestamp && receivedAt && receivedAt < afterTimestamp) { @@ -148,20 +190,25 @@ const subject = normalizeText(message?.subject); const preview = String(message?.bodyPreview || ''); const combinedText = [subject, sender, preview].filter(Boolean).join(' '); - const code = extractVerificationCode(combinedText); + const code = extractVerificationCode(combinedText, { + codePatterns: filters.codePatterns, + }); const excludedCodes = new Set((filters.excludeCodes || []).filter(Boolean)); if (code && excludedCodes.has(code)) { return null; } - const senderMatch = senderFilters.length === 0 - ? true - : senderFilters.some((item) => sender.includes(item) || normalizeText(preview).includes(item)); - const subjectMatch = subjectFilters.length === 0 - ? true - : subjectFilters.some((item) => subject.includes(item) || normalizeText(preview).includes(item)); + const senderMatch = hasSenderFilters + ? senderFilters.some((item) => sender.includes(item) || normalizeText(preview).includes(item)) + : false; + const subjectMatch = hasSubjectFilters + ? subjectFilters.some((item) => subject.includes(item) || normalizeText(preview).includes(item)) + : false; + const keywordMatch = hasKeywordHints + ? requiredKeywords.some((item) => normalizeText(combinedText).includes(item)) + : false; - if (!senderMatch && !subjectMatch) { + if ((hasSenderFilters || hasSubjectFilters || hasKeywordHints) && !senderMatch && !subjectMatch && !keywordMatch) { return null; } diff --git a/manifest.json b/manifest.json index f79b796..436a0ec 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "codex-oauth-automation-extension", - "version": "9.7", - "version_name": "Ultra9.7", + "version": "9.9", + "version_name": "Ultra9.9", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", @@ -49,6 +49,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/operation-delay.js", "content/auth-page-recovery.js", @@ -65,6 +66,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/qq-mail.js" ], @@ -81,6 +83,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/mail-163.js" ], @@ -94,6 +97,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/icloud-mail.js" ], @@ -106,6 +110,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/operation-delay.js", "content/duck-mail.js" diff --git a/microsoft-email.js b/microsoft-email.js index 131af23..83b784a 100644 --- a/microsoft-email.js +++ b/microsoft-email.js @@ -1,8 +1,4 @@ (function attachMicrosoftEmailHelpers(globalScope) { - const OPENAI_SENDER_PATTERNS = [ - /openai\.com/i, - /auth0\.openai\.com/i, - ]; const CODE_PATTERN = /\b(\d{6})\b/; const GRAPH_SCOPES = 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read'; const GRAPH_DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'; @@ -179,6 +175,57 @@ return String(value || '').trim().toLowerCase(); } + function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; + } + + function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; + } + + function extractVerificationCode(text, options = {}) { + const source = String(text || ''); + const matchedByRule = extractCodeByRulePatterns(source, options?.codePatterns); + if (matchedByRule) return matchedByRule; + + const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i); + if (matchCn) return matchCn[1]; + + const matchLoginCode = source.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; + + const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i); + if (matchEn) return matchEn[1]; + + const matchStandalone = source.match(CODE_PATTERN); + return matchStandalone?.[1] || ''; + } + function getMessageSender(message) { return String( message?.from?.emailAddress?.address @@ -203,22 +250,13 @@ .join('\n'); } - function isOpenAiMessage(message) { - const sender = getMessageSender(message); - if (OPENAI_SENDER_PATTERNS.some((pattern) => pattern.test(sender))) { - return true; - } - - const searchText = getMessageSearchText(message); - return OPENAI_SENDER_PATTERNS.some((pattern) => pattern.test(searchText)); - } - function extractVerificationCodeFromMessages(messages, options = {}) { const filterAfterTimestamp = Number(options.filterAfterTimestamp || 0) || 0; const senderFilters = (options.senderFilters || []).map(normalizeFilterValue).filter(Boolean); const subjectFilters = (options.subjectFilters || []).map(normalizeFilterValue).filter(Boolean); + const requiredKeywords = (options.requiredKeywords || []).map(normalizeFilterValue).filter(Boolean); const excludedCodes = new Set((options.excludeCodes || []).map((value) => String(value || '').trim()).filter(Boolean)); - const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0; + const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0 || requiredKeywords.length > 0; const sortedMessages = (Array.isArray(messages) ? messages : []) .map((raw) => normalizeMessage(raw, raw?.mailbox)) @@ -234,23 +272,23 @@ const subject = normalizeFilterValue(message?.subject); const preview = normalizeFilterValue(message?.bodyPreview); const searchText = normalizeFilterValue(getMessageSearchText(message)); - const codeMatch = getMessageSearchText(message).match(CODE_PATTERN); - const code = codeMatch?.[1] || ''; + const code = extractVerificationCode(getMessageSearchText(message), { + codePatterns: options.codePatterns, + }); if (!code || excludedCodes.has(code)) { continue; } - if (!hasExplicitFilters && !isOpenAiMessage(message)) { - continue; - } - const senderMatched = senderFilters.length === 0 - ? true + ? false : senderFilters.some((filter) => sender.includes(filter) || preview.includes(filter) || searchText.includes(filter)); const subjectMatched = subjectFilters.length === 0 - ? true + ? false : subjectFilters.some((filter) => subject.includes(filter) || preview.includes(filter) || searchText.includes(filter)); - if (!senderMatched && !subjectMatched) { + const keywordMatched = requiredKeywords.length === 0 + ? false + : requiredKeywords.some((filter) => preview.includes(filter) || searchText.includes(filter)); + if (hasExplicitFilters && !senderMatched && !subjectMatched && !keywordMatched) { continue; } @@ -401,6 +439,8 @@ filterAfterTimestamp, senderFilters, subjectFilters, + requiredKeywords: options.requiredKeywords, + codePatterns: options.codePatterns, excludeCodes, }); if (match) { @@ -437,7 +477,6 @@ fetchOutlookMessages, getMessageSender, getMessageTimestamp, - isOpenAiMessage, normalizeMailboxId, normalizeMailboxLabel, normalizeMessage, diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py index 768b480..cfe45b3 100644 --- a/scripts/hotmail_helper.py +++ b/scripts/hotmail_helper.py @@ -122,12 +122,6 @@ def compact_text(value, limit=400): def log_info(message): print(f"[HotmailHelper] {message}", flush=True) - -def is_openai_sender(address): - sender = str(address or "").strip().lower() - return "openai" in sender - - def get_message_body_content(message): body = message.get("body") or {} if not isinstance(body, dict): @@ -135,36 +129,6 @@ def get_message_body_content(message): return str(body.get("content") or "").strip() -def log_openai_messages(messages, transport=""): - for message in messages or []: - sender_info = message.get("from", {}).get("emailAddress", {}) or {} - sender = str(sender_info.get("address") or "").strip() - if not is_openai_sender(sender): - continue - - sender_name = str(sender_info.get("name") or "").strip() - mailbox = str(message.get("mailbox") or "").strip() or "INBOX" - subject = str(message.get("subject") or "").strip() - transport_label = str(transport or "").strip() or "unknown" - base = ( - f"transport={transport_label} mailbox={mailbox} sender={sender} " - f"senderName={sender_name or '-'} subject={subject}" - ) - - log_info(f"openai mail received {base}") - - body_content = get_message_body_content(message) - if body_content: - log_info(f"openai mail full body start {base}") - print(body_content, flush=True) - log_info("openai mail full body end") - continue - - preview = str(message.get("bodyPreview") or "").strip() - if preview: - log_info(f"openai mail preview {base} preview={compact_text(preview, 1000)}") - - def get_proxy_debug_context(): names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"] parts = [] @@ -725,7 +689,6 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top): try: log_info(f"message collection start transport={transport_name}") result = collector(email_addr, client_id, refresh_token, mailboxes, top) - log_openai_messages(result.get("messages") or [], transport=transport_name) log_info( f"message collection success transport={transport_name} " f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}" @@ -739,10 +702,37 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top): raise RuntimeError(f"Message collection failed on all transports: {' | '.join(errors)}") -def extract_code(text): +def extract_code(text, code_patterns=None): source = str(text or "") + for pattern in code_patterns or []: + try: + source_pattern = str((pattern or {}).get("source") or "").strip() + if not source_pattern: + continue + flags = str((pattern or {}).get("flags") or "").lower() + re_flags = 0 + if "i" in flags: + re_flags |= re.IGNORECASE + if "m" in flags: + re_flags |= re.MULTILINE + if "s" in flags: + re_flags |= re.DOTALL + match = re.search(source_pattern, source, flags=re_flags) + if not match: + continue + if match.lastindex: + for group_index in range(1, match.lastindex + 1): + candidate = str(match.group(group_index) or "").strip() + if candidate: + return candidate + candidate = str(match.group(0) or "").strip() + if candidate: + return candidate + except re.error: + continue patterns = [ r"(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})", + r"(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})", r"code(?:\s+is|[\s:])+(\d{6})", r"\b(\d{6})\b", ] @@ -753,9 +743,10 @@ def extract_code(text): return "" -def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp): +def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp, required_keywords=None, code_patterns=None): sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()] subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()] + required_keyword_hints = [str(item).strip().lower() for item in required_keywords or [] if str(item).strip()] excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()} def match_message(message, apply_time_filter): @@ -767,17 +758,18 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, subject = str(message.get("subject", "")) preview = str(message.get("bodyPreview", "")) combined = " ".join([sender, subject.lower(), preview.lower()]) - code = extract_code(" ".join([subject, preview, sender])) + code = extract_code(" ".join([subject, preview, sender]), code_patterns=code_patterns) if not code: body_content = get_message_body_content(message) if body_content: - code = extract_code(" ".join([subject, body_content, sender])) + code = extract_code(" ".join([subject, body_content, sender]), code_patterns=code_patterns) if not code or code in excluded: return None - sender_ok = not sender_keywords or any(keyword in combined for keyword in sender_keywords) - subject_ok = not subject_keywords or any(keyword in combined for keyword in subject_keywords) - if not sender_ok and not subject_ok: + sender_ok = bool(sender_keywords) and any(keyword in combined for keyword in sender_keywords) + subject_ok = bool(subject_keywords) and any(keyword in combined for keyword in subject_keywords) + keyword_ok = bool(required_keyword_hints) and any(keyword in combined for keyword in required_keyword_hints) + if (sender_keywords or subject_keywords or required_keyword_hints) and not sender_ok and not subject_ok and not keyword_ok: return None return {"code": code, "message": message} @@ -862,6 +854,8 @@ class HotmailHelperHandler(BaseHTTPRequestHandler): payload.get("subjectFilters") or [], payload.get("excludeCodes") or [], int(payload.get("filterAfterTimestamp") or 0), + payload.get("requiredKeywords") or [], + payload.get("codePatterns") or [], ) json_response(self, 200, { "ok": True, diff --git a/shared/flow-capabilities.js b/shared/flow-capabilities.js new file mode 100644 index 0000000..89274e1 --- /dev/null +++ b/shared/flow-capabilities.js @@ -0,0 +1,437 @@ +(function attachMultiPageFlowCapabilities(root, factory) { + root.MultiPageFlowCapabilities = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createFlowCapabilitiesModule() { + const DEFAULT_FLOW_ID = 'openai'; + const DEFAULT_PANEL_MODE = 'cpa'; + const SIGNUP_METHOD_EMAIL = 'email'; + const SIGNUP_METHOD_PHONE = 'phone'; + const VALID_PANEL_MODES = Object.freeze(['cpa', 'sub2api', 'codex2api']); + + const DEFAULT_FLOW_CAPABILITIES = Object.freeze({ + supportsEmailSignup: true, + supportsPhoneSignup: false, + supportsPhoneVerificationSettings: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsPlatformBinding: [], + supportsLuckmail: false, + supportsOauthTimeoutBudget: false, + canSwitchFlow: false, + stepDefinitionMode: 'default', + }); + + const FLOW_CAPABILITIES = Object.freeze({ + openai: Object.freeze({ + ...DEFAULT_FLOW_CAPABILITIES, + supportsPhoneSignup: true, + supportsPhoneVerificationSettings: true, + supportsPlusMode: true, + supportsContributionMode: true, + supportsPlatformBinding: ['cpa', 'sub2api', 'codex2api'], + supportsLuckmail: true, + supportsOauthTimeoutBudget: true, + stepDefinitionMode: 'openai-dynamic', + }), + }); + + const DEFAULT_PANEL_CAPABILITIES = Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }); + const MODE_SWITCH_RELEVANT_KEYS = Object.freeze([ + 'activeFlowId', + 'contributionMode', + 'panelMode', + 'phoneVerificationEnabled', + 'plusModeEnabled', + 'signupMethod', + ]); + + const PANEL_CAPABILITIES = Object.freeze({ + cpa: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: true, + }), + sub2api: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }), + codex2api: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }), + }); + + function normalizeFlowId(value = '', fallback = DEFAULT_FLOW_ID) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return fallbackValue || DEFAULT_FLOW_ID; + } + + function normalizePanelMode(value = '', fallback = DEFAULT_PANEL_MODE) { + const normalized = String(value || '').trim().toLowerCase(); + if (VALID_PANEL_MODES.includes(normalized)) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return VALID_PANEL_MODES.includes(fallbackValue) ? fallbackValue : DEFAULT_PANEL_MODE; + } + + function normalizeSignupMethod(value = '') { + return String(value || '').trim().toLowerCase() === SIGNUP_METHOD_PHONE + ? SIGNUP_METHOD_PHONE + : SIGNUP_METHOD_EMAIL; + } + + function normalizePanelModeList(values = []) { + if (!Array.isArray(values)) { + return []; + } + const seen = new Set(); + const normalized = []; + values.forEach((value) => { + const mode = normalizePanelMode(value, ''); + if (!mode || seen.has(mode)) { + return; + } + seen.add(mode); + normalized.push(mode); + }); + return normalized; + } + + function getPanelModeLabel(panelMode = '') { + const normalized = normalizePanelMode(panelMode); + if (normalized === 'sub2api') { + return 'SUB2API'; + } + if (normalized === 'codex2api') { + return 'Codex2API'; + } + return 'CPA'; + } + + function createFlowCapabilityRegistry(deps = {}) { + const { + defaultFlowCapabilities = DEFAULT_FLOW_CAPABILITIES, + defaultFlowId = DEFAULT_FLOW_ID, + defaultPanelCapabilities = DEFAULT_PANEL_CAPABILITIES, + flowCapabilities = FLOW_CAPABILITIES, + panelCapabilities = PANEL_CAPABILITIES, + } = deps; + + function getFlowCapabilities(flowId) { + const normalizedFlowId = normalizeFlowId(flowId, defaultFlowId); + const entry = flowCapabilities[normalizedFlowId] || null; + return { + ...defaultFlowCapabilities, + ...(entry || {}), + supportsPlatformBinding: normalizePanelModeList(entry?.supportsPlatformBinding || defaultFlowCapabilities.supportsPlatformBinding), + }; + } + + function getPanelCapabilities(panelMode) { + const normalizedPanelMode = normalizePanelMode(panelMode); + return { + ...defaultPanelCapabilities, + ...(panelCapabilities[normalizedPanelMode] || {}), + }; + } + + function normalizeChangedKeys(values = []) { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const normalized = []; + list.forEach((value) => { + const key = String(value || '').trim(); + if (!key || seen.has(key)) { + return; + } + seen.add(key); + normalized.push(key); + }); + return normalized; + } + + function resolveSidepanelCapabilities(options = {}) { + const state = options?.state || {}; + const activeFlowId = normalizeFlowId( + options?.activeFlowId ?? state?.activeFlowId, + defaultFlowId + ); + const flowState = getFlowCapabilities(activeFlowId); + const requestedPanelMode = normalizePanelMode( + options?.panelMode ?? state?.panelMode, + DEFAULT_PANEL_MODE + ); + const supportedPanelModes = normalizePanelModeList(flowState.supportsPlatformBinding); + const panelModeSupported = supportedPanelModes.length === 0 + ? true + : supportedPanelModes.includes(requestedPanelMode); + const effectivePanelMode = panelModeSupported + ? requestedPanelMode + : supportedPanelModes[0]; + const panelState = getPanelCapabilities(effectivePanelMode); + const runtimeLocks = { + autoRunLocked: Boolean(options?.autoRunLocked ?? state?.autoRunLocked), + contributionMode: flowState.supportsContributionMode && Boolean(state?.contributionMode), + phoneVerificationEnabled: flowState.supportsPhoneVerificationSettings && Boolean(state?.phoneVerificationEnabled), + plusModeEnabled: flowState.supportsPlusMode && Boolean(state?.plusModeEnabled), + settingsMenuLocked: Boolean(options?.settingsMenuLocked ?? state?.settingsMenuLocked), + }; + const effectiveSignupMethods = []; + if (flowState.supportsEmailSignup !== false) { + effectiveSignupMethods.push(SIGNUP_METHOD_EMAIL); + } + const canSelectPhoneSignup = Boolean(flowState.supportsPhoneSignup) + && Boolean(panelState.supportsPhoneSignup) + && runtimeLocks.phoneVerificationEnabled + && !runtimeLocks.plusModeEnabled + && !runtimeLocks.contributionMode; + if (canSelectPhoneSignup) { + effectiveSignupMethods.push(SIGNUP_METHOD_PHONE); + } + if (!effectiveSignupMethods.length) { + effectiveSignupMethods.push(SIGNUP_METHOD_EMAIL); + } + const requestedSignupMethod = normalizeSignupMethod( + options?.signupMethod ?? state?.signupMethod + ); + const effectiveSignupMethod = requestedSignupMethod === SIGNUP_METHOD_PHONE && canSelectPhoneSignup + ? SIGNUP_METHOD_PHONE + : (effectiveSignupMethods.includes(SIGNUP_METHOD_EMAIL) + ? SIGNUP_METHOD_EMAIL + : effectiveSignupMethods[0]); + + return { + activeFlowId, + canShowContributionMode: Boolean(flowState.supportsContributionMode), + canShowLuckmail: Boolean(flowState.supportsLuckmail), + canShowPhoneSettings: Boolean(flowState.supportsPhoneVerificationSettings), + canShowPlusSettings: Boolean(flowState.supportsPlusMode), + canSwitchFlow: Boolean(flowState.canSwitchFlow), + canUsePhoneSignup: canSelectPhoneSignup, + canUseSelectedPanelMode: panelModeSupported, + effectivePanelMode, + effectiveSignupMethod, + effectiveSignupMethods, + flowCapabilities: flowState, + panelCapabilities: panelState, + panelMode: effectivePanelMode, + requestedPanelMode, + requestedSignupMethod, + runtimeLocks, + shouldWarnCpaPhoneSignup: effectiveSignupMethod === SIGNUP_METHOD_PHONE + && Boolean(panelState.requiresPhoneSignupWarning), + stepDefinitionOptions: { + activeFlowId, + panelMode: effectivePanelMode, + plusModeEnabled: runtimeLocks.plusModeEnabled, + signupMethod: effectiveSignupMethod, + }, + supportedPanelModes, + }; + } + + function buildPhoneSignupValidationError(capabilityState = {}) { + const flowState = capabilityState.flowCapabilities || {}; + const panelState = capabilityState.panelCapabilities || {}; + const runtimeLocks = capabilityState.runtimeLocks || {}; + + if (!flowState.supportsPhoneSignup) { + return { + code: 'phone_signup_flow_unsupported', + message: '当前 flow 不支持手机号注册。', + }; + } + if (!panelState.supportsPhoneSignup) { + return { + code: 'phone_signup_panel_unsupported', + message: `当前面板模式 ${getPanelModeLabel(capabilityState.requestedPanelMode)} 不支持手机号注册。`, + }; + } + if (!runtimeLocks.phoneVerificationEnabled) { + return { + code: 'phone_signup_phone_verification_disabled', + message: '请先开启接码功能后再使用手机号注册。', + }; + } + if (runtimeLocks.plusModeEnabled) { + return { + code: 'phone_signup_plus_mode_locked', + message: 'Plus 模式开启时不能使用手机号注册。', + }; + } + if (runtimeLocks.contributionMode) { + return { + code: 'phone_signup_contribution_mode_locked', + message: '贡献模式开启时不能使用手机号注册。', + }; + } + return { + code: 'phone_signup_unavailable', + message: '当前设置暂不支持手机号注册。', + }; + } + + function validateAutoRunStart(options = {}) { + const state = options?.state || {}; + const capabilityState = resolveSidepanelCapabilities(options); + const errors = []; + + if ( + Array.isArray(capabilityState.supportedPanelModes) + && capabilityState.supportedPanelModes.length > 0 + && capabilityState.canUseSelectedPanelMode === false + ) { + errors.push({ + code: 'panel_mode_unsupported', + message: `当前 flow 不支持 ${getPanelModeLabel(capabilityState.requestedPanelMode)} 面板模式。`, + }); + } + + if (Boolean(state?.plusModeEnabled) && !capabilityState.flowCapabilities?.supportsPlusMode) { + errors.push({ + code: 'plus_mode_unsupported', + message: '当前 flow 不支持 Plus 模式。', + }); + } + + if (Boolean(state?.contributionMode) && !capabilityState.flowCapabilities?.supportsContributionMode) { + errors.push({ + code: 'contribution_mode_unsupported', + message: '当前 flow 不支持贡献模式。', + }); + } + + if ( + capabilityState.requestedSignupMethod === SIGNUP_METHOD_PHONE + && capabilityState.effectiveSignupMethod !== SIGNUP_METHOD_PHONE + ) { + errors.push(buildPhoneSignupValidationError(capabilityState)); + } + + return { + ok: errors.length === 0, + errors, + capabilityState, + }; + } + + function validateModeSwitch(options = {}) { + const state = options?.state || {}; + const changedKeys = normalizeChangedKeys( + options?.changedKeys !== undefined + ? options.changedKeys + : Object.keys(state || {}) + ); + const changedKeySet = new Set(changedKeys); + const capabilityState = resolveSidepanelCapabilities(options); + const errors = []; + const normalizedUpdates = {}; + const flowState = capabilityState.flowCapabilities || {}; + const requestedPhoneSignup = capabilityState.requestedSignupMethod === SIGNUP_METHOD_PHONE; + const shouldReconcileSignupMethod = MODE_SWITCH_RELEVANT_KEYS.some((key) => changedKeySet.has(key)); + + if ( + changedKeySet.has('panelMode') + && Array.isArray(capabilityState.supportedPanelModes) + && capabilityState.supportedPanelModes.length > 0 + && capabilityState.canUseSelectedPanelMode === false + ) { + normalizedUpdates.panelMode = capabilityState.effectivePanelMode; + errors.push({ + code: 'panel_mode_unsupported', + message: `当前 flow 不支持 ${getPanelModeLabel(capabilityState.requestedPanelMode)} 面板模式。`, + }); + } + + if (changedKeySet.has('plusModeEnabled') && Boolean(state?.plusModeEnabled) && !flowState.supportsPlusMode) { + normalizedUpdates.plusModeEnabled = false; + errors.push({ + code: 'plus_mode_unsupported', + message: '当前 flow 不支持 Plus 模式。', + }); + } + + if (changedKeySet.has('contributionMode') && Boolean(state?.contributionMode) && !flowState.supportsContributionMode) { + normalizedUpdates.contributionMode = false; + errors.push({ + code: 'contribution_mode_unsupported', + message: '当前 flow 不支持贡献模式。', + }); + } + + if ( + changedKeySet.has('phoneVerificationEnabled') + && Boolean(state?.phoneVerificationEnabled) + && !flowState.supportsPhoneVerificationSettings + ) { + normalizedUpdates.phoneVerificationEnabled = false; + errors.push({ + code: 'phone_verification_unsupported', + message: '当前 flow 不支持接码配置。', + }); + } + + if ( + shouldReconcileSignupMethod + && requestedPhoneSignup + && capabilityState.effectiveSignupMethod !== SIGNUP_METHOD_PHONE + ) { + normalizedUpdates.signupMethod = capabilityState.effectiveSignupMethod; + errors.push(buildPhoneSignupValidationError(capabilityState)); + } + + return { + ok: errors.length === 0, + changedKeys, + capabilityState, + errors, + normalizedUpdates, + }; + } + + function canUsePhoneSignup(state = {}) { + return resolveSidepanelCapabilities({ state }).canUsePhoneSignup; + } + + function resolveSignupMethod(state = {}, signupMethod = undefined) { + return resolveSidepanelCapabilities({ + signupMethod, + state, + }).effectiveSignupMethod; + } + + return { + canUsePhoneSignup, + getFlowCapabilities, + getPanelCapabilities, + normalizeFlowId, + normalizePanelMode, + normalizeSignupMethod, + resolveSidepanelCapabilities, + resolveSignupMethod, + validateAutoRunStart, + validateModeSwitch, + }; + } + + return { + createFlowCapabilityRegistry, + DEFAULT_FLOW_CAPABILITIES, + DEFAULT_FLOW_ID, + DEFAULT_PANEL_CAPABILITIES, + DEFAULT_PANEL_MODE, + FLOW_CAPABILITIES, + PANEL_CAPABILITIES, + SIGNUP_METHOD_EMAIL, + SIGNUP_METHOD_PHONE, + normalizeFlowId, + normalizePanelMode, + normalizeSignupMethod, + }; +}); diff --git a/shared/source-registry.js b/shared/source-registry.js new file mode 100644 index 0000000..6d7ce66 --- /dev/null +++ b/shared/source-registry.js @@ -0,0 +1,433 @@ +(function attachMultiPageSourceRegistry(root, factory) { + root.MultiPageSourceRegistry = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createSourceRegistryModule() { + const SOURCE_ALIASES = Object.freeze({ + 'signup-page': 'openai-auth', + }); + + const SOURCE_DEFINITIONS = Object.freeze({ + 'openai-auth': { + flowId: 'openai', + kind: 'flow-page', + label: '认证页', + readyPolicy: 'allow-child-frame', + family: 'openai-auth-family', + driverId: 'content/signup-page', + cleanupScopes: ['oauth-localhost-callback'], + }, + chatgpt: { + flowId: 'openai', + kind: 'flow-entry', + label: 'ChatGPT 首页', + readyPolicy: 'allow-child-frame', + family: 'chatgpt-entry-family', + driverId: null, + cleanupScopes: [], + }, + 'qq-mail': { + flowId: null, + kind: 'mail-provider', + label: 'QQ 邮箱', + readyPolicy: 'top-frame-only', + family: 'qq-mail-family', + driverId: 'content/qq-mail', + cleanupScopes: [], + }, + 'mail-163': { + flowId: null, + kind: 'mail-provider', + label: '163 邮箱', + readyPolicy: 'top-frame-only', + family: 'mail-163-family', + driverId: 'content/mail-163', + cleanupScopes: [], + }, + 'gmail-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Gmail 邮箱', + readyPolicy: 'top-frame-only', + family: 'gmail-mail-family', + driverId: 'content/gmail-mail', + cleanupScopes: [], + }, + 'icloud-mail': { + flowId: null, + kind: 'mail-provider', + label: 'iCloud 邮箱', + readyPolicy: 'allow-child-frame', + family: 'icloud-mail-family', + driverId: 'content/icloud-mail', + cleanupScopes: [], + }, + 'inbucket-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Inbucket 邮箱', + readyPolicy: 'top-frame-only', + family: 'inbucket-mail-family', + driverId: 'content/inbucket-mail', + cleanupScopes: [], + }, + 'mail-2925': { + flowId: null, + kind: 'mail-provider', + label: '2925 邮箱', + readyPolicy: 'top-frame-only', + family: 'mail-2925-family', + driverId: 'content/mail-2925', + cleanupScopes: [], + }, + 'duck-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Duck 邮箱', + readyPolicy: 'allow-child-frame', + family: 'duck-mail-family', + driverId: 'content/duck-mail', + cleanupScopes: [], + }, + 'vps-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'CPA 面板', + readyPolicy: 'allow-child-frame', + family: 'vps-panel-family', + driverId: 'content/vps-panel', + cleanupScopes: [], + }, + 'sub2api-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'SUB2API 后台', + readyPolicy: 'allow-child-frame', + family: 'sub2api-panel-family', + driverId: 'content/sub2api-panel', + cleanupScopes: [], + }, + 'codex2api-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'Codex2API 后台', + readyPolicy: 'allow-child-frame', + family: 'codex2api-panel-family', + driverId: 'content/sub2api-panel', + cleanupScopes: [], + }, + 'plus-checkout': { + flowId: 'openai', + kind: 'flow-page', + label: 'Plus Checkout', + readyPolicy: 'top-frame-only', + family: 'plus-checkout-family', + driverId: 'content/plus-checkout', + cleanupScopes: [], + }, + 'paypal-flow': { + flowId: 'openai', + kind: 'flow-page', + label: 'PayPal 授权页', + readyPolicy: 'allow-child-frame', + family: 'paypal-flow-family', + driverId: 'content/paypal-flow', + cleanupScopes: [], + }, + 'gopay-flow': { + flowId: 'openai', + kind: 'flow-page', + label: 'GoPay 授权页', + readyPolicy: 'allow-child-frame', + family: 'gopay-flow-family', + driverId: 'content/gopay-flow', + cleanupScopes: [], + }, + 'unknown-source': { + flowId: null, + kind: 'unknown', + label: '未知来源', + readyPolicy: 'disabled', + family: 'unknown-family', + driverId: null, + cleanupScopes: [], + }, + }); + + const DRIVER_DEFINITIONS = Object.freeze({ + 'content/signup-page': { + sourceId: 'openai-auth', + commands: [ + 'OPEN_SIGNUP', + 'SUBMIT_SIGNUP_IDENTIFIER', + 'SUBMIT_PASSWORD', + 'SUBMIT_PROFILE', + 'SUBMIT_LOGIN_CODE', + 'SUBMIT_PHONE_CODE', + 'DETECT_AUTH_STATE', + ], + }, + 'content/qq-mail': { + sourceId: 'qq-mail', + commands: ['POLL_EMAIL'], + }, + 'content/mail-163': { + sourceId: 'mail-163', + commands: ['POLL_EMAIL'], + }, + 'content/gmail-mail': { + sourceId: 'gmail-mail', + commands: ['POLL_EMAIL'], + }, + 'content/icloud-mail': { + sourceId: 'icloud-mail', + commands: ['POLL_EMAIL'], + }, + 'content/mail-2925': { + sourceId: 'mail-2925', + commands: ['POLL_EMAIL'], + }, + 'content/duck-mail': { + sourceId: 'duck-mail', + commands: ['FETCH_ALIAS_EMAIL'], + }, + 'content/sub2api-panel': { + sourceId: 'sub2api-panel', + commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL', 'VERIFY_PLATFORM'], + }, + 'content/vps-panel': { + sourceId: 'vps-panel', + commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL'], + }, + 'content/plus-checkout': { + sourceId: 'plus-checkout', + commands: ['CREATE_CHECKOUT', 'FILL_CHECKOUT'], + }, + 'content/paypal-flow': { + sourceId: 'paypal-flow', + commands: ['APPROVE_PAYPAL'], + }, + 'content/gopay-flow': { + sourceId: 'gopay-flow', + commands: ['APPROVE_GOPAY'], + }, + }); + + const CLEANUP_SCOPE_OWNERS = Object.freeze({ + 'oauth-localhost-callback': 'openai-auth', + }); + + const AUTH_PAGE_HOSTS = new Set(['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com']); + const ENTRY_PAGE_HOSTS = new Set(['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com']); + const CHILD_FRAME_BLOCKED_SOURCES = new Set([ + 'qq-mail', + 'mail-163', + 'gmail-mail', + 'mail-2925', + 'inbucket-mail', + 'plus-checkout', + ]); + + function createSourceRegistry() { + function parseUrlSafely(rawUrl) { + if (!rawUrl) return null; + try { + return new URL(rawUrl); + } catch { + return null; + } + } + + function normalizeSourceId(source) { + return String(source || '').trim(); + } + + function resolveCanonicalSource(source) { + const normalized = normalizeSourceId(source); + if (!normalized) return ''; + return SOURCE_ALIASES[normalized] || normalized; + } + + function getAliasKeysForCanonicalSource(source) { + const canonical = resolveCanonicalSource(source); + return Object.keys(SOURCE_ALIASES).filter((alias) => SOURCE_ALIASES[alias] === canonical); + } + + function getSourceKeys(source) { + const normalized = normalizeSourceId(source); + const canonical = resolveCanonicalSource(normalized); + return Array.from(new Set([ + canonical, + ...getAliasKeysForCanonicalSource(canonical), + normalized, + ].filter(Boolean))); + } + + function getSourceMeta(source) { + const canonical = resolveCanonicalSource(source); + const definition = SOURCE_DEFINITIONS[canonical]; + if (!definition) { + return null; + } + return { + id: canonical, + aliases: getAliasKeysForCanonicalSource(canonical), + ...definition, + }; + } + + function getSourceLabel(source) { + return getSourceMeta(source)?.label || normalizeSourceId(source) || '未知来源'; + } + + function getDriverIdForSource(source) { + return getSourceMeta(source)?.driverId || null; + } + + function getDriverMeta(sourceOrDriverId) { + const directDriverId = normalizeSourceId(sourceOrDriverId); + const driverId = Object.prototype.hasOwnProperty.call(DRIVER_DEFINITIONS, directDriverId) + ? directDriverId + : getDriverIdForSource(sourceOrDriverId); + if (!driverId || !Object.prototype.hasOwnProperty.call(DRIVER_DEFINITIONS, driverId)) { + return null; + } + return { + id: driverId, + ...DRIVER_DEFINITIONS[driverId], + }; + } + + function isSignupPageHost(hostname = '') { + return AUTH_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); + } + + function isSignupEntryHost(hostname = '') { + return ENTRY_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); + } + + function is163MailHost(hostname = '') { + const normalized = String(hostname || '').toLowerCase(); + return normalized === 'mail.163.com' + || normalized.endsWith('.mail.163.com') + || normalized === 'mail.126.com' + || normalized.endsWith('.mail.126.com') + || normalized === 'webmail.vip.163.com'; + } + + function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { + const candidate = parseUrlSafely(candidateUrl); + if (!candidate) return false; + + const canonical = resolveCanonicalSource(source); + const reference = parseUrlSafely(referenceUrl); + + switch (canonical) { + case 'openai-auth': + return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); + case 'chatgpt': + return isSignupEntryHost(candidate.hostname); + case 'duck-mail': + return candidate.hostname === 'duckduckgo.com' && candidate.pathname.startsWith('/email/'); + case 'qq-mail': + return candidate.hostname === 'mail.qq.com' || candidate.hostname === 'wx.mail.qq.com'; + case 'mail-163': + return is163MailHost(candidate.hostname); + case 'gmail-mail': + return candidate.hostname === 'mail.google.com'; + case 'icloud-mail': + return candidate.hostname === 'www.icloud.com' + || candidate.hostname === 'www.icloud.com.cn'; + case 'inbucket-mail': + return Boolean(reference) + && candidate.origin === reference.origin + && candidate.pathname.startsWith('/m/'); + case 'mail-2925': + return candidate.hostname === '2925.com' || candidate.hostname === 'www.2925.com'; + case 'vps-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && candidate.pathname === reference.pathname; + case 'sub2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname.startsWith('/login') + || candidate.pathname === '/' + ); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname === '/admin' + || candidate.pathname === '/' + ); + case 'plus-checkout': + return candidate.hostname === 'chatgpt.com' + && candidate.pathname.startsWith('/checkout/'); + case 'paypal-flow': + return candidate.hostname.endsWith('paypal.com'); + case 'gopay-flow': + return /gopay|gojek/i.test(candidate.hostname); + default: + return false; + } + } + + function detectSourceFromLocation({ + injectedSource, + url = '', + hostname = '', + } = {}) { + if (injectedSource) return resolveCanonicalSource(injectedSource); + + const normalizedHostname = String(hostname || '').toLowerCase(); + const normalizedUrl = String(url || ''); + + if (isSignupPageHost(normalizedHostname)) return 'openai-auth'; + if (normalizedHostname === 'mail.qq.com' || normalizedHostname === 'wx.mail.qq.com') return 'qq-mail'; + if (is163MailHost(normalizedHostname)) return 'mail-163'; + if (normalizedHostname === 'mail.google.com') return 'gmail-mail'; + if (normalizedHostname === 'www.icloud.com' || normalizedHostname === 'www.icloud.com.cn') return 'icloud-mail'; + if (normalizedUrl.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail'; + if (normalizedUrl.includes('2925.com')) return 'mail-2925'; + if (isSignupEntryHost(normalizedHostname)) return 'chatgpt'; + return 'unknown-source'; + } + + function shouldReportReadyForFrame(source, isChildFrame) { + const canonical = resolveCanonicalSource(source); + const readyPolicy = getSourceMeta(canonical)?.readyPolicy || 'allow-child-frame'; + if (readyPolicy === 'disabled') return false; + if (!isChildFrame) return true; + if (readyPolicy === 'top-frame-only') return false; + if (CHILD_FRAME_BLOCKED_SOURCES.has(canonical)) return false; + return true; + } + + function getCleanupOwnerSource(cleanupScope) { + return resolveCanonicalSource(CLEANUP_SCOPE_OWNERS[String(cleanupScope || '').trim()] || ''); + } + + return { + detectSourceFromLocation, + getCleanupOwnerSource, + getDriverIdForSource, + getDriverMeta, + getSourceKeys, + getSourceLabel, + getSourceMeta, + is163MailHost, + isSignupEntryHost, + isSignupPageHost, + matchesSourceUrlFamily, + parseUrlSafely, + resolveCanonicalSource, + shouldReportReadyForFrame, + }; + } + + return { + createSourceRegistry, + }; +}); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index f26087c..3db3046 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -110,6 +110,14 @@
+
+ Flow + +
来源