diff --git a/.gitignore b/.gitignore index 9ed4eab3..38370bf0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .omx/ /node_modules /.runtime +/.env /docs/新步骤顺序 __pycache__/ *.pyc diff --git a/background.js b/background.js index 62bb91a2..a2ce5eaf 100644 --- a/background.js +++ b/background.js @@ -670,6 +670,7 @@ const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL; +const DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL = 'http://127.0.0.1:17374'; const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000; const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; const DEFAULT_HERO_SMS_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php'; @@ -696,6 +697,7 @@ const DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net'; const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot'; const DEFAULT_NEX_SMS_COUNTRY_ORDER = Object.freeze([1]); const DEFAULT_HERO_SMS_REUSE_ENABLED = true; +const DEFAULT_HERO_SMS_OPERATOR = 'any'; const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price'; const HERO_SMS_ACQUIRE_PRIORITY_PRICE_HIGH = 'price_high'; @@ -1422,6 +1424,7 @@ const PERSISTED_SETTING_DEFAULTS = { heroSmsCountryId: HERO_SMS_COUNTRY_ID, heroSmsCountryLabel: HERO_SMS_COUNTRY_LABEL, heroSmsCountryFallback: [], + heroSmsOperator: DEFAULT_HERO_SMS_OPERATOR, fiveSimApiKey: '', fiveSimProduct: DEFAULT_FIVE_SIM_PRODUCT, fiveSimCountryId: FIVE_SIM_COUNTRY_ID, @@ -1801,6 +1804,21 @@ function normalizeHeroSmsCountryFallback(value = []) { return normalized; } +function normalizeHeroSmsOperator(value = '', fallback = DEFAULT_HERO_SMS_OPERATOR) { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, ''); + if (normalized) { + return normalized; + } + const fallbackNormalized = String(fallback || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, ''); + return fallbackNormalized || DEFAULT_HERO_SMS_OPERATOR; +} + function normalizePhoneSmsProvider(value = '') { const rootScope = typeof self !== 'undefined' ? self : globalThis; @@ -2707,6 +2725,14 @@ async function markCurrentRegistrationAccountUsed(state = {}, options = {}) { updated = Boolean(result?.updated) || updated; } + if (typeof removeCurrentCustomMailProviderPoolEmail === 'function') { + const result = await removeCurrentCustomMailProviderPoolEmail(latestState, { + logPrefix: `${reasonPrefix}:自定义邮箱号池`, + level: options.level || 'warn', + }); + updated = Boolean(result?.updated) || updated; + } + return { updated }; } @@ -2726,6 +2752,46 @@ function getCustomMailProviderPoolEmailForRun(state = {}, targetRun = 1) { return entries[numericRun - 1] || ''; } +async function removeCurrentCustomMailProviderPoolEmail(state = {}, options = {}) { + if (String(state?.mailProvider || '').trim().toLowerCase() !== 'custom') { + return { updated: false }; + } + + const currentEmail = String(state?.email || '').trim().toLowerCase(); + if (!currentEmail) { + return { updated: false }; + } + + const currentPool = getCustomMailProviderPool(state); + if (!currentPool.length || !currentPool.includes(currentEmail)) { + return { updated: false }; + } + + const nextPool = currentPool.filter((email) => email !== currentEmail); + const nextEmail = nextPool[0] || null; + await setPersistentSettings({ customMailProviderPool: nextPool }); + await setEmailStateSilently(nextEmail, { + source: nextEmail ? 'custom-mail-provider-pool-next' : 'custom-mail-provider-pool-empty', + }); + await setState({ customMailProviderPool: nextPool }); + broadcastDataUpdate({ + customMailProviderPool: nextPool, + email: nextEmail, + }); + const logPrefix = String(options.logPrefix || '').trim() || '自定义邮箱号池:流程成功后'; + await addLog( + nextEmail + ? `${logPrefix}已从号池删除 ${currentEmail},下轮将使用 ${nextEmail}。` + : `${logPrefix}已从号池删除 ${currentEmail},号池已为空。`, + options.level || 'ok' + ); + return { + updated: true, + customMailProviderPool: nextPool, + email: nextEmail, + }; +} + function normalizePanelMode(value = '') { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'sub2api') { @@ -3526,6 +3592,8 @@ function normalizePersistentSettingValue(key, value) { return String(value || HERO_SMS_COUNTRY_LABEL).trim() || HERO_SMS_COUNTRY_LABEL; case 'heroSmsCountryFallback': return normalizeHeroSmsCountryFallback(value); + case 'heroSmsOperator': + return normalizeHeroSmsOperator(value, DEFAULT_HERO_SMS_OPERATOR); case 'fiveSimApiKey': return String(value || ''); case 'fiveSimProduct': @@ -3900,6 +3968,15 @@ function mergeAutoRunKeepStateValue(baseValue, patchValue) { function collectAutoRunFreshResetRuntimeSettingKeys() { const keySet = new Set(); + [ + 'currentCompletionTokenByNode', + 'seenCodes', + 'seenInbucketMailIds', + 'signupVerificationRequestedAt', + 'loginVerificationRequestedAt', + 'oauthFlowDeadlineAt', + 'oauthFlowDeadlineSourceUrl', + ].forEach((field) => keySet.add(field)); const flowFieldGroups = isPlainObjectValue(runtimeStateHelpers?.FLOW_FIELD_GROUPS) ? runtimeStateHelpers.FLOW_FIELD_GROUPS : {}; @@ -5271,6 +5348,106 @@ function buildHotmailLocalEndpoint(baseUrl, path) { return new URL(path, `${normalizedBaseUrl}/`).toString(); } +function buildCustomMailLocalEndpoint(path) { + return new URL(path, `${DEFAULT_CUSTOM_MAIL_HELPER_BASE_URL}/`).toString(); +} + +async function requestCustomMailLocalCode(pollPayload = {}) { + const requestTimeoutMs = HOTMAIL_LOCAL_HELPER_TIMEOUT_MS; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs); + + let response; + try { + response = await fetch(buildCustomMailLocalEndpoint('/code'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + top: pollPayload.top || 20, + targetEmail: pollPayload.targetEmail || '', + senderFilters: pollPayload.senderFilters || [], + subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], + excludeCodes: pollPayload.excludeCodes || [], + filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0, + }), + signal: controller.signal, + }); + } catch (err) { + if (err?.name === 'AbortError') { + throw new Error(`自定义邮箱本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`); + } + throw new Error(`自定义邮箱本地助手请求失败:${err.message}`); + } finally { + clearTimeout(timeoutId); + } + + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + + if (!response.ok || payload?.ok === false) { + const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`; + throw new Error(`自定义邮箱本地助手返回失败:${errorText}`); + } + + return { + code: String(payload?.code || ''), + message: payload?.message || null, + usedTimeFallback: Boolean(payload?.usedTimeFallback), + }; +} + +async function pollCustomMailVerificationCode(step, state, pollPayload = {}) { + const maxAttempts = Number(pollPayload.maxAttempts) || 5; + const intervalMs = Number(pollPayload.intervalMs) || 3000; + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + throwIfStopped(); + try { + await addLog(`步骤 ${step}:正在通过自定义邮箱本地助手轮询验证码(${attempt}/${maxAttempts})...`, 'info'); + const fetchResult = await requestCustomMailLocalCode({ + ...pollPayload, + targetEmail: pollPayload.targetEmail || state?.email || '', + }); + + if (fetchResult.code) { + if (fetchResult.usedTimeFallback) { + await addLog(`步骤 ${step}:自定义邮箱本地助手使用时间回退后命中验证码。`, 'warn'); + } + await addLog(`步骤 ${step}:已通过自定义邮箱本地助手找到验证码:${fetchResult.code}`, 'ok'); + return { + ok: true, + code: fetchResult.code, + emailTimestamp: fetchResult.message?.receivedTimestamp || Date.now(), + mailId: fetchResult.message?.id || '', + }; + } + + lastError = new Error(`步骤 ${step}:自定义邮箱本地助手暂未返回匹配验证码(${attempt}/${maxAttempts})。`); + await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info'); + } catch (err) { + lastError = err; + await addLog(`步骤 ${step}:自定义邮箱本地助手轮询失败:${err.message}`, 'warn'); + } + + if (attempt < maxAttempts) { + await sleepWithStop(intervalMs); + } + } + + throw lastError || new Error(`步骤 ${step}:自定义邮箱本地助手未返回新的匹配验证码。`); +} + async function requestHotmailRemoteMailbox(account, mailbox = 'INBOX') { if (!account?.email) { throw new Error('Hotmail 账号缺少邮箱地址。'); @@ -9139,9 +9316,15 @@ async function setNodeStatus(nodeId, status) { const state = await getState(); const nodeStatuses = { ...(state.nodeStatuses || {}) }; nodeStatuses[normalizedNodeId] = status; + const nextCurrentNodeId = isStepDoneStatus(status) + ? (getFirstUnfinishedNodeId(nodeStatuses, { + ...state, + nodeStatuses, + }) || normalizedNodeId) + : normalizedNodeId; await setState({ nodeStatuses, - currentNodeId: normalizedNodeId, + currentNodeId: nextCurrentNodeId, }); chrome.runtime.sendMessage({ type: 'NODE_STATUS_CHANGED', @@ -10870,6 +11053,7 @@ const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ 'fetch-bound-email-login-code', 'post-bound-email-phone-verification', 'confirm-oauth', + 'platform-verify', 'kiro-open-register-page', 'kiro-submit-email', 'kiro-submit-name', @@ -10884,7 +11068,6 @@ const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([ 'fill-password', 'fill-profile', 'gopay-subscription-confirm', - 'platform-verify', ]); const STEP_COMPLETION_SIGNAL_TIMEOUTS_BY_STEP_KEY = new Map([ ['fill-profile', 150000], @@ -10999,10 +11182,24 @@ function getStepCompletionSignalTimeoutMs(step, state = {}) { return getNodeCompletionSignalTimeoutMs(getNodeIdByStepForState(step, state), state); } +function createNodeCompletionToken(nodeId) { + const normalizedNodeId = String(nodeId || '').trim() || 'node'; + const randomPart = Math.random().toString(36).slice(2, 10); + return `${normalizedNodeId}:${Date.now()}:${randomPart}`; +} + function notifyNodeComplete(nodeId, payload) { const normalizedNodeId = String(nodeId || '').trim(); const waiter = nodeWaiters.get(normalizedNodeId); console.log(LOG_PREFIX, `[notifyNodeComplete] node ${normalizedNodeId}, hasWaiter=${Boolean(waiter)}`); + const expectedToken = String(waiter?.completionToken || '').trim(); + if (waiter && expectedToken) { + const actualToken = String(payload?.completionToken || payload?.stepCompletionToken || '').trim(); + if (actualToken !== expectedToken) { + console.warn(LOG_PREFIX, `[notifyNodeComplete] ignore stale completion for node ${normalizedNodeId}: expected token ${expectedToken}, got ${actualToken || 'empty'}`); + return; + } + } if (waiter) waiter.resolve(payload); } @@ -11129,6 +11326,53 @@ async function finalizeDeferredStepExecutionError(step, error) { return finalizeDeferredNodeExecutionError(nodeId, error); } +async function recoverFillProfileCompletionFromBackground(executeError = null, completionToken = '') { + const transportMessage = getErrorMessage(executeError); + await addLog('步骤 5:资料提交后页面通信中断,正在通过后台复核最终状态...', 'warn', { + step: 5, + stepKey: 'fill-profile', + }); + + const signupTabId = await getTabId('openai-auth'); + if (!signupTabId) { + throw new Error('步骤 5:资料提交后页面通信中断,且缺少认证页标签页,无法通过后台复核最终状态。'); + } + + await waitForTabStableComplete(signupTabId, { + timeoutMs: 120000, + retryDelayMs: 300, + stableMs: 1000, + initialDelayMs: 800, + }); + + const completionPayload = { + nodeId: 'fill-profile', + step: 5, + ...(completionToken ? { completionToken } : {}), + outcome: 'background_transport_recovered', + navigationStarted: true, + requireContentStateBeforeUrlSuccess: true, + retryableTransportError: transportMessage, + }; + const pageState = await validateStep5PostCompletion(signupTabId, completionPayload); + const recoveredPayload = { + ...completionPayload, + ...(pageState?.successState ? { successState: pageState.successState } : {}), + ...(pageState?.url ? { url: pageState.url } : {}), + ...(pageState?.postSubmitPromptActionsCompleted ? { postSubmitPromptActionsCompleted: true } : {}), + ...(Number(pageState?.postSubmitPromptActionCount) > 0 ? { postSubmitPromptActionCount: Number(pageState.postSubmitPromptActionCount) } : {}), + recoveredByBackground: true, + step5PostCompletionValidated: true, + }; + + await addLog('步骤 5 [调试] 资料页完成信号丢失,后台复核已确认成功。', 'ok', { + step: 5, + stepKey: 'fill-profile', + }); + notifyNodeComplete('fill-profile', recoveredPayload); + return recoveredPayload; +} + async function executeNodeViaCompletionSignal(nodeId, timeoutMs = 0) { const normalizedNodeId = String(nodeId || '').trim(); const executionState = await getState(); @@ -11138,14 +11382,25 @@ async function executeNodeViaCompletionSignal(nodeId, timeoutMs = 0) { const resolvedTimeoutMs = Number(timeoutMs) > 0 ? timeoutMs : getNodeCompletionSignalTimeoutMs(normalizedNodeId, executionState); + const completionToken = createNodeCompletionToken(normalizedNodeId); + await setState({ + currentCompletionTokenByNode: { + ...(executionState.currentCompletionTokenByNode || {}), + [normalizedNodeId]: completionToken, + }, + }); const completionResultPromise = waitForNodeComplete(normalizedNodeId, resolvedTimeoutMs).then( payload => ({ ok: true, payload }), error => ({ ok: false, error }), ); + const waiter = nodeWaiters.get(normalizedNodeId); + if (waiter) { + waiter.completionToken = completionToken; + } let executeError = null; try { - await executeNode(normalizedNodeId, { deferRetryableTransportError: true }); + await executeNode(normalizedNodeId, { deferRetryableTransportError: true, completionToken }); } catch (err) { executeError = err; if (isStopError(err) || !isRetryableContentScriptTransportError(err)) { @@ -11153,6 +11408,60 @@ async function executeNodeViaCompletionSignal(nodeId, timeoutMs = 0) { } } + const shouldTryStep5BackgroundRecovery = ( + normalizedNodeId === 'fill-profile' + && executeError + && isRetryableContentScriptTransportError(executeError) + ); + if (shouldTryStep5BackgroundRecovery) { + const taggedCompletionPromise = completionResultPromise.then((result) => ({ + source: 'completion', + ...result, + })); + const taggedBackgroundRecoveryPromise = recoverFillProfileCompletionFromBackground(executeError, completionToken).then( + (payload) => ({ + source: 'background-recovery', + ok: true, + payload, + }), + (error) => ({ + source: 'background-recovery', + ok: false, + error, + }), + ); + + const firstResult = await Promise.race([ + taggedCompletionPromise, + taggedBackgroundRecoveryPromise, + ]); + + if (firstResult.ok) { + console.warn( + LOG_PREFIX, + `[executeNodeViaCompletionSignal] node ${normalizedNodeId} completed after deferred execute error: ${getErrorMessage(executeError)}` + ); + return firstResult.payload; + } + + const alternateResult = firstResult.source === 'completion' + ? await taggedBackgroundRecoveryPromise + : await taggedCompletionPromise; + if (alternateResult.ok) { + console.warn( + LOG_PREFIX, + `[executeNodeViaCompletionSignal] node ${normalizedNodeId} completed after deferred execute error: ${getErrorMessage(executeError)}` + ); + return alternateResult.payload; + } + + const recoveryError = firstResult.source === 'background-recovery' + ? firstResult.error + : alternateResult.error; + await finalizeDeferredNodeExecutionError(normalizedNodeId, recoveryError || executeError); + throw recoveryError || executeError; + } + const completionResult = await completionResultPromise; if (completionResult.ok) { if (executeError) { @@ -11520,7 +11829,7 @@ const STEP_FETCH_NETWORK_RETRY_POLICIES = new Map([ ]); async function executeNode(nodeId, options = {}) { - const { deferRetryableTransportError = false } = options; + const { deferRetryableTransportError = false, completionToken = '' } = options; const normalizedNodeId = String(nodeId || '').trim(); if (!normalizedNodeId) { throw new Error('executeNode 缺少 nodeId。'); @@ -11574,6 +11883,7 @@ async function executeNode(nodeId, options = {}) { ...state, visibleStep: Number(step), nodeId: normalizedNodeId, + ...(completionToken ? { completionToken } : {}), nodeDefinition: getNodeDefinitionForState(normalizedNodeId, state), stepDefinition: getStepDefinitionForState(step, state), }); @@ -11693,15 +12003,22 @@ async function executeNodeAndWait(nodeId, delayAfter = 2000) { if (normalizedNodeId === 'fill-profile') { const signupTabId = await getTabId('openai-auth'); if (signupTabId) { - await addLog('自动运行:填写资料节点已收到完成信号,正在等待当前页面完成加载并稳定...', 'info'); - await waitForTabStableComplete(signupTabId, { - timeoutMs: 120000, - retryDelayMs: 300, - stableMs: 1000, - initialDelayMs: 800, - }); try { - await validateStep5PostCompletion(signupTabId, completionPayload || {}); + if (!completionPayload?.step5PostCompletionValidated) { + await addLog('自动运行:填写资料节点已收到完成信号,正在等待当前页面完成加载并稳定...', 'info'); + await waitForTabStableComplete(signupTabId, { + timeoutMs: 120000, + retryDelayMs: 300, + stableMs: 1000, + initialDelayMs: 800, + }); + await validateStep5PostCompletion(signupTabId, completionPayload || {}); + } else { + await addLog('自动运行:步骤 5 后台恢复已完成最终复核,直接进入后续节点。', 'ok', { + step: 5, + stepKey: 'fill-profile', + }); + } await setNodeStatus(normalizedNodeId, 'completed'); await addLog('已完成', 'ok', { nodeId: normalizedNodeId }); await addLog('步骤 5 [调试] 资料页完成信号已通过后台复核。', 'ok', { @@ -12525,10 +12842,6 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { return null; } - if (currentState.email) { - return currentState.email; - } - if (isCustomMailProvider(currentState)) { const poolSize = getCustomMailProviderPool(currentState).length; if (poolSize > 0) { @@ -12537,11 +12850,15 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { throw new Error(`自定义邮箱号池第 ${targetRun} 个邮箱不存在,请检查号池数量是否与自动轮数一致。`); } await setEmailState(queuedEmail); - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;第 4/8 步仍需手动输入验证码)===`, 'ok'); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;请确保自定义邮箱本地助手已启动)===`, 'ok'); return queuedEmail; } } + if (currentState.email) { + return currentState.email; + } + if (isCustomEmailPoolGenerator(currentState)) { const queuedEmail = getCustomEmailPoolEmailForRun(currentState, targetRun); if (!queuedEmail) { @@ -12695,7 +13012,7 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { throw new Error(`自定义邮箱号池第 ${targetRun} 个邮箱不存在,请检查号池数量是否与自动轮数一致。`); } await setEmailState(queuedEmail); - await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;第 4/8 步仍需手动输入验证码)===`, 'ok'); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:自定义邮箱号池已就绪:${queuedEmail}(第 ${attemptRuns} 次尝试;请确保自定义邮箱本地助手已启动)===`, 'ok'); return queuedEmail; } } @@ -13462,6 +13779,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, + CUSTOM_MAIL_PROVIDER: 'custom', completeNodeFromBackground, confirmCustomVerificationStepBypassRequest: (step) => chrome.runtime.sendMessage({ type: 'REQUEST_CUSTOM_VERIFICATION_BYPASS_CONFIRMATION', @@ -13483,6 +13801,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create MAIL_2925_VERIFICATION_MAX_ATTEMPTS, pollCloudflareTempEmailVerificationCode, pollCloudMailVerificationCode, + pollCustomMailVerificationCode, pollHotmailVerificationCode, pollLuckmailVerificationCode, pollYydsMailVerificationCode, @@ -13630,6 +13949,7 @@ const step5Executor = self.MultiPageBackgroundStep5?.createStep5Executor({ addLog, generateRandomBirthday, generateRandomName, + getState, sendToContentScript, }); const step6Executor = self.MultiPageBackgroundStep6?.createStep6Executor({ @@ -14083,13 +14403,20 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter if (!signupTabId) { throw new Error('步骤 5:缺少认证页标签页,无法确认是否已跳转到 https://chatgpt.com。'); } - await waitForTabStableComplete(signupTabId, { - timeoutMs: 120000, - retryDelayMs: 300, - stableMs: 1000, - initialDelayMs: 800, - }); - await validateStep5PostCompletion(signupTabId, completionPayload || {}); + if (!completionPayload?.step5PostCompletionValidated) { + await waitForTabStableComplete(signupTabId, { + timeoutMs: 120000, + retryDelayMs: 300, + stableMs: 1000, + initialDelayMs: 800, + }); + await validateStep5PostCompletion(signupTabId, completionPayload || {}); + } else { + await addLog('步骤 5:后台已完成最终状态复核,直接标记完成并进入下一步。', 'ok', { + step: 5, + stepKey: 'fill-profile', + }); + } await setNodeStatus('fill-profile', 'completed'); await addLog('已完成', 'ok', { nodeId: 'fill-profile' }); }, @@ -14131,6 +14458,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter listIcloudAliases, listLuckmailPurchasesForManagement, markCurrentCustomEmailPoolEntryUsed, + removeCurrentCustomMailProviderPoolEmail, markCurrentRegistrationAccountUsed, getCurrentMail2925Account, normalizeHotmailAccounts, @@ -14876,6 +15204,119 @@ async function getStep5SubmitStateFromContent(options = {}) { return result || {}; } +async function advanceStep5PostSubmitPromptOnTab(options = {}) { + try { + const result = await sendToContentScriptResilient( + 'openai-auth', + { + type: 'ADVANCE_STEP5_POST_SUBMIT_PROMPT', + source: 'background', + payload: {}, + }, + { + timeoutMs: options.timeoutMs ?? 15000, + retryDelayMs: options.retryDelayMs ?? 600, + responseTimeoutMs: options.responseTimeoutMs ?? (options.timeoutMs ?? 15000), + logMessage: options.logMessage || '步骤 5:正在检查注册完成后的弹窗按钮...', + logStep: 5, + logStepKey: options.logStepKey || 'fill-profile', + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + if (result?.advanced) { + return result; + } + } catch (error) { + await addLog(`步骤 5:页面脚本处理注册后弹窗未返回,改用后台兜底点击。${getErrorMessage(error)}`, 'info', { + step: 5, + stepKey: options.logStepKey || 'fill-profile', + }); + } + + const tabId = Number.isInteger(options.tabId) + ? options.tabId + : await getTabId('openai-auth').catch(() => null); + if (!Number.isInteger(tabId)) { + return { advanced: false }; + } + + try { + const executionResults = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'ISOLATED', + func: () => { + const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + const isVisible = (el) => { + if (!el || el.hidden) return false; + const rect = typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null; + if (rect && rect.width <= 0 && rect.height <= 0) return false; + const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null; + return !(style && (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')); + }; + const isEnabled = (el) => Boolean(el) + && !el.disabled + && el.getAttribute?.('aria-disabled') !== 'true' + && el.getAttribute?.('data-disabled') !== 'true'; + const getText = (el) => normalize([ + el?.innerText, + el?.textContent, + el?.value, + el?.getAttribute?.('aria-label'), + el?.getAttribute?.('title'), + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('data-dd-action-name'), + ].filter(Boolean).join(' ')); + const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]')) + .filter((el) => isVisible(el) && isEnabled(el)) + .map((el) => ({ el, text: getText(el) })) + .filter(({ text }) => text && !/(?:完成\s*(?:帐户|账户|账号)?\s*创建|创建\s*(?:帐户|账户|账号)|create\s+(?:an\s+)?account|create\s+your\s+account|complete\s+(?:account\s+)?creation)/i.test(text)); + + if (candidates.length === 0) { + return { advanced: false, fallback: true, reason: 'prompt_not_detected' }; + } + + const pageText = normalize(document.body?.innerText || document.documentElement?.innerText || ''); + const looksLikePostSubmitPrompt = /(?:是什么促使你使用\s*chatgpt|我们会利用这些信息|學校|学校|工作|个人任务|乐趣和娱乐|其他|你已准备就绪|可能会犯错|继续操作即表示你同意|条款|隐私政策|跳过|下一步|继续|同意|what\s+brings\s+you\s+to\s+chatgpt|you(?:'|’)re\s+all\s+set|continue|skip|agree)/i.test(pageText); + if (!looksLikePostSubmitPrompt) { + return { advanced: false, fallback: true, reason: 'prompt_not_detected', buttons: candidates.map(({ text }) => text).slice(0, 20) }; + } + + const exactSkip = candidates.find(({ text }) => /^(?:跳过|稍后|以后|skip|not\s+now|maybe\s+later)$/i.test(text)); + const looseSkip = candidates.find(({ text }) => /(?:跳过|稍后|以后|skip|not\s+now|maybe\s+later)/i.test(text)); + const exactContinue = candidates.find(({ text }) => /^(?:继续|下一步|同意|确认|知道了|我知道了|好的|continue|next|agree|accept|confirm|got\s+it|ok|okay|done|finish)$/i.test(text)); + const looseContinue = candidates.find(({ text }) => /(?:继续|下一步|同意|确认|知道了|我知道了|好的|continue|next|agree|accept|confirm|got\s+it|ok|okay|done|finish)/i.test(text)); + const target = exactSkip || looseSkip || exactContinue || looseContinue; + if (!target?.el) { + return { advanced: false, fallback: true, reason: 'button_not_found', buttons: candidates.map(({ text }) => text).slice(0, 20), pageText: pageText.slice(0, 300) }; + } + + target.el.scrollIntoView?.({ behavior: 'instant', block: 'center', inline: 'center' }); + target.el.focus?.(); + target.el.click?.(); + return { advanced: true, actionText: target.text, fallback: true }; + }, + }); + const fallbackResult = executionResults?.[0]?.result || { advanced: false }; + if (fallbackResult?.advanced) { + await addLog(`步骤 5:后台兜底已点击注册后弹窗按钮“${fallbackResult.actionText || '跳过/继续'}”。`, 'info', { + step: 5, + stepKey: options.logStepKey || 'fill-profile', + }); + } + return fallbackResult || { advanced: false }; + } catch (error) { + await addLog(`步骤 5:后台兜底点击注册后弹窗失败:${getErrorMessage(error)}`, 'warn', { + step: 5, + stepKey: options.logStepKey || 'fill-profile', + }); + return { advanced: false, error: getErrorMessage(error) }; + } +} + async function recoverStep5SubmitRetryPageOnTab(options = {}) { const result = await sendToContentScriptResilient( 'openai-auth', @@ -14949,7 +15390,11 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) { }; const maxAuthRetryRecoveries = Math.max(1, Number(completionPayload?.maxAuthRetryRecoveries) || 2); + const maxPostSubmitPromptActions = Math.max(0, Number(completionPayload?.maxPostSubmitPromptActions) || 3); + const minPostSubmitPromptActions = Math.min(maxPostSubmitPromptActions, Math.max(0, Number(completionPayload?.minPostSubmitPromptActions) || 2)); let authRetryRecoveryCount = 0; + let postSubmitPromptActionCount = 0; + let lastPromptResult = null; await debugLog('后台已收到资料页完成信号,准备开始最终状态复核。', { completionOutcome: String(completionPayload?.outcome || '').trim(), completionUrl: String(completionPayload?.url || '').trim(), @@ -14959,8 +15404,81 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) { while (true) { const tab = await chrome.tabs.get(tabId).catch(() => null); const currentUrl = String(tab?.url || completionPayload?.url || '').trim(); - if (currentUrl && isStep5CompletionChatgptUrl(currentUrl)) { - await debugLog('后台直接通过标签页 URL 确认已进入 chatgpt.com,步骤 5 完成。', { + + if (postSubmitPromptActionCount < maxPostSubmitPromptActions) { + const promptResult = await advanceStep5PostSubmitPromptOnTab({ + tabId, + timeoutMs: 15000, + responseTimeoutMs: 15000, + retryDelayMs: 500, + logMessage: '步骤 5:正在处理注册完成后的跳过/继续弹窗...', + }); + lastPromptResult = promptResult || null; + if (promptResult?.advanced) { + postSubmitPromptActionCount += 1; + await debugLog(`后台复核已处理注册后弹窗(${postSubmitPromptActionCount}/${maxPostSubmitPromptActions})。`, { + completionOutcome: String(completionPayload?.outcome || '').trim(), + completionUrl: String(completionPayload?.url || '').trim(), + navigationStarted: Boolean(completionPayload?.navigationStarted), + tabUrl: currentUrl, + pageState: promptResult?.state, + level: 'info', + }); + await waitForTabStableComplete(tabId, { + timeoutMs: 10000, + retryDelayMs: 250, + stableMs: 500, + initialDelayMs: 300, + }).catch(() => null); + const latestTab = await chrome.tabs.get(tabId).catch(() => null); + const latestUrl = String(latestTab?.url || currentUrl || completionPayload?.url || '').trim(); + if (postSubmitPromptActionCount < maxPostSubmitPromptActions) { + continue; + } + await debugLog(`后台复核已处理注册后弹窗 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,按已完成账号注册进入步骤 6。`, { + completionOutcome: String(completionPayload?.outcome || '').trim(), + completionUrl: String(completionPayload?.url || '').trim(), + navigationStarted: Boolean(completionPayload?.navigationStarted), + tabUrl: latestUrl, + pageState: promptResult?.state, + level: 'ok', + }); + return { + successState: isStep5CompletionChatgptUrl(latestUrl) ? 'logged_in_home' : 'post_submit_prompts_completed', + url: latestUrl, + postSubmitPromptActionsCompleted: true, + postSubmitPromptActionCount, + }; + } + if (postSubmitPromptActionCount >= minPostSubmitPromptActions) { + await debugLog(`后台复核已处理注册后弹窗 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,最后一轮未检测到新弹窗,忽略等待并进入步骤 6。`, { + completionOutcome: String(completionPayload?.outcome || '').trim(), + completionUrl: String(completionPayload?.url || '').trim(), + navigationStarted: Boolean(completionPayload?.navigationStarted), + tabUrl: currentUrl, + pageState: lastPromptResult, + level: 'ok', + }); + return { + successState: isStep5CompletionChatgptUrl(currentUrl) ? 'logged_in_home' : 'post_submit_prompts_completed', + url: currentUrl, + postSubmitPromptActionsCompleted: true, + postSubmitPromptActionCount, + }; + } + } + + if (!completionPayload?.requireContentStateBeforeUrlSuccess && currentUrl && isStep5CompletionChatgptUrl(currentUrl)) { + if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < maxPostSubmitPromptActions) { + await debugLog(`后台已进入 chatgpt.com,但注册后弹窗仅处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,继续等待下一轮弹窗处理。`, { + completionOutcome: String(completionPayload?.outcome || '').trim(), + completionUrl: String(completionPayload?.url || '').trim(), + navigationStarted: Boolean(completionPayload?.navigationStarted), + tabUrl: currentUrl, + level: 'info', + }); + } else { + await debugLog('后台确认已进入 chatgpt.com 且没有待处理的注册后弹窗,步骤 5 完成。', { completionOutcome: String(completionPayload?.outcome || '').trim(), completionUrl: String(completionPayload?.url || '').trim(), navigationStarted: Boolean(completionPayload?.navigationStarted), @@ -14971,6 +15489,41 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) { successState: 'logged_in_home', url: currentUrl, }; + } + } + + if ( + completionPayload?.requireContentStateBeforeUrlSuccess + && currentUrl + && isStep5CompletionChatgptUrl(currentUrl) + && postSubmitPromptActionCount > 0 + && lastPromptResult?.fallback + && lastPromptResult?.reason === 'prompt_not_detected' + ) { + if (postSubmitPromptActionCount < maxPostSubmitPromptActions) { + await debugLog(`后台兜底已进入 chatgpt.com,但注册后弹窗仅处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,继续等待下一轮弹窗处理。`, { + completionOutcome: String(completionPayload?.outcome || '').trim(), + completionUrl: String(completionPayload?.url || '').trim(), + navigationStarted: Boolean(completionPayload?.navigationStarted), + tabUrl: currentUrl, + pageState: lastPromptResult, + level: 'warn', + }); + } else { + await debugLog('后台兜底确认注册后弹窗已处理完,当前已进入 chatgpt.com 正常首页,步骤 5 完成。', { + completionOutcome: String(completionPayload?.outcome || '').trim(), + completionUrl: String(completionPayload?.url || '').trim(), + navigationStarted: Boolean(completionPayload?.navigationStarted), + tabUrl: currentUrl, + pageState: lastPromptResult, + level: 'ok', + }); + return { + successState: 'logged_in_home', + url: currentUrl, + recoveredByFallback: true, + }; + } } const pageState = await getStep5SubmitStateFromContent({ @@ -15026,6 +15579,17 @@ async function validateStep5PostCompletion(tabId, completionPayload = {}) { } if (pageState.successState === 'logged_in_home' && isStep5CompletionChatgptUrl(pageState.url)) { + if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < maxPostSubmitPromptActions) { + await debugLog(`后台复核确认已进入 chatgpt.com,但注册后弹窗仅处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,继续等待下一轮弹窗处理。`, { + completionOutcome: String(completionPayload?.outcome || '').trim(), + completionUrl: String(completionPayload?.url || '').trim(), + navigationStarted: Boolean(completionPayload?.navigationStarted), + tabUrl: currentUrl, + pageState, + level: 'info', + }); + continue; + } await debugLog(`后台复核确认成功状态:${pageState.successState}`, { completionOutcome: String(completionPayload?.outcome || '').trim(), completionUrl: String(completionPayload?.url || '').trim(), diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 82f0ed8c..aec60809 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -634,8 +634,17 @@ attemptRun, sessionId, }), + runId: `${sessionId}:${targetRun}:${attemptRun}`, + activeRunId: `${sessionId}:${targetRun}:${attemptRun}`, currentNodeId: '', nodeStatuses: buildFreshAttemptNodeStatuses(prevState), + currentCompletionTokenByNode: {}, + seenCodes: [], + seenInbucketMailIds: [], + signupVerificationRequestedAt: null, + loginVerificationRequestedAt: null, + oauthFlowDeadlineAt: null, + oauthFlowDeadlineSourceUrl: null, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), autoRunSessionId: sessionId, tabRegistry: {}, diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index 08141efd..01f776b5 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -23,6 +23,7 @@ DEFAULT_FIVE_SIM_BASE_URL = 'https://5sim.net/v1', DEFAULT_FIVE_SIM_PRODUCT = 'openai', DEFAULT_FIVE_SIM_OPERATOR = 'any', + DEFAULT_HERO_SMS_OPERATOR = 'any', DEFAULT_FIVE_SIM_COUNTRY_ORDER = ['thailand'], DEFAULT_NEX_SMS_BASE_URL = 'https://api.nexsms.net', DEFAULT_NEX_SMS_COUNTRY_ORDER = [1], @@ -548,6 +549,21 @@ return String(value || '').trim() || fallback; } + function normalizeHeroSmsOperator(value = '', fallback = DEFAULT_HERO_SMS_OPERATOR) { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, ''); + if (normalized) { + return normalized; + } + const fallbackNormalized = String(fallback || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, ''); + return fallbackNormalized || DEFAULT_HERO_SMS_OPERATOR; + } + function inferHeroSmsCountryFromPhoneNumber(phoneNumber = '') { const digits = String(phoneNumber || '').replace(/\D+/g, ''); if (!digits) { @@ -2100,6 +2116,7 @@ provider, apiKey, baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), + operator: normalizeHeroSmsOperator(state.heroSmsOperator, DEFAULT_HERO_SMS_OPERATOR), countryCandidates: resolveCountryCandidates(state), }; } @@ -2113,10 +2130,33 @@ provider: PHONE_SMS_PROVIDER_HERO, apiKey, baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL), + operator: normalizeHeroSmsOperator(state.heroSmsOperator, DEFAULT_HERO_SMS_OPERATOR), countryCandidates: resolveCountryCandidates(state), }; } + async function mergeLatestPhoneSettingsState(state = {}) { + if (typeof getState !== 'function') { + return state || {}; + } + try { + const latestState = await getState(); + if (!latestState || typeof latestState !== 'object') { + return state || {}; + } + return { + ...latestState, + ...(state || {}), + heroSmsOperator: normalizeHeroSmsOperator( + state?.heroSmsOperator, + latestState?.heroSmsOperator || DEFAULT_HERO_SMS_OPERATOR + ), + }; + } catch (_) { + return state || {}; + } + } + function parseActivationPayload(payload, fallback = null) { const normalizedFallback = normalizeActivation(fallback) || normalizeActivationFallback(fallback); const directActivation = normalizeActivation(payload); @@ -2537,6 +2577,10 @@ service: HERO_SMS_SERVICE_CODE, country: countryConfig.id, }; + const operator = normalizeHeroSmsOperator(config?.operator, DEFAULT_HERO_SMS_OPERATOR); + if (operator && operator !== DEFAULT_HERO_SMS_OPERATOR) { + query.operator = operator; + } if (options.maxPrice !== null && options.maxPrice !== undefined) { query.maxPrice = options.maxPrice; if (options.fixedPrice !== false) { @@ -3584,6 +3628,7 @@ } async function requestPhoneActivation(state = {}, options = {}) { + state = await mergeLatestPhoneSettingsState(state); if (normalizePhoneSmsProvider(state?.phoneSmsProvider) === PHONE_SMS_PROVIDER_FIVE_SIM) { const provider = getFiveSimProviderForState(state); if (provider) { @@ -5101,6 +5146,7 @@ } async function acquirePhoneActivation(state = {}, options = {}) { + state = await mergeLatestPhoneSettingsState(state); const provider = normalizePhoneSmsProvider(state?.phoneSmsProvider || DEFAULT_PHONE_SMS_PROVIDER); const providerOrder = resolvePhoneProviderOrder(state, provider); const countryCandidates = resolveCountryCandidatesForProvider(state, provider); diff --git a/background/signup-flow-helpers.js b/background/signup-flow-helpers.js index 75d20705..8d783e1d 100644 --- a/background/signup-flow-helpers.js +++ b/background/signup-flow-helpers.js @@ -60,6 +60,7 @@ const tabId = await reuseOrCreateTab('openai-auth', SIGNUP_ENTRY_URL, { inject: OPENAI_AUTH_INJECT_FILES, injectSource: 'openai-auth', + forceNew: Number(step) === 1, }); await waitForSignupEntryTabToSettle(tabId, step); diff --git a/background/verification-flow.js b/background/verification-flow.js index c417724c..137adb35 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -12,6 +12,7 @@ closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER = 'cloudmail', + CUSTOM_MAIL_PROVIDER = 'custom', completeNodeFromBackground, confirmCustomVerificationStepBypassRequest, getNodeIdByStepForState, @@ -29,6 +30,7 @@ MAIL_2925_VERIFICATION_MAX_ATTEMPTS, pollCloudflareTempEmailVerificationCode, pollCloudMailVerificationCode, + pollCustomMailVerificationCode, pollHotmailVerificationCode, pollLuckmailVerificationCode, pollYydsMailVerificationCode, @@ -987,6 +989,13 @@ }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); return pollCloudMailVerificationCode(step, state, timedPoll.payload); } + if (mail.provider === CUSTOM_MAIL_PROVIDER && typeof pollCustomMailVerificationCode === 'function') { + const timedPoll = await applyMailPollingTimeBudget(step, { + ...getVerificationPollPayload(step, state), + ...cleanPollOverrides, + }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`); + return pollCustomMailVerificationCode(step, state, timedPoll.payload); + } if (mail.provider === YYDS_MAIL_PROVIDER) { const timedPoll = await applyMailPollingTimeBudget(step, { ...getVerificationPollPayload(step, state), diff --git a/flows/openai/background/steps/fetch-login-code.js b/flows/openai/background/steps/fetch-login-code.js index 1fc50829..acb5a82e 100644 --- a/flows/openai/background/steps/fetch-login-code.js +++ b/flows/openai/background/steps/fetch-login-code.js @@ -594,14 +594,6 @@ await addLog(`步骤 ${visibleStep}:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info'); } - if (shouldUseCustomRegistrationEmail(preparedState)) { - await confirmCustomVerificationStepBypass(8, { - completionStep: visibleStep, - promptStep: visibleStep, - }); - return { lastResendAt: latestResendAt }; - } - if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') { await addLog(`步骤 ${visibleStep}:正在确认 iCloud 邮箱登录态...`, 'info'); await ensureIcloudMailSession({ @@ -611,12 +603,21 @@ }); } + if (shouldUseCustomRegistrationEmail(preparedState) && mail.provider !== 'custom') { + await confirmCustomVerificationStepBypass(8, { + completionStep: visibleStep, + promptStep: visibleStep, + }); + return { lastResendAt: latestResendAt }; + } + throwIfStopped(); if ( mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === CLOUD_MAIL_PROVIDER + || mail.provider === 'custom' ) { await addLog(`步骤 ${visibleStep}:正在通过 ${mail.label} 轮询验证码...`); } else { diff --git a/flows/openai/background/steps/fetch-signup-code.js b/flows/openai/background/steps/fetch-signup-code.js index 4701b8d7..35dd06d4 100644 --- a/flows/openai/background/steps/fetch-signup-code.js +++ b/flows/openai/background/steps/fetch-signup-code.js @@ -93,14 +93,14 @@ } async function executeSignupEmailVerificationStep(state, stepStartedAt, verificationSessionKey) { - if (shouldUseCustomRegistrationEmail(state)) { + const mail = getMailConfig(state); + if (mail.error) throw new Error(mail.error); + + if (shouldUseCustomRegistrationEmail(state) && mail.provider !== 'custom') { await confirmCustomVerificationStepBypass(4); return; } - const mail = getMailConfig(state); - if (mail.error) throw new Error(mail.error); - const verificationFilterAfterTimestamp = mail.provider === '2925' ? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS) : stepStartedAt; @@ -120,6 +120,7 @@ || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === CLOUD_MAIL_PROVIDER + || mail.provider === 'custom' ) { await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`); } else if (mail.provider === '2925') { @@ -146,6 +147,7 @@ LUCKMAIL_PROVIDER, CLOUDFLARE_TEMP_EMAIL_PROVIDER, CLOUD_MAIL_PROVIDER, + 'custom', ].includes(mail.provider); const signupProfile = buildSignupProfileForVerificationStep(); diff --git a/flows/openai/background/steps/fill-profile.js b/flows/openai/background/steps/fill-profile.js index 9bc18886..dd7deeaa 100644 --- a/flows/openai/background/steps/fill-profile.js +++ b/flows/openai/background/steps/fill-profile.js @@ -6,12 +6,17 @@ addLog, generateRandomBirthday, generateRandomName, + getState, sendToContentScript, } = deps; - async function executeStep5() { + async function executeStep5(state = {}) { const { firstName, lastName } = generateRandomName(); const { year, month, day } = generateRandomBirthday(); + const currentState = state && typeof state === 'object' && Object.keys(state).length + ? state + : (typeof getState === 'function' ? await getState() : {}); + const completionToken = String(currentState?.completionToken || currentState?.currentCompletionTokenByNode?.['fill-profile'] || '').trim(); await addLog(`步骤 5:已生成姓名 ${firstName} ${lastName},生日 ${year}-${month}-${day}`); @@ -26,6 +31,7 @@ year, month, day, + ...(completionToken ? { completionToken } : {}), }, }); } diff --git a/flows/openai/content/openai-auth.js b/flows/openai/content/openai-auth.js index 2658fd1d..60f09e02 100644 --- a/flows/openai/content/openai-auth.js +++ b/flows/openai/content/openai-auth.js @@ -28,6 +28,7 @@ if (document.documentElement.getAttribute(OPENAI_AUTH_LISTENER_SENTINEL) !== '1' || message.type === 'GET_LOGIN_AUTH_STATE' || message.type === 'SUBMIT_ADD_EMAIL' || message.type === 'GET_STEP5_SUBMIT_STATE' + || message.type === 'ADVANCE_STEP5_POST_SUBMIT_PROMPT' || message.type === 'PREPARE_SIGNUP_VERIFICATION' || message.type === 'RECOVER_AUTH_RETRY_PAGE' || message.type === 'RECOVER_STEP5_SUBMIT_RETRY_PAGE' @@ -135,6 +136,13 @@ async function handleCommand(message) { return await submitAddEmailAndContinue(message.payload); case 'GET_STEP5_SUBMIT_STATE': return getStep5SubmitState(); + case 'ADVANCE_STEP5_POST_SUBMIT_PROMPT': { + const advanced = await advanceStep5PostSubmitOnboardingPage({ allowProfileVisiblePrompt: true }); + return { + advanced: Boolean(advanced), + state: getStep5SubmitState(), + }; + } case 'PREPARE_SIGNUP_VERIFICATION': return await prepareSignupVerificationFlow(message.payload); case 'RECOVER_AUTH_RETRY_PAGE': @@ -249,9 +257,12 @@ function getVerificationCodeTarget() { function getActionText(el) { return [ el?.textContent, + el?.innerText, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title'), + el?.getAttribute?.('data-testid'), + el?.getAttribute?.('data-dd-action-name'), ] .filter(Boolean) .join(' ') @@ -2989,6 +3000,9 @@ function isStep5CompletionChatgptUrl(rawUrl = location.href) { } const path = String(parsed.pathname || ''); + if (/^\/(?:onboarding|questionnaire|survey|personalization|personalize|getting-started|welcome|consult)(?:[/?#]|$)/i.test(path)) { + return false; + } return !/^\/(?:auth\/|create-account\/|email-verification|log-in|add-phone)(?:[/?#]|$)/i.test(path); } catch { return false; @@ -3021,6 +3035,29 @@ function getStep4PostVerificationState(options = {}) { return null; } +async function advanceStep4PostVerificationInterstitialPage() { + if (typeof isStep5PostSubmitOnboardingPage !== 'function' || typeof findStep5PostSubmitOnboardingAction !== 'function') { + return false; + } + if (!isStep5PostSubmitOnboardingPage()) { + return false; + } + + const action = findStep5PostSubmitOnboardingAction(); + if (!action) { + return false; + } + + const text = typeof getActionText === 'function' + ? getActionText(action) + : String(action?.textContent || action?.value || '').trim(); + log(`步骤 4:检测到验证码通过后的弹窗/入门页面,正在点击“${text || '跳过/继续'}”直到进入步骤 5。`, 'warn'); + await humanPause(350, 900); + simulateClick(action); + await sleep(1000); + return true; +} + function getPageTextSnapshot() { return (document.body?.innerText || document.body?.textContent || '') .replace(/\s+/g, ' ') @@ -5013,6 +5050,12 @@ async function waitForVerificationSubmitOutcome(step, timeout, options = {}) { } if (step === 4) { + if ( + typeof advanceStep4PostVerificationInterstitialPage === 'function' + && await advanceStep4PostVerificationInterstitialPage() + ) { + continue; + } const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true }); if (postVerificationState?.state === 'logged_in_home') { return { @@ -5056,6 +5099,23 @@ async function waitForVerificationSubmitOutcome(step, timeout, options = {}) { throw createSignupUserAlreadyExistsError(); } + if ( + typeof advanceStep4PostVerificationInterstitialPage === 'function' + && await advanceStep4PostVerificationInterstitialPage() + ) { + const postAdvanceState = getStep4PostVerificationState({ ignoreVerificationVisibility: true }); + if (postAdvanceState?.state === 'logged_in_home') { + return { + success: true, + skipProfileStep: true, + url: postAdvanceState.url || location.href, + }; + } + if (postAdvanceState?.state === 'step5') { + return { success: true }; + } + } + const postVerificationState = getStep4PostVerificationState({ ignoreVerificationVisibility: true }); if (postVerificationState?.state === 'logged_in_home') { return { @@ -6745,6 +6805,10 @@ function getStep5PostSubmitSuccessState() { return null; } + if (typeof isStep5PostSubmitOnboardingPage === 'function' && isStep5PostSubmitOnboardingPage()) { + return null; + } + if (isStep5CompletionChatgptUrl()) { return { state: 'logged_in_home', @@ -6755,6 +6819,111 @@ function getStep5PostSubmitSuccessState() { return null; } +function isStep5PostSubmitOnboardingPage(options = {}) { + const allowProfileVisiblePrompt = Boolean(options?.allowProfileVisiblePrompt); + if (!allowProfileVisiblePrompt && isStep5ProfileStillVisible()) { + return false; + } + + let host = ''; + let path = ''; + try { + const parsed = new URL(String(location.href || '').trim()); + host = String(parsed.hostname || '').toLowerCase(); + path = String(parsed.pathname || '').toLowerCase(); + } catch { + return false; + } + + if (!['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com', 'auth.openai.com', 'auth0.openai.com', 'accounts.openai.com'].includes(host)) { + return false; + } + + if (/\/(?:onboarding|questionnaire|survey|personalization|personalize|getting-started|welcome|consult)(?:[/?#]|$)/i.test(path)) { + return true; + } + + const action = findStep5PostSubmitOnboardingAction(); + if (!action) { + return false; + } + + const pageText = typeof getPageTextSnapshot === 'function' + ? getPageTextSnapshot() + : String(document.body?.innerText || document.body?.textContent || '').replace(/\s+/g, ' ').trim(); + return /(?:what\s+brings\s+you\s+to\s+chatgpt|tell\s+us\s+about\s+yourself|customi[sz]e\s+chatgpt|personalize\s+your\s+experience|how\s+will\s+you\s+use\s+chatgpt|which\s+best\s+describes\s+you|start\s+using\s+chatgpt|you(?:'|’)re\s+all\s+set|you\s+are\s+all\s+set|ready\s+to\s+go|chatgpt\s+may\s+make\s+mistakes|chats\s+may\s+be\s+reviewed|by\s+(?:continuing|clicking|selecting)\s+you\s+agree|terms\s+of\s+use|privacy\s+policy|accept\s+(?:all\s+)?(?:terms|cookies)|got\s+it|skip\s+for\s+now|你已准备就绪|你已準備就緒|已准备就绪|已準備就緒|可能会犯错|可能會犯錯|聊天可能会被审查|聊天可能會被審查|继续操作即表示你同意|繼續操作即表示你同意|点击即表示同意|點擊即表示同意|是什么促使你使用\s*chatgpt|是什麼促使你使用\s*chatgpt|我们会利用这些信息|我們會利用這些資訊|提出一些可能会对你有用的建议|學校|学校|工作|个人任务|個人任務|乐趣和娱乐|樂趣和娛樂|其他|条款|條款|隐私政策|隱私政策|服务条款|服務條款|使用条款|使用條款|接受|同意|确认|確認|知道了|我知道了|明白|入门|开始使用|告诉我们|个人化|个性化|问卷|调查|咨询|跳过|稍后|下一步|继续)/i.test(pageText) + && Boolean(action); +} + +function findStep5PostSubmitOnboardingAction() { + const directButtons = Array.from(document.querySelectorAll('button, [role="button"]')); + const directSkipButton = directButtons.find((el) => { + if (!isVisibleElement(el) || !isActionEnabled(el)) return false; + return /^(?:跳过|稍后|以后|skip|not\s+now|maybe\s+later|do\s+this\s+later|スキップ|後で)$/i.test(getActionText(el)); + }); + if (directSkipButton) { + return directSkipButton; + } + + const directContinueButton = directButtons.find((el) => { + if (!isVisibleElement(el) || !isActionEnabled(el)) return false; + return /^(?:继续|下一步|同意|确认|知道了|我知道了|好的|continue|next|agree|accept|confirm|got\s+it|ok|okay|done|finish)$/i.test(getActionText(el)); + }); + if (directContinueButton) { + return directContinueButton; + } + + const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="link"], input[type="button"], input[type="submit"]')); + const scored = []; + + for (const el of candidates) { + if (!isVisibleElement(el) || !isActionEnabled(el)) { + continue; + } + const text = typeof getActionText === 'function' + ? getActionText(el) + : [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')] + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + if (!text) { + continue; + } + if (/(?:完成\s*(?:帐户|账户|账号)?\s*创建|创建\s*(?:帐户|账户|账号)|create\s+(?:an\s+)?account|create\s+your\s+account|complete\s+(?:account\s+)?creation|アカウント(?:を)?作成)/i.test(text)) { + continue; + } + if (/skip|not\s+now|maybe\s+later|do\s+this\s+later|稍后|以后|跳过|スキップ|後で/i.test(text)) { + scored.push({ el, score: 1 }); + continue; + } + if (/accept|agree|allow|confirm|got\s+it|okay?|ok|i\s+understand|next|continue|start|done|finish|let'?s\s+go|同意|接受|允许|允許|确认|確認|知道了|我知道了|明白|好的|下一步|继续|继续使用|开始|完成|次へ|続行|始める|同意する|許可|確認/i.test(text)) { + scored.push({ el, score: 2 }); + } + } + + scored.sort((a, b) => a.score - b.score); + return scored[0]?.el || null; +} + +async function advanceStep5PostSubmitOnboardingPage(options = {}) { + if (!isStep5PostSubmitOnboardingPage(options)) { + return false; + } + + const action = findStep5PostSubmitOnboardingAction(); + if (!action) { + return false; + } + + const text = typeof getActionText === 'function' ? getActionText(action) : String(action?.textContent || action?.value || '').trim(); + log(`步骤 5:检测到注册后的咨询/入门页面,正在点击“${text || '跳过/下一步'}”继续流程。`, 'info'); + await humanPause(350, 900); + simulateClick(action); + await sleep(1000); + return true; +} + function getStep5SubmitState() { const retryState = getStep5AuthRetryPageState(); const successState = getStep5PostSubmitSuccessState(); @@ -6839,7 +7008,7 @@ function installStep5NavigationCompletionReporter(completeOnce) { const onNavigationStarted = (event) => { const eventType = String(event?.type || 'navigation').trim() || 'navigation'; debugLog(`检测到页面开始导航(event=${eventType})。`, { - level: 'warn', + level: 'info', }); }; @@ -6866,11 +7035,14 @@ async function waitForStep5SubmitOutcome(options = {}) { const { timeoutMs = 120000, maxAuthRetryRecoveries = 2, + maxPostSubmitPromptActions = 3, maxSubmitClicks = 3, retryClickIntervalMs = 3500, } = options; const start = Date.now(); let authRetryRecoveryCount = 0; + let postSubmitPromptActionCount = 0; + const minPostSubmitPromptActions = Math.min(maxPostSubmitPromptActions, Math.max(0, Number(options?.minPostSubmitPromptActions) || 2)); let submitClickCount = 1; let lastSubmitClickAt = Date.now(); let lastStep5Error = ''; @@ -6909,8 +7081,33 @@ async function waitForStep5SubmitOutcome(options = {}) { continue; } + if (postSubmitPromptActionCount < maxPostSubmitPromptActions && await advanceStep5PostSubmitOnboardingPage({ allowProfileVisiblePrompt: true })) { + postSubmitPromptActionCount += 1; + lastSubmitClickAt = Date.now(); + const url = String(location.href || '').trim(); + debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次。`, { + level: 'ok', + }); + if (postSubmitPromptActionCount < maxPostSubmitPromptActions) { + continue; + } + debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,按已完成账号注册进入后续步骤。`, { + level: 'ok', + }); + return { + state: isStep5CompletionChatgptUrl(url) ? 'logged_in_home' : 'post_submit_prompts_completed', + url, + postSubmitPromptActionsCompleted: true, + postSubmitPromptActionCount, + }; + } + const successState = getStep5PostSubmitSuccessState(); if (successState) { + if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < maxPostSubmitPromptActions) { + await sleep(250); + continue; + } debugLog(`检测到资料提交成功状态:${successState.state || 'unknown'}`, { level: 'ok', }); @@ -6955,9 +7152,38 @@ async function waitForStep5SubmitOutcome(options = {}) { const finalSuccessState = getStep5PostSubmitSuccessState(); if (finalSuccessState) { + if (postSubmitPromptActionCount > 0 && postSubmitPromptActionCount < minPostSubmitPromptActions) { + throw new Error(`步骤 5:注册后弹窗仅处理 ${postSubmitPromptActionCount}/${minPostSubmitPromptActions} 次,尚未达到完成门槛。URL: ${location.href}`); + } + if (postSubmitPromptActionCount >= minPostSubmitPromptActions && postSubmitPromptActionCount < maxPostSubmitPromptActions) { + const url = String(location.href || '').trim(); + debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,最后一轮未检测到新弹窗,忽略等待超时并按成功进入后续步骤。`, { + level: 'ok', + }); + return { + ...finalSuccessState, + state: finalSuccessState.state || (isStep5CompletionChatgptUrl(url) ? 'logged_in_home' : 'post_submit_prompts_completed'), + url: finalSuccessState.url || url, + postSubmitPromptActionsCompleted: true, + postSubmitPromptActionCount, + }; + } return finalSuccessState; } + if (postSubmitPromptActionCount >= minPostSubmitPromptActions) { + const url = String(location.href || '').trim(); + debugLog(`注册后弹窗已处理 ${postSubmitPromptActionCount}/${maxPostSubmitPromptActions} 次,最后一轮未检测到新弹窗,忽略等待超时并按成功进入后续步骤。`, { + level: 'ok', + }); + return { + state: isStep5CompletionChatgptUrl(url) ? 'logged_in_home' : 'post_submit_prompts_completed', + url, + postSubmitPromptActionsCompleted: true, + postSubmitPromptActionCount, + }; + } + const finalStep5Error = (typeof getStep5ErrorText === 'function' ? getStep5ErrorText() : '') || lastStep5Error; if (finalStep5Error) { throw new Error(`步骤 5:资料提交后页面返回错误:${finalStep5Error}。URL: ${location.href}`); @@ -6968,6 +7194,7 @@ async function waitForStep5SubmitOutcome(options = {}) { async function step5_fillNameBirthday(payload) { const { firstName, lastName, age, year, month, day, prefillOnly = false } = payload; + const completionToken = String(payload?.completionToken || '').trim(); if (!firstName || !lastName) throw new Error('未提供姓名数据。'); const performOperationWithDelay = typeof getOperationDelayRunner === 'function' ? getOperationDelayRunner() @@ -7242,6 +7469,9 @@ async function step5_fillNameBirthday(payload) { navigationStarted: Boolean(extra.navigationStarted), outcome: extra.outcome || null, }); + if (completionToken) { + completionPayload.completionToken = completionToken; + } debugLog(`准备发送完成信号(reason=${completionReason},isAgeMode=${isAgeMode})。`, { level: extra?.navigationStarted ? 'warn' : 'info', }); diff --git a/scripts/custom_mail_helper.py b/scripts/custom_mail_helper.py new file mode 100644 index 00000000..b6e4d8b0 --- /dev/null +++ b/scripts/custom_mail_helper.py @@ -0,0 +1,428 @@ +import email +import html +import imaplib +import json +import os +import re +import secrets +import ssl +import sqlite3 +import string +import traceback +from datetime import datetime, timezone +from email.header import decode_header +from email.utils import getaddresses, parseaddr, parsedate_to_datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def load_dotenv_file(path): + if not os.path.exists(path): + return + with open(path, "r", encoding="utf-8") as env_file: + for raw_line in env_file: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if not key or key in os.environ: + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + os.environ[key] = value + + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +load_dotenv_file(os.path.join(PROJECT_ROOT, ".env")) + + +HOST = "127.0.0.1" +PORT = int(os.environ.get("FLOWPILOT_CUSTOM_MAIL_HELPER_PORT", "17374")) +IMAP_HOST = os.environ.get("FLOWPILOT_CUSTOM_IMAP_HOST", "imap.mxhichina.com") +IMAP_PORT = int(os.environ.get("FLOWPILOT_CUSTOM_IMAP_PORT", "993")) +IMAP_USER = os.environ.get("FLOWPILOT_CUSTOM_IMAP_USER", "") +IMAP_PASS = os.environ.get("FLOWPILOT_CUSTOM_IMAP_PASS", "") +IMAP_MAILBOX = os.environ.get("FLOWPILOT_CUSTOM_IMAP_MAILBOX", "INBOX") +REQUEST_TIMEOUT_SECONDS = int(os.environ.get("FLOWPILOT_CUSTOM_IMAP_TIMEOUT", "45")) +DEFAULT_TOP = 20 +RANDOM_EMAIL_DB_PATH = os.environ.get("FLOWPILOT_RANDOM_EMAIL_DB_PATH", os.path.join(PROJECT_ROOT, "data", "custom-mail-helper.sqlite3")) +RANDOM_EMAIL_MAX_COUNT = int(os.environ.get("FLOWPILOT_RANDOM_EMAIL_MAX_COUNT", "20")) +PUBLIC_ENV_KEYS = [ + "FLOWPILOT_SUB2API_REDIRECT_URI", +] +DEFAULT_MAIL_FROM_ALLOW = [ + "no-reply@codeium.com", + "noreply@codeium.com", + "no-reply@windsurf.com", + "noreply@windsurf.com", + "noreply@tm.openai.com", + "noreply@tm1.openai.com", +] + + +def json_response(handler, status, payload): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Access-Control-Allow-Origin", "*") + handler.send_header("Access-Control-Allow-Headers", "Content-Type") + handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + handler.end_headers() + handler.wfile.write(body) + + +def read_json_payload(handler): + length = int(handler.headers.get("Content-Length", "0") or 0) + raw = handler.rfile.read(length) if length > 0 else b"{}" + try: + return json.loads(raw.decode("utf-8")) + except Exception as exc: + raise RuntimeError(f"Invalid JSON payload: {exc}") from exc + + +def get_public_env_payload(): + return {key: os.environ.get(key, "") for key in PUBLIC_ENV_KEYS} + + +def decode_mime_header(value): + if not value: + return "" + parts = [] + for chunk, charset in decode_header(value): + if isinstance(chunk, bytes): + parts.append(chunk.decode(charset or "utf-8", errors="ignore")) + else: + parts.append(str(chunk)) + return "".join(parts).strip() + + +def extract_text_part(message): + if message.is_multipart(): + html_text = "" + for part in message.walk(): + if part.get_content_maintype() == "multipart": + continue + if "attachment" in str(part.get("Content-Disposition") or "").lower(): + continue + payload = part.get_payload(decode=True) or b"" + charset = part.get_content_charset() or "utf-8" + text = payload.decode(charset, errors="ignore").strip() + if part.get_content_type() == "text/plain" and text: + return text + if part.get_content_type() == "text/html" and text and not html_text: + html_text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip() + return html_text + + payload = message.get_payload(decode=True) or b"" + charset = message.get_content_charset() or "utf-8" + text = payload.decode(charset, errors="ignore").strip() + if message.get_content_type() == "text/html": + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip() + return text + + +def to_timestamp_ms(raw_date): + if not raw_date: + return 0 + try: + parsed = parsedate_to_datetime(raw_date) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp() * 1000) + except Exception: + return 0 + + +def to_iso_string(timestamp_ms): + if not timestamp_ms: + return "" + return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def parse_addresses(value): + return [addr.strip().lower() for _, addr in getaddresses([str(value or "")]) if addr.strip()] + + +def normalize_domain(value): + domain = str(value or "").strip().lower() + if domain.startswith("@"): + domain = domain[1:] + if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+", domain): + raise RuntimeError("Invalid domain") + return domain + + +def parse_generation_count(value): + if value in (None, ""): + return 1 + try: + count = int(value) + except Exception as exc: + raise RuntimeError("n must be an integer") from exc + if count < 1: + raise RuntimeError("n must be >= 1") + if count > RANDOM_EMAIL_MAX_COUNT: + raise RuntimeError(f"n must be <= {RANDOM_EMAIL_MAX_COUNT}") + return count + + +def ensure_random_email_db(): + db_dir = os.path.dirname(RANDOM_EMAIL_DB_PATH) + if db_dir: + os.makedirs(db_dir, exist_ok=True) + connection = sqlite3.connect(RANDOM_EMAIL_DB_PATH) + try: + connection.execute(""" + CREATE TABLE IF NOT EXISTS generated_emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + domain TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + connection.execute("CREATE INDEX IF NOT EXISTS idx_generated_emails_domain ON generated_emails(domain)") + connection.commit() + return connection + except Exception: + connection.close() + raise + + +def random_email_prefix(): + length = secrets.randbelow(5) + 8 + alphabet = string.ascii_lowercase + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def generate_random_emails(payload): + domain = normalize_domain((payload or {}).get("domain")) + count = parse_generation_count((payload or {}).get("n")) + emails = [] + connection = ensure_random_email_db() + try: + attempts = 0 + max_attempts = max(100, count * 20) + while len(emails) < count and attempts < max_attempts: + attempts += 1 + prefix = random_email_prefix() + email_address = f"{prefix}@{domain}" + try: + connection.execute( + "INSERT INTO generated_emails(email, prefix, domain) VALUES (?, ?, ?)", + (email_address, prefix, domain), + ) + emails.append(email_address) + except sqlite3.IntegrityError: + continue + if len(emails) != count: + connection.rollback() + raise RuntimeError("Unable to generate enough unique emails") + connection.commit() + return {"email": emails[0] if emails else "", "emails": emails, "domain": domain, "count": len(emails)} + finally: + connection.close() + + +def normalize_message(message_id, raw_bytes): + parsed = email.message_from_bytes(raw_bytes) + sender_name, sender_addr = parseaddr(parsed.get("From", "")) + subject = decode_mime_header(parsed.get("Subject", "")) + body = extract_text_part(parsed) + timestamp_ms = to_timestamp_ms(parsed.get("Date")) + return { + "id": str(message_id), + "mailbox": IMAP_MAILBOX, + "subject": subject, + "from": { + "emailAddress": { + "address": sender_addr.strip().lower(), + "name": sender_name.strip(), + } + }, + "to": parse_addresses(parsed.get("To", "")), + "cc": parse_addresses(parsed.get("Cc", "")), + "deliveredTo": parse_addresses(parsed.get("Delivered-To", "")), + "bodyPreview": body[:500], + "body": {"content": body}, + "receivedDateTime": to_iso_string(timestamp_ms), + "receivedTimestamp": timestamp_ms, + } + + +def fetch_recent_messages(top=DEFAULT_TOP): + if not IMAP_USER or not IMAP_PASS: + raise RuntimeError("Missing FLOWPILOT_CUSTOM_IMAP_USER/FLOWPILOT_CUSTOM_IMAP_PASS") + + context = ssl.create_default_context() + client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context, timeout=REQUEST_TIMEOUT_SECONDS) + try: + client.login(IMAP_USER, IMAP_PASS) + status, _ = client.select(IMAP_MAILBOX) + if status != "OK": + raise RuntimeError(f"Mailbox not found: {IMAP_MAILBOX}") + status, data = client.search(None, "ALL") + if status != "OK" or not data or not data[0]: + return [] + + message_ids = data[0].split() + selected_ids = list(reversed(message_ids[-max(1, min(int(top or DEFAULT_TOP), 50)):])) + messages = [] + for message_id in selected_ids: + fetch_status, fetch_data = client.fetch(message_id, "(RFC822)") + if fetch_status != "OK" or not fetch_data: + continue + raw_bytes = b"" + for item in fetch_data: + if isinstance(item, tuple) and len(item) >= 2: + raw_bytes = item[1] + break + if raw_bytes: + messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes)) + messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True) + return messages + finally: + try: + client.logout() + except Exception: + pass + + +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 match: + return next((str(match.group(i) or "").strip() for i in range(1, (match.lastindex or 0) + 1) if str(match.group(i) or "").strip()), str(match.group(0) or "").strip()) + except re.error: + continue + for pattern in [ + 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", + ]: + match = re.search(pattern, source, flags=re.IGNORECASE) + if match: + return match.group(1) + return "" + + +def message_matches_target(message, target_email): + target = str(target_email or "").strip().lower() + if not target: + return True + recipients = set(message.get("to") or []) | set(message.get("cc") or []) | set(message.get("deliveredTo") or []) + return target in recipients + + +def select_latest_code(messages, payload): + target_email = str(payload.get("targetEmail") or "").strip().lower() + filter_after_timestamp = int(payload.get("filterAfterTimestamp") or 0) + excluded = {str(item).strip() for item in payload.get("excludeCodes") or [] if str(item).strip()} + sender_filters = [str(item).strip().lower() for item in payload.get("senderFilters") or [] if str(item).strip()] + if not sender_filters: + sender_filters = DEFAULT_MAIL_FROM_ALLOW + subject_filters = [str(item).strip().lower() for item in payload.get("subjectFilters") or [] if str(item).strip()] + required_keywords = [str(item).strip().lower() for item in payload.get("requiredKeywords") or [] if str(item).strip()] + + def candidate(message, apply_time_filter): + timestamp = int(message.get("receivedTimestamp") or 0) + if apply_time_filter and filter_after_timestamp and timestamp and timestamp < filter_after_timestamp: + return None + if not message_matches_target(message, target_email): + return None + sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower() + subject = str(message.get("subject") or "") + preview = str(message.get("bodyPreview") or "") + body = str((message.get("body") or {}).get("content") or "") + combined = " ".join([sender, subject, preview, body]).lower() + if sender_filters and sender not in sender_filters and not any(item in combined for item in sender_filters): + return None + if subject_filters and not any(item in combined for item in subject_filters): + return None + if required_keywords and not any(item in combined for item in required_keywords): + return None + code = extract_code("\n".join([subject, preview, body, sender]), payload.get("codePatterns") or []) + if not code or code in excluded: + return None + return {"code": code, "message": message} + + for use_time_fallback in [False, True]: + matches = [item for item in (candidate(message, not use_time_fallback) for message in messages) if item] + if matches: + matches.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True) + best = matches[0] + return {"code": best["code"], "message": best["message"], "usedTimeFallback": use_time_fallback} + return {"code": "", "message": None, "usedTimeFallback": False} + + +class CustomMailHelperHandler(BaseHTTPRequestHandler): + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.end_headers() + + def do_POST(self): + try: + payload = read_json_payload(self) + if self.path == "/messages": + messages = fetch_recent_messages(payload.get("top") or DEFAULT_TOP) + json_response(self, 200, {"ok": True, "messages": messages}) + return + if self.path == "/code": + messages = fetch_recent_messages(payload.get("top") or DEFAULT_TOP) + selected = select_latest_code(messages, payload) + json_response(self, 200, { + "ok": True, + "code": selected["code"], + "message": selected["message"], + "usedTimeFallback": selected["usedTimeFallback"], + }) + return + if self.path == "/health": + json_response(self, 200, {"ok": True}) + return + if self.path == "/env": + json_response(self, 200, {"ok": True, "env": get_public_env_payload()}) + return + if self.path == "/random-email": + result = generate_random_emails(payload) + json_response(self, 200, {"ok": True, **result}) + return + json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"}) + except Exception as exc: + traceback.print_exc() + json_response(self, 500, {"ok": False, "error": str(exc)}) + + +def main(): + server = ThreadingHTTPServer((HOST, PORT), CustomMailHelperHandler) + print(f"Custom mail helper listening on http://{HOST}:{PORT}", flush=True) + print(f"IMAP host={IMAP_HOST}:{IMAP_PORT} user={IMAP_USER or '(unset)'} mailbox={IMAP_MAILBOX}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index ccc80e7b..3eadc45a 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -1500,6 +1500,15 @@ +