diff --git a/background.js b/background.js index e6017a3..8529b90 100644 --- a/background.js +++ b/background.js @@ -61,21 +61,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, @@ -85,7 +94,6 @@ 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_ACTIVE_FLOW_ID = 'openai'; const DEFAULT_STEP_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) @@ -597,11 +605,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; @@ -633,10 +651,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; } @@ -1374,6 +1401,38 @@ function resolveCurrentFlowCapabilities(state = {}, options = {}) { }); } +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) @@ -3029,6 +3088,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); @@ -4151,6 +4234,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, }), @@ -4427,6 +4512,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; @@ -5609,6 +5696,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; @@ -8680,6 +8769,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; @@ -11814,6 +11927,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter normalizeSignupMethod, canUsePhoneSignup, resolveSignupMethod, + validateAutoRunStart: validateAutoRunStartState, getTabId, getStopRequested: () => stopRequested, handleCloudflareSecurityBlocked, @@ -11902,6 +12016,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/message-router.js b/background/message-router.js index 25ed7b4..336aae9 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -74,6 +74,44 @@ } 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, @@ -908,6 +946,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('已有自动运行倒计时计划,请先取消或立即开始。'); } @@ -935,6 +977,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, @@ -1006,6 +1053,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, @@ -1017,6 +1074,7 @@ || 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); } @@ -1117,7 +1175,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/verification-flow.js b/background/verification-flow.js index dcfb066..2e4dd7f 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -412,27 +412,22 @@ 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 13dfe4f..a897ffc 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -670,7 +670,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, @@ -742,11 +791,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 || ''); @@ -754,11 +808,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]; @@ -771,9 +822,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); @@ -783,7 +834,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) { @@ -796,19 +859,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 ? { @@ -1175,14 +1239,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)); @@ -1249,21 +1314,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/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/flows/openai/mail-rules.js b/flows/openai/mail-rules.js index 940e80e..44dc4ca 100644 --- a/flows/openai/mail-rules.js +++ b/flows/openai/mail-rules.js @@ -3,6 +3,42 @@ })(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 { @@ -24,24 +60,30 @@ 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: signupStep - ? state?.email - : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email), + targetEmail, + targetEmailHints: buildTargetEmailHints(targetEmail), mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), maxAttempts: mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, intervalMs: mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, 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/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 index 8946645..89274e1 100644 --- a/shared/flow-capabilities.js +++ b/shared/flow-capabilities.js @@ -38,6 +38,14 @@ 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({ @@ -95,6 +103,17 @@ 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, @@ -122,6 +141,21 @@ }; } + 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( @@ -202,6 +236,165 @@ }; } + 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; } @@ -222,6 +415,8 @@ normalizeSignupMethod, resolveSidepanelCapabilities, resolveSignupMethod, + validateAutoRunStart, + validateModeSwitch, }; } diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 99453c1..c71804e 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -789,6 +789,7 @@ function initPhoneVerificationSectionExpandedState() { } function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) { + const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID !== 'undefined' ? DEFAULT_ACTIVE_FLOW_ID : 'openai'; const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; const rawPaymentMethod = typeof options === 'string' ? options @@ -796,7 +797,11 @@ function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) { const rawSignupMethod = typeof options === 'string' ? currentSignupMethod : (options.signupMethod || currentSignupMethod || DEFAULT_SIGNUP_METHOD); + const activeFlowId = typeof options === 'string' + ? ((typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId) + : (options.activeFlowId || (typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId); return (window.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: String(activeFlowId || '').trim().toLowerCase() || defaultFlowId, plusModeEnabled, plusPaymentMethod: normalizePlusPaymentMethod(rawPaymentMethod), signupMethod: normalizeSignupMethod(rawSignupMethod), @@ -830,6 +835,7 @@ function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) { currentPlusPaymentMethod = normalizePlusPaymentMethod(rawPaymentMethod); currentSignupMethod = normalizeSignupMethod(rawSignupMethod); stepDefinitions = getStepDefinitionsForMode(currentPlusModeEnabled, { + activeFlowId: options?.activeFlowId, plusPaymentMethod: currentPlusPaymentMethod, signupMethod: currentSignupMethod, }); @@ -8524,6 +8530,7 @@ function renderStepsList() { } function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOrOptions = {}, maybeOptions = {}) { + const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID !== 'undefined' ? DEFAULT_ACTIVE_FLOW_ID : 'openai'; const nextPlusModeEnabled = Boolean(plusModeEnabled); const options = typeof plusPaymentMethodOrOptions === 'string' ? maybeOptions @@ -8533,9 +8540,15 @@ function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOr : (options.plusPaymentMethod || getSelectedPlusPaymentMethod(latestState)); const nextSignupMethod = normalizeSignupMethod(options.signupMethod || currentSignupMethod || DEFAULT_SIGNUP_METHOD); const nextPaymentMethod = normalizePlusPaymentMethod(rawPaymentMethod); + const nextActiveFlowId = String( + options.activeFlowId + || (typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') + || defaultFlowId + ).trim().toLowerCase() || defaultFlowId; const rootScope = typeof window !== 'undefined' ? window : globalThis; const currentPaymentStep = stepDefinitions.find((step) => step.key === 'paypal-approve'); const nextPaymentTitle = rootScope.MultiPageStepDefinitions?.getPlusPaymentStepTitle?.({ + activeFlowId: nextActiveFlowId, plusModeEnabled: nextPlusModeEnabled, plusPaymentMethod: nextPaymentMethod, signupMethod: nextSignupMethod, @@ -8551,6 +8564,7 @@ function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOr } rebuildStepDefinitionState(nextPlusModeEnabled, { + activeFlowId: nextActiveFlowId, plusPaymentMethod: nextPaymentMethod, signupMethod: nextSignupMethod, }); @@ -11834,6 +11848,37 @@ async function startAutoRunFromCurrentSettings() { if (typeof persistCurrentSettingsForAction === 'function') { await persistCurrentSettingsForAction(); } + const autoRunStartValidation = (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + if (!registry?.validateAutoRunStart) { + return { ok: true, errors: [] }; + } + const validationState = { + ...(latestState || {}), + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + signupMethod: typeof getSelectedSignupMethod === 'function' ? getSelectedSignupMethod() : latestState?.signupMethod, + phoneVerificationEnabled: typeof inputPhoneVerificationEnabled !== 'undefined' && inputPhoneVerificationEnabled + ? Boolean(inputPhoneVerificationEnabled.checked) + : Boolean(latestState?.phoneVerificationEnabled), + plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled), + contributionMode: Boolean(latestState?.contributionMode), + }; + return registry.validateAutoRunStart({ + activeFlowId: validationState.activeFlowId, + panelMode: validationState.panelMode, + signupMethod: validationState.signupMethod, + state: validationState, + }); + })(); + if (autoRunStartValidation?.ok === false) { + clearPendingAutoRunStartRunCount(); + throw new Error(autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'); + } if (!(await ensureGpcApiKeyReadyForStart())) { clearPendingAutoRunStartRunCount(); return false; diff --git a/tests/auto-run-timer-session-guard.test.js b/tests/auto-run-timer-session-guard.test.js index 1f74bf2..ab1e45b 100644 --- a/tests/auto-run-timer-session-guard.test.js +++ b/tests/auto-run-timer-session-guard.test.js @@ -145,3 +145,109 @@ return { assert.equal(snapshot.autoRunTotalRuns, 1); assert.equal(snapshot.autoRunAttemptRun, 0); }); + +test('launchAutoRunTimerPlan cancels an invalid scheduled start before restarting auto-run', async () => { + const api = new Function(` +const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; +const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds'; +const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry'; +const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3; + +let autoRunTimerLaunching = false; +let autoRunActive = false; +let autoRunCurrentRun = 0; +let autoRunTotalRuns = 1; +let autoRunAttemptRun = 0; +let autoRunSessionId = 0; + +const state = { + activeFlowId: 'site-a', + panelMode: 'cpa', + signupMethod: 'phone', + autoRunDelayEnabled: false, + autoRunTimerPlan: { + kind: AUTO_RUN_TIMER_KIND_SCHEDULED_START, + fireAt: Date.now() + 60_000, + totalRuns: 2, + autoRunSkipFailures: false, + autoRunSessionId: 0, + countdownTitle: '已计划自动运行', + countdownNote: '等待启动', + }, +}; + +let startCalls = 0; +let clearStopCalls = 0; +let clearAlarmCalls = 0; +const broadcasts = []; +const logs = []; + +async function getState() { + return { ...state }; +} + +function getPendingAutoRunTimerPlan() { + return state.autoRunTimerPlan; +} + +async function clearAutoRunTimerAlarm() { + clearAlarmCalls += 1; +} + +async function broadcastAutoRunStatus(phase, statusPayload, statePayload) { + broadcasts.push({ phase, statusPayload, statePayload }); +} +async function addLog(message, level) { + logs.push({ message, level }); +} +async function setAutoRunDelayEnabledState() {} +function serializeAutoRunRoundSummaries(totalRuns, summaries = []) { + return Array.isArray(summaries) ? summaries : []; +} +function clearStopRequest() { + clearStopCalls += 1; +} +function startAutoRunLoop() { + startCalls += 1; +} +function validateAutoRunStartState() { + return { + ok: false, + errors: [{ message: '当前 flow 不支持手机号注册。' }], + }; +} + +${helperBundle} + +return { + launchAutoRunTimerPlan, + snapshot() { + return { + startCalls, + clearStopCalls, + clearAlarmCalls, + broadcasts, + logs, + autoRunCurrentRun, + autoRunTotalRuns, + autoRunAttemptRun, + }; + }, +}; +`)(); + + const started = await api.launchAutoRunTimerPlan('alarm'); + const snapshot = api.snapshot(); + + assert.equal(started, false); + assert.equal(snapshot.startCalls, 0); + assert.equal(snapshot.clearStopCalls, 0); + assert.equal(snapshot.clearAlarmCalls, 1); + assert.equal(snapshot.broadcasts.length, 1); + assert.equal(snapshot.broadcasts[0].phase, 'idle'); + assert.match(snapshot.logs[0].message, /自动运行计划已取消:当前 flow 不支持手机号注册。/); + assert.equal(snapshot.logs[0].level, 'error'); + assert.equal(snapshot.autoRunCurrentRun, 0); + assert.equal(snapshot.autoRunTotalRuns, 1); + assert.equal(snapshot.autoRunAttemptRun, 0); +}); diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index ad1c3b7..afe84ee 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -351,6 +351,65 @@ test('message router re-syncs contribution mode before AUTO_RUN when sidepanel p ]); }); +test('message router blocks AUTO_RUN and SCHEDULE_AUTO_RUN when shared auto-run validation fails', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + + const calls = []; + const router = api.createMessageRouter({ + clearStopRequest: () => {}, + getPendingAutoRunTimerPlan: () => null, + getState: async () => ({ + activeFlowId: 'site-a', + panelMode: 'cpa', + signupMethod: 'phone', + stepStatuses: {}, + }), + normalizeRunCount: (value) => Number(value) || 1, + scheduleAutoRun: async () => { + calls.push({ type: 'scheduleAutoRun' }); + return { ok: true }; + }, + setState: async (updates) => { + calls.push({ type: 'setState', updates }); + }, + startAutoRunLoop: () => { + calls.push({ type: 'startAutoRunLoop' }); + }, + validateAutoRunStart: () => ({ + ok: false, + errors: [{ message: '当前 flow 不支持手机号注册。' }], + }), + }); + + await assert.rejects( + () => router.handleMessage({ + type: 'AUTO_RUN', + payload: { + totalRuns: 2, + autoRunSkipFailures: true, + mode: 'restart', + }, + }), + /当前 flow 不支持手机号注册。/ + ); + + await assert.rejects( + () => router.handleMessage({ + type: 'SCHEDULE_AUTO_RUN', + payload: { + totalRuns: 2, + delayMinutes: 5, + autoRunSkipFailures: false, + }, + }), + /当前 flow 不支持手机号注册。/ + ); + + assert.deepStrictEqual(calls, []); +}); + test('account run history snapshot sync is disabled in contribution mode', () => { const source = fs.readFileSync('background/account-run-history.js', 'utf8'); const globalScope = {}; diff --git a/tests/background-hotmail-local-helper-rule-forwarding.test.js b/tests/background-hotmail-local-helper-rule-forwarding.test.js new file mode 100644 index 0000000..d26ac41 --- /dev/null +++ b/tests/background-hotmail-local-helper-rule-forwarding.test.js @@ -0,0 +1,28 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('background forwards mail rule metadata to Hotmail helper and shared picker paths', () => { + const source = fs.readFileSync('background.js', 'utf8'); + + assert.match( + source, + /requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\]/, + 'Hotmail helper 请求体应转发 requiredKeywords' + ); + assert.match( + source, + /codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/, + 'Hotmail helper 请求体应转发 codePatterns' + ); + assert.match( + source, + /pickVerificationMessageWithTimeFallback\(fetchResult\.messages,\s*\{[\s\S]*requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\],[\s\S]*codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/, + 'Hotmail API 轮询应把 rule metadata 传给共享验证码筛选器' + ); + assert.match( + source, + /pickVerificationMessageWithTimeFallback\(messages,\s*\{[\s\S]*requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\],[\s\S]*codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/, + 'Cloudflare Temp Email 轮询也应复用同一套 rule metadata' + ); +}); diff --git a/tests/background-mail-rule-registry-module.test.js b/tests/background-mail-rule-registry-module.test.js index d6cc6c0..d487c42 100644 --- a/tests/background-mail-rule-registry-module.test.js +++ b/tests/background-mail-rule-registry-module.test.js @@ -42,10 +42,26 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', ( ruleId: 'openai-signup-code', step: 4, artifactType: 'code', + codePatterns: [ + { + source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', + flags: 'i', + }, + { + source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})', + flags: 'i', + }, + { + 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', + }, + ], filterAfterTimestamp: 0, + requiredKeywords: ['openai', 'chatgpt', 'verify', 'verification', 'confirm', '验证码', '代码'], senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], targetEmail: 'user@example.com', + targetEmailHints: ['user@example.com', 'user=example.com'], mail2925MatchTargetEmail: true, maxAttempts: 15, intervalMs: 15000, @@ -64,10 +80,26 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', ( ruleId: 'openai-login-code', step: 8, artifactType: 'code', + codePatterns: [ + { + source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', + flags: 'i', + }, + { + source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})', + flags: 'i', + }, + { + 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', + }, + ], filterAfterTimestamp: 456, + requiredKeywords: ['openai', 'chatgpt', 'verify', 'verification', 'confirm', '验证码', '代码', 'login'], senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], targetEmail: 'login@example.com', + targetEmailHints: ['login@example.com', 'login=example.com'], mail2925MatchTargetEmail: false, maxAttempts: 5, intervalMs: 3000, diff --git a/tests/background-message-router-module.test.js b/tests/background-message-router-module.test.js index f1a2da0..46f2e0c 100644 --- a/tests/background-message-router-module.test.js +++ b/tests/background-message-router-module.test.js @@ -198,3 +198,67 @@ test('SAVE_SETTING re-resolves signup method when panel mode changes', async () assert.equal(state.panelMode, 'cpa'); assert.equal(state.signupMethod, 'email'); }); + +test('SAVE_SETTING applies shared mode-switch normalization before persisting incompatible capability flags', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = { console }; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + const persistedPayloads = []; + let state = { + activeFlowId: 'site-a', + signupMethod: 'email', + phoneVerificationEnabled: false, + plusModeEnabled: false, + panelMode: 'cpa', + }; + + const router = api.createMessageRouter({ + addLog: async () => {}, + buildLuckmailSessionSettingsPayload: () => ({}), + buildPersistentSettingsPayload: (input = {}) => ({ + plusModeEnabled: Boolean(input.plusModeEnabled), + phoneVerificationEnabled: Boolean(input.phoneVerificationEnabled), + signupMethod: String(input.signupMethod || 'email'), + }), + broadcastDataUpdate: () => {}, + getState: async () => ({ ...state }), + resolveSignupMethod: (nextState = {}) => ( + Boolean(nextState.phoneVerificationEnabled) && Boolean(nextState.plusModeEnabled) ? 'phone' : 'email' + ), + setPersistentSettings: async (updates) => { + persistedPayloads.push({ ...updates }); + }, + setState: async (updates) => { + state = { ...state, ...updates }; + }, + validateModeSwitch: () => ({ + ok: false, + errors: [{ code: 'plus_mode_unsupported', message: '当前 flow 不支持 Plus 模式。' }], + normalizedUpdates: { + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }, + }), + }); + + const response = await router.handleMessage({ + type: 'SAVE_SETTING', + payload: { + plusModeEnabled: true, + phoneVerificationEnabled: true, + signupMethod: 'phone', + }, + }); + + assert.equal(response.ok, true); + assert.equal(state.plusModeEnabled, false); + assert.equal(state.phoneVerificationEnabled, false); + assert.equal(state.signupMethod, 'email'); + assert.deepEqual(persistedPayloads[0], { + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }); + assert.equal(response.modeValidation?.errors?.[0]?.code, 'plus_mode_unsupported'); +}); diff --git a/tests/background-settings-import-mode-validation.test.js b/tests/background-settings-import-mode-validation.test.js new file mode 100644 index 0000000..3348397 --- /dev/null +++ b/tests/background-settings-import-mode-validation.test.js @@ -0,0 +1,132 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') parenDepth += 1; + if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) signatureEnded = true; + } + if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + return source.slice(start, end); +} + +test('importSettingsBundle normalizes unsupported capability flags before persisting imported settings', async () => { + const api = new Function(` +const SETTINGS_EXPORT_SCHEMA_VERSION = 1; +const DEFAULT_REGISTRATION_EMAIL_STATE = { emailHistory: [] }; +let persistedUpdates = null; +let stateUpdates = null; +let broadcastPayload = null; +let currentState = { + activeFlowId: 'site-a', + panelMode: 'sub2api', + signupMethod: 'phone', + plusModeEnabled: false, + phoneVerificationEnabled: false, + stepStatuses: {}, +}; +async function ensureManualInteractionAllowed() { + return currentState; +} +function buildPersistentSettingsPayload(settings = {}) { + return { ...settings }; +} +function validateModeSwitchState() { + return { + ok: false, + errors: [{ code: 'panel_mode_unsupported', message: '当前 flow 不支持 SUB2API 面板模式。' }], + normalizedUpdates: { + panelMode: 'cpa', + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }, + }; +} +function resolveSignupMethod(state = {}) { + return String(state?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; +} +async function setPersistentSettings(updates) { + persistedUpdates = { ...updates }; +} +async function setState(updates) { + stateUpdates = { ...updates }; + currentState = { ...currentState, ...updates }; +} +function broadcastDataUpdate(payload) { + broadcastPayload = { ...payload }; +} +async function getState() { + return { ...currentState }; +} +${extractFunction('importSettingsBundle')} +return { + importSettingsBundle, + getPersistedUpdates: () => persistedUpdates, + getStateUpdates: () => stateUpdates, + getBroadcastPayload: () => broadcastPayload, +}; +`)(); + + const result = await api.importSettingsBundle({ + schemaVersion: 1, + settings: { + panelMode: 'sub2api', + plusModeEnabled: true, + phoneVerificationEnabled: true, + signupMethod: 'phone', + }, + }); + + assert.deepEqual(api.getPersistedUpdates(), { + panelMode: 'cpa', + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }); + assert.equal(api.getStateUpdates().panelMode, 'cpa'); + assert.equal(api.getStateUpdates().plusModeEnabled, false); + assert.equal(api.getStateUpdates().phoneVerificationEnabled, false); + assert.equal(api.getStateUpdates().signupMethod, 'email'); + assert.equal(api.getBroadcastPayload().panelMode, 'cpa'); + assert.equal(api.getBroadcastPayload().signupMethod, 'email'); + assert.equal(result.signupMethod, 'email'); +}); diff --git a/tests/background-signup-method-settings.test.js b/tests/background-signup-method-settings.test.js index 3af827a..6688263 100644 --- a/tests/background-signup-method-settings.test.js +++ b/tests/background-signup-method-settings.test.js @@ -177,6 +177,7 @@ return { }); assert.deepEqual(api.getCaptured(), [{ + activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'phone', diff --git a/tests/flow-capabilities-module.test.js b/tests/flow-capabilities-module.test.js index e6d8125..d974976 100644 --- a/tests/flow-capabilities-module.test.js +++ b/tests/flow-capabilities-module.test.js @@ -70,3 +70,91 @@ test('flow capability registry defaults unknown flows to minimal non-phone capab assert.equal(capabilityState.panelMode, 'codex2api'); assert.deepEqual(capabilityState.supportedPanelModes, []); }); + +test('flow capability registry exposes shared auto-run validation for phone locks and panel support', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry({ + flowCapabilities: { + openai: api.FLOW_CAPABILITIES.openai, + 'site-a': { + ...api.DEFAULT_FLOW_CAPABILITIES, + supportsPlatformBinding: ['cpa'], + }, + }, + }); + + const plusLockedResult = registry.validateAutoRunStart({ + state: { + activeFlowId: 'openai', + panelMode: 'cpa', + signupMethod: 'phone', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: false, + }, + }); + + assert.equal(plusLockedResult.ok, false); + assert.equal(plusLockedResult.errors[0].code, 'phone_signup_plus_mode_locked'); + + const unsupportedPanelResult = registry.validateAutoRunStart({ + state: { + activeFlowId: 'site-a', + panelMode: 'sub2api', + signupMethod: 'email', + }, + }); + + assert.equal(unsupportedPanelResult.ok, false); + assert.equal(unsupportedPanelResult.errors[0].code, 'panel_mode_unsupported'); +}); + +test('flow capability registry normalizes unsupported mode switches back to the effective capability set', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry({ + flowCapabilities: { + openai: api.FLOW_CAPABILITIES.openai, + 'site-a': { + ...api.DEFAULT_FLOW_CAPABILITIES, + supportsPlatformBinding: ['cpa'], + }, + }, + }); + + const validation = registry.validateModeSwitch({ + state: { + activeFlowId: 'site-a', + panelMode: 'sub2api', + signupMethod: 'phone', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: true, + }, + changedKeys: [ + 'panelMode', + 'signupMethod', + 'phoneVerificationEnabled', + 'plusModeEnabled', + 'contributionMode', + ], + }); + + assert.equal(validation.ok, false); + assert.deepEqual(validation.normalizedUpdates, { + panelMode: 'cpa', + signupMethod: 'email', + phoneVerificationEnabled: false, + plusModeEnabled: false, + contributionMode: false, + }); + assert.deepEqual( + validation.errors.map((entry) => entry.code), + [ + 'panel_mode_unsupported', + 'plus_mode_unsupported', + 'contribution_mode_unsupported', + 'phone_verification_unsupported', + 'phone_signup_flow_unsupported', + ] + ); +}); diff --git a/tests/hotmail-utils.test.js b/tests/hotmail-utils.test.js index 6744dd6..44275ff 100644 --- a/tests/hotmail-utils.test.js +++ b/tests/hotmail-utils.test.js @@ -185,6 +185,15 @@ test('extractVerificationCode returns first six-digit code from multilingual mai assert.equal(extractVerificationCode('No code here'), null); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + assert.equal( + extractVerificationCode('Mailbox notice: use pin A-778899 to continue.', { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '778899' + ); +}); + test('extractVerificationCodeFromMessage reads code from the latest message subject or preview', () => { assert.equal( extractVerificationCodeFromMessage({ @@ -214,6 +223,30 @@ test('extractVerificationCodeFromMessage reads code from the latest message subj ); }); +test('pickVerificationMessageWithTimeFallback supports required keyword hints and runtime code patterns', () => { + const messages = [ + { + id: 'mail-1', + subject: 'Security center', + from: { emailAddress: { address: 'alerts@example.com' } }, + bodyPreview: 'Use pin A-661122 to continue', + receivedDateTime: '2026-04-14T10:06:00.000Z', + }, + ]; + + const result = pickVerificationMessageWithTimeFallback(messages, { + afterTimestamp: 0, + senderFilters: [], + subjectFilters: [], + requiredKeywords: ['security'], + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + excludeCodes: [], + }); + + assert.equal(result.match?.code, '661122'); + assert.equal(result.usedTimeFallback, false); +}); + test('getHotmailListToggleLabel reflects expanded state and account count', () => { assert.equal(getHotmailListToggleLabel(false, 0), '展开列表'); assert.equal(getHotmailListToggleLabel(false, 7), '展开列表(7)'); diff --git a/tests/icloud-mail-content.test.js b/tests/icloud-mail-content.test.js index 9cfffbe..12223a1 100644 --- a/tests/icloud-mail-content.test.js +++ b/tests/icloud-mail-content.test.js @@ -54,6 +54,8 @@ function extractFunction(name) { test('readOpenedMailBody falls back to thread detail pane and extracts verification code', () => { const bundle = [ extractFunction('normalizeText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('getOpenedMailBodyRoot'), extractFunction('readOpenedMailBody'), @@ -83,6 +85,8 @@ return { readOpenedMailBody, extractVerificationCode }; test('extractVerificationCode matches the new suspicious log-in mail body', () => { const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -95,6 +99,27 @@ return { extractVerificationCode }; assert.equal(api.extractVerificationCode(bodyText), '982219'); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + const bodyText = 'Mailbox notice\nUse verification pin A-445566 to continue.'; + assert.equal( + api.extractVerificationCode(bodyText, { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '445566' + ); +}); + test('readOpenedMailBody ignores conversation list rows when no detail pane is open', () => { const bundle = [ extractFunction('normalizeText'), @@ -199,6 +224,8 @@ test('icloud poll session baseline is reused across calls and enables fallback a extractFunction('normalizeText'), extractFunction('getThreadItemMetadata'), extractFunction('buildItemSignature'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('normalizePollSessionKey'), extractFunction('getOrCreatePollSessionBaseline'), @@ -300,6 +327,8 @@ test('icloud step8 polling finds a visible first-row code immediately', async () extractFunction('normalizeText'), extractFunction('getThreadItemMetadata'), extractFunction('buildItemSignature'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('normalizePollSessionKey'), extractFunction('getOrCreatePollSessionBaseline'), @@ -378,6 +407,8 @@ test('icloud step8 visible first-row code still respects excluded codes', async extractFunction('normalizeText'), extractFunction('getThreadItemMetadata'), extractFunction('buildItemSignature'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('normalizePollSessionKey'), extractFunction('getOrCreatePollSessionBaseline'), diff --git a/tests/mail-163-content.test.js b/tests/mail-163-content.test.js index 8d8f384..7eafd80 100644 --- a/tests/mail-163-content.test.js +++ b/tests/mail-163-content.test.js @@ -166,6 +166,8 @@ test('readOpenedMailText prefers opened body content that contains the verificat extractFunction('collectOpenedMailTextCandidates'), extractFunction('selectOpenedMailTextCandidate'), extractFunction('readOpenedMailText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -216,6 +218,8 @@ test('openMailAndGetMessageText reads opened body text and returns to inbox', as extractFunction('readOpenedMailText'), extractFunction('returnToInbox'), extractFunction('openMailAndGetMessageText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -289,6 +293,8 @@ test('openMailAndGetMessageText ignores stale pre-open text that contains an old extractFunction('readOpenedMailText'), extractFunction('returnToInbox'), extractFunction('openMailAndGetMessageText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -359,6 +365,8 @@ return { openMailAndGetMessageText, mailItem }; test('extractVerificationCode matches the new suspicious log-in mail body', () => { const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -371,6 +379,27 @@ return { extractVerificationCode }; assert.equal(api.extractVerificationCode(bodyText), '982219'); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + const bodyText = 'Security Center\nUse verification pin A-778899 to continue.'; + assert.equal( + api.extractVerificationCode(bodyText, { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '778899' + ); +}); + test('handlePollEmail ignores same-minute old snapshot mail before fallback', async () => { const bundle = [ extractFunction('normalizeText'), diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index 54ebb47..d3f3d70 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -319,6 +319,7 @@ return { test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => { const bundle = [ extractFunction('extractEmails'), + extractFunction('normalizeTargetEmailHints'), extractFunction('extractForwardedTargetEmails'), extractFunction('emailMatchesTarget'), extractFunction('getTargetEmailMatchState'), @@ -407,6 +408,34 @@ return { assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-2']); }); +test('getTargetEmailMatchState decodes generic forwarded bounce aliases without OpenAI-specific domains', () => { + const bundle = [ + extractFunction('extractEmails'), + extractFunction('normalizeTargetEmailHints'), + extractFunction('extractForwardedTargetEmails'), + extractFunction('emailMatchesTarget'), + extractFunction('getTargetEmailMatchState'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { getTargetEmailMatchState }; +`)(); + + const state = api.getTargetEmailMatchState( + 'Return-Path: ', + 'expected.user@example.com', + { + targetEmailHints: ['expected.user@example.com', 'expected.user=example.com'], + } + ); + + assert.deepEqual(state, { + matches: true, + hasExplicitEmail: true, + }); +}); + test('handlePollEmail only accepts 2925 mails inside the fixed lookback window', async () => { const bundle = [ extractFunction('normalizeMinuteTimestamp'), @@ -549,7 +578,9 @@ return { test('extractVerificationCode strict mode matches the new suspicious log-in mail body', () => { const bundle = [ - extractFunction('extractStrictChatGPTVerificationCode'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractLegacyStrictVerificationCode'), extractFunction('isLikelyCompactTimeValue'), extractFunction('isLikelyHeaderTimestampCode'), extractFunction('findSafeStandaloneSixDigitCode'), @@ -566,9 +597,36 @@ return { extractVerificationCode }; assert.equal(api.extractVerificationCode(bodyText, false), '982219'); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractLegacyStrictVerificationCode'), + extractFunction('isLikelyCompactTimeValue'), + extractFunction('isLikelyHeaderTimestampCode'), + extractFunction('findSafeStandaloneSixDigitCode'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + const bodyText = 'System alert\nUse verification pin A-556677 to continue.'; + assert.equal( + api.extractVerificationCode(bodyText, { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '556677' + ); +}); + test('extractVerificationCode ignores compact header time before fallback code', () => { const bundle = [ - extractFunction('extractStrictChatGPTVerificationCode'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractLegacyStrictVerificationCode'), extractFunction('isLikelyCompactTimeValue'), extractFunction('isLikelyHeaderTimestampCode'), extractFunction('findSafeStandaloneSixDigitCode'), diff --git a/tests/microsoft-email.test.js b/tests/microsoft-email.test.js index cf08547..3cf439f 100644 --- a/tests/microsoft-email.test.js +++ b/tests/microsoft-email.test.js @@ -64,6 +64,36 @@ test('extractVerificationCodeFromMessages 支持显式过滤条件并跳过排 }); }); +test('extractVerificationCodeFromMessages supports keyword hints and runtime code patterns without OpenAI-specific fallback', () => { + const result = extractVerificationCodeFromMessages([ + { + From: { EmailAddress: { Address: 'alerts@example.com' } }, + Subject: 'Security center', + BodyPreview: 'Use pin A-551199 to continue', + ReceivedDateTime: '2026-04-14T10:07:00.000Z', + Id: 'mail-1', + }, + { + From: { EmailAddress: { Address: 'news@example.com' } }, + Subject: 'Newsletter', + BodyPreview: 'pin A-000000', + ReceivedDateTime: '2026-04-14T10:06:00.000Z', + Id: 'mail-2', + }, + ], { + filterAfterTimestamp: 0, + senderFilters: [], + subjectFilters: [], + requiredKeywords: ['security'], + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + excludeCodes: [], + }); + + assert.equal(result.code, '551199'); + assert.equal(result.messageId, 'mail-1'); + assert.equal(result.sender, 'alerts@example.com'); +}); + test('normalizeMailboxId 将 Junk 归一为微软邮箱夹 ID', () => { assert.equal(normalizeMailboxId('INBOX'), 'inbox'); assert.equal(normalizeMailboxId('junk'), 'junkemail'); diff --git a/tests/qq-mail-content.test.js b/tests/qq-mail-content.test.js new file mode 100644 index 0000000..c72081d --- /dev/null +++ b/tests/qq-mail-content.test.js @@ -0,0 +1,139 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/qq-mail.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +test('qq extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + assert.equal( + api.extractVerificationCode('Mailbox notice: use pin A-441122 to continue.', { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '441122' + ); +}); + +test('qq handlePollEmail forwards runtime code patterns to new-mail matching', async () => { + const bundle = [ + extractFunction('getCurrentMailIds'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + extractFunction('handlePollEmail'), + ].join('\n'); + + const api = new Function(` +let currentItems = []; +let refreshCount = 0; + +function createMailItem(mailId, sender, subject, digest) { + return { + getAttribute(name) { + if (name === 'data-mailid') return mailId; + return ''; + }, + querySelector(selector) { + if (selector === '.cmp-account-nick') return { textContent: sender }; + if (selector === '.mail-subject') return { textContent: subject }; + if (selector === '.mail-digest') return { textContent: digest }; + return null; + }, + }; +} + +const document = { + querySelectorAll(selector) { + if (selector === '.mail-list-page-item[data-mailid]') { + return currentItems; + } + return []; + }, +}; + +async function waitForElement() { + return true; +} +async function refreshInbox() { + refreshCount += 1; + if (refreshCount >= 1) { + currentItems = [ + createMailItem('mail-1', 'alerts@example.com', 'Security center', 'Use pin A-551188 to continue'), + ]; + } +} +async function sleep() {} +function log() {} + +${bundle} + +return { handlePollEmail }; +`)(); + + const result = await api.handlePollEmail(4, { + senderFilters: ['alerts'], + subjectFilters: ['security'], + maxAttempts: 2, + intervalMs: 1, + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }); + + assert.equal(result.code, '551188'); +}); diff --git a/tests/sidepanel-auto-run-content-refresh.test.js b/tests/sidepanel-auto-run-content-refresh.test.js index 950bd07..a7ffdf3 100644 --- a/tests/sidepanel-auto-run-content-refresh.test.js +++ b/tests/sidepanel-auto-run-content-refresh.test.js @@ -196,6 +196,107 @@ test('startAutoRunFromCurrentSettings freezes run count before async settings sy assert.equal(events[3].message.payload.totalRuns, 20); }); +test('startAutoRunFromCurrentSettings blocks when shared flow capability validation fails', async () => { + const bundle = [ + extractFunction('normalizePendingAutoRunStartRunCount'), + extractFunction('registerPendingAutoRunStartRunCount'), + extractFunction('clearPendingAutoRunStartRunCount'), + extractFunction('startAutoRunFromCurrentSettings'), + ].join('\n'); + + const api = new Function(` +const events = []; +const latestState = { + activeFlowId: 'site-a', + panelMode: 'cpa', + signupMethod: 'phone', + contributionMode: false, + phoneVerificationEnabled: true, +}; +const inputAutoSkipFailures = { checked: false }; +const inputContributionNickname = { value: 'tester' }; +const inputContributionQq = { value: '123456' }; +const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' }; +const inputAutoDelayEnabled = { checked: false }; +const inputAutoDelayMinutes = { value: '30' }; +const btnAutoRun = { disabled: false, innerHTML: '' }; +const inputRunCount = { disabled: false, value: '1' }; +const inputPhoneVerificationEnabled = { checked: true }; +const inputPlusModeEnabled = { checked: false }; +let runCountValue = 1; +let pendingAutoRunStartTotalRuns = 0; +let pendingAutoRunStartExpiresAt = 0; +const chrome = { + runtime: { + async sendMessage(message) { + events.push({ type: 'send', message }); + return { ok: true }; + }, + }, +}; +const console = { + warn(...args) { + events.push({ type: 'warn', args }); + }, +}; +const window = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + validateAutoRunStart() { + return { + ok: false, + errors: [{ message: '当前 flow 不支持手机号注册。' }], + }; + }, + }; + }, + }, +}; +async function sendSidepanelMessage(message) { + return chrome.runtime.sendMessage(message); +} +async function persistCurrentSettingsForAction() { + events.push({ type: 'sync-settings' }); +} +function getRunCountValue() { return Math.max(1, Number(runCountValue) || 1); } +function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; } +function shouldOfferAutoModeChoice() { return false; } +async function openAutoStartChoiceDialog() { throw new Error('should not be called'); } +function getFirstUnfinishedStep() { return 1; } +function getRunningSteps() { return []; } +function shouldWarnAutoRunFallbackRisk() { return false; } +function isAutoRunFallbackRiskPromptDismissed() { return false; } +async function openAutoRunFallbackRiskConfirmModal() { throw new Error('should not be called'); } +function setAutoRunFallbackRiskPromptDismissed() {} +function normalizeAutoDelayMinutes(value) { return Number(value) || 30; } +async function refreshContributionContentHint() { + events.push({ type: 'refresh' }); + return null; +} +async function ensureGpcApiKeyReadyForStart() { + return true; +} +${bundle} +return { + startAutoRunFromCurrentSettings, + getEvents() { + return events; + }, +}; +`)(); + + await assert.rejects( + () => api.startAutoRunFromCurrentSettings(), + /当前 flow 不支持手机号注册。/ + ); + + assert.deepEqual( + api.getEvents().map((entry) => entry.type), + ['refresh', 'sync-settings'] + ); +}); + test('persistCurrentSettingsForAction forces a silent save even when settings are not marked dirty', async () => { const bundle = [ extractFunction('waitForSettingsSaveIdle'), diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js index 1a936be..6adba6f 100644 --- a/tests/sidepanel-plus-payment-method.test.js +++ b/tests/sidepanel-plus-payment-method.test.js @@ -106,7 +106,7 @@ return { assert.deepEqual(api.getStepIds(), [7]); assert.deepEqual(api.calls[0], { type: 'getSteps', - options: { plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email' }, + options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email' }, }); assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] }); }); @@ -266,7 +266,7 @@ return { assert.deepEqual(api.getStepIds(), [13]); assert.deepEqual(api.calls[0], { type: 'getSteps', - options: { plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email' }, + options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email' }, }); }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index 4d0caba..af176b2 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -16,6 +16,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', () assert.equal(Array.isArray(steps), true); assert.equal(steps.length, 10); + assert.equal(steps.every((step) => step.flowId === 'openai'), true); assert.deepStrictEqual( steps.map((step) => step.order), steps.map((step) => step.order).slice().sort((left, right) => left - right) @@ -68,6 +69,11 @@ test('step definitions module exposes ordered normal and Plus step metadata', () assert.equal(api.getPlusPaymentStepTitle({ plusModeEnabled: true, plusPaymentMethod: 'gopay' }), ''); assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); assert.equal(api.getLastStepId({ plusModeEnabled: true }), 13); + assert.equal(api.hasFlow('openai'), true); + assert.equal(api.hasFlow('site-a'), false); + assert.deepStrictEqual(api.getRegisteredFlowIds(), ['openai']); + assert.deepStrictEqual(api.getSteps({ activeFlowId: 'site-a' }), []); + assert.equal(api.getStepById(2, { activeFlowId: 'site-a' }), null); assert.equal(plusSteps[5].title, '创建 Plus Checkout'); assert.equal(plusSteps[7].title, 'PayPal 登录与授权');