diff --git a/background.js b/background.js index 7b71a1d..9ad771a 100644 --- a/background.js +++ b/background.js @@ -5,6 +5,8 @@ importScripts( 'flows/openai/workflow.js', 'flows/kiro/index.js', 'flows/kiro/workflow.js', + 'flows/grok/index.js', + 'flows/grok/workflow.js', 'flows/index.js', 'core/flow-kernel/flow-registry.js', 'shared/contribution-registry.js', @@ -34,9 +36,11 @@ importScripts( 'core/flow-kernel/workflow-engine.js', 'core/flow-kernel/runtime-state.js', 'flows/kiro/background/state.js', + 'flows/grok/background/state.js', 'flows/kiro/background/credential-artifact.js', 'background/contribution/adapters/kiro-builder-id.js', 'flows/kiro/background/register-runner.js', + 'flows/grok/background/register-runner.js', 'flows/kiro/background/desktop-client.js', 'flows/kiro/background/desktop-authorize-runner.js', 'flows/kiro/background/publisher-kiro-rs.js', @@ -44,6 +48,9 @@ importScripts( 'background/signup-flow-helpers.js', 'background/mail-rule-registry.js', 'flows/openai/mail-rules.js', + 'flows/kiro/mail-rules.js', + 'flows/grok/mail-rules.js', + 'background/flow-mail-polling.js', 'background/message-router.js', 'background/verification-flow.js', 'background/auto-run-controller.js', @@ -424,6 +431,7 @@ const runtimeStateHelpers = self.MultiPageBackgroundRuntimeState?.createRuntimeS defaultNodeStatuses: DEFAULT_NODE_STATUSES, }) || null; const kiroStateHelpers = self.MultiPageBackgroundKiroState || null; +const grokStateHelpers = self.MultiPageBackgroundGrokState || null; const DEFAULT_REGISTRATION_EMAIL_STATE = registrationEmailStateHelpers?.DEFAULT_REGISTRATION_EMAIL_STATE || { current: '', previous: '', @@ -496,6 +504,9 @@ function buildStateViewWithRuntimeState(state = {}) { if (kiroStateHelpers?.buildStateView) { nextState = kiroStateHelpers.buildStateView(nextState); } + if (grokStateHelpers?.buildStateView) { + nextState = grokStateHelpers.buildStateView(nextState); + } return nextState; } @@ -3809,20 +3820,20 @@ function buildSettingsStatePatchFromFlatUpdates(updates = {}) { assignIfUpdated('kiroRsKey', ['flows', 'kiro', 'targets', 'kiro-rs', 'apiKey']); if (hasUpdate('stepExecutionRangeByFlow') && isPlainObjectValue(updates.stepExecutionRangeByFlow)) { - if (isPlainObjectValue(updates.stepExecutionRangeByFlow.openai)) { + Object.entries(updates.stepExecutionRangeByFlow).forEach(([rawFlowId, range]) => { + if (!isPlainObjectValue(range)) { + return; + } + const flowId = normalizePatchFlowId(rawFlowId, ''); + if (!flowId) { + return; + } setSettingsStatePatchValue( patch, - ['flows', 'openai', 'autoRun', 'stepExecutionRange'], - updates.stepExecutionRangeByFlow.openai + ['flows', flowId, 'autoRun', 'stepExecutionRange'], + range ); - } - if (isPlainObjectValue(updates.stepExecutionRangeByFlow.kiro)) { - setSettingsStatePatchValue( - patch, - ['flows', 'kiro', 'autoRun', 'stepExecutionRange'], - updates.stepExecutionRangeByFlow.kiro - ); - } + }); } return patch; @@ -3941,6 +3952,25 @@ function buildAutoRunFreshResetSettingsState(prevState = {}, activeFlowId = DEFA }) : currentSettingsState; const normalizedStepExecutionRangeByFlow = normalizeStepExecutionRangeByFlow(prevState?.stepExecutionRangeByFlow || {}); + const flowIds = typeof self.MultiPageFlowRegistry?.getRegisteredFlowIds === 'function' + ? self.MultiPageFlowRegistry.getRegisteredFlowIds() + .map((flowId) => self.MultiPageFlowRegistry.normalizeFlowId?.(flowId, '') || String(flowId || '').trim().toLowerCase()) + .filter(Boolean) + : ['openai', 'kiro']; + const flowPatch = {}; + flowIds.forEach((flowId) => { + const selectedTargetId = settingsSchemaApi?.getSelectedTargetId + ? settingsSchemaApi.getSelectedTargetId(normalizedCurrentSettingsState, flowId) + : undefined; + flowPatch[flowId] = { + selectedTargetId, + autoRun: normalizedStepExecutionRangeByFlow[flowId] + ? { + stepExecutionRange: normalizedStepExecutionRangeByFlow[flowId], + } + : undefined, + }; + }); const nextSettingsStatePatch = { activeFlowId, services: { @@ -3956,28 +3986,7 @@ function buildAutoRunFreshResetSettingsState(prevState = {}, activeFlowId = DEFA mode: prevState?.ipProxyMode, }, }, - flows: { - openai: { - selectedTargetId: settingsSchemaApi?.getSelectedTargetId - ? settingsSchemaApi.getSelectedTargetId(normalizedCurrentSettingsState, 'openai') - : undefined, - autoRun: normalizedStepExecutionRangeByFlow.openai - ? { - stepExecutionRange: normalizedStepExecutionRangeByFlow.openai, - } - : undefined, - }, - kiro: { - selectedTargetId: settingsSchemaApi?.getSelectedTargetId - ? settingsSchemaApi.getSelectedTargetId(normalizedCurrentSettingsState, 'kiro') - : undefined, - autoRun: normalizedStepExecutionRangeByFlow.kiro - ? { - stepExecutionRange: normalizedStepExecutionRangeByFlow.kiro, - } - : undefined, - }, - }, + flows: flowPatch, }; return mergeAutoRunKeepStateValue(currentSettingsState, nextSettingsStatePatch); @@ -4021,6 +4030,9 @@ function buildFreshAutoRunKeepState(prevState = {}) { if (typeof kiroStateHelpers?.buildFreshKeepState === 'function') { Object.assign(keepState, kiroStateHelpers.buildFreshKeepState(sourceState)); } + if (typeof grokStateHelpers?.buildFreshKeepState === 'function') { + Object.assign(keepState, grokStateHelpers.buildFreshKeepState(sourceState)); + } if (Object.prototype.hasOwnProperty.call(sourceState, 'settingsSchemaVersion')) { keepState.settingsSchemaVersion = Number(sourceState.settingsSchemaVersion) || 0; } @@ -4329,6 +4341,28 @@ function broadcastDataUpdate(payload) { }).catch(() => { }); } +async function clearGrokSsoCookies() { + const currentState = await getState(); + const patch = typeof grokStateHelpers?.buildRuntimeStatePatch === 'function' + ? grokStateHelpers.buildRuntimeStatePatch(currentState, { + sso: { + currentCookie: '', + cookies: [], + extractedAt: 0, + }, + }) + : { + grokSsoCookie: '', + grokSsoCookies: [], + grokSsoExtractedAt: 0, + }; + await setState(patch); + const nextState = await getState(); + broadcastDataUpdate(patch); + await addLog('Grok SSO Cookie 已清空。', 'info', { nodeId: 'grok-extract-sso-cookie' }); + return { ok: true, state: nextState }; +} + function broadcastIcloudAliasesChanged(payload = {}) { chrome.runtime.sendMessage({ type: 'ICLOUD_ALIASES_CHANGED', @@ -9607,6 +9641,14 @@ function getDownstreamStateResets(step, state = {}) { }; } } + if (String(stepKey || '').trim().toLowerCase().startsWith('grok-')) { + const grokResets = typeof grokStateHelpers?.buildDownstreamResetPatch === 'function' + ? grokStateHelpers.buildDownstreamResetPatch(stepKey, state) + : {}; + if (Object.keys(grokResets).length > 0) { + return grokResets; + } + } const plusRuntimeResets = { plusCheckoutTabId: null, plusCheckoutUrl: null, @@ -10801,6 +10843,16 @@ async function handleNodeData(nodeId, payload) { } return; } + if (String(nodeDefinition?.flowId || '').trim().toLowerCase() === 'grok') { + const updates = typeof grokStateHelpers?.applyNodeCompletionPayload === 'function' + ? grokStateHelpers.applyNodeCompletionPayload(state, payload || {}) + : {}; + if (Object.keys(updates).length > 0) { + await setState(updates); + broadcastDataUpdate(updates); + } + return; + } const step = getStepIdByNodeIdForState(nodeId, state); if (!Number.isInteger(step) || step <= 0) { return; @@ -10858,6 +10910,11 @@ const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([ 'kiro-start-desktop-authorize', 'kiro-complete-desktop-authorize', 'kiro-upload-credential', + 'grok-open-signup-page', + 'grok-submit-email', + 'grok-submit-verification-code', + 'grok-submit-profile', + 'grok-extract-sso-cookie', ]); const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([ 'fill-password', @@ -13369,8 +13426,9 @@ async function resumeAutoRun() { const SIGNUP_ENTRY_URL = 'https://chatgpt.com/'; const OPENAI_AUTH_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'flows/openai/content/auth-page-recovery.js', 'flows/openai/content/phone-country-utils.js', 'flows/openai/content/phone-auth.js', 'flows/openai/content/openai-auth.js']; -const KIRO_REGISTER_INJECT_FILES = ['flows/openai/index.js', 'flows/kiro/index.js', 'flows/index.js', 'core/flow-kernel/flow-registry.js', 'core/flow-kernel/source-registry.js', 'shared/kiro-timeouts.js', 'content/utils.js', 'flows/kiro/content/register-page.js']; -const KIRO_DESKTOP_AUTHORIZE_INJECT_FILES = ['flows/openai/index.js', 'flows/kiro/index.js', 'flows/index.js', 'core/flow-kernel/flow-registry.js', 'core/flow-kernel/source-registry.js', 'shared/kiro-timeouts.js', 'content/utils.js', 'flows/kiro/content/desktop-authorize-page.js']; +const KIRO_REGISTER_INJECT_FILES = ['flows/openai/index.js', 'flows/kiro/index.js', 'flows/grok/index.js', 'flows/index.js', 'core/flow-kernel/flow-registry.js', 'core/flow-kernel/source-registry.js', 'shared/kiro-timeouts.js', 'content/utils.js', 'flows/kiro/content/register-page.js']; +const KIRO_DESKTOP_AUTHORIZE_INJECT_FILES = ['flows/openai/index.js', 'flows/kiro/index.js', 'flows/grok/index.js', 'flows/index.js', 'core/flow-kernel/flow-registry.js', 'core/flow-kernel/source-registry.js', 'shared/kiro-timeouts.js', 'content/utils.js', 'flows/kiro/content/desktop-authorize-page.js']; +const GROK_REGISTER_INJECT_FILES = ['flows/openai/index.js', 'flows/kiro/index.js', 'flows/grok/index.js', 'flows/index.js', 'core/flow-kernel/flow-registry.js', 'core/flow-kernel/source-registry.js', 'content/utils.js', 'flows/grok/content/register-page.js']; const panelBridge = self.MultiPageBackgroundPanelBridge?.createPanelBridge({ chrome, addLog, @@ -13428,12 +13486,50 @@ const openAiMailRules = self.MultiPageOpenAiMailRules?.createOpenAiMailRules({ MAIL_2925_VERIFICATION_INTERVAL_MS, MAIL_2925_VERIFICATION_MAX_ATTEMPTS, }); +const kiroMailRules = self.MultiPageKiroMailRules?.createKiroMailRules({ + LUCKMAIL_PROVIDER, + MAIL_2925_VERIFICATION_INTERVAL_MS, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS, +}); +const grokMailRules = self.MultiPageGrokMailRules?.createGrokMailRules({ + LUCKMAIL_PROVIDER, + MAIL_2925_VERIFICATION_INTERVAL_MS, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS, +}); const mailRuleRegistry = self.MultiPageBackgroundMailRuleRegistry?.createMailRuleRegistry({ defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, flowBuilders: { openai: openAiMailRules, + kiro: kiroMailRules, + grok: grokMailRules, }, }); +const flowMailPollingService = self.MultiPageBackgroundFlowMailPolling?.createFlowMailPollingService({ + addLog, + buildVerificationPollPayloadForNode: mailRuleRegistry?.buildVerificationPollPayloadForNode, + chrome, + CLOUDFLARE_TEMP_EMAIL_PROVIDER, + CLOUD_MAIL_PROVIDER, + ensureIcloudMailSession: ensureIcloudMailSessionForVerification, + ensureMail2925MailboxSession, + getMailConfig, + getTabId, + handleMail2925LimitReachedError, + HOTMAIL_PROVIDER, + isMail2925LimitReachedError, + isStopError, + isTabAlive, + LUCKMAIL_PROVIDER, + pollCloudflareTempEmailVerificationCode, + pollCloudMailVerificationCode, + pollHotmailVerificationCode, + pollLuckmailVerificationCode, + pollYydsMailVerificationCode, + reuseOrCreateTab, + sendToMailContentScriptResilient, + throwIfStopped, + YYDS_MAIL_PROVIDER, +}); const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.createVerificationFlowHelpers({ addLog, buildVerificationPollPayload: mailRuleRegistry?.buildVerificationPollPayload, @@ -13802,33 +13898,18 @@ const kiroRegisterRunner = self.MultiPageBackgroundKiroRegisterRunner?.createKir chrome, ensureContentScriptReadyOnTab, completeNodeFromBackground, - ensureIcloudMailSession: ensureIcloudMailSessionForVerification, - ensureMail2925MailboxSession, fetchImpl: typeof fetch === 'function' ? fetch.bind(globalThis) : null, generatePassword, generateRandomName, - getMailConfig, getTabId, getState, - HOTMAIL_PROVIDER, isTabAlive, - LUCKMAIL_PROVIDER, - CLOUDFLARE_TEMP_EMAIL_PROVIDER, - CLOUD_MAIL_PROVIDER, - YYDS_MAIL_PROVIDER, - MAIL_2925_VERIFICATION_INTERVAL_MS, - MAIL_2925_VERIFICATION_MAX_ATTEMPTS, isRetryableContentScriptTransportError, - pollCloudflareTempEmailVerificationCode, - pollCloudMailVerificationCode, - pollHotmailVerificationCode, - pollLuckmailVerificationCode, - pollYydsMailVerificationCode, + pollFlowVerificationCode: flowMailPollingService?.pollFlowVerificationCode, registerTab, resolveSignupEmailForFlow, reuseOrCreateTab, sendToContentScriptResilient, - sendToMailContentScriptResilient, setPasswordState, setState, sleepWithStop, @@ -13836,6 +13917,29 @@ const kiroRegisterRunner = self.MultiPageBackgroundKiroRegisterRunner?.createKir waitForTabStableComplete, KIRO_REGISTER_INJECT_FILES, }); +const grokRegisterRunner = self.MultiPageBackgroundGrokRegisterRunner?.createGrokRegisterRunner({ + addLog, + chrome, + ensureContentScriptReadyOnTab, + completeNodeFromBackground, + generatePassword, + generateRandomName, + getTabId, + getState, + isTabAlive, + pollFlowVerificationCode: flowMailPollingService?.pollFlowVerificationCode, + registerTab, + resolveSignupEmailForFlow, + reuseOrCreateTab, + sendToContentScriptResilient, + setPasswordState, + setState, + sleepWithStop, + throwIfStopped, + waitForTabStableComplete, + GROK_REGISTER_INJECT_FILES, + markCurrentRegistrationAccountUsed, +}); const kiroBuilderIdContributionAdapter = self.MultiPageBackgroundKiroBuilderIdContributionAdapter?.createKiroBuilderIdContributionAdapter?.({ addLog, fetchImpl: typeof fetch === 'function' ? fetch.bind(globalThis) : null, @@ -13867,31 +13971,16 @@ const kiroDesktopAuthorizeRunner = self.MultiPageBackgroundKiroDesktopAuthorizeR chrome, completeNodeFromBackground, ensureContentScriptReadyOnTab, - ensureIcloudMailSession: ensureIcloudMailSessionForVerification, - ensureMail2925MailboxSession, fetchImpl: typeof fetch === 'function' ? fetch.bind(globalThis) : null, - getMailConfig, getTabId, getState, - HOTMAIL_PROVIDER, isTabAlive, KIRO_REGISTER_INJECT_FILES, - LUCKMAIL_PROVIDER, - CLOUDFLARE_TEMP_EMAIL_PROVIDER, - CLOUD_MAIL_PROVIDER, - YYDS_MAIL_PROVIDER, - MAIL_2925_VERIFICATION_INTERVAL_MS, - MAIL_2925_VERIFICATION_MAX_ATTEMPTS, maybeSubmitFlowContribution, - pollCloudflareTempEmailVerificationCode, - pollCloudMailVerificationCode, - pollHotmailVerificationCode, - pollLuckmailVerificationCode, - pollYydsMailVerificationCode, + pollFlowVerificationCode: flowMailPollingService?.pollFlowVerificationCode, registerTab, reuseOrCreateTab, sendToContentScriptResilient, - sendToMailContentScriptResilient, setState, sleepWithStop, throwIfStopped, @@ -14000,6 +14089,11 @@ const stepExecutorsByKey = { 'kiro-start-desktop-authorize': (state) => kiroDesktopAuthorizeRunner.executeKiroStartDesktopAuthorize(state), 'kiro-complete-desktop-authorize': (state) => kiroDesktopAuthorizeRunner.executeKiroCompleteDesktopAuthorize(state), 'kiro-upload-credential': (state) => kiroPublisher.executeKiroUploadCredential(state), + 'grok-open-signup-page': (state) => grokRegisterRunner.executeGrokOpenSignupPage(state), + 'grok-submit-email': (state) => grokRegisterRunner.executeGrokSubmitEmail(state), + 'grok-submit-verification-code': (state) => grokRegisterRunner.executeGrokSubmitVerificationCode(state), + 'grok-submit-profile': (state) => grokRegisterRunner.executeGrokSubmitProfile(state), + 'grok-extract-sso-cookie': (state) => grokRegisterRunner.executeGrokExtractSsoCookie(state), }; const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter({ addLog, @@ -14016,6 +14110,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter deleteAccountRunHistoryRecords: (...args) => deleteAndBroadcastAccountRunHistoryRecords(...args), clearAutoRunTimerAlarm, clearFreeReusablePhoneActivation, + clearGrokSsoCookies, clearLuckmailRuntimeState, clearYydsMailRuntimeState, clearStopRequest, diff --git a/background/flow-mail-polling.js b/background/flow-mail-polling.js new file mode 100644 index 0000000..820115b --- /dev/null +++ b/background/flow-mail-polling.js @@ -0,0 +1,354 @@ +(function attachBackgroundFlowMailPolling(root, factory) { + root.MultiPageBackgroundFlowMailPolling = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundFlowMailPollingModule() { + const ICLOUD_MAIL_POLL_MIN_ATTEMPTS = 5; + const ICLOUD_MAIL_POLL_TIMEOUT_MARGIN_MS = 25000; + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function normalizeProviderId(value = '') { + return cleanString(value).toLowerCase(); + } + + function getMailPollingResponseTimeoutMs(payload = {}) { + const maxAttempts = Math.max(1, Math.floor(Number(payload?.maxAttempts) || 1)); + const intervalMs = Math.max(1, Number(payload?.intervalMs) || 3000); + return Math.max(45000, maxAttempts * intervalMs + ICLOUD_MAIL_POLL_TIMEOUT_MARGIN_MS); + } + + function isIcloudMail(mail = {}) { + return mail?.source === 'icloud-mail' || mail?.provider === 'icloud'; + } + + function normalizeIcloudMailPollPayload(mail = {}, payload = {}) { + if (!isIcloudMail(mail)) { + return payload; + } + + const maxAttempts = Math.max(1, Math.floor(Number(payload?.maxAttempts) || 1)); + if (maxAttempts >= ICLOUD_MAIL_POLL_MIN_ATTEMPTS) { + return payload; + } + + return { + ...payload, + maxAttempts: ICLOUD_MAIL_POLL_MIN_ATTEMPTS, + }; + } + + function resolveMailPollingTimeouts(mail = {}, payload = {}) { + const normalizedPayload = normalizeIcloudMailPollPayload(mail, payload); + const responseTimeoutMs = getMailPollingResponseTimeoutMs(normalizedPayload); + return { + payload: normalizedPayload, + responseTimeoutMs, + timeoutMs: responseTimeoutMs, + }; + } + + function getExpectedMail2925MailboxEmail(state = {}) { + if (Boolean(state?.mail2925UseAccountPool)) { + const currentAccountId = cleanString(state?.currentMail2925AccountId); + const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; + const currentAccount = accounts.find((account) => cleanString(account?.id) === currentAccountId) || null; + const accountEmail = cleanString(currentAccount?.email).toLowerCase(); + if (accountEmail) { + return accountEmail; + } + } + + return cleanString(state?.mail2925BaseEmail).toLowerCase(); + } + + function createFlowMailPollingService(deps = {}) { + const { + addLog = async () => {}, + buildVerificationPollPayloadForNode = null, + chrome = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), + CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email', + CLOUD_MAIL_PROVIDER = 'cloudmail', + ensureIcloudMailSession = null, + ensureMail2925MailboxSession = null, + getMailConfig = null, + getTabId = async () => null, + handleMail2925LimitReachedError = null, + HOTMAIL_PROVIDER = 'hotmail-api', + isMail2925LimitReachedError = null, + isStopError = null, + isTabAlive = async () => false, + LUCKMAIL_PROVIDER = 'luckmail-api', + pollCloudflareTempEmailVerificationCode = null, + pollCloudMailVerificationCode = null, + pollHotmailVerificationCode = null, + pollLuckmailVerificationCode = null, + pollYydsMailVerificationCode = null, + reuseOrCreateTab = async () => null, + sendToMailContentScriptResilient = null, + throwIfStopped = () => {}, + YYDS_MAIL_PROVIDER = 'yyds-mail', + } = deps; + + const apiProviderHandlers = new Map([ + [normalizeProviderId(HOTMAIL_PROVIDER), { + label: 'Hotmail', + poll: pollHotmailVerificationCode, + }], + [normalizeProviderId(LUCKMAIL_PROVIDER), { + label: 'LuckMail', + poll: pollLuckmailVerificationCode, + }], + [normalizeProviderId(CLOUDFLARE_TEMP_EMAIL_PROVIDER), { + label: 'Cloudflare Temp Email', + poll: pollCloudflareTempEmailVerificationCode, + }], + [normalizeProviderId(CLOUD_MAIL_PROVIDER), { + label: 'Cloud Mail', + poll: pollCloudMailVerificationCode, + }], + [normalizeProviderId(YYDS_MAIL_PROVIDER), { + label: 'YYDS Mail', + poll: pollYydsMailVerificationCode, + }], + ]); + + async function log(message, level = 'info', options = {}) { + const logOptions = options && typeof options === 'object' ? { ...options } : {}; + await addLog(message, level, logOptions); + } + + async function activateTab(tabId) { + if (!Number.isInteger(tabId) || !chrome?.tabs?.update) { + return; + } + await chrome.tabs.update(tabId, { active: true }); + } + + async function focusOrOpenMailTab(mail = {}) { + if (!mail?.source) { + return; + } + + const alive = await isTabAlive(mail.source); + if (alive) { + if (mail.navigateOnReuse) { + await reuseOrCreateTab(mail.source, mail.url, { + inject: mail.inject, + injectSource: mail.injectSource, + }); + return; + } + + const tabId = await getTabId(mail.source); + if (Number.isInteger(tabId)) { + await activateTab(tabId); + } + return; + } + + await reuseOrCreateTab(mail.source, mail.url, { + inject: mail.inject, + injectSource: mail.injectSource, + }); + } + + function assertPollResult(result, notFoundMessage) { + if (result?.error) { + throw new Error(result.error); + } + if (!result?.code) { + throw new Error(notFoundMessage || '邮箱轮询结束,但未获取到验证码。'); + } + return result; + } + + function isMail2925Provider(mail = {}) { + return normalizeProviderId(mail?.provider) === '2925'; + } + + async function pollThroughApiProvider(providerId, step, state, pollPayload, mail, options) { + const handler = apiProviderHandlers.get(providerId); + if (!handler) { + return null; + } + if (typeof handler.poll !== 'function') { + throw new Error(`${handler.label} 邮箱轮询能力未接入,无法继续执行。`); + } + + await log( + `步骤 ${step}:正在通过 ${mail.label || handler.label} 轮询${options.actionLabel || '验证码'}...`, + 'info', + options.logOptions + ); + const result = await handler.poll(step, state, pollPayload); + return assertPollResult(result, options.notFoundMessage); + } + + async function ensureBrowserMailSession(step, state, mail, options) { + if (isIcloudMail(mail) && typeof ensureIcloudMailSession === 'function') { + await log( + `步骤 ${step}:正在确认 ${mail.label || 'iCloud 邮箱'} 登录状态...`, + 'info', + options.logOptions + ); + await ensureIcloudMailSession({ + state, + step, + actionLabel: `步骤 ${step}:确认 iCloud 邮箱登录状态`, + }); + return; + } + + if (isMail2925Provider(mail) && typeof ensureMail2925MailboxSession === 'function') { + await log( + `步骤 ${step}:正在确认 ${mail.label || '2925 邮箱'} 登录状态...`, + 'info', + options.logOptions + ); + await ensureMail2925MailboxSession({ + accountId: state.currentMail2925AccountId || null, + forceRelogin: false, + allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), + expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), + actionLabel: `步骤 ${step}:确认 2925 邮箱登录状态`, + }); + return; + } + + await log(`步骤 ${step}:正在打开 ${mail.label || '邮箱'}...`, 'info', options.logOptions); + await focusOrOpenMailTab(mail); + } + + async function pollThroughBrowserProvider(step, state, mail, pollPayload, options) { + await ensureBrowserMailSession(step, state, mail, options); + + if (typeof sendToMailContentScriptResilient !== 'function') { + throw new Error(options.missingContentScriptMessage || '当前验证码步骤缺少邮箱内容脚本通信能力,无法继续执行。'); + } + + const timeoutWindow = resolveMailPollingTimeouts(mail, pollPayload); + try { + const result = await sendToMailContentScriptResilient( + mail, + { + type: 'POLL_EMAIL', + step, + source: 'background', + payload: timeoutWindow.payload, + }, + { + timeoutMs: timeoutWindow.timeoutMs, + responseTimeoutMs: timeoutWindow.responseTimeoutMs, + maxRecoveryAttempts: 2, + logStep: options.logStep || step, + logStepKey: options.logStepKey || options.nodeId || '', + } + ); + return assertPollResult(result, options.notFoundMessage); + } catch (error) { + if (typeof isStopError === 'function' && isStopError(error)) { + throw error; + } + if ( + isMail2925Provider(mail) + && typeof isMail2925LimitReachedError === 'function' + && isMail2925LimitReachedError(error) + && typeof handleMail2925LimitReachedError === 'function' + ) { + throw await handleMail2925LimitReachedError(step, error); + } + throw error; + } + } + + async function pollFlowVerificationCode(options = {}) { + const { + actionLabel = '验证码', + filterAfterTimestamp, + flowId = '', + logStep, + logStepKey = '', + missingCapabilityMessage = '当前验证码步骤缺少共享邮件轮询能力,无法继续执行。', + nodeId = '', + notFoundMessage = '', + payloadOverrides = {}, + state = {}, + step = 0, + } = options; + + if (typeof getMailConfig !== 'function') { + throw new Error('当前验证码步骤缺少邮箱配置能力,无法继续执行。'); + } + if (typeof buildVerificationPollPayloadForNode !== 'function') { + throw new Error(missingCapabilityMessage); + } + + const mail = getMailConfig(state); + if (mail?.error) { + throw new Error(mail.error); + } + + const ruleState = flowId + ? { + ...state, + activeFlowId: flowId, + flowId, + } + : state; + const nextOverrides = { + ...(payloadOverrides || {}), + }; + if (filterAfterTimestamp !== undefined) { + nextOverrides.filterAfterTimestamp = filterAfterTimestamp; + } + const pollPayload = buildVerificationPollPayloadForNode(nodeId, ruleState, nextOverrides); + const normalizedStep = Math.max(1, Math.floor(Number(pollPayload?.step || step) || 1)); + const providerId = normalizeProviderId(mail?.provider); + const logOptions = {}; + const normalizedLogStep = Math.floor(Number(logStep || normalizedStep) || 0); + if (normalizedLogStep > 0) { + logOptions.step = normalizedLogStep; + } + if (logStepKey || nodeId) { + logOptions.stepKey = logStepKey || nodeId; + logOptions.nodeId = nodeId || logStepKey; + } + + throwIfStopped(); + const apiResult = await pollThroughApiProvider(providerId, normalizedStep, ruleState, pollPayload, mail, { + actionLabel, + logOptions, + notFoundMessage, + }); + if (apiResult) { + return apiResult; + } + + return pollThroughBrowserProvider(normalizedStep, ruleState, mail, pollPayload, { + actionLabel, + logOptions, + logStep: normalizedLogStep || normalizedStep, + logStepKey: logStepKey || nodeId, + missingContentScriptMessage: missingCapabilityMessage, + nodeId, + notFoundMessage: notFoundMessage || `步骤 ${normalizedStep}:邮箱轮询结束,但未获取到验证码。`, + }); + } + + return { + focusOrOpenMailTab, + getExpectedMail2925MailboxEmail, + getMailPollingResponseTimeoutMs, + pollFlowVerificationCode, + resolveMailPollingTimeouts, + }; + } + + return { + createFlowMailPollingService, + getExpectedMail2925MailboxEmail, + getMailPollingResponseTimeoutMs, + resolveMailPollingTimeouts, + }; +}); diff --git a/background/message-router.js b/background/message-router.js index 5de76ed..93003c9 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -17,6 +17,7 @@ deleteAccountRunHistoryRecords, clearAutoRunTimerAlarm, clearFreeReusablePhoneActivation, + clearGrokSsoCookies, clearLuckmailRuntimeState, clearYydsMailRuntimeState, clearStopRequest, @@ -1165,6 +1166,13 @@ return await clearFreeReusablePhoneActivation(); } + case 'CLEAR_GROK_SSO_COOKIES': { + if (typeof clearGrokSsoCookies !== 'function') { + throw new Error('Grok SSO 清空能力未接入。'); + } + return await clearGrokSsoCookies(); + } + case 'SET_FREE_REUSABLE_PHONE': { if (typeof setFreeReusablePhoneActivation !== 'function') { throw new Error('白嫖复用手机号记录能力未接入。'); diff --git a/core/flow-kernel/settings-schema.js b/core/flow-kernel/settings-schema.js index 8e8afad..8571706 100644 --- a/core/flow-kernel/settings-schema.js +++ b/core/flow-kernel/settings-schema.js @@ -17,6 +17,23 @@ return value; } + function mergePlainObjects(baseValue = {}, patchValue = {}) { + if (Array.isArray(patchValue)) { + return patchValue.map((entry) => cloneValue(entry)); + } + if (!isPlainObject(patchValue)) { + return patchValue === undefined ? cloneValue(baseValue) : patchValue; + } + const baseObject = isPlainObject(baseValue) ? baseValue : {}; + const next = { + ...cloneValue(baseObject), + }; + Object.entries(patchValue).forEach(([key, value]) => { + next[key] = mergePlainObjects(baseObject[key], value); + }); + return next; + } + function normalizeStepExecutionRangeEntry(value = {}, fallback = {}) { const source = isPlainObject(value) ? value : {}; const fallbackSource = isPlainObject(fallback) ? fallback : {}; @@ -47,6 +64,19 @@ const normalizeTargetId = typeof flowRegistry.normalizeTargetId === 'function' ? flowRegistry.normalizeTargetId : ((_flowId, value = '', fallback = '') => String(value || fallback || '').trim().toLowerCase()); + const getRegisteredFlowIds = typeof flowRegistry.getRegisteredFlowIds === 'function' + ? flowRegistry.getRegisteredFlowIds + : (() => ['openai', 'kiro']); + const getFlowDefinition = typeof flowRegistry.getFlowDefinition === 'function' + ? flowRegistry.getFlowDefinition + : (() => null); + const getDefaultTargetId = typeof flowRegistry.getDefaultTargetId === 'function' + ? flowRegistry.getDefaultTargetId + : ((flowId) => (flowId === 'kiro' ? defaultKiroTargetId : defaultOpenAiTargetId)); + const getTargetDefinitions = typeof flowRegistry.getTargetDefinitions === 'function' + ? flowRegistry.getTargetDefinitions + : (() => ({})); + const normalizePlusAccountAccessStrategy = (value = '') => { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'sub2api_codex_session') { @@ -58,6 +88,146 @@ return 'oauth'; }; + function getCanonicalFlowIds() { + const ids = Array.isArray(getRegisteredFlowIds()) + ? getRegisteredFlowIds() + : []; + const seen = new Set(); + return ids + .map((flowId) => normalizeFlowId(flowId, defaultFlowId)) + .filter((flowId) => { + if (!flowId || seen.has(flowId)) { + return false; + } + seen.add(flowId); + return true; + }); + } + + function getFlowSettingsDefaults(flowId) { + const definition = getFlowDefinition(flowId) || {}; + return isPlainObject(definition.settingsDefaults) ? definition.settingsDefaults : {}; + } + + function getDefaultStepExecutionRange(flowId) { + const flowDefaults = getFlowSettingsDefaults(flowId); + const range = flowDefaults?.autoRun?.stepExecutionRange; + if (isPlainObject(range)) { + return normalizeStepExecutionRangeEntry(range, { enabled: false, fromStep: 1, toStep: 1 }); + } + if (flowId === 'openai') { + return { enabled: false, fromStep: 1, toStep: 11 }; + } + if (flowId === 'kiro') { + return { enabled: false, fromStep: 1, toStep: 9 }; + } + const lastStep = Math.max(1, Number(getFlowDefinition(flowId)?.workflowStepCount) || 1); + return { enabled: false, fromStep: 1, toStep: lastStep }; + } + + function buildDefaultTargetState(flowId, targetId) { + if (flowId === 'openai' && targetId === 'cpa') { + return { + vpsUrl: '', + vpsPassword: '', + localCpaStep9Mode: 'submit', + }; + } + if (flowId === 'openai' && targetId === 'sub2api') { + return { + sub2apiUrl: '', + sub2apiEmail: '', + sub2apiPassword: '', + sub2apiGroupName: 'codex', + sub2apiGroupNames: ['codex', 'openai-plus'], + sub2apiAccountPriority: 1, + sub2apiDefaultProxyName: '', + }; + } + if (flowId === 'openai' && targetId === 'codex2api') { + return { + codex2apiUrl: '', + codex2apiAdminKey: '', + }; + } + if (flowId === 'kiro' && targetId === 'kiro-rs') { + return { + baseUrl: defaultKiroRsUrl, + apiKey: '', + }; + } + + const definition = getFlowDefinition(flowId) || {}; + const flowDefaults = getFlowSettingsDefaults(flowId); + const targetDefaults = isPlainObject(flowDefaults?.targets?.[targetId]) + ? flowDefaults.targets[targetId] + : {}; + const sharedTargetDefaults = isPlainObject(definition.defaultTargetState) + ? definition.defaultTargetState + : {}; + const targetDefinition = isPlainObject(getTargetDefinitions(flowId)?.[targetId]) + ? getTargetDefinitions(flowId)[targetId] + : {}; + const targetDefinitionDefaults = isPlainObject(targetDefinition.defaultState) + ? targetDefinition.defaultState + : {}; + return mergePlainObjects( + mergePlainObjects(sharedTargetDefaults, targetDefinitionDefaults), + targetDefaults + ); + } + + function buildDefaultTargets(flowId) { + const targetDefinitions = getTargetDefinitions(flowId) || {}; + const targetIds = Object.keys(targetDefinitions); + const defaults = {}; + targetIds.forEach((targetId) => { + defaults[targetId] = buildDefaultTargetState(flowId, targetId); + }); + return defaults; + } + + function buildDefaultFlowSettings(flowId) { + const defaultTargetId = normalizeTargetId( + flowId, + getDefaultTargetId(flowId), + flowId === 'kiro' ? defaultKiroTargetId : defaultOpenAiTargetId + ); + const base = { + selectedTargetId: defaultTargetId, + targets: buildDefaultTargets(flowId), + autoRun: { + stepExecutionRange: getDefaultStepExecutionRange(flowId), + }, + }; + if (flowId === 'openai') { + return mergePlainObjects(base, { + signup: { + signupMethod: 'email', + phoneVerificationEnabled: false, + phoneSignupReloginAfterBindEmailEnabled: false, + }, + plus: { + plusModeEnabled: false, + plusPaymentMethod: 'paypal-hosted', + plusAccountAccessStrategy: 'oauth', + hostedCheckoutVerificationUrl: '', + hostedCheckoutPhoneNumber: '', + plusHostedCheckoutOauthDelaySeconds: 3, + }, + }); + } + return base; + } + + function buildDefaultFlows() { + const flows = {}; + getCanonicalFlowIds().forEach((flowId) => { + flows[flowId] = buildDefaultFlowSettings(flowId); + }); + return flows; + } + function buildDefaultSettingsState() { return { schemaVersion: 5, @@ -75,67 +245,7 @@ mode: 'account', }, }, - flows: { - openai: { - selectedTargetId: defaultOpenAiTargetId, - targets: { - cpa: { - vpsUrl: '', - vpsPassword: '', - localCpaStep9Mode: 'submit', - }, - sub2api: { - sub2apiUrl: '', - sub2apiEmail: '', - sub2apiPassword: '', - sub2apiGroupName: 'codex', - sub2apiGroupNames: ['codex', 'openai-plus'], - sub2apiAccountPriority: 1, - sub2apiDefaultProxyName: '', - }, - codex2api: { - codex2apiUrl: '', - codex2apiAdminKey: '', - }, - }, - signup: { - signupMethod: 'email', - phoneVerificationEnabled: false, - phoneSignupReloginAfterBindEmailEnabled: false, - }, - plus: { - plusModeEnabled: false, - plusPaymentMethod: 'paypal-hosted', - plusAccountAccessStrategy: 'oauth', - hostedCheckoutVerificationUrl: '', - hostedCheckoutPhoneNumber: '', - plusHostedCheckoutOauthDelaySeconds: 3, - }, - autoRun: { - stepExecutionRange: { - enabled: false, - fromStep: 1, - toStep: 11, - }, - }, - }, - kiro: { - selectedTargetId: defaultKiroTargetId, - targets: { - 'kiro-rs': { - baseUrl: defaultKiroRsUrl, - apiKey: '', - }, - }, - autoRun: { - stepExecutionRange: { - enabled: false, - fromStep: 1, - toStep: 9, - }, - }, - }, - }, + flows: buildDefaultFlows(), }; } @@ -147,6 +257,223 @@ return cloneValue(resolvedValue); } + function normalizeFlowTargetState(flowId, targetId, nested = {}, defaults = {}) { + const targetState = mergePlainObjects(defaults, nested); + if (flowId === 'openai' && targetId === 'cpa') { + return { + ...targetState, + vpsUrl: String(targetState.vpsUrl ?? '').trim(), + vpsPassword: String(targetState.vpsPassword ?? ''), + localCpaStep9Mode: String(targetState.localCpaStep9Mode ?? 'submit').trim() || 'submit', + }; + } + if (flowId === 'openai' && targetId === 'sub2api') { + return { + ...targetState, + sub2apiUrl: String(targetState.sub2apiUrl ?? '').trim(), + sub2apiEmail: String(targetState.sub2apiEmail ?? '').trim(), + sub2apiPassword: String(targetState.sub2apiPassword ?? ''), + sub2apiGroupName: String(targetState.sub2apiGroupName ?? 'codex').trim() || 'codex', + sub2apiGroupNames: Array.isArray(targetState.sub2apiGroupNames) + ? targetState.sub2apiGroupNames.map((entry) => String(entry || '').trim()).filter(Boolean) + : ['codex', 'openai-plus'], + sub2apiAccountPriority: Math.max(1, Number(targetState.sub2apiAccountPriority) || 1), + sub2apiDefaultProxyName: String(targetState.sub2apiDefaultProxyName ?? '').trim(), + }; + } + if (flowId === 'openai' && targetId === 'codex2api') { + return { + ...targetState, + codex2apiUrl: String(targetState.codex2apiUrl ?? '').trim(), + codex2apiAdminKey: String(targetState.codex2apiAdminKey ?? '').trim(), + }; + } + if (flowId === 'kiro' && targetId === 'kiro-rs') { + return { + ...targetState, + baseUrl: String(targetState.baseUrl ?? defaultKiroRsUrl).trim() || defaultKiroRsUrl, + apiKey: String(targetState.apiKey ?? ''), + }; + } + return targetState; + } + + function buildNormalizedTargets(flowId, nestedFlow = {}, defaultFlow = {}) { + const targetIds = Array.from(new Set([ + ...Object.keys(defaultFlow.targets || {}), + ...Object.keys(isPlainObject(nestedFlow.targets) ? nestedFlow.targets : {}), + ...Object.keys(getTargetDefinitions(flowId) || {}), + ])); + const targets = {}; + targetIds.forEach((targetId) => { + targets[targetId] = normalizeFlowTargetState( + flowId, + targetId, + isPlainObject(nestedFlow.targets?.[targetId]) ? nestedFlow.targets[targetId] : {}, + defaultFlow.targets?.[targetId] || buildDefaultTargetState(flowId, targetId) + ); + }); + return targets; + } + + function normalizeFlowSettings(flowId, input = {}, nested = {}, defaults = {}) { + const nestedFlow = isPlainObject(nested?.flows?.[flowId]) ? nested.flows[flowId] : {}; + const activeFlowId = normalizeFlowId( + input?.activeFlowId + ?? nested?.activeFlowId + ?? defaults.activeFlowId, + defaults.activeFlowId + ); + const mergedFlow = mergePlainObjects(defaults.flows?.[flowId] || buildDefaultFlowSettings(flowId), nestedFlow); + const defaultTargetId = defaults.flows?.[flowId]?.selectedTargetId || getDefaultTargetId(flowId); + const selectedTargetId = normalizeTargetId( + flowId, + nestedFlow?.selectedTargetId + ?? nestedFlow?.targetId + ?? (flowId === 'openai' ? nestedFlow?.integrationTargetId : undefined) + ?? (activeFlowId === flowId ? (input?.selectedTargetId ?? input?.targetId) : undefined) + ?? defaultTargetId, + defaultTargetId + ); + const stepExecutionRangeByFlow = isPlainObject(input?.stepExecutionRangeByFlow) + ? input.stepExecutionRangeByFlow + : {}; + const autoRun = { + ...(isPlainObject(mergedFlow.autoRun) ? mergedFlow.autoRun : {}), + stepExecutionRange: normalizeStepExecutionRangeEntry( + stepExecutionRangeByFlow[flowId] + ?? nestedFlow?.autoRun?.stepExecutionRange + ?? {}, + defaults.flows?.[flowId]?.autoRun?.stepExecutionRange || getDefaultStepExecutionRange(flowId) + ), + }; + + return { + ...mergedFlow, + selectedTargetId, + targets: buildNormalizedTargets(flowId, nestedFlow, defaults.flows?.[flowId] || {}), + autoRun, + }; + } + + function normalizeOpenAiSettings(input = {}, nested = {}, defaults = {}, currentFlow = {}) { + const cpaSource = { + ...currentFlow.targets.cpa, + ...getTargetValue( + nested, + (state) => state.flows?.openai?.integrationTargets?.cpa, + null, + {} + ), + vpsUrl: input?.vpsUrl ?? currentFlow.targets.cpa.vpsUrl, + vpsPassword: input?.vpsPassword ?? currentFlow.targets.cpa.vpsPassword, + localCpaStep9Mode: input?.localCpaStep9Mode ?? currentFlow.targets.cpa.localCpaStep9Mode, + }; + const sub2apiSource = { + ...currentFlow.targets.sub2api, + ...getTargetValue( + nested, + (state) => state.flows?.openai?.integrationTargets?.sub2api, + null, + {} + ), + sub2apiUrl: input?.sub2apiUrl ?? currentFlow.targets.sub2api.sub2apiUrl, + sub2apiEmail: input?.sub2apiEmail ?? currentFlow.targets.sub2api.sub2apiEmail, + sub2apiPassword: input?.sub2apiPassword ?? currentFlow.targets.sub2api.sub2apiPassword, + sub2apiGroupName: input?.sub2apiGroupName ?? currentFlow.targets.sub2api.sub2apiGroupName, + sub2apiGroupNames: input?.sub2apiGroupNames ?? currentFlow.targets.sub2api.sub2apiGroupNames, + sub2apiAccountPriority: input?.sub2apiAccountPriority ?? currentFlow.targets.sub2api.sub2apiAccountPriority, + sub2apiDefaultProxyName: input?.sub2apiDefaultProxyName ?? currentFlow.targets.sub2api.sub2apiDefaultProxyName, + }; + const codex2apiSource = { + ...currentFlow.targets.codex2api, + ...getTargetValue( + nested, + (state) => state.flows?.openai?.integrationTargets?.codex2api, + null, + {} + ), + codex2apiUrl: input?.codex2apiUrl ?? currentFlow.targets.codex2api.codex2apiUrl, + codex2apiAdminKey: input?.codex2apiAdminKey ?? currentFlow.targets.codex2api.codex2apiAdminKey, + }; + return { + ...currentFlow, + targets: { + ...currentFlow.targets, + cpa: normalizeFlowTargetState('openai', 'cpa', cpaSource, defaults.flows.openai.targets.cpa), + sub2api: normalizeFlowTargetState('openai', 'sub2api', sub2apiSource, defaults.flows.openai.targets.sub2api), + codex2api: normalizeFlowTargetState('openai', 'codex2api', codex2apiSource, defaults.flows.openai.targets.codex2api), + }, + signup: { + signupMethod: String( + input?.signupMethod + ?? currentFlow.signup?.signupMethod + ?? defaults.flows.openai.signup.signupMethod + ).trim().toLowerCase() === 'phone' ? 'phone' : 'email', + phoneVerificationEnabled: Boolean( + input?.phoneVerificationEnabled + ?? currentFlow.signup?.phoneVerificationEnabled + ?? defaults.flows.openai.signup.phoneVerificationEnabled + ), + phoneSignupReloginAfterBindEmailEnabled: Boolean( + input?.phoneSignupReloginAfterBindEmailEnabled + ?? currentFlow.signup?.phoneSignupReloginAfterBindEmailEnabled + ?? defaults.flows.openai.signup.phoneSignupReloginAfterBindEmailEnabled + ), + }, + plus: { + plusModeEnabled: Boolean( + input?.plusModeEnabled + ?? currentFlow.plus?.plusModeEnabled + ?? defaults.flows.openai.plus.plusModeEnabled + ), + plusPaymentMethod: String( + input?.plusPaymentMethod + ?? currentFlow.plus?.plusPaymentMethod + ?? defaults.flows.openai.plus.plusPaymentMethod + ).trim() || defaults.flows.openai.plus.plusPaymentMethod, + plusAccountAccessStrategy: normalizePlusAccountAccessStrategy( + input?.plusAccountAccessStrategy + ?? currentFlow.plus?.plusAccountAccessStrategy + ?? defaults.flows.openai.plus.plusAccountAccessStrategy + ), + hostedCheckoutVerificationUrl: String( + input?.hostedCheckoutVerificationUrl + ?? currentFlow.plus?.hostedCheckoutVerificationUrl + ?? defaults.flows.openai.plus.hostedCheckoutVerificationUrl + ).trim(), + hostedCheckoutPhoneNumber: String( + input?.hostedCheckoutPhoneNumber + ?? currentFlow.plus?.hostedCheckoutPhoneNumber + ?? defaults.flows.openai.plus.hostedCheckoutPhoneNumber + ).trim(), + plusHostedCheckoutOauthDelaySeconds: (() => { + const numeric = Number( + input?.plusHostedCheckoutOauthDelaySeconds + ?? currentFlow.plus?.plusHostedCheckoutOauthDelaySeconds + ?? defaults.flows.openai.plus.plusHostedCheckoutOauthDelaySeconds + ); + return Math.min(120, Math.max(0, Math.floor(Number.isFinite(numeric) ? numeric : defaults.flows.openai.plus.plusHostedCheckoutOauthDelaySeconds))); + })(), + }, + }; + } + + function normalizeKiroSettings(input = {}, defaults = {}, currentFlow = {}) { + const targetSource = { + ...currentFlow.targets['kiro-rs'], + baseUrl: input?.kiroRsUrl ?? input?.kiroRsBaseUrl ?? currentFlow.targets['kiro-rs'].baseUrl, + apiKey: input?.kiroRsKey ?? input?.kiroRsApiKey ?? currentFlow.targets['kiro-rs'].apiKey, + }; + return { + ...currentFlow, + targets: { + ...currentFlow.targets, + 'kiro-rs': normalizeFlowTargetState('kiro', 'kiro-rs', targetSource, defaults.flows.kiro.targets['kiro-rs']), + }, + }; + } + function normalizeSettingsState(input = {}, options = {}) { const defaults = buildDefaultSettingsState(); const nested = isPlainObject(input?.settingsState) @@ -159,31 +486,7 @@ ?? defaults.activeFlowId, defaults.activeFlowId ); - const openaiSelectedTargetId = normalizeTargetId( - 'openai', - nested?.flows?.openai?.selectedTargetId - ?? nested?.flows?.openai?.integrationTargetId - ?? (activeFlowId === 'openai' - ? (input?.selectedTargetId ?? input?.targetId) - : undefined) - ?? defaults.flows.openai.selectedTargetId, - defaults.flows.openai.selectedTargetId - ); - const kiroSelectedTargetId = normalizeTargetId( - 'kiro', - nested?.flows?.kiro?.selectedTargetId - ?? nested?.flows?.kiro?.targetId - ?? (activeFlowId === 'kiro' - ? (input?.selectedTargetId ?? input?.targetId) - : undefined) - ?? defaults.flows.kiro.selectedTargetId, - defaults.flows.kiro.selectedTargetId - ); - const stepExecutionRangeByFlow = isPlainObject(input?.stepExecutionRangeByFlow) - ? input.stepExecutionRangeByFlow - : {}; - - return { + const normalized = { schemaVersion: Number(input?.settingsSchemaVersion || nested?.schemaVersion || defaults.schemaVersion) || defaults.schemaVersion, activeFlowId, services: { @@ -219,203 +522,22 @@ ).trim(), }, }, - flows: { - openai: { - selectedTargetId: openaiSelectedTargetId, - targets: { - cpa: { - ...defaults.flows.openai.targets.cpa, - ...getTargetValue( - nested, - (state) => state.flows?.openai?.targets?.cpa, - (state) => state.flows?.openai?.integrationTargets?.cpa - ), - vpsUrl: String( - input?.vpsUrl - ?? nested?.flows?.openai?.targets?.cpa?.vpsUrl - ?? nested?.flows?.openai?.integrationTargets?.cpa?.vpsUrl - ?? '' - ).trim(), - vpsPassword: String( - input?.vpsPassword - ?? nested?.flows?.openai?.targets?.cpa?.vpsPassword - ?? nested?.flows?.openai?.integrationTargets?.cpa?.vpsPassword - ?? '' - ), - localCpaStep9Mode: String( - input?.localCpaStep9Mode - ?? nested?.flows?.openai?.targets?.cpa?.localCpaStep9Mode - ?? nested?.flows?.openai?.integrationTargets?.cpa?.localCpaStep9Mode - ?? defaults.flows.openai.targets.cpa.localCpaStep9Mode - ).trim() || defaults.flows.openai.targets.cpa.localCpaStep9Mode, - }, - sub2api: { - ...defaults.flows.openai.targets.sub2api, - ...getTargetValue( - nested, - (state) => state.flows?.openai?.targets?.sub2api, - (state) => state.flows?.openai?.integrationTargets?.sub2api - ), - sub2apiUrl: String( - input?.sub2apiUrl - ?? nested?.flows?.openai?.targets?.sub2api?.sub2apiUrl - ?? nested?.flows?.openai?.integrationTargets?.sub2api?.sub2apiUrl - ?? '' - ).trim(), - sub2apiEmail: String( - input?.sub2apiEmail - ?? nested?.flows?.openai?.targets?.sub2api?.sub2apiEmail - ?? nested?.flows?.openai?.integrationTargets?.sub2api?.sub2apiEmail - ?? '' - ).trim(), - sub2apiPassword: String( - input?.sub2apiPassword - ?? nested?.flows?.openai?.targets?.sub2api?.sub2apiPassword - ?? nested?.flows?.openai?.integrationTargets?.sub2api?.sub2apiPassword - ?? '' - ), - sub2apiGroupName: String( - input?.sub2apiGroupName - ?? nested?.flows?.openai?.targets?.sub2api?.sub2apiGroupName - ?? nested?.flows?.openai?.integrationTargets?.sub2api?.sub2apiGroupName - ?? defaults.flows.openai.targets.sub2api.sub2apiGroupName - ).trim() || defaults.flows.openai.targets.sub2api.sub2apiGroupName, - sub2apiGroupNames: Array.isArray(input?.sub2apiGroupNames) - ? input.sub2apiGroupNames.map((entry) => String(entry || '').trim()).filter(Boolean) - : (Array.isArray(nested?.flows?.openai?.targets?.sub2api?.sub2apiGroupNames) - ? nested.flows.openai.targets.sub2api.sub2apiGroupNames.map((entry) => String(entry || '').trim()).filter(Boolean) - : (Array.isArray(nested?.flows?.openai?.integrationTargets?.sub2api?.sub2apiGroupNames) - ? nested.flows.openai.integrationTargets.sub2api.sub2apiGroupNames.map((entry) => String(entry || '').trim()).filter(Boolean) - : [...defaults.flows.openai.targets.sub2api.sub2apiGroupNames])), - sub2apiAccountPriority: Math.max(1, Number( - input?.sub2apiAccountPriority - ?? nested?.flows?.openai?.targets?.sub2api?.sub2apiAccountPriority - ?? nested?.flows?.openai?.integrationTargets?.sub2api?.sub2apiAccountPriority - ?? defaults.flows.openai.targets.sub2api.sub2apiAccountPriority - ) || defaults.flows.openai.targets.sub2api.sub2apiAccountPriority), - sub2apiDefaultProxyName: String( - input?.sub2apiDefaultProxyName - ?? nested?.flows?.openai?.targets?.sub2api?.sub2apiDefaultProxyName - ?? nested?.flows?.openai?.integrationTargets?.sub2api?.sub2apiDefaultProxyName - ?? '' - ).trim(), - }, - codex2api: { - ...defaults.flows.openai.targets.codex2api, - ...getTargetValue( - nested, - (state) => state.flows?.openai?.targets?.codex2api, - (state) => state.flows?.openai?.integrationTargets?.codex2api - ), - codex2apiUrl: String( - input?.codex2apiUrl - ?? nested?.flows?.openai?.targets?.codex2api?.codex2apiUrl - ?? nested?.flows?.openai?.integrationTargets?.codex2api?.codex2apiUrl - ?? '' - ).trim(), - codex2apiAdminKey: String( - input?.codex2apiAdminKey - ?? nested?.flows?.openai?.targets?.codex2api?.codex2apiAdminKey - ?? nested?.flows?.openai?.integrationTargets?.codex2api?.codex2apiAdminKey - ?? '' - ).trim(), - }, - }, - signup: { - signupMethod: String( - input?.signupMethod - ?? nested?.flows?.openai?.signup?.signupMethod - ?? defaults.flows.openai.signup.signupMethod - ).trim().toLowerCase() === 'phone' ? 'phone' : 'email', - phoneVerificationEnabled: Boolean( - input?.phoneVerificationEnabled - ?? nested?.flows?.openai?.signup?.phoneVerificationEnabled - ?? defaults.flows.openai.signup.phoneVerificationEnabled - ), - phoneSignupReloginAfterBindEmailEnabled: Boolean( - input?.phoneSignupReloginAfterBindEmailEnabled - ?? nested?.flows?.openai?.signup?.phoneSignupReloginAfterBindEmailEnabled - ?? defaults.flows.openai.signup.phoneSignupReloginAfterBindEmailEnabled - ), - }, - plus: { - plusModeEnabled: Boolean( - input?.plusModeEnabled - ?? nested?.flows?.openai?.plus?.plusModeEnabled - ?? defaults.flows.openai.plus.plusModeEnabled - ), - plusPaymentMethod: String( - input?.plusPaymentMethod - ?? nested?.flows?.openai?.plus?.plusPaymentMethod - ?? defaults.flows.openai.plus.plusPaymentMethod - ).trim() || defaults.flows.openai.plus.plusPaymentMethod, - plusAccountAccessStrategy: normalizePlusAccountAccessStrategy( - input?.plusAccountAccessStrategy - ?? nested?.flows?.openai?.plus?.plusAccountAccessStrategy - ?? defaults.flows.openai.plus.plusAccountAccessStrategy - ), - hostedCheckoutVerificationUrl: String( - input?.hostedCheckoutVerificationUrl - ?? nested?.flows?.openai?.plus?.hostedCheckoutVerificationUrl - ?? defaults.flows.openai.plus.hostedCheckoutVerificationUrl - ).trim(), - hostedCheckoutPhoneNumber: String( - input?.hostedCheckoutPhoneNumber - ?? nested?.flows?.openai?.plus?.hostedCheckoutPhoneNumber - ?? defaults.flows.openai.plus.hostedCheckoutPhoneNumber - ).trim(), - plusHostedCheckoutOauthDelaySeconds: (() => { - const numeric = Number( - input?.plusHostedCheckoutOauthDelaySeconds - ?? nested?.flows?.openai?.plus?.plusHostedCheckoutOauthDelaySeconds - ?? defaults.flows.openai.plus.plusHostedCheckoutOauthDelaySeconds - ); - return Math.min(120, Math.max(0, Math.floor(Number.isFinite(numeric) ? numeric : defaults.flows.openai.plus.plusHostedCheckoutOauthDelaySeconds))); - })(), - }, - autoRun: { - stepExecutionRange: normalizeStepExecutionRangeEntry( - stepExecutionRangeByFlow.openai - ?? nested?.flows?.openai?.autoRun?.stepExecutionRange - ?? {}, - defaults.flows.openai.autoRun.stepExecutionRange - ), - }, - }, - kiro: { - selectedTargetId: kiroSelectedTargetId, - targets: { - 'kiro-rs': { - ...defaults.flows.kiro.targets['kiro-rs'], - ...getTargetValue( - nested, - (state) => state.flows?.kiro?.targets?.['kiro-rs'] - ), - baseUrl: String( - input?.kiroRsUrl - ?? input?.kiroRsBaseUrl - ?? nested?.flows?.kiro?.targets?.['kiro-rs']?.baseUrl - ?? defaults.flows.kiro.targets['kiro-rs'].baseUrl - ).trim() || defaults.flows.kiro.targets['kiro-rs'].baseUrl, - apiKey: String( - input?.kiroRsKey - ?? input?.kiroRsApiKey - ?? nested?.flows?.kiro?.targets?.['kiro-rs']?.apiKey - ?? defaults.flows.kiro.targets['kiro-rs'].apiKey - ), - }, - }, - autoRun: { - stepExecutionRange: normalizeStepExecutionRangeEntry( - stepExecutionRangeByFlow.kiro - ?? nested?.flows?.kiro?.autoRun?.stepExecutionRange - ?? {}, - defaults.flows.kiro.autoRun.stepExecutionRange - ), - }, - }, - }, + flows: {}, }; + + getCanonicalFlowIds().forEach((flowId) => { + normalized.flows[flowId] = normalizeFlowSettings(flowId, { + ...input, + activeFlowId, + }, nested, defaults); + }); + if (normalized.flows.openai) { + normalized.flows.openai = normalizeOpenAiSettings(input, nested, defaults, normalized.flows.openai); + } + if (normalized.flows.kiro) { + normalized.flows.kiro = normalizeKiroSettings(input, defaults, normalized.flows.kiro); + } + return normalized; } function mergeSettingsState(baseValue = {}, patchValue = {}) { @@ -425,24 +547,8 @@ activeFlowId: patchValue?.activeFlowId ?? baseSettingsState.activeFlowId, }); - function mergeRecursive(baseNode, patchNode) { - if (Array.isArray(patchNode)) { - return patchNode.map((entry) => cloneValue(entry)); - } - if (!isPlainObject(patchNode)) { - return patchNode === undefined ? cloneValue(baseNode) : patchNode; - } - const next = { - ...cloneValue(isPlainObject(baseNode) ? baseNode : {}), - }; - Object.entries(patchNode).forEach(([key, value]) => { - next[key] = mergeRecursive(baseNode?.[key], value); - }); - return next; - } - return normalizeSettingsState({ - settingsState: mergeRecursive(baseSettingsState, patchSettingsState), + settingsState: mergePlainObjects(baseSettingsState, patchSettingsState), }); } @@ -459,7 +565,7 @@ return normalizeTargetId( normalizedFlowId, flowSettings?.selectedTargetId, - normalizedFlowId === 'kiro' ? defaultKiroTargetId : defaultOpenAiTargetId + getDefaultTargetId(normalizedFlowId) ); } @@ -476,16 +582,16 @@ function buildStepExecutionRangeByFlow(settingsState = {}) { const normalizedState = normalizeSettingsState(settingsState); - return { - openai: normalizeStepExecutionRangeEntry( - normalizedState?.flows?.openai?.autoRun?.stepExecutionRange, - buildDefaultSettingsState().flows.openai.autoRun.stepExecutionRange - ), - kiro: normalizeStepExecutionRangeEntry( - normalizedState?.flows?.kiro?.autoRun?.stepExecutionRange, - buildDefaultSettingsState().flows.kiro.autoRun.stepExecutionRange - ), - }; + const defaults = buildDefaultSettingsState(); + return Object.fromEntries( + Object.entries(normalizedState.flows || {}).map(([flowId, flowSettings]) => [ + flowId, + normalizeStepExecutionRangeEntry( + flowSettings?.autoRun?.stepExecutionRange, + defaults.flows?.[flowId]?.autoRun?.stepExecutionRange || getDefaultStepExecutionRange(flowId) + ), + ]) + ); } function buildSettingsView(settingsState = {}, baseInput = {}) { @@ -493,38 +599,38 @@ const next = { ...(isPlainObject(baseInput) ? cloneValue(baseInput) : {}), }; - const openaiState = normalizedState.flows.openai; - const kiroState = normalizedState.flows.kiro; + const openaiState = normalizedState.flows.openai || buildDefaultFlowSettings('openai'); + const kiroState = normalizedState.flows.kiro || buildDefaultFlowSettings('kiro'); next.activeFlowId = normalizedState.activeFlowId; next.targetId = getSelectedTargetId(normalizedState, normalizedState.activeFlowId); - next.vpsUrl = openaiState.targets.cpa.vpsUrl; - next.vpsPassword = openaiState.targets.cpa.vpsPassword; - next.localCpaStep9Mode = openaiState.targets.cpa.localCpaStep9Mode; - next.sub2apiUrl = openaiState.targets.sub2api.sub2apiUrl; - next.sub2apiEmail = openaiState.targets.sub2api.sub2apiEmail; - next.sub2apiPassword = openaiState.targets.sub2api.sub2apiPassword; - next.sub2apiGroupName = openaiState.targets.sub2api.sub2apiGroupName; - next.sub2apiGroupNames = cloneValue(openaiState.targets.sub2api.sub2apiGroupNames); - next.sub2apiAccountPriority = openaiState.targets.sub2api.sub2apiAccountPriority; - next.sub2apiDefaultProxyName = openaiState.targets.sub2api.sub2apiDefaultProxyName; - next.codex2apiUrl = openaiState.targets.codex2api.codex2apiUrl; - next.codex2apiAdminKey = openaiState.targets.codex2api.codex2apiAdminKey; + next.vpsUrl = openaiState.targets.cpa?.vpsUrl || ''; + next.vpsPassword = openaiState.targets.cpa?.vpsPassword || ''; + next.localCpaStep9Mode = openaiState.targets.cpa?.localCpaStep9Mode || 'submit'; + next.sub2apiUrl = openaiState.targets.sub2api?.sub2apiUrl || ''; + next.sub2apiEmail = openaiState.targets.sub2api?.sub2apiEmail || ''; + next.sub2apiPassword = openaiState.targets.sub2api?.sub2apiPassword || ''; + next.sub2apiGroupName = openaiState.targets.sub2api?.sub2apiGroupName || 'codex'; + next.sub2apiGroupNames = cloneValue(openaiState.targets.sub2api?.sub2apiGroupNames || ['codex', 'openai-plus']); + next.sub2apiAccountPriority = openaiState.targets.sub2api?.sub2apiAccountPriority || 1; + next.sub2apiDefaultProxyName = openaiState.targets.sub2api?.sub2apiDefaultProxyName || ''; + next.codex2apiUrl = openaiState.targets.codex2api?.codex2apiUrl || ''; + next.codex2apiAdminKey = openaiState.targets.codex2api?.codex2apiAdminKey || ''; next.customPassword = normalizedState.services.account.customPassword; - next.signupMethod = openaiState.signup.signupMethod; - next.phoneVerificationEnabled = openaiState.signup.phoneVerificationEnabled; - next.phoneSignupReloginAfterBindEmailEnabled = openaiState.signup.phoneSignupReloginAfterBindEmailEnabled; - next.plusModeEnabled = openaiState.plus.plusModeEnabled; - next.plusPaymentMethod = openaiState.plus.plusPaymentMethod; - next.plusAccountAccessStrategy = openaiState.plus.plusAccountAccessStrategy; - next.hostedCheckoutVerificationUrl = openaiState.plus.hostedCheckoutVerificationUrl; - next.hostedCheckoutPhoneNumber = openaiState.plus.hostedCheckoutPhoneNumber; - next.plusHostedCheckoutOauthDelaySeconds = openaiState.plus.plusHostedCheckoutOauthDelaySeconds; + next.signupMethod = openaiState.signup?.signupMethod || 'email'; + next.phoneVerificationEnabled = Boolean(openaiState.signup?.phoneVerificationEnabled); + next.phoneSignupReloginAfterBindEmailEnabled = Boolean(openaiState.signup?.phoneSignupReloginAfterBindEmailEnabled); + next.plusModeEnabled = Boolean(openaiState.plus?.plusModeEnabled); + next.plusPaymentMethod = openaiState.plus?.plusPaymentMethod || 'paypal-hosted'; + next.plusAccountAccessStrategy = openaiState.plus?.plusAccountAccessStrategy || 'oauth'; + next.hostedCheckoutVerificationUrl = openaiState.plus?.hostedCheckoutVerificationUrl || ''; + next.hostedCheckoutPhoneNumber = openaiState.plus?.hostedCheckoutPhoneNumber || ''; + next.plusHostedCheckoutOauthDelaySeconds = openaiState.plus?.plusHostedCheckoutOauthDelaySeconds ?? 3; next.mailProvider = normalizedState.services.email.provider; next.ipProxyEnabled = normalizedState.services.proxy.enabled; next.ipProxyService = normalizedState.services.proxy.provider; next.ipProxyMode = normalizedState.services.proxy.mode; - next.kiroRsUrl = kiroState.targets['kiro-rs'].baseUrl; - next.kiroRsKey = kiroState.targets['kiro-rs'].apiKey; + next.kiroRsUrl = kiroState.targets['kiro-rs']?.baseUrl || ''; + next.kiroRsKey = kiroState.targets['kiro-rs']?.apiKey || ''; next.stepExecutionRangeByFlow = buildStepExecutionRangeByFlow(normalizedState); next.settingsSchemaVersion = normalizedState.schemaVersion; next.settingsState = cloneValue(normalizedState); diff --git a/flows/grok/background/register-runner.js b/flows/grok/background/register-runner.js new file mode 100644 index 0000000..f4bb501 --- /dev/null +++ b/flows/grok/background/register-runner.js @@ -0,0 +1,691 @@ +(function attachBackgroundGrokRegisterRunner(root, factory) { + root.MultiPageBackgroundGrokRegisterRunner = factory(root); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundGrokRegisterRunnerModule() { + const GROK_SIGNUP_URL = 'https://accounts.x.ai/sign-up?redirect=grok-com'; + const GROK_REGISTER_PAGE_SOURCE_ID = 'grok-register-page'; + const DEFAULT_GROK_PAGE_TIMEOUT_MS = 90 * 1000; + const GROK_POST_PROFILE_CF_WAIT_MS = 20 * 1000; + const GROK_PRE_SSO_EXTRACT_WAIT_MS = 10 * 1000; + const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000; + const GROK_COOKIE_CLEAR_DOMAINS = Object.freeze([ + 'x.ai', + 'accounts.x.ai', + 'grok.com', + ]); + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function getErrorMessage(error) { + return error instanceof Error ? error.message : cleanString(error) || '未知错误'; + } + + function createGeneratedPassword() { + const alphabet = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*'; + let output = ''; + for (let index = 0; index < 18; index += 1) { + output += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return `${output}aA1!`; + } + + function createGrokRegisterRunner(deps = {}) { + const { + addLog = async () => {}, + chrome = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), + completeNodeFromBackground, + ensureContentScriptReadyOnTab = null, + generatePassword = null, + generateRandomName = null, + getState = async () => ({}), + getTabId = async () => null, + isTabAlive = async () => false, + pollFlowVerificationCode = null, + registerTab = async () => {}, + resolveSignupEmailForFlow = null, + reuseOrCreateTab = async () => null, + sendToContentScriptResilient = null, + setPasswordState = async () => {}, + setState = async () => {}, + sleepWithStop = async (ms) => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }, + throwIfStopped = () => {}, + waitForTabStableComplete = null, + GROK_REGISTER_INJECT_FILES = null, + markCurrentRegistrationAccountUsed = null, + } = deps; + + if (typeof completeNodeFromBackground !== 'function') { + throw new Error('Grok register runner requires completeNodeFromBackground.'); + } + + async function log(message, level = 'info', nodeId = '') { + await addLog(message, level, nodeId ? { nodeId } : {}); + } + + async function activateTab(tabId) { + if (!Number.isInteger(tabId) || !chrome?.tabs?.update) { + return; + } + await chrome.tabs.update(tabId, { active: true }); + } + + async function getExecutionState(state = {}) { + if (state && typeof state === 'object' && !Array.isArray(state) && Object.keys(state).length) { + return state; + } + return getState(); + } + + async function persistState(patch = {}) { + await setState(patch); + return patch; + } + + function buildGrokRuntimePatch(patch = {}) { + return { + runtimeState: { + flowState: { + grok: patch, + }, + }, + }; + } + + async function completeNode(nodeId, patch = {}) { + await persistState(patch); + await completeNodeFromBackground(nodeId, patch); + return patch; + } + + async function isUsableTabId(tabId) { + if (!Number.isInteger(tabId)) { + return false; + } + if (typeof isTabAlive === 'function' && await isTabAlive(GROK_REGISTER_PAGE_SOURCE_ID)) { + return true; + } + if (chrome?.tabs?.get) { + const tab = await chrome.tabs.get(tabId).catch(() => null); + return Boolean(tab?.id === tabId); + } + return true; + } + + async function ensureGrokRegisterTab(state = {}, options = {}) { + const existingTabId = Number( + state?.grokRegisterTabId + || state?.runtimeState?.flowState?.grok?.session?.registerTabId + || state?.tabRegistry?.[GROK_REGISTER_PAGE_SOURCE_ID]?.tabId + || 0 + ); + if (Number.isInteger(existingTabId) && existingTabId > 0 && await isUsableTabId(existingTabId)) { + await registerTab(GROK_REGISTER_PAGE_SOURCE_ID, existingTabId); + return existingTabId; + } + + const tabId = await getTabId(GROK_REGISTER_PAGE_SOURCE_ID); + if (Number.isInteger(tabId) && await isUsableTabId(tabId)) { + await registerTab(GROK_REGISTER_PAGE_SOURCE_ID, tabId); + return tabId; + } + + if (!options.openIfMissing) { + throw new Error(options.missingMessage || '缺少 Grok 注册页,请先执行步骤 1。'); + } + + const openedTabId = await reuseOrCreateTab(GROK_REGISTER_PAGE_SOURCE_ID, GROK_SIGNUP_URL, { + inject: Array.isArray(GROK_REGISTER_INJECT_FILES) ? GROK_REGISTER_INJECT_FILES : null, + injectSource: GROK_REGISTER_PAGE_SOURCE_ID, + }); + if (!Number.isInteger(openedTabId)) { + throw new Error('无法打开 Grok 注册页。'); + } + await registerTab(GROK_REGISTER_PAGE_SOURCE_ID, openedTabId); + return openedTabId; + } + + async function ensureContentReady(tabId, options = {}) { + if (!Number.isInteger(tabId)) { + throw new Error('缺少 Grok 注册页标签页,无法连接内容脚本。'); + } + if (typeof waitForTabStableComplete === 'function') { + await waitForTabStableComplete(tabId, { + timeoutMs: options.timeoutMs || DEFAULT_GROK_PAGE_TIMEOUT_MS, + retryDelayMs: 300, + stableMs: Number(options.stableMs) || 1200, + initialDelayMs: Number(options.initialDelayMs) || 120, + }); + } + if (typeof ensureContentScriptReadyOnTab === 'function') { + await ensureContentScriptReadyOnTab(GROK_REGISTER_PAGE_SOURCE_ID, tabId, { + inject: Array.isArray(GROK_REGISTER_INJECT_FILES) ? GROK_REGISTER_INJECT_FILES : null, + injectSource: GROK_REGISTER_PAGE_SOURCE_ID, + timeoutMs: options.timeoutMs || DEFAULT_GROK_PAGE_TIMEOUT_MS, + retryDelayMs: 700, + logMessage: options.logMessage || 'Grok 注册页内容脚本未就绪,正在等待页面恢复...', + }); + } + } + + async function sendGrokCommand(nodeId, payload = {}, options = {}) { + if (typeof sendToContentScriptResilient !== 'function') { + throw new Error('Grok 注册页通信能力不可用。'); + } + const result = await sendToContentScriptResilient(GROK_REGISTER_PAGE_SOURCE_ID, { + type: 'EXECUTE_NODE', + nodeId, + step: options.step || 0, + source: 'background', + payload, + }, { + timeoutMs: options.timeoutMs || 45000, + retryDelayMs: 700, + logMessage: options.logMessage || '', + }); + if (result?.error) { + throw new Error(result.error); + } + return result || {}; + } + + function shouldClearGrokCookie(cookie = {}) { + const domain = cleanString(cookie.domain).replace(/^\.+/, '').toLowerCase(); + return GROK_COOKIE_CLEAR_DOMAINS.some((target) => ( + domain === target || domain.endsWith(`.${target}`) + )); + } + + function buildCookieRemovalUrl(cookie = {}) { + const host = cleanString(cookie.domain).replace(/^\.+/, '').toLowerCase(); + const path = cleanString(cookie.path) || '/'; + return `https://${host}${path.startsWith('/') ? path : `/${path}`}`; + } + + async function clearGrokCookiesBeforeStep1() { + if (!chrome?.cookies?.getAll || !chrome.cookies?.remove) { + await log('步骤 1:当前浏览器不支持 cookies API,跳过 Grok Cookie 清理。', 'warn', 'grok-open-signup-page'); + return; + } + + const stores = chrome.cookies.getAllCookieStores + ? await chrome.cookies.getAllCookieStores() + : [{ id: undefined }]; + let removedCount = 0; + const seen = new Set(); + + for (const store of stores) { + const storeId = store?.id; + const cookies = await chrome.cookies.getAll(storeId ? { storeId } : {}).catch(() => []); + for (const cookie of cookies || []) { + if (!shouldClearGrokCookie(cookie)) { + continue; + } + const key = [ + cookie.storeId || storeId || '', + cookie.domain || '', + cookie.path || '', + cookie.name || '', + cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : '', + ].join('|'); + if (seen.has(key)) { + continue; + } + seen.add(key); + try { + const details = { + url: buildCookieRemovalUrl(cookie), + name: cookie.name, + }; + if (cookie.storeId) { + details.storeId = cookie.storeId; + } + if (cookie.partitionKey) { + details.partitionKey = cookie.partitionKey; + } + const removed = await chrome.cookies.remove(details); + if (removed) { + removedCount += 1; + } + } catch (error) { + console.warn('[MultiPage:grok-register] remove cookie failed', { + domain: cookie?.domain, + name: cookie?.name, + message: getErrorMessage(error), + }); + } + } + } + await log(`步骤 1:已清理 Grok/xAI Cookie ${removedCount} 个。`, removedCount ? 'ok' : 'info', 'grok-open-signup-page'); + } + + function resolveProfile(currentState = {}) { + const firstFromState = cleanString(currentState.grokFirstName); + const lastFromState = cleanString(currentState.grokLastName); + if (firstFromState && lastFromState) { + return { + firstName: firstFromState, + lastName: lastFromState, + }; + } + const generated = typeof generateRandomName === 'function' ? generateRandomName() : null; + const fullName = cleanString(generated?.fullName || generated?.name || 'Alex Morgan'); + const parts = fullName.split(/\s+/).filter(Boolean); + return { + firstName: firstFromState || cleanString(generated?.firstName || parts[0] || 'Alex'), + lastName: lastFromState || cleanString(generated?.lastName || parts.slice(1).join(' ') || 'Morgan'), + }; + } + + function resolvePassword(currentState = {}) { + return cleanString(currentState.grokPassword || currentState.customPassword || currentState.password) + || (typeof generatePassword === 'function' ? generatePassword() : createGeneratedPassword()); + } + + function normalizeGrokVerificationCode(value = '') { + return cleanString(value).replace(/[^A-Za-z0-9]/g, ''); + } + + async function readSsoCookieFromChrome() { + if (!chrome?.cookies?.get) { + return ''; + } + const candidates = [ + { url: 'https://x.ai/', name: 'sso' }, + { url: 'https://grok.com/', name: 'sso' }, + { url: 'https://accounts.x.ai/', name: 'sso' }, + ]; + for (const details of candidates) { + const cookie = await chrome.cookies.get(details).catch(() => null); + const value = cleanString(cookie?.value); + if (value) { + return value; + } + } + return ''; + } + + async function executeGrokOpenSignupPage(state = {}) { + const nodeId = cleanString(state?.nodeId) || 'grok-open-signup-page'; + const currentState = await getExecutionState(state); + try { + await clearGrokCookiesBeforeStep1(); + const tabId = await ensureGrokRegisterTab(currentState, { openIfMissing: true }); + await activateTab(tabId); + await persistState({ + grokRegisterTabId: tabId, + grokSignupUrl: GROK_SIGNUP_URL, + ...buildGrokRuntimePatch({ + session: { + registerTabId: tabId, + startedAt: Date.now(), + pageUrl: GROK_SIGNUP_URL, + lastError: '', + }, + }), + }); + await ensureContentReady(tabId); + const result = await sendGrokCommand(nodeId, {}, { + step: 1, + timeoutMs: DEFAULT_GROK_PAGE_TIMEOUT_MS, + logMessage: '步骤 1:正在打开 Grok 邮箱注册入口...', + }); + await log('步骤 1:已打开 Grok 邮箱注册页。', 'ok', nodeId); + await completeNode(nodeId, { + grokRegisterTabId: tabId, + grokPageState: result.state || 'email_signup_ready', + grokPageUrl: result.url || GROK_SIGNUP_URL, + ...buildGrokRuntimePatch({ + session: { + registerTabId: tabId, + startedAt: Date.now(), + pageState: result.state || 'email_signup_ready', + pageUrl: result.url || GROK_SIGNUP_URL, + lastError: '', + }, + register: { + status: 'signup_page_opened', + }, + }), + }); + } catch (error) { + const message = getErrorMessage(error); + await persistState(buildGrokRuntimePatch({ + session: { + lastError: message, + }, + })); + await log(`步骤 1:${message}`, 'error', nodeId); + throw error; + } + } + + async function executeGrokSubmitEmail(state = {}) { + const nodeId = cleanString(state?.nodeId) || 'grok-submit-email'; + const currentState = await getExecutionState(state); + try { + if (typeof resolveSignupEmailForFlow !== 'function') { + throw new Error('Grok 邮箱步骤缺少公共邮箱解析能力,无法继续执行。'); + } + const tabId = await ensureGrokRegisterTab(currentState, { openIfMissing: false }); + await activateTab(tabId); + await ensureContentReady(tabId); + const resolvedEmail = await resolveSignupEmailForFlow(currentState, { + preserveAccountIdentity: true, + }); + const email = cleanString(resolvedEmail).toLowerCase(); + if (!email) { + throw new Error('Grok 注册邮箱为空,无法继续执行。'); + } + const requestedAt = Date.now(); + await persistState({ + grokEmail: email, + email, + accountIdentifierType: 'email', + accountIdentifier: email, + ...buildGrokRuntimePatch({ + register: { + email, + verificationRequestedAt: requestedAt, + status: 'email_submitting', + }, + }), + }); + const result = await sendGrokCommand(nodeId, { email }, { + step: 2, + logMessage: '步骤 2:正在提交 Grok 注册邮箱...', + }); + await log(`步骤 2:已提交 Grok 注册邮箱 ${email}。`, 'ok', nodeId); + await completeNode(nodeId, { + grokEmail: email, + grokVerificationRequestedAt: requestedAt, + grokPageState: result.state || '', + grokPageUrl: result.url || '', + email, + accountIdentifierType: 'email', + accountIdentifier: email, + ...buildGrokRuntimePatch({ + session: { + pageState: result.state || '', + pageUrl: result.url || '', + lastError: '', + }, + register: { + email, + verificationRequestedAt: requestedAt, + status: 'verification_requested', + }, + }), + }); + } catch (error) { + const message = getErrorMessage(error); + await persistState(buildGrokRuntimePatch({ + session: { + lastError: message, + }, + register: { + status: 'error', + }, + })); + await log(`步骤 2:${message}`, 'error', nodeId); + throw error; + } + } + + async function executeGrokSubmitVerificationCode(state = {}) { + const nodeId = cleanString(state?.nodeId) || 'grok-submit-verification-code'; + const currentState = await getExecutionState(state); + try { + if (typeof pollFlowVerificationCode !== 'function') { + throw new Error('Grok 验证码步骤缺少共享邮件轮询能力,无法继续执行。'); + } + const requestedAt = Math.max( + 0, + Number( + currentState.grokVerificationRequestedAt + || currentState.runtimeState?.flowState?.grok?.register?.verificationRequestedAt + ) || Date.now() + ); + const filterAfterTimestamp = cleanString(currentState?.mailProvider).toLowerCase() === '2925' + ? Math.max(0, requestedAt - MAIL_2925_FILTER_LOOKBACK_MS) + : requestedAt; + const email = cleanString( + currentState.grokEmail + || currentState.runtimeState?.flowState?.grok?.register?.email + || currentState.email + ).toLowerCase(); + const pollResult = await pollFlowVerificationCode({ + actionLabel: 'Grok 验证码', + filterAfterTimestamp, + flowId: 'grok', + logStep: 3, + logStepKey: nodeId, + nodeId, + notFoundMessage: '步骤 3:邮箱轮询结束,但未获取到 xAI 验证码。', + state: { + ...currentState, + activeFlowId: 'grok', + flowId: 'grok', + visibleStep: 3, + grokEmail: email, + email, + }, + step: 3, + }); + const code = normalizeGrokVerificationCode(pollResult?.code); + if (!code) { + throw new Error('未能获取到 xAI 邮箱验证码。'); + } + const tabId = await ensureGrokRegisterTab(currentState, { openIfMissing: false }); + await activateTab(tabId); + await ensureContentReady(tabId); + const result = await sendGrokCommand(nodeId, { code }, { + step: 3, + logMessage: '步骤 3:正在填写 xAI 邮箱验证码...', + }); + await log(`步骤 3:已提交 xAI 邮箱验证码,当前页面状态:${result.state || 'unknown'}。`, 'ok', nodeId); + await completeNode(nodeId, { + grokVerificationCode: code, + grokVerificationRawCode: cleanString(pollResult?.code), + grokVerificationMessageId: cleanString(pollResult?.messageId || pollResult?.mailId), + grokPageState: result.state || '', + grokPageUrl: result.url || '', + ...buildGrokRuntimePatch({ + session: { + pageState: result.state || '', + pageUrl: result.url || '', + lastError: '', + }, + register: { + verificationCode: code, + status: 'verified', + }, + }), + }); + } catch (error) { + const message = getErrorMessage(error); + await persistState(buildGrokRuntimePatch({ + session: { + lastError: message, + }, + register: { + status: 'error', + }, + })); + await log(`步骤 3:${message}`, 'error', nodeId); + throw error; + } + } + + async function executeGrokSubmitProfile(state = {}) { + const nodeId = cleanString(state?.nodeId) || 'grok-submit-profile'; + const currentState = await getExecutionState(state); + try { + const tabId = await ensureGrokRegisterTab(currentState, { openIfMissing: false }); + const profile = resolveProfile(currentState); + const password = resolvePassword(currentState); + await persistState({ + grokFirstName: profile.firstName, + grokLastName: profile.lastName, + grokPassword: password, + ...buildGrokRuntimePatch({ + register: { + firstName: profile.firstName, + lastName: profile.lastName, + password, + status: 'profile_submitting', + }, + }), + }); + if (typeof setPasswordState === 'function') { + await setPasswordState(password); + } + await activateTab(tabId); + await ensureContentReady(tabId); + const result = await sendGrokCommand(nodeId, { + firstName: profile.firstName, + lastName: profile.lastName, + password, + }, { + step: 4, + logMessage: '步骤 4:正在填写 xAI 注册资料...', + }); + await log(`步骤 4:已提交 Grok 注册资料,等待 ${Math.floor(GROK_POST_PROFILE_CF_WAIT_MS / 1000)} 秒完成注册验证...`, 'info', nodeId); + await sleepWithStop(GROK_POST_PROFILE_CF_WAIT_MS); + await ensureContentReady(tabId, { timeoutMs: DEFAULT_GROK_PAGE_TIMEOUT_MS }); + await log('步骤 4:已提交 Grok 注册资料并完成等待。', 'ok', nodeId); + await completeNode(nodeId, { + grokFirstName: profile.firstName, + grokLastName: profile.lastName, + grokPassword: password, + grokPageState: result.state || 'profile_submitted', + grokPageUrl: result.url || '', + ...buildGrokRuntimePatch({ + session: { + pageState: result.state || 'profile_submitted', + pageUrl: result.url || '', + lastError: '', + }, + register: { + firstName: profile.firstName, + lastName: profile.lastName, + password, + status: 'profile_submitted', + }, + }), + }); + } catch (error) { + const message = getErrorMessage(error); + await persistState(buildGrokRuntimePatch({ + session: { + lastError: message, + }, + register: { + status: 'error', + }, + })); + await log(`步骤 4:${message}`, 'error', nodeId); + throw error; + } + } + + async function executeGrokExtractSsoCookie(state = {}) { + const nodeId = cleanString(state?.nodeId) || 'grok-extract-sso-cookie'; + const currentState = await getExecutionState(state); + try { + const tabId = await ensureGrokRegisterTab(currentState, { openIfMissing: false }); + await activateTab(tabId); + await log(`步骤 5:等待 ${Math.floor(GROK_PRE_SSO_EXTRACT_WAIT_MS / 1000)} 秒后提取 Grok SSO...`, 'info', nodeId); + await sleepWithStop(GROK_PRE_SSO_EXTRACT_WAIT_MS); + + let ssoCookie = await readSsoCookieFromChrome(); + if (!ssoCookie) { + await ensureContentReady(tabId); + const result = await sendGrokCommand(nodeId, {}, { + step: 5, + logMessage: '步骤 5:正在从 Grok 注册页读取 sso Cookie...', + }); + ssoCookie = cleanString(result?.ssoCookie); + } + if (!ssoCookie) { + throw new Error('未找到 x.ai/grok sso Cookie。'); + } + + const latestState = await getState(); + const existingSsoCookies = Array.isArray(latestState?.grokSsoCookies) + ? latestState.grokSsoCookies + .map((entry) => cleanString(entry)) + .filter(Boolean) + : []; + const nextSsoCookies = existingSsoCookies.includes(ssoCookie) + ? existingSsoCookies + : [...existingSsoCookies, ssoCookie]; + const completedAt = Date.now(); + const completionPatch = { + grokSsoCookie: ssoCookie, + grokSsoCookies: nextSsoCookies, + grokSsoExtractedAt: completedAt, + grokCompletedAt: completedAt, + grokRegisterStatus: 'completed', + ...buildGrokRuntimePatch({ + register: { + status: 'completed', + completedAt, + }, + sso: { + currentCookie: ssoCookie, + cookies: nextSsoCookies, + extractedAt: completedAt, + }, + session: { + lastError: '', + }, + }), + }; + if (typeof markCurrentRegistrationAccountUsed === 'function') { + await markCurrentRegistrationAccountUsed({ + ...currentState, + ...completionPatch, + }, { + logPrefix: 'Grok 注册成功', + level: 'ok', + }); + } + await log('步骤 5:已提取 Grok SSO Cookie。', 'ok', nodeId); + await completeNode(nodeId, completionPatch); + } catch (error) { + const message = getErrorMessage(error); + await persistState(buildGrokRuntimePatch({ + session: { + lastError: message, + }, + register: { + status: 'error', + }, + })); + await log(`步骤 5:${message}`, 'error', nodeId); + throw error; + } + } + + return { + executeGrokExtractSsoCookie, + executeGrokOpenSignupPage, + executeGrokSubmitEmail, + executeGrokSubmitProfile, + executeGrokSubmitVerificationCode, + }; + } + + return { + DEFAULT_GROK_PAGE_TIMEOUT_MS, + GROK_COOKIE_CLEAR_DOMAINS, + GROK_POST_PROFILE_CF_WAIT_MS, + GROK_PRE_SSO_EXTRACT_WAIT_MS, + GROK_REGISTER_PAGE_SOURCE_ID, + GROK_SIGNUP_URL, + createGrokRegisterRunner, + }; +}); diff --git a/flows/grok/background/state.js b/flows/grok/background/state.js new file mode 100644 index 0000000..8eb5507 --- /dev/null +++ b/flows/grok/background/state.js @@ -0,0 +1,374 @@ +(function attachBackgroundGrokState(root, factory) { + root.MultiPageBackgroundGrokState = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundGrokStateModule() { + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cloneValue(value) { + if (Array.isArray(value)) { + return value.map((entry) => cloneValue(entry)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, cloneValue(entryValue)]) + ); + } + return value; + } + + function deepMerge(baseValue, patchValue) { + if (Array.isArray(patchValue)) { + return patchValue.map((entry) => cloneValue(entry)); + } + if (!isPlainObject(patchValue)) { + return patchValue === undefined ? cloneValue(baseValue) : patchValue; + } + + const baseObject = isPlainObject(baseValue) ? baseValue : {}; + const next = { + ...cloneValue(baseObject), + }; + Object.entries(patchValue).forEach(([key, value]) => { + next[key] = deepMerge(baseObject[key], value); + }); + return next; + } + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function normalizeInteger(value, fallback = 0) { + const numeric = Math.floor(Number(value)); + return Number.isInteger(numeric) ? numeric : fallback; + } + + function normalizeNullableInteger(value, fallback = null) { + if (value === null || value === undefined || value === '') { + return fallback; + } + const numeric = Math.floor(Number(value)); + return Number.isInteger(numeric) ? numeric : fallback; + } + + function normalizeSsoCookies(values = []) { + if (!Array.isArray(values)) { + return []; + } + return Array.from(new Set( + values + .map((entry) => cleanString(entry)) + .filter(Boolean) + )); + } + + function buildDefaultRuntimeState() { + return { + session: { + registerTabId: null, + startedAt: 0, + pageState: '', + pageUrl: '', + lastError: '', + }, + register: { + email: '', + firstName: '', + lastName: '', + password: '', + verificationRequestedAt: 0, + verificationCode: '', + status: '', + completedAt: 0, + }, + sso: { + currentCookie: '', + cookies: [], + extractedAt: 0, + }, + }; + } + + function normalizeRuntimeState(runtimeState = {}) { + const merged = deepMerge(buildDefaultRuntimeState(), runtimeState); + return { + session: { + registerTabId: normalizeNullableInteger(merged.session?.registerTabId), + startedAt: Math.max(0, normalizeInteger(merged.session?.startedAt)), + pageState: cleanString(merged.session?.pageState), + pageUrl: cleanString(merged.session?.pageUrl), + lastError: cleanString(merged.session?.lastError), + }, + register: { + email: cleanString(merged.register?.email).toLowerCase(), + firstName: cleanString(merged.register?.firstName), + lastName: cleanString(merged.register?.lastName), + password: cleanString(merged.register?.password), + verificationRequestedAt: Math.max(0, normalizeInteger(merged.register?.verificationRequestedAt)), + verificationCode: cleanString(merged.register?.verificationCode), + status: cleanString(merged.register?.status), + completedAt: Math.max(0, normalizeInteger(merged.register?.completedAt)), + }, + sso: { + currentCookie: cleanString(merged.sso?.currentCookie || merged.ssoCookie), + cookies: normalizeSsoCookies(merged.sso?.cookies || merged.ssoCookies), + extractedAt: Math.max(0, normalizeInteger(merged.sso?.extractedAt)), + }, + }; + } + + function buildCanonicalRuntimeStatePatch(state = {}, runtimeState = {}) { + const normalizedRuntimeState = normalizeRuntimeState(runtimeState); + const baseRuntimeState = isPlainObject(state?.runtimeState) + ? cloneValue(state.runtimeState) + : {}; + const baseFlowState = isPlainObject(baseRuntimeState.flowState) + ? cloneValue(baseRuntimeState.flowState) + : {}; + return { + ...baseRuntimeState, + flowState: { + ...baseFlowState, + grok: normalizedRuntimeState, + }, + }; + } + + function projectRuntimeFields(runtimeState = {}) { + const normalizedRuntimeState = normalizeRuntimeState(runtimeState); + return { + grokRegisterTabId: normalizedRuntimeState.session.registerTabId, + grokPageState: normalizedRuntimeState.session.pageState, + grokPageUrl: normalizedRuntimeState.session.pageUrl, + grokEmail: normalizedRuntimeState.register.email, + grokFirstName: normalizedRuntimeState.register.firstName, + grokLastName: normalizedRuntimeState.register.lastName, + grokPassword: normalizedRuntimeState.register.password, + grokVerificationRequestedAt: normalizedRuntimeState.register.verificationRequestedAt, + grokVerificationCode: normalizedRuntimeState.register.verificationCode, + grokRegisterStatus: normalizedRuntimeState.register.status, + grokCompletedAt: normalizedRuntimeState.register.completedAt, + grokSsoCookie: normalizedRuntimeState.sso.currentCookie, + grokSsoCookies: normalizedRuntimeState.sso.cookies, + grokSsoExtractedAt: normalizedRuntimeState.sso.extractedAt, + }; + } + + function ensureRuntimeState(state = {}) { + const runtimeFlowState = isPlainObject(state?.runtimeState?.flowState) + ? state.runtimeState.flowState + : {}; + const legacyFlowState = isPlainObject(state?.flowState?.grok) + ? state.flowState.grok + : {}; + const flatRuntime = { + session: { + registerTabId: state.grokRegisterTabId, + pageState: state.grokPageState, + pageUrl: state.grokPageUrl || state.grokPostVerificationUrl, + }, + register: { + email: state.grokEmail || state.email, + firstName: state.grokFirstName, + lastName: state.grokLastName, + password: state.grokPassword, + verificationRequestedAt: state.grokVerificationRequestedAt, + verificationCode: state.grokVerificationCode, + status: state.grokRegisterStatus, + completedAt: state.grokCompletedAt, + }, + sso: { + currentCookie: state.grokSsoCookie, + cookies: state.grokSsoCookies, + extractedAt: state.grokSsoExtractedAt || state.grokCompletedAt, + }, + }; + return normalizeRuntimeState(deepMerge(deepMerge(runtimeFlowState.grok || {}, legacyFlowState), flatRuntime)); + } + + function buildStateView(state = {}) { + const runtimeState = ensureRuntimeState(state); + const canonicalRuntimeState = buildCanonicalRuntimeStatePatch(state, runtimeState); + return { + ...state, + ...projectRuntimeFields(runtimeState), + runtimeState: canonicalRuntimeState, + flowState: { + ...(isPlainObject(state?.flowState) ? state.flowState : {}), + grok: runtimeState, + }, + flows: { + ...(isPlainObject(state?.flows) ? state.flows : {}), + grok: runtimeState, + }, + }; + } + + function buildRuntimeStatePatch(currentState = {}, patch = {}) { + if (!isPlainObject(patch)) { + return {}; + } + const nextRuntimeState = normalizeRuntimeState( + deepMerge(ensureRuntimeState(currentState), patch) + ); + return { + ...projectRuntimeFields(nextRuntimeState), + runtimeState: buildCanonicalRuntimeStatePatch(currentState, nextRuntimeState), + }; + } + + function buildSessionStatePatch(currentState = {}, updates = {}) { + const runtimePatch = isPlainObject(updates?.runtimeState?.flowState?.grok) + ? updates.runtimeState.flowState.grok + : (isPlainObject(updates?.flowState?.grok) ? updates.flowState.grok : null); + if (!runtimePatch) { + return {}; + } + return buildRuntimeStatePatch(currentState, runtimePatch); + } + + function buildRuntimeResetPatch(currentState = {}, patch = {}) { + return buildRuntimeStatePatch(currentState, patch); + } + + function buildStartRegisterResetPatch(currentState = {}) { + return buildRuntimeStatePatch(currentState, buildDefaultRuntimeState()); + } + + function buildRegisterOnlyResetPatch(currentState = {}, registerPatch = {}) { + const currentRuntimeState = ensureRuntimeState(currentState); + return buildRuntimeStatePatch(currentState, { + ...currentRuntimeState, + session: { + ...currentRuntimeState.session, + pageState: '', + pageUrl: '', + lastError: '', + }, + register: { + ...buildDefaultRuntimeState().register, + email: currentRuntimeState.register?.email || '', + ...registerPatch, + }, + }); + } + + function buildSsoResetPatch(currentState = {}) { + const currentRuntimeState = ensureRuntimeState(currentState); + return buildRuntimeStatePatch(currentState, { + ...currentRuntimeState, + sso: buildDefaultRuntimeState().sso, + }); + } + + function buildDownstreamResetPatch(stepKey = '', currentState = {}) { + switch (cleanString(stepKey)) { + case 'grok-open-signup-page': + return { + flowStartTime: null, + ...buildStartRegisterResetPatch(currentState), + }; + case 'grok-submit-email': + return buildRegisterOnlyResetPatch(currentState, { + email: '', + }); + case 'grok-submit-verification-code': + return buildRegisterOnlyResetPatch(currentState, {}); + case 'grok-submit-profile': + case 'grok-extract-sso-cookie': + return buildSsoResetPatch(currentState); + default: + return {}; + } + } + + function applyNodeCompletionPayload(currentState = {}, payload = {}) { + const runtimePatch = isPlainObject(payload?.runtimeState?.flowState?.grok) + ? payload.runtimeState.flowState.grok + : (isPlainObject(payload?.flowState?.grok) ? payload.flowState.grok : null); + if (runtimePatch) { + return buildRuntimeStatePatch(currentState, runtimePatch); + } + + const patch = {}; + if (Object.prototype.hasOwnProperty.call(payload, 'grokRegisterTabId')) { + patch.session = { ...(patch.session || {}), registerTabId: payload.grokRegisterTabId }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokPageState')) { + patch.session = { ...(patch.session || {}), pageState: payload.grokPageState }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokPageUrl') || Object.prototype.hasOwnProperty.call(payload, 'grokSignupUrl') || Object.prototype.hasOwnProperty.call(payload, 'grokPostVerificationUrl')) { + patch.session = { + ...(patch.session || {}), + pageUrl: payload.grokPageUrl || payload.grokPostVerificationUrl || payload.grokSignupUrl || '', + }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokEmail') || Object.prototype.hasOwnProperty.call(payload, 'email')) { + patch.register = { + ...(patch.register || {}), + email: payload.grokEmail || payload.email || '', + }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokFirstName')) { + patch.register = { ...(patch.register || {}), firstName: payload.grokFirstName }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokLastName')) { + patch.register = { ...(patch.register || {}), lastName: payload.grokLastName }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokPassword')) { + patch.register = { ...(patch.register || {}), password: payload.grokPassword }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokVerificationRequestedAt')) { + patch.register = { + ...(patch.register || {}), + verificationRequestedAt: payload.grokVerificationRequestedAt, + }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokVerificationCode')) { + patch.register = { ...(patch.register || {}), verificationCode: payload.grokVerificationCode }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokRegisterStatus')) { + patch.register = { ...(patch.register || {}), status: payload.grokRegisterStatus }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokCompletedAt')) { + patch.register = { ...(patch.register || {}), completedAt: payload.grokCompletedAt }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokSsoCookie')) { + patch.sso = { ...(patch.sso || {}), currentCookie: payload.grokSsoCookie }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokSsoCookies')) { + patch.sso = { ...(patch.sso || {}), cookies: payload.grokSsoCookies }; + } + if (Object.prototype.hasOwnProperty.call(payload, 'grokSsoExtractedAt') || Object.prototype.hasOwnProperty.call(payload, 'grokCompletedAt')) { + patch.sso = { + ...(patch.sso || {}), + extractedAt: payload.grokSsoExtractedAt || payload.grokCompletedAt || 0, + }; + } + if (!Object.keys(patch).length) { + return {}; + } + return buildRuntimeStatePatch(currentState, patch); + } + + function buildFreshKeepState(currentState = {}) { + const currentRuntimeState = ensureRuntimeState(currentState); + const nextRuntimeState = buildDefaultRuntimeState(); + nextRuntimeState.sso = currentRuntimeState.sso || buildDefaultRuntimeState().sso; + return buildRuntimeStatePatch(currentState, nextRuntimeState); + } + + return { + applyNodeCompletionPayload, + buildDefaultRuntimeState, + buildDownstreamResetPatch, + buildFreshKeepState, + buildRuntimeStatePatch, + buildSessionStatePatch, + buildStateView, + ensureRuntimeState, + normalizeSsoCookies, + projectRuntimeFields, + }; +}); diff --git a/flows/grok/content/register-page.js b/flows/grok/content/register-page.js new file mode 100644 index 0000000..8d2ea5a --- /dev/null +++ b/flows/grok/content/register-page.js @@ -0,0 +1,317 @@ +console.log('[MultiPage:grok-register-page] Content script loaded on', location.href); + +const GROK_REGISTER_PAGE_LISTENER_SENTINEL = 'data-multipage-grok-register-page-listener'; +const GROK_SIGNUP_URL = 'https://accounts.x.ai/sign-up?redirect=grok-com'; +const GROK_EMAIL_SIGNUP_TEXT_PATTERN = /使用邮箱注册|sign\s*up\s*with\s*email|continue\s*with\s*email|email/i; +const GROK_CONTINUE_TEXT_PATTERN = /continue|next|sign\s*up|submit|verify|继续|下一步|注册|提交|验证/i; +const GROK_PROFILE_TEXT_PATTERN = /given\s*name|family\s*name|first\s*name|last\s*name|password|名字|姓氏|密码/i; +const GROK_PROFILE_SUBMIT_PRE_CLICK_DELAY_MS = 2000; + +function isVisibleGrokElement(element) { + if (!element || !(element instanceof Element)) return false; + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +function getGrokElementText(element) { + if (!element) return ''; + return String( + element.innerText + || element.textContent + || element.getAttribute?.('aria-label') + || element.getAttribute?.('title') + || '' + ) + .replace(/\s+/g, ' ') + .trim(); +} + +function queryVisibleGrokElement(selector) { + return Array.from(document.querySelectorAll(selector)).find(isVisibleGrokElement) || null; +} + +function findGrokClickableByText(pattern) { + const selectors = 'button, a, [role="button"], input[type="button"], input[type="submit"]'; + return Array.from(document.querySelectorAll(selectors)).find((element) => { + if (!isVisibleGrokElement(element)) return false; + const text = element instanceof HTMLInputElement ? element.value : getGrokElementText(element); + return pattern.test(text); + }) || null; +} + +function simulateGrokClick(element) { + throwIfStopped(); + if (!element) { + throw new Error('无法点击空元素。'); + } + const rect = element.getBoundingClientRect(); + const clientX = Math.max(0, Math.floor(rect.left + Math.min(rect.width - 1, Math.max(1, rect.width / 2)))); + const clientY = Math.max(0, Math.floor(rect.top + Math.min(rect.height - 1, Math.max(1, rect.height / 2)))); + const eventOptions = { + bubbles: true, + cancelable: true, + view: window, + clientX, + clientY, + screenX: window.screenX + clientX, + screenY: window.screenY + clientY, + }; + element.dispatchEvent(new MouseEvent('mouseover', eventOptions)); + element.dispatchEvent(new MouseEvent('mousedown', eventOptions)); + element.dispatchEvent(new MouseEvent('mouseup', eventOptions)); + if (typeof element.click === 'function') { + element.click(); + return; + } + element.dispatchEvent(new MouseEvent('click', eventOptions)); +} + +async function waitForGrok(predicate, options = {}) { + const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 30000); + const intervalMs = Math.max(100, Number(options.intervalMs) || 250); + const deadline = Date.now() + timeoutMs; + let lastValue = null; + while (Date.now() <= deadline) { + throwIfStopped(); + lastValue = predicate(); + if (lastValue) return lastValue; + await sleep(intervalMs); + } + return lastValue; +} + +function findGrokEmailInput() { + return queryVisibleGrokElement([ + 'input[type="email"]', + 'input[name="email" i]', + 'input[autocomplete="email"]', + 'input[placeholder*="email" i]', + 'input[inputmode="email"]', + ].join(', ')); +} + +function findGrokOtpInputs() { + const inputs = Array.from(document.querySelectorAll([ + 'input[autocomplete="one-time-code"]', + 'input[inputmode="numeric"]', + 'input[name*="otp" i]', + 'input[name*="code" i]', + 'input[aria-label*="code" i]', + 'input[placeholder*="code" i]', + ].join(', '))).filter(isVisibleGrokElement); + if (inputs.length) return inputs; + const oneCharInputs = Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"])')) + .filter((input) => isVisibleGrokElement(input) && Number(input.maxLength || 0) === 1); + return oneCharInputs.length >= 4 ? oneCharInputs : []; +} + +function findGrokProfileInput(names) { + const selectors = names.flatMap((name) => [ + `input[name="${name}" i]`, + `input[id="${name}" i]`, + `input[autocomplete="${name}" i]`, + `input[placeholder*="${name}" i]`, + `input[aria-label*="${name}" i]`, + ]).join(', '); + return queryVisibleGrokElement(selectors); +} + +function findGrokPasswordInputs() { + return Array.from(document.querySelectorAll([ + 'input[type="password"]', + 'input[name*="password" i]', + 'input[autocomplete="new-password"]', + 'input[placeholder*="password" i]', + 'input[aria-label*="password" i]', + ].join(', '))).filter(isVisibleGrokElement); +} + +function findGrokSubmitButton(contextPattern = GROK_CONTINUE_TEXT_PATTERN) { + return findGrokClickableByText(contextPattern) + || Array.from(document.querySelectorAll('button:not([disabled]), [role="button"]')).filter(isVisibleGrokElement).at(-1) + || null; +} + +function getGrokPageState() { + const pageText = document.body?.innerText || ''; + if (/grok|xai|x\.ai/i.test(location.hostname) && /(?:^|;\s*)sso=/.test(document.cookie || '')) return 'signed_in'; + if (findGrokProfileInput(['givenName', 'firstName']) || findGrokPasswordInputs().length || GROK_PROFILE_TEXT_PATTERN.test(pageText)) return 'profile_entry'; + if (findGrokOtpInputs().length) return 'verification_code_entry'; + if (findGrokEmailInput()) return 'email_entry'; + return 'unknown'; +} + +async function openGrokSignupPage() { + if (!/accounts\.x\.ai$/i.test(location.hostname) || !/\/sign-up/i.test(location.pathname)) { + location.href = GROK_SIGNUP_URL; + return { submitted: true, state: 'navigating', url: location.href }; + } + const emailButton = await waitForGrok(() => ( + findGrokClickableByText(GROK_EMAIL_SIGNUP_TEXT_PATTERN) || findGrokEmailInput() + ), { timeoutMs: 30000 }); + if (!emailButton) throw new Error('未找到 x.ai 邮箱注册入口。'); + if (!(emailButton instanceof HTMLInputElement)) { + simulateGrokClick(emailButton); + await sleep(500); + } + return { submitted: true, state: getGrokPageState(), url: location.href }; +} + +function getGrokEmailErrorText() { + const text = String(document.body?.innerText || '').trim(); + const patterns = [ + /Your email domain[^\n]+has been rejected[^\n]*/i, + /Please use a different email address[^\n]*/i, + /邮箱域名[^\n]*(?:被拒绝|不可用|不支持)[^\n]*/i, + /请使用其他邮箱[^\n]*/i, + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match?.[0]) return match[0].trim(); + } + return ''; +} + +async function submitGrokEmail(payload = {}) { + const email = String(payload.email || '').trim(); + if (!email) throw new Error('缺少 Grok 注册邮箱。'); + const input = await waitForGrok(findGrokEmailInput, { timeoutMs: 45000 }); + if (!input) throw new Error('未找到 x.ai 邮箱输入框。'); + fillInput(input, email); + await sleep(200); + const button = findGrokSubmitButton(); + if (!button) throw new Error('未找到 x.ai 邮箱提交按钮。'); + simulateGrokClick(button); + await sleep(1200); + const errorText = getGrokEmailErrorText(); + if (errorText) { + throw new Error(errorText); + } + return { submitted: true, state: getGrokPageState(), url: location.href }; +} + +function getGrokVerificationErrorText() { + const text = String(document.body?.innerText || '').trim(); + const patterns = [ + /(?:verification|confirmation)?\s*code\s*(?:is\s*)?(?:invalid|incorrect|expired)[^\n]*/i, + /invalid\s*(?:verification|confirmation)?\s*code[^\n]*/i, + /验证码[^\n]*(?:错误|无效|过期)[^\n]*/i, + /代码[^\n]*(?:错误|无效|过期)[^\n]*/i, + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match?.[0]) return match[0].trim(); + } + return ''; +} + +async function submitGrokVerificationCode(payload = {}) { + const normalizedCode = String(payload.code || '').replace(/[^A-Za-z0-9]/g, '').trim(); + if (!normalizedCode) throw new Error('缺少 xAI 验证码。'); + const inputs = await waitForGrok(() => findGrokOtpInputs(), { timeoutMs: 45000 }); + if (!inputs?.length) throw new Error('未找到 xAI 验证码输入框。'); + if (inputs.length === 1) { + fillInput(inputs[0], normalizedCode); + } else { + normalizedCode.split('').forEach((char, index) => { + if (inputs[index]) fillInput(inputs[index], char); + }); + } + await sleep(200); + const button = findGrokSubmitButton(); + if (button) simulateGrokClick(button); + const settledState = await waitForGrok(() => { + const errorText = getGrokVerificationErrorText(); + if (errorText) return { state: 'verification_error', error: errorText }; + const state = getGrokPageState(); + return state && state !== 'verification_code_entry' ? { state } : null; + }, { timeoutMs: 20000, intervalMs: 500 }); + const finalState = settledState?.state || getGrokPageState(); + if (settledState?.error) { + throw new Error(settledState.error); + } + if (finalState === 'email_entry') { + throw new Error('x.ai 验证码提交后回到邮箱注册页,可能是验证码无效、会话过期或注册风控重置。'); + } + if (!['profile_entry', 'signed_in'].includes(finalState)) { + throw new Error(`x.ai 验证码提交后进入未知页面状态:${finalState || 'unknown'}。`); + } + return { submitted: true, state: finalState, url: location.href }; +} + +async function submitGrokProfile(payload = {}) { + const firstName = String(payload.firstName || '').trim(); + const lastName = String(payload.lastName || '').trim(); + const password = String(payload.password || ''); + if (!firstName || !lastName || !password) throw new Error('缺少 Grok 注册资料。'); + const ready = await waitForGrok(() => { + const firstInput = findGrokProfileInput(['givenName', 'firstName', 'given-name']); + const lastInput = findGrokProfileInput(['familyName', 'lastName', 'family-name']); + const passwordInputs = findGrokPasswordInputs(); + return firstInput && lastInput && passwordInputs.length ? { firstInput, lastInput, passwordInputs } : null; + }, { timeoutMs: 45000 }); + if (!ready) throw new Error('未找到 x.ai 资料或密码表单。'); + fillInput(ready.firstInput, firstName); + fillInput(ready.lastInput, lastName); + ready.passwordInputs.forEach((input) => fillInput(input, password)); + await sleep(GROK_PROFILE_SUBMIT_PRE_CLICK_DELAY_MS); + const button = findGrokSubmitButton(); + if (!button) throw new Error('未找到 x.ai 资料提交按钮。'); + simulateGrokClick(button); + return { submitted: true, state: 'profile_submitted', url: location.href }; +} + +async function extractGrokSsoCookie() { + const match = String(document.cookie || '').match(/(?:^|;\s*)sso=([^;]+)/); + return { + submitted: true, + state: match ? 'sso_cookie_found' : getGrokPageState(), + ssoCookie: match ? decodeURIComponent(match[1]) : '', + url: location.href, + }; +} + +async function executeGrokCommand(command, payload = {}) { + switch (command) { + case 'grok-open-signup-page': + return openGrokSignupPage(payload); + case 'grok-submit-email': + return submitGrokEmail(payload); + case 'grok-submit-verification-code': + return submitGrokVerificationCode(payload); + case 'grok-submit-profile': + return submitGrokProfile(payload); + case 'grok-extract-sso-cookie': + return extractGrokSsoCookie(payload); + case 'GET_PAGE_STATE': + return { state: getGrokPageState(), url: location.href }; + default: + throw new Error(`未知 Grok 注册命令:${command}`); + } +} + +if (!document.documentElement.hasAttribute(GROK_REGISTER_PAGE_LISTENER_SENTINEL)) { + document.documentElement.setAttribute(GROK_REGISTER_PAGE_LISTENER_SENTINEL, '1'); + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== 'EXECUTE_NODE' && message?.type !== 'GET_PAGE_STATE') return false; + resetStopState(); + const command = message.command || message.nodeId || message.type; + executeGrokCommand(command, message.payload || {}) + .then((result) => sendResponse({ ok: true, ...result })) + .catch((error) => { + if (isStopError(error)) { + sendResponse({ stopped: true, error: error.message }); + return; + } + sendResponse({ ok: false, error: error?.message || String(error) }); + }); + return true; + }); +} + +window.__MULTIPAGE_GROK_REGISTER_PAGE__ = { + executeGrokCommand, + getGrokPageState, +}; diff --git a/flows/grok/index.js b/flows/grok/index.js new file mode 100644 index 0000000..a06eac8 --- /dev/null +++ b/flows/grok/index.js @@ -0,0 +1,141 @@ +(function attachMultiPageGrokFlowDefinition(root, factory) { + root.MultiPageGrokFlowDefinition = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createMultiPageGrokFlowDefinition() { + function freezeDeep(entry) { + if (!entry || typeof entry !== 'object' || Object.isFrozen(entry)) { + return entry; + } + Object.getOwnPropertyNames(entry).forEach((key) => { + freezeDeep(entry[key]); + }); + return Object.freeze(entry); + } + + const VALUE = freezeDeep({ + id: 'grok', + label: 'Grok / xAI', + services: [ + 'account', + 'email', + 'proxy', + ], + capabilities: { + supportsEmailSignup: true, + supportsPhoneSignup: false, + supportsPhoneVerificationSettings: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsAccountContribution: false, + supportsOpenAiOAuthContribution: false, + contributionAdapterIds: [], + supportedTargetIds: ['webchat2api'], + supportsLuckmail: false, + supportsOauthTimeoutBudget: false, + canSwitchFlow: true, + stepDefinitionMode: 'grok', + targetSelectorLabel: '来源', + }, + baseGroups: ['grok-runtime-status'], + targets: { + webchat2api: { + id: 'webchat2api', + label: 'webchat2api', + groups: [ + 'grok-target-webchat2api', + ], + }, + }, + publicationTargets: {}, + runtimeSources: { + 'grok-register-page': { + flowId: 'grok', + kind: 'flow-page', + label: 'Grok 注册页', + readyPolicy: 'top-frame-only', + family: 'grok-register-page-family', + driverId: 'flows/grok/content/register-page', + cleanupScopes: [], + detectionMatchers: [ + { + hostnames: [ + 'accounts.x.ai', + 'x.ai', + 'grok.com', + ], + hostnameEndsWith: [ + '.x.ai', + '.grok.com', + ], + matchMode: 'any', + }, + ], + familyMatchers: [ + { + hostnames: [ + 'accounts.x.ai', + 'x.ai', + 'grok.com', + ], + hostnameEndsWith: [ + '.x.ai', + '.grok.com', + ], + matchMode: 'any', + }, + ], + }, + }, + driverDefinitions: { + 'flows/grok/content/register-page': { + sourceId: 'grok-register-page', + commands: [ + 'grok-open-signup-page', + 'grok-submit-email', + 'grok-submit-verification-code', + 'grok-submit-profile', + 'grok-extract-sso-cookie', + ], + }, + 'flows/grok/background/register-runner': { + sourceId: 'grok-register-page', + commands: [ + 'grok-open-signup-page', + 'grok-submit-email', + 'grok-submit-verification-code', + 'grok-submit-profile', + 'grok-extract-sso-cookie', + ], + }, + }, + defaultTargetId: 'webchat2api', + settingsDefaults: { + autoRun: { + stepExecutionRange: { + enabled: false, + fromStep: 1, + toStep: 5, + }, + }, + }, + settingsGroups: { + 'grok-target-webchat2api': { + id: 'grok-target-webchat2api', + label: 'webchat2api', + rowIds: [ + 'row-grok-sso-settings', + ], + }, + 'grok-runtime-status': { + id: 'grok-runtime-status', + label: 'Grok 运行态', + rowIds: [ + 'row-grok-register-status', + 'row-grok-sso-status', + ], + }, + }, + sourceAliases: {}, + }); + + return VALUE; +}); diff --git a/flows/grok/mail-rules.js b/flows/grok/mail-rules.js new file mode 100644 index 0000000..aa2b501 --- /dev/null +++ b/flows/grok/mail-rules.js @@ -0,0 +1,157 @@ +(function attachGrokMailRules(root, factory) { + root.MultiPageGrokMailRules = factory(root); +})(typeof self !== 'undefined' ? self : globalThis, function createGrokMailRulesModule(root) { + const grokStateApi = root.MultiPageBackgroundGrokState || null; + const SUBMIT_VERIFICATION_CODE_RULE_ID = 'grok-submit-verification-code'; + const SUBMIT_VERIFICATION_CODE_NODE_ID = 'grok-submit-verification-code'; + const GROK_VERIFICATION_CODE_PATTERNS = Object.freeze([ + Object.freeze({ + source: '\\b([A-Z0-9]{3}-[A-Z0-9]{3})\\b', + flags: 'gi', + }), + Object.freeze({ + source: '(?:verification\\s*code|confirmation\\s*code|code\\s*is)[::\\s]*(\\d{6})', + flags: 'gi', + }), + Object.freeze({ + source: '(?:验证码|代码|确认码)[::\\s为]+(\\d{6})', + flags: 'gi', + }), + Object.freeze({ + source: '(? 0 ? explicitStep : 3; + } + + function isMail2925Provider(state = {}) { + return cleanString(state?.mailProvider).toLowerCase() === '2925'; + } + + function shouldMatchMail2925TargetEmail(state = {}) { + return isMail2925Provider(state) + && cleanString(state?.mail2925Mode).toLowerCase() === 'receive'; + } + + function createGrokMailRules(deps = {}) { + const { + LUCKMAIL_PROVIDER = 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, + } = deps; + + function getRuleDefinition(_input, state = {}) { + const runtimeState = readGrokRuntime(state); + const targetEmail = cleanString(runtimeState.register?.email || state?.grokEmail || state?.email).toLowerCase(); + const normalizedProvider = cleanString(state?.mailProvider).toLowerCase(); + const mail2925Provider = isMail2925Provider(state); + const luckmailProvider = normalizedProvider === cleanString(LUCKMAIL_PROVIDER).toLowerCase(); + + return { + flowId: 'grok', + ruleId: SUBMIT_VERIFICATION_CODE_RULE_ID, + nodeId: SUBMIT_VERIFICATION_CODE_NODE_ID, + step: getVisibleStep(state), + artifactType: 'code', + codePatterns: GROK_VERIFICATION_CODE_PATTERNS, + filterAfterTimestamp: 0, + requiredKeywords: GROK_REQUIRED_KEYWORDS, + senderFilters: GROK_SENDER_FILTERS, + subjectFilters: GROK_SUBJECT_FILTERS, + targetEmail, + targetEmailHints: buildTargetEmailHints(targetEmail), + mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), + maxAttempts: luckmailProvider + ? 3 + : (mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5), + intervalMs: luckmailProvider + ? 15000 + : (mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 5000), + }; + } + + function getRuleDefinitionForNode(nodeId, state = {}) { + const normalizedNodeId = cleanString(nodeId); + if (normalizedNodeId && normalizedNodeId !== SUBMIT_VERIFICATION_CODE_NODE_ID) { + throw new Error(`Grok 邮件规则不支持节点:${normalizedNodeId}`); + } + return getRuleDefinition({ nodeId: SUBMIT_VERIFICATION_CODE_NODE_ID }, state); + } + + function buildVerificationPollPayload(input, state = {}, overrides = {}) { + return { + ...getRuleDefinition(input, state), + ...(overrides || {}), + }; + } + + function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) { + return { + ...getRuleDefinitionForNode(nodeId, state), + ...(overrides || {}), + }; + } + + return { + buildVerificationPollPayload, + buildVerificationPollPayloadForNode, + getRuleDefinition, + getRuleDefinitionForNode, + }; + } + + return { + GROK_REQUIRED_KEYWORDS, + GROK_SENDER_FILTERS, + GROK_SUBJECT_FILTERS, + GROK_VERIFICATION_CODE_PATTERNS, + SUBMIT_VERIFICATION_CODE_NODE_ID, + SUBMIT_VERIFICATION_CODE_RULE_ID, + createGrokMailRules, + }; +}); diff --git a/flows/grok/workflow.js b/flows/grok/workflow.js new file mode 100644 index 0000000..f872a75 --- /dev/null +++ b/flows/grok/workflow.js @@ -0,0 +1,98 @@ +(function attachMultiPageGrokWorkflow(root, factory) { + root.MultiPageGrokWorkflow = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createMultiPageGrokWorkflow() { + function freezeDeep(entry) { + if (!entry || typeof entry !== 'object' || Object.isFrozen(entry)) { + return entry; + } + Object.getOwnPropertyNames(entry).forEach((key) => { + freezeDeep(entry[key]); + }); + return Object.freeze(entry); + } + + const STEP_VARIANTS = freezeDeep({ + default: [ + { + id: 1, + order: 10, + key: 'grok-open-signup-page', + title: '打开 Grok 注册页', + sourceId: 'grok-register-page', + driverId: 'flows/grok/background/register-runner', + command: 'grok-open-signup-page', + flowId: 'grok', + }, + { + id: 2, + order: 20, + key: 'grok-submit-email', + title: '获取邮箱并继续', + sourceId: 'grok-register-page', + driverId: 'flows/grok/background/register-runner', + command: 'grok-submit-email', + flowId: 'grok', + }, + { + id: 3, + order: 30, + key: 'grok-submit-verification-code', + title: '获取验证码并继续', + sourceId: 'grok-register-page', + driverId: 'flows/grok/background/register-runner', + command: 'grok-submit-verification-code', + mailRuleId: 'grok-submit-verification-code', + flowId: 'grok', + }, + { + id: 4, + order: 40, + key: 'grok-submit-profile', + title: '填写资料并继续', + sourceId: 'grok-register-page', + driverId: 'flows/grok/background/register-runner', + command: 'grok-submit-profile', + flowId: 'grok', + }, + { + id: 5, + order: 50, + key: 'grok-extract-sso-cookie', + title: '提取 SSO Cookie', + sourceId: 'grok-register-page', + driverId: 'flows/grok/background/register-runner', + command: 'grok-extract-sso-cookie', + flowId: 'grok', + }, + ], + }); + + function getVariantStepDefinitions(variantKey = 'default') { + return Array.isArray(STEP_VARIANTS[variantKey]) ? STEP_VARIANTS[variantKey] : STEP_VARIANTS.default; + } + + function getModeStepDefinitions() { + return getVariantStepDefinitions('default'); + } + + function getAllSteps() { + return getVariantStepDefinitions('default'); + } + + function getPlusPaymentStepTitle() { + return ''; + } + + function resolveStepTitle(step = {}) { + return step?.title || ''; + } + + return { + flowId: 'grok', + getAllSteps, + getModeStepDefinitions, + getPlusPaymentStepTitle, + getVariantStepDefinitions, + resolveStepTitle, + }; +}); diff --git a/flows/index.js b/flows/index.js index 90ff613..3fa9b96 100644 --- a/flows/index.js +++ b/flows/index.js @@ -12,6 +12,10 @@ id: 'kiro', path: 'flows/kiro/', }, + grok: { + id: 'grok', + path: 'flows/grok/', + }, }); function normalizeFlowId(value = '') { @@ -32,10 +36,14 @@ ...baseEntry, definition: normalized === 'openai' ? (rootScope.MultiPageOpenAiFlowDefinition || null) - : (rootScope.MultiPageKiroFlowDefinition || null), + : (normalized === 'kiro' + ? (rootScope.MultiPageKiroFlowDefinition || null) + : (rootScope.MultiPageGrokFlowDefinition || null)), workflow: normalized === 'openai' ? (rootScope.MultiPageOpenAiWorkflow || null) - : (rootScope.MultiPageKiroWorkflow || null), + : (normalized === 'kiro' + ? (rootScope.MultiPageKiroWorkflow || null) + : (rootScope.MultiPageGrokWorkflow || null)), }; } diff --git a/flows/kiro/background/desktop-authorize-runner.js b/flows/kiro/background/desktop-authorize-runner.js index 0f1f15b..3d7117b 100644 --- a/flows/kiro/background/desktop-authorize-runner.js +++ b/flows/kiro/background/desktop-authorize-runner.js @@ -14,43 +14,6 @@ 'https://app.kiro.dev/*', 'https://kiro.dev/*', ]); - const KIRO_AWS_VERIFICATION_CODE_PATTERNS = Object.freeze([ - Object.freeze({ - source: '(?:verification\\s*code|验证码|Your code is|code is)[::\\s]*(\\d{6})', - flags: 'gi', - }), - Object.freeze({ - source: '^\\s*(\\d{6})\\s*$', - flags: 'gm', - }), - Object.freeze({ - source: '>\\s*(\\d{6})\\s*<', - flags: 'g', - }), - ]); - const KIRO_AWS_SENDER_FILTERS = Object.freeze([ - 'no-reply@signin.aws', - 'no-reply@login.awsapps.com', - 'noreply@amazon.com', - 'account-update@amazon.com', - 'no-reply@aws.amazon.com', - 'noreply@aws.amazon.com', - 'aws', - ]); - const KIRO_AWS_SUBJECT_FILTERS = Object.freeze([ - 'aws builder id', - 'verification', - '验证码', - 'code', - 'aws', - ]); - const KIRO_AWS_REQUIRED_KEYWORDS = Object.freeze([ - 'verification', - '验证码', - 'code', - 'aws', - ]); - function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } @@ -375,32 +338,17 @@ chrome = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), completeNodeFromBackground, ensureContentScriptReadyOnTab = null, - ensureIcloudMailSession = null, - ensureMail2925MailboxSession = null, fetchImpl = typeof fetch === 'function' ? fetch.bind(globalThis) : null, - getMailConfig = null, getState = async () => ({}), getTabId = async () => null, - HOTMAIL_PROVIDER = 'hotmail-api', - LUCKMAIL_PROVIDER = 'luckmail-api', - CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email', - CLOUD_MAIL_PROVIDER = 'cloudmail', - YYDS_MAIL_PROVIDER = 'yyds-mail', - MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, - MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, isTabAlive = async () => false, maybeSubmitFlowContribution = async () => null, KIRO_REGISTER_INJECT_FILES = null, KIRO_DESKTOP_AUTHORIZE_INJECT_FILES = null, - pollCloudflareTempEmailVerificationCode = null, - pollCloudMailVerificationCode = null, - pollHotmailVerificationCode = null, - pollLuckmailVerificationCode = null, - pollYydsMailVerificationCode = null, + pollFlowVerificationCode = null, registerTab = async () => {}, reuseOrCreateTab = async () => null, sendToContentScriptResilient = null, - sendToMailContentScriptResilient = null, setState = async () => {}, sleepWithStop = async (ms) => { await new Promise((resolve) => setTimeout(resolve, ms)); @@ -658,44 +606,6 @@ return password; } - function getExpectedMail2925MailboxEmail(state = {}) { - if (Boolean(state?.mail2925UseAccountPool)) { - const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); - const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; - const currentAccount = accounts.find((account) => String(account?.id || '') === currentAccountId) || null; - const accountEmail = String(currentAccount?.email || '').trim().toLowerCase(); - if (accountEmail) { - return accountEmail; - } - } - return String(state?.mail2925BaseEmail || '').trim().toLowerCase(); - } - - async function focusOrOpenMailTab(mail) { - if (!mail?.source) { - return; - } - const alive = await isTabAlive(mail.source); - if (alive) { - if (mail.navigateOnReuse) { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - return; - } - const tabId = await getTabId(mail.source); - if (Number.isInteger(tabId)) { - await activateTab(tabId); - } - return; - } - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - } - async function collectKiroWebSessionTabs(currentState = {}) { const runtimeState = readKiroRuntime(currentState); const candidates = []; @@ -901,131 +811,35 @@ throw new Error(`Kiro Web 登录态尚未建立。请在自动打开的 Kiro 账号页登录后,从步骤 7 继续。${detail}`); } - function buildDesktopOtpPollPayload(step, state = {}, mail = {}, filterAfterTimestamp = 0) { - const runtimeState = readKiroRuntime(state); - const targetEmail = cleanString(runtimeState.register?.email || state?.email).toLowerCase(); - const targetEmailHints = targetEmail ? [targetEmail] : []; - const isMail2925Provider = String(mail?.provider || '').trim().toLowerCase() === '2925'; - const normalizedProvider = String(mail?.provider || '').trim().toLowerCase(); - const maxAttempts = normalizedProvider === String(LUCKMAIL_PROVIDER || '').trim().toLowerCase() - ? 3 - : (isMail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5); - const intervalMs = normalizedProvider === String(LUCKMAIL_PROVIDER || '').trim().toLowerCase() - ? 15000 - : (isMail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000); - - return { - flowId: 'kiro', - step, - targetEmail, - targetEmailHints, - filterAfterTimestamp, - senderFilters: [...KIRO_AWS_SENDER_FILTERS], - subjectFilters: [...KIRO_AWS_SUBJECT_FILTERS], - requiredKeywords: [...KIRO_AWS_REQUIRED_KEYWORDS], - codePatterns: [...KIRO_AWS_VERIFICATION_CODE_PATTERNS], - mail2925MatchTargetEmail: isMail2925Provider - && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive', - maxAttempts, - intervalMs, - }; - } - - function getMailPollingResponseTimeoutMs(payload = {}) { - const maxAttempts = Math.max(1, Math.floor(Number(payload?.maxAttempts) || 1)); - const intervalMs = Math.max(1, Number(payload?.intervalMs) || 3000); - return Math.max(45000, maxAttempts * intervalMs + 25000); - } - async function pollDesktopOtpCode(step, state = {}, nodeId = '') { - if (typeof getMailConfig !== 'function') { - throw new Error('Kiro 桌面授权验证码步骤缺少邮箱配置能力,无法继续执行。'); - } - const mail = getMailConfig(state); - if (mail?.error) { - throw new Error(mail.error); + if (typeof pollFlowVerificationCode !== 'function') { + throw new Error('Kiro 桌面授权验证码步骤缺少共享邮件轮询能力,无法继续执行。'); } const runtimeState = readKiroRuntime(state); const requestedAt = Math.max(0, Number(runtimeState.desktopAuth?.otpRequestedAt) || Date.now()); - const filterAfterTimestamp = mail.provider === '2925' + const mailProvider = cleanString(state?.mailProvider).toLowerCase(); + const filterAfterTimestamp = mailProvider === '2925' ? Math.max(0, requestedAt - MAIL_2925_FILTER_LOOKBACK_MS) : requestedAt; - const pollPayload = buildDesktopOtpPollPayload(step, state, mail, filterAfterTimestamp); - if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') { - await log(`步骤 ${step}:正在确认 ${mail.label || 'iCloud 邮箱'} 登录状态...`, 'info', nodeId); - await ensureIcloudMailSession({ - state, - step, - actionLabel: `步骤 ${step}:确认 iCloud 邮箱登录状态`, - }); - } - - if (mail.provider === HOTMAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'Hotmail'} 轮询桌面授权验证码...`, 'info', nodeId); - return pollHotmailVerificationCode(step, state, pollPayload); - } - if (mail.provider === LUCKMAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'LuckMail'} 轮询桌面授权验证码...`, 'info', nodeId); - return pollLuckmailVerificationCode(step, state, pollPayload); - } - if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'Cloudflare Temp Email'} 轮询桌面授权验证码...`, 'info', nodeId); - return pollCloudflareTempEmailVerificationCode(step, state, pollPayload); - } - if (mail.provider === CLOUD_MAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'Cloud Mail'} 轮询桌面授权验证码...`, 'info', nodeId); - return pollCloudMailVerificationCode(step, state, pollPayload); - } - if (mail.provider === YYDS_MAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'YYDS Mail'} 轮询桌面授权验证码...`, 'info', nodeId); - return pollYydsMailVerificationCode(step, state, pollPayload); - } - - if (mail.provider === '2925' && typeof ensureMail2925MailboxSession === 'function') { - await log(`步骤 ${step}:正在确认 ${mail.label || '2925 邮箱'} 登录状态...`, 'info', nodeId); - await ensureMail2925MailboxSession({ - accountId: state.currentMail2925AccountId || null, - forceRelogin: false, - allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), - expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), - actionLabel: `步骤 ${step}:确认 2925 邮箱登录状态`, - }); - } else { - await log(`步骤 ${step}:正在打开 ${mail.label || '邮箱'}...`, 'info', nodeId); - await focusOrOpenMailTab(mail); - } - - if (typeof sendToMailContentScriptResilient !== 'function') { - throw new Error('Kiro 桌面授权验证码步骤缺少邮箱内容脚本通信能力,无法继续执行。'); - } - - const responseTimeoutMs = getMailPollingResponseTimeoutMs(pollPayload); - const result = await sendToMailContentScriptResilient( - mail, - { - type: 'POLL_EMAIL', - step, - source: 'background', - payload: pollPayload, + return pollFlowVerificationCode({ + actionLabel: '桌面授权验证码', + filterAfterTimestamp, + flowId: 'kiro', + logStep: step, + logStepKey: 'kiro-complete-desktop-authorize', + missingCapabilityMessage: 'Kiro 桌面授权验证码步骤缺少共享邮件轮询能力,无法继续执行。', + nodeId: 'kiro-complete-desktop-authorize', + notFoundMessage: `步骤 ${step}:邮箱轮询结束,但未获取到桌面授权验证码。`, + state: { + ...state, + activeFlowId: 'kiro', + flowId: 'kiro', + visibleStep: step, }, - { - timeoutMs: responseTimeoutMs, - responseTimeoutMs, - maxRecoveryAttempts: 2, - logStep: step, - logStepKey: 'kiro-complete-desktop-authorize', - } - ); - - if (result?.error) { - throw new Error(result.error); - } - if (!result?.code) { - throw new Error(`步骤 ${step}:邮箱轮询结束,但未获取到桌面授权验证码。`); - } - return result; + step, + }); } async function executeKiroStartDesktopAuthorize(state = {}) { diff --git a/flows/kiro/background/register-runner.js b/flows/kiro/background/register-runner.js index 521f29c..43098c1 100644 --- a/flows/kiro/background/register-runner.js +++ b/flows/kiro/background/register-runner.js @@ -32,42 +32,6 @@ 'https://profile.aws.amazon.com', ]); const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000; - const KIRO_AWS_VERIFICATION_CODE_PATTERNS = Object.freeze([ - Object.freeze({ - source: '(?:verification\\s*code|验证码|Your code is|code is)[::\\s]*(\\d{6})', - flags: 'gi', - }), - Object.freeze({ - source: '^\\s*(\\d{6})\\s*$', - flags: 'gm', - }), - Object.freeze({ - source: '>\\s*(\\d{6})\\s*<', - flags: 'g', - }), - ]); - const KIRO_AWS_SENDER_FILTERS = Object.freeze([ - 'no-reply@signin.aws', - 'no-reply@login.awsapps.com', - 'noreply@amazon.com', - 'account-update@amazon.com', - 'no-reply@aws.amazon.com', - 'noreply@aws.amazon.com', - 'aws', - ]); - const KIRO_AWS_SUBJECT_FILTERS = Object.freeze([ - 'aws builder id', - 'verification', - '验证码', - 'code', - 'aws', - ]); - const KIRO_AWS_REQUIRED_KEYWORDS = Object.freeze([ - 'verification', - '验证码', - 'code', - 'aws', - ]); const KIRO_REGISTER_PAGE_STATES = Object.freeze([ 'kiro_signin_page', 'email_entry', @@ -387,32 +351,17 @@ chrome = (typeof globalThis !== 'undefined' ? globalThis.chrome : null), completeNodeFromBackground, ensureContentScriptReadyOnTab = null, - ensureIcloudMailSession = null, - ensureMail2925MailboxSession = null, generatePassword = null, generateRandomName = null, - getMailConfig = null, getState = async () => ({}), getTabId = async () => null, - HOTMAIL_PROVIDER = 'hotmail-api', - LUCKMAIL_PROVIDER = 'luckmail-api', - CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email', - CLOUD_MAIL_PROVIDER = 'cloudmail', - YYDS_MAIL_PROVIDER = 'yyds-mail', - MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, - MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, isTabAlive = async () => false, KIRO_REGISTER_INJECT_FILES = null, - pollCloudflareTempEmailVerificationCode = null, - pollCloudMailVerificationCode = null, - pollHotmailVerificationCode = null, - pollLuckmailVerificationCode = null, - pollYydsMailVerificationCode = null, + pollFlowVerificationCode = null, registerTab = async () => {}, resolveSignupEmailForFlow = null, reuseOrCreateTab = async () => null, sendToContentScriptResilient = null, - sendToMailContentScriptResilient = null, setPasswordState = async () => {}, setState = async () => {}, sleepWithStop = async (ms) => { @@ -836,174 +785,36 @@ }; } - function getExpectedMail2925MailboxEmail(state = {}) { - if (Boolean(state?.mail2925UseAccountPool)) { - const currentAccountId = String(state?.currentMail2925AccountId || '').trim(); - const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : []; - const currentAccount = accounts.find((account) => String(account?.id || '') === currentAccountId) || null; - const accountEmail = String(currentAccount?.email || '').trim().toLowerCase(); - if (accountEmail) { - return accountEmail; - } - } - - return String(state?.mail2925BaseEmail || '').trim().toLowerCase(); - } - - async function focusOrOpenMailTab(mail) { - if (!mail?.source) { - return; - } - const alive = await isTabAlive(mail.source); - if (alive) { - if (mail.navigateOnReuse) { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - return; - } - - const tabId = await getTabId(mail.source); - if (Number.isInteger(tabId)) { - await activateTab(tabId); - } - return; - } - - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - } - - function buildKiroVerificationPollPayload(step, state = {}, mail = {}, filterAfterTimestamp = 0) { - const runtimeState = readKiroRuntime(state); - const targetEmail = cleanString(runtimeState.register?.email || state?.email).toLowerCase(); - const targetEmailHints = targetEmail ? [targetEmail] : []; - const isMail2925Provider = String(mail?.provider || '').trim().toLowerCase() === '2925'; - const normalizedProvider = String(mail?.provider || '').trim().toLowerCase(); - const maxAttempts = normalizedProvider === String(LUCKMAIL_PROVIDER || '').trim().toLowerCase() - ? 3 - : (isMail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5); - const intervalMs = normalizedProvider === String(LUCKMAIL_PROVIDER || '').trim().toLowerCase() - ? 15000 - : (isMail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000); - - return { - flowId: 'kiro', - step, - targetEmail, - targetEmailHints, - filterAfterTimestamp, - senderFilters: [...KIRO_AWS_SENDER_FILTERS], - subjectFilters: [...KIRO_AWS_SUBJECT_FILTERS], - requiredKeywords: [...KIRO_AWS_REQUIRED_KEYWORDS], - codePatterns: [...KIRO_AWS_VERIFICATION_CODE_PATTERNS], - mail2925MatchTargetEmail: isMail2925Provider - && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive', - maxAttempts, - intervalMs, - }; - } - - function getMailPollingResponseTimeoutMs(payload = {}) { - const maxAttempts = Math.max(1, Math.floor(Number(payload?.maxAttempts) || 1)); - const intervalMs = Math.max(1, Number(payload?.intervalMs) || 3000); - return Math.max(45000, maxAttempts * intervalMs + 25000); - } - async function pollKiroVerificationCode(step, state = {}, nodeId = '') { - if (typeof getMailConfig !== 'function') { - throw new Error('Kiro 验证码步骤缺少邮箱配置能力,无法继续执行。'); - } - const mail = getMailConfig(state); - if (mail?.error) { - throw new Error(mail.error); + if (typeof pollFlowVerificationCode !== 'function') { + throw new Error('Kiro 验证码步骤缺少共享邮件轮询能力,无法继续执行。'); } const runtimeState = readKiroRuntime(state); const recordedRequestedAt = Math.max(0, Number(runtimeState.register?.verificationRequestedAt) || 0); const requestedAt = recordedRequestedAt || Math.max(0, Date.now() - MAIL_2925_FILTER_LOOKBACK_MS); - const filterAfterTimestamp = mail.provider === '2925' + const mailProvider = cleanString(state?.mailProvider).toLowerCase(); + const filterAfterTimestamp = mailProvider === '2925' ? Math.max(0, requestedAt - MAIL_2925_FILTER_LOOKBACK_MS) : requestedAt; - const pollPayload = buildKiroVerificationPollPayload(step, state, mail, filterAfterTimestamp); - if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') { - await log(`步骤 ${step}:正在确认 ${mail.label || 'iCloud 邮箱'} 登录状态...`, 'info', nodeId); - await ensureIcloudMailSession({ - state, - step, - actionLabel: `步骤 ${step}:确认 iCloud 邮箱登录状态`, - }); - } - - throwIfStopped(); - if (mail.provider === HOTMAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'Hotmail'} 轮询验证码...`, 'info', nodeId); - return pollHotmailVerificationCode(step, state, pollPayload); - } - if (mail.provider === LUCKMAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'LuckMail'} 轮询验证码...`, 'info', nodeId); - return pollLuckmailVerificationCode(step, state, pollPayload); - } - if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'Cloudflare Temp Email'} 轮询验证码...`, 'info', nodeId); - return pollCloudflareTempEmailVerificationCode(step, state, pollPayload); - } - if (mail.provider === CLOUD_MAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'Cloud Mail'} 轮询验证码...`, 'info', nodeId); - return pollCloudMailVerificationCode(step, state, pollPayload); - } - if (mail.provider === YYDS_MAIL_PROVIDER) { - await log(`步骤 ${step}:正在通过 ${mail.label || 'YYDS Mail'} 轮询验证码...`, 'info', nodeId); - return pollYydsMailVerificationCode(step, state, pollPayload); - } - - if (mail.provider === '2925' && typeof ensureMail2925MailboxSession === 'function') { - await log(`步骤 ${step}:正在确认 ${mail.label || '2925 邮箱'} 登录状态...`, 'info', nodeId); - await ensureMail2925MailboxSession({ - accountId: state.currentMail2925AccountId || null, - forceRelogin: false, - allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool), - expectedMailboxEmail: getExpectedMail2925MailboxEmail(state), - actionLabel: `步骤 ${step}:确认 2925 邮箱登录状态`, - }); - } else { - await log(`步骤 ${step}:正在打开 ${mail.label || '邮箱'}...`, 'info', nodeId); - await focusOrOpenMailTab(mail); - } - - if (typeof sendToMailContentScriptResilient !== 'function') { - throw new Error('Kiro 验证码步骤缺少邮箱内容脚本通信能力,无法继续执行。'); - } - - const responseTimeoutMs = getMailPollingResponseTimeoutMs(pollPayload); - const result = await sendToMailContentScriptResilient( - mail, - { - type: 'POLL_EMAIL', - step, - source: 'background', - payload: pollPayload, + return pollFlowVerificationCode({ + actionLabel: 'Kiro 验证码', + filterAfterTimestamp, + flowId: 'kiro', + logStep: step, + logStepKey: 'kiro-submit-verification-code', + missingCapabilityMessage: 'Kiro 验证码步骤缺少共享邮件轮询能力,无法继续执行。', + nodeId: 'kiro-submit-verification-code', + notFoundMessage: `步骤 ${step}:邮箱轮询结束,但未获取到 Kiro 验证码。`, + state: { + ...state, + activeFlowId: 'kiro', + flowId: 'kiro', + visibleStep: step, }, - { - timeoutMs: responseTimeoutMs, - responseTimeoutMs, - maxRecoveryAttempts: 2, - logStep: step, - logStepKey: 'kiro-submit-verification-code', - } - ); - - if (result?.error) { - throw new Error(result.error); - } - if (!result?.code) { - throw new Error(`步骤 ${step}:邮箱轮询结束,但未获取到验证码。`); - } - return result; + step, + }); } async function executeKiroOpenRegisterPage(state = {}) { diff --git a/flows/kiro/mail-rules.js b/flows/kiro/mail-rules.js new file mode 100644 index 0000000..5eac02b --- /dev/null +++ b/flows/kiro/mail-rules.js @@ -0,0 +1,162 @@ +(function attachKiroMailRules(root, factory) { + root.MultiPageKiroMailRules = factory(root); +})(typeof self !== 'undefined' ? self : globalThis, function createKiroMailRulesModule(root) { + const kiroStateApi = root.MultiPageBackgroundKiroState || null; + const SUBMIT_VERIFICATION_CODE_RULE_ID = 'kiro-submit-verification-code'; + const DESKTOP_AUTHORIZE_CODE_RULE_ID = 'kiro-complete-desktop-authorize'; + const SUBMIT_VERIFICATION_CODE_NODE_ID = 'kiro-submit-verification-code'; + const DESKTOP_AUTHORIZE_NODE_ID = 'kiro-complete-desktop-authorize'; + const KIRO_AWS_VERIFICATION_CODE_PATTERNS = Object.freeze([ + Object.freeze({ + source: '(?:verification\\s*code|验证码|Your code is|code is)[::\\s]*(\\d{6})', + flags: 'gi', + }), + Object.freeze({ + source: '^\\s*(\\d{6})\\s*$', + flags: 'gm', + }), + Object.freeze({ + source: '>\\s*(\\d{6})\\s*<', + flags: 'g', + }), + ]); + const KIRO_AWS_SENDER_FILTERS = Object.freeze([ + 'no-reply@signin.aws', + 'no-reply@login.awsapps.com', + 'noreply@amazon.com', + 'account-update@amazon.com', + 'no-reply@aws.amazon.com', + 'noreply@aws.amazon.com', + 'aws', + ]); + const KIRO_AWS_SUBJECT_FILTERS = Object.freeze([ + 'aws builder id', + 'verification', + '验证码', + 'code', + 'aws', + ]); + const KIRO_AWS_REQUIRED_KEYWORDS = Object.freeze([ + 'verification', + '验证码', + 'code', + 'aws', + ]); + + function cleanString(value = '') { + return String(value ?? '').trim(); + } + + function readKiroRuntime(state = {}) { + if (typeof kiroStateApi?.ensureRuntimeState === 'function') { + return kiroStateApi.ensureRuntimeState(state); + } + return state?.runtimeState?.flowState?.kiro || state?.flowState?.kiro || {}; + } + + function buildTargetEmailHints(targetEmail = '') { + const normalizedTarget = cleanString(targetEmail).toLowerCase(); + return normalizedTarget ? [normalizedTarget] : []; + } + + function resolveNodeId(input) { + const directNodeId = cleanString(input?.nodeId || input); + if (directNodeId === DESKTOP_AUTHORIZE_NODE_ID) { + return DESKTOP_AUTHORIZE_NODE_ID; + } + return SUBMIT_VERIFICATION_CODE_NODE_ID; + } + + function getVisibleStepForNode(nodeId, state = {}) { + const explicitStep = Number(state?.visibleStep || state?.step); + if (Number.isInteger(explicitStep) && explicitStep > 0) { + return explicitStep; + } + return nodeId === DESKTOP_AUTHORIZE_NODE_ID ? 8 : 4; + } + + function isMail2925Provider(state = {}) { + return cleanString(state?.mailProvider).toLowerCase() === '2925'; + } + + function shouldMatchMail2925TargetEmail(state = {}) { + return isMail2925Provider(state) + && cleanString(state?.mail2925Mode).toLowerCase() === 'receive'; + } + + function createKiroMailRules(deps = {}) { + const { + LUCKMAIL_PROVIDER = 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, + } = deps; + + function getRuleDefinition(input, state = {}) { + const nodeId = resolveNodeId(input); + const normalizedStep = getVisibleStepForNode(nodeId, state); + const runtimeState = readKiroRuntime(state); + const targetEmail = cleanString(runtimeState.register?.email || state?.email).toLowerCase(); + const normalizedProvider = cleanString(state?.mailProvider).toLowerCase(); + const mail2925Provider = isMail2925Provider(state); + const luckmailProvider = normalizedProvider === cleanString(LUCKMAIL_PROVIDER).toLowerCase(); + + return { + flowId: 'kiro', + ruleId: nodeId === DESKTOP_AUTHORIZE_NODE_ID + ? DESKTOP_AUTHORIZE_CODE_RULE_ID + : SUBMIT_VERIFICATION_CODE_RULE_ID, + nodeId, + step: normalizedStep, + artifactType: 'code', + codePatterns: KIRO_AWS_VERIFICATION_CODE_PATTERNS, + filterAfterTimestamp: 0, + requiredKeywords: KIRO_AWS_REQUIRED_KEYWORDS, + senderFilters: KIRO_AWS_SENDER_FILTERS, + subjectFilters: KIRO_AWS_SUBJECT_FILTERS, + targetEmail, + targetEmailHints: buildTargetEmailHints(targetEmail), + mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), + maxAttempts: luckmailProvider + ? 3 + : (mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5), + intervalMs: luckmailProvider + ? 15000 + : (mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000), + }; + } + + function getRuleDefinitionForNode(nodeId, state = {}) { + return getRuleDefinition({ nodeId }, state); + } + + function buildVerificationPollPayload(input, state = {}, overrides = {}) { + return { + ...getRuleDefinition(input, state), + ...(overrides || {}), + }; + } + + function buildVerificationPollPayloadForNode(nodeId, state = {}, overrides = {}) { + return buildVerificationPollPayload({ nodeId }, state, overrides); + } + + return { + buildVerificationPollPayload, + buildVerificationPollPayloadForNode, + getRuleDefinition, + getRuleDefinitionForNode, + }; + } + + return { + DESKTOP_AUTHORIZE_CODE_RULE_ID, + DESKTOP_AUTHORIZE_NODE_ID, + KIRO_AWS_REQUIRED_KEYWORDS, + KIRO_AWS_SENDER_FILTERS, + KIRO_AWS_SUBJECT_FILTERS, + KIRO_AWS_VERIFICATION_CODE_PATTERNS, + SUBMIT_VERIFICATION_CODE_NODE_ID, + SUBMIT_VERIFICATION_CODE_RULE_ID, + createKiroMailRules, + }; +}); diff --git a/manifest.json b/manifest.json index 49d8706..f63369f 100644 --- a/manifest.json +++ b/manifest.json @@ -51,6 +51,7 @@ "content/activation-utils.js", "flows/openai/index.js", "flows/kiro/index.js", + "flows/grok/index.js", "flows/index.js", "core/flow-kernel/flow-registry.js", "core/flow-kernel/source-registry.js", @@ -72,6 +73,7 @@ "content/activation-utils.js", "flows/openai/index.js", "flows/kiro/index.js", + "flows/grok/index.js", "flows/index.js", "core/flow-kernel/flow-registry.js", "core/flow-kernel/source-registry.js", @@ -93,6 +95,7 @@ "content/activation-utils.js", "flows/openai/index.js", "flows/kiro/index.js", + "flows/grok/index.js", "flows/index.js", "core/flow-kernel/flow-registry.js", "core/flow-kernel/source-registry.js", @@ -111,6 +114,7 @@ "content/activation-utils.js", "flows/openai/index.js", "flows/kiro/index.js", + "flows/grok/index.js", "flows/index.js", "core/flow-kernel/flow-registry.js", "core/flow-kernel/source-registry.js", @@ -128,6 +132,7 @@ "content/activation-utils.js", "flows/openai/index.js", "flows/kiro/index.js", + "flows/grok/index.js", "flows/index.js", "core/flow-kernel/flow-registry.js", "core/flow-kernel/source-registry.js", diff --git a/shared/contribution-registry.js b/shared/contribution-registry.js index 9ea6610..b36abbe 100644 --- a/shared/contribution-registry.js +++ b/shared/contribution-registry.js @@ -191,15 +191,44 @@ return Boolean(normalizedAdapterId) && getContributionAdapterIds(flowId).includes(normalizedAdapterId); } - function assertPublishedFlowsHaveContributionAdapters(flowIds = undefined) { + function hasPublicationTargets(flowId = DEFAULT_FLOW_ID) { + if (typeof flowRegistryApi.getPublicationTargetDefinitions !== 'function') { + return false; + } + const definitions = flowRegistryApi.getPublicationTargetDefinitions(flowId) || {}; + return Object.keys(definitions).length > 0; + } + + function expectsContributionAdapter(flowId = DEFAULT_FLOW_ID) { + const normalizedFlowId = normalizeFlowId(flowId, DEFAULT_FLOW_ID); + const capabilities = typeof flowRegistryApi.getFlowCapabilities === 'function' + ? (flowRegistryApi.getFlowCapabilities(normalizedFlowId) || {}) + : {}; + return Boolean( + hasPublicationTargets(normalizedFlowId) + || capabilities.supportsAccountContribution + || capabilities.supportsContributionMode + || getContributionAdapterIds(normalizedFlowId).length + ); + } + + function getPublishedContributionFlowIds(flowIds = undefined) { const ids = Array.isArray(flowIds) ? flowIds : (typeof flowRegistryApi.getRegisteredFlowIds === 'function' ? flowRegistryApi.getRegisteredFlowIds() : Object.keys(FLOW_ADAPTER_IDS)); - const missing = ids + return ids .map((flowId) => normalizeString(flowId).toLowerCase()) .filter(Boolean) + .filter((flowId) => expectsContributionAdapter(flowId)); + } + + function assertPublishedFlowsHaveContributionAdapters(flowIds = undefined) { + const ids = Array.isArray(flowIds) + ? flowIds.map((flowId) => normalizeString(flowId).toLowerCase()).filter(Boolean) + : getPublishedContributionFlowIds(); + const missing = ids .filter((flowId) => getContributionAdapterIds(flowId).length === 0); if (missing.length) { throw new Error(`缺少账号贡献适配器:${missing.join(', ')}`); @@ -217,6 +246,7 @@ getContributionAdapterIds, getContributionAdapters, getDefaultContributionAdapterId, + getPublishedContributionFlowIds, hasContributionAdapter, normalizeAdapterId, normalizeFlowId, diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index ccc80e7..6b0c79e 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -126,6 +126,7 @@
@@ -290,6 +291,23 @@ 上传状态 未开始
+ + +
账户密码
@@ -1881,6 +1899,8 @@ + + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index dd3ae4d..465a8c0 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -192,6 +192,15 @@ const rowKiroLoginUrl = document.getElementById('row-kiro-login-url'); const displayKiroLoginUrl = document.getElementById('display-kiro-login-url'); const rowKiroUploadStatus = document.getElementById('row-kiro-upload-status'); const displayKiroUploadStatus = document.getElementById('display-kiro-upload-status'); +const rowGrokRegisterStatus = document.getElementById('row-grok-register-status'); +const displayGrokRegisterStatus = document.getElementById('display-grok-register-status'); +const rowGrokSsoStatus = document.getElementById('row-grok-sso-status'); +const displayGrokSsoStatus = document.getElementById('display-grok-sso-status'); +const rowGrokSsoSettings = document.getElementById('row-grok-sso-settings'); +const displayGrokSsoCookie = document.getElementById('display-grok-sso-cookie'); +const btnCopyGrokSso = document.getElementById('btn-copy-grok-sso'); +const btnExportGrokSso = document.getElementById('btn-export-grok-sso'); +const btnClearGrokSso = document.getElementById('btn-clear-grok-sso'); const rowCustomPassword = document.getElementById('row-custom-password'); const rowPlusMode = document.getElementById('row-plus-mode'); const inputPlusModeEnabled = document.getElementById('input-plus-mode-enabled'); @@ -2661,6 +2670,95 @@ function getKiroRuntimeState(state = {}) { return {}; } +function getGrokRuntimeState(state = {}) { + const runtimeState = state?.runtimeState?.flowState?.grok; + if (runtimeState && typeof runtimeState === 'object' && !Array.isArray(runtimeState)) { + return runtimeState; + } + const flowState = state?.flowState?.grok; + if (flowState && typeof flowState === 'object' && !Array.isArray(flowState)) { + return flowState; + } + return {}; +} + +function normalizeGrokSsoCookies(state = latestState) { + const runtimeState = getGrokRuntimeState(state); + const rawCookies = Array.isArray(runtimeState?.sso?.cookies) + ? runtimeState.sso.cookies + : (Array.isArray(state?.grokSsoCookies) ? state.grokSsoCookies : []); + const currentCookie = String(runtimeState?.sso?.currentCookie || state?.grokSsoCookie || '').trim(); + return Array.from(new Set([ + currentCookie, + ...rawCookies, + ].map((entry) => String(entry || '').trim()).filter(Boolean))); +} + +function getGrokRegisterStatusLabel(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + switch (normalized) { + case 'signup_page_opened': + return '注册页已打开'; + case 'email_submitting': + return '正在提交邮箱'; + case 'verification_requested': + return '等待验证码'; + case 'verified': + return '验证码已提交'; + case 'profile_submitting': + return '正在提交资料'; + case 'profile_submitted': + return '资料已提交'; + case 'completed': + return '已完成'; + case 'error': + return '失败'; + default: + return String(value || '').trim() || '未开始'; + } +} + +function renderGrokRuntimeState(state = latestState) { + const runtimeState = getGrokRuntimeState(state); + const registerStatus = String( + runtimeState?.register?.status + || state?.grokRegisterStatus + || '' + ).trim(); + const cookies = normalizeGrokSsoCookies(state); + const currentCookie = String( + runtimeState?.sso?.currentCookie + || state?.grokSsoCookie + || cookies[0] + || '' + ).trim(); + const extractedAt = Number( + runtimeState?.sso?.extractedAt + || state?.grokSsoExtractedAt + || 0 + ) || 0; + + if (displayGrokRegisterStatus) { + displayGrokRegisterStatus.textContent = getGrokRegisterStatusLabel(registerStatus); + } + if (displayGrokSsoStatus) { + displayGrokSsoStatus.textContent = currentCookie + ? `已提取 ${cookies.length || 1} 条${extractedAt ? `,${new Date(extractedAt).toLocaleString()}` : ''}` + : '未提取'; + } + if (displayGrokSsoCookie) { + displayGrokSsoCookie.textContent = currentCookie + ? `${currentCookie.slice(0, 8)}...${currentCookie.slice(-6)}` + : '未提取'; + displayGrokSsoCookie.title = currentCookie ? '已隐藏完整 SSO Cookie,可使用复制或导出' : ''; + } + [btnCopyGrokSso, btnExportGrokSso, btnClearGrokSso].forEach((button) => { + if (button) { + button.disabled = cookies.length === 0; + } + }); +} + function setKiroRsConnectionTestStatus(message = '') { const nextText = String(message || '').trim() || '未测试'; kiroRsConnectionTestStatusText = nextText; @@ -11069,6 +11167,7 @@ function applySettingsState(state) { ).trim(); displayKiroUploadStatus.textContent = getKiroUploadStatusLabel(kiroUploadStatus); } + renderGrokRuntimeState(state); const normalizedIpProxyService = resolveIpProxyService(state?.ipProxyService); const normalizedIpProxyServiceProfiles = typeof normalizeIpProxyServiceProfiles === 'function' ? normalizeIpProxyServiceProfiles(state?.ipProxyServiceProfiles || {}, state || {}) @@ -13944,6 +14043,64 @@ async function copyTextToClipboard(text) { await navigator.clipboard.writeText(value); } +btnCopyGrokSso?.addEventListener('click', async () => { + try { + const cookies = normalizeGrokSsoCookies(latestState); + await copyTextToClipboard(cookies.join('\n')); + showToast('Grok SSO Cookie 已复制。', 'success'); + } catch (error) { + showToast(error?.message || '复制 Grok SSO Cookie 失败。', 'error'); + } +}); + +btnExportGrokSso?.addEventListener('click', () => { + try { + const cookies = normalizeGrokSsoCookies(latestState); + if (!cookies.length) { + throw new Error('没有可导出的 Grok SSO Cookie。'); + } + downloadTextFile( + `${cookies.join('\n')}\n`, + `grok-sso-cookies-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`, + 'text/plain;charset=utf-8' + ); + showToast('Grok SSO Cookie 已导出。', 'success'); + } catch (error) { + showToast(error?.message || '导出 Grok SSO Cookie 失败。', 'error'); + } +}); + +btnClearGrokSso?.addEventListener('click', async () => { + try { + const cookies = normalizeGrokSsoCookies(latestState); + if (!cookies.length) { + showToast('当前没有 Grok SSO Cookie。', 'info'); + return; + } + const response = await chrome.runtime.sendMessage({ + type: 'CLEAR_GROK_SSO_COOKIES', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) { + throw new Error(response.error); + } + if (response?.state) { + syncLatestState(response.state); + } else { + syncLatestState({ + grokSsoCookie: '', + grokSsoCookies: [], + grokSsoExtractedAt: 0, + }); + } + renderGrokRuntimeState(latestState); + showToast('Grok SSO Cookie 已清空。', 'success'); + } catch (error) { + showToast(error?.message || '清空 Grok SSO Cookie 失败。', 'error'); + } +}); + const hotmailManager = window.SidepanelHotmailManager?.createHotmailManager({ state: { getLatestState: () => latestState, @@ -14590,7 +14747,9 @@ stepsList?.addEventListener('click', async (event) => { if (step === gpcCreateStep && !(await ensureGpcApiKeyReadyForStart())) { return; } - const shouldPersistSharedPassword = nodeId === 'fill-password' || nodeId === 'kiro-submit-password'; + const shouldPersistSharedPassword = nodeId === 'fill-password' + || nodeId === 'kiro-submit-password' + || nodeId === 'grok-submit-profile'; if (shouldPersistSharedPassword && inputPassword.value !== (latestState?.customPassword || '')) { await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', @@ -17331,6 +17490,17 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ) { syncPasswordField(latestState || {}); } + if ( + message.payload.grokRegisterStatus !== undefined + || message.payload.grokCompletedAt !== undefined + || message.payload.grokSsoCookie !== undefined + || message.payload.grokSsoCookies !== undefined + || message.payload.grokSsoExtractedAt !== undefined + || message.payload.runtimeState !== undefined + || message.payload.flowState !== undefined + ) { + renderGrokRuntimeState(latestState); + } if (message.payload.localCpaStep9Mode !== undefined) { setLocalCpaStep9Mode(message.payload.localCpaStep9Mode); } diff --git a/tests/background-flow-mail-polling-module.test.js b/tests/background-flow-mail-polling-module.test.js new file mode 100644 index 0000000..818f2ee --- /dev/null +++ b/tests/background-flow-mail-polling-module.test.js @@ -0,0 +1,148 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadFlowMailPollingApi() { + const source = fs.readFileSync('background/flow-mail-polling.js', 'utf8'); + const globalScope = {}; + new Function('self', `${source}; return self;`)(globalScope); + return globalScope.MultiPageBackgroundFlowMailPolling; +} + +test('flow mail polling service dispatches API mail providers through shared helper', async () => { + const api = loadFlowMailPollingApi(); + const logs = []; + let buildCall = null; + let hotmailCall = null; + const service = api.createFlowMailPollingService({ + addLog: async (message, level, options) => { + logs.push({ message, level, options }); + }, + buildVerificationPollPayloadForNode: (nodeId, state, overrides) => { + buildCall = { nodeId, state, overrides }; + return { + flowId: state.activeFlowId, + nodeId, + step: 4, + targetEmail: 'user@example.com', + maxAttempts: 2, + intervalMs: 100, + ...overrides, + }; + }, + getMailConfig: () => ({ provider: 'hotmail-api', label: 'Hotmail' }), + pollHotmailVerificationCode: async (step, state, payload) => { + hotmailCall = { step, state, payload }; + return { code: '123456', emailTimestamp: 456 }; + }, + }); + + const result = await service.pollFlowVerificationCode({ + flowId: 'kiro', + nodeId: 'kiro-submit-verification-code', + state: { activeFlowId: 'kiro', email: 'user@example.com' }, + step: 4, + filterAfterTimestamp: 123, + logStepKey: 'kiro-submit-verification-code', + }); + + assert.equal(result.code, '123456'); + assert.equal(buildCall.nodeId, 'kiro-submit-verification-code'); + assert.equal(buildCall.overrides.filterAfterTimestamp, 123); + assert.equal(hotmailCall.step, 4); + assert.equal(hotmailCall.payload.filterAfterTimestamp, 123); + assert.equal(logs.some((entry) => entry.message.includes('Hotmail')), true); +}); + +test('flow mail polling service prepares browser mail provider sessions and payload timeouts', async () => { + const api = loadFlowMailPollingApi(); + let ensured2925 = null; + let mailMessage = null; + let mailOptions = null; + const service = api.createFlowMailPollingService({ + addLog: async () => {}, + buildVerificationPollPayloadForNode: () => ({ + flowId: 'kiro', + nodeId: 'kiro-submit-verification-code', + step: 4, + targetEmail: 'user@example.com', + maxAttempts: 2, + intervalMs: 100, + }), + ensureMail2925MailboxSession: async (options) => { + ensured2925 = options; + }, + getMailConfig: () => ({ + provider: '2925', + source: 'mail-2925', + label: '2925 邮箱', + }), + sendToMailContentScriptResilient: async (_mail, message, options) => { + mailMessage = message; + mailOptions = options; + return { code: '654321', emailTimestamp: 789 }; + }, + }); + + const result = await service.pollFlowVerificationCode({ + flowId: 'kiro', + nodeId: 'kiro-submit-verification-code', + state: { + activeFlowId: 'kiro', + currentMail2925AccountId: 'acct-1', + mail2925UseAccountPool: true, + mail2925Accounts: [ + { id: 'acct-1', email: 'pool@example.com' }, + ], + }, + step: 4, + logStepKey: 'kiro-submit-verification-code', + }); + + assert.equal(result.code, '654321'); + assert.equal(ensured2925.accountId, 'acct-1'); + assert.equal(ensured2925.expectedMailboxEmail, 'pool@example.com'); + assert.equal(mailMessage.type, 'POLL_EMAIL'); + assert.equal(mailMessage.payload.targetEmail, 'user@example.com'); + assert.equal(mailOptions.logStepKey, 'kiro-submit-verification-code'); + assert.equal(mailOptions.responseTimeoutMs, 45000); +}); + +test('flow mail polling service lets 2925 limit errors flow through shared recovery', async () => { + const api = loadFlowMailPollingApi(); + const limitError = new Error('MAIL2925_LIMIT_REACHED::子邮箱已达上限'); + const recoveredError = new Error('switched-account'); + const service = api.createFlowMailPollingService({ + addLog: async () => {}, + buildVerificationPollPayloadForNode: () => ({ + step: 4, + maxAttempts: 1, + intervalMs: 100, + }), + getMailConfig: () => ({ + provider: '2925', + source: 'mail-2925', + label: '2925 邮箱', + }), + ensureMail2925MailboxSession: async () => {}, + isMail2925LimitReachedError: (error) => error === limitError, + handleMail2925LimitReachedError: async (step, error) => { + assert.equal(step, 4); + assert.equal(error, limitError); + return recoveredError; + }, + sendToMailContentScriptResilient: async () => { + throw limitError; + }, + }); + + await assert.rejects( + () => service.pollFlowVerificationCode({ + flowId: 'kiro', + nodeId: 'kiro-submit-verification-code', + state: { activeFlowId: 'kiro' }, + step: 4, + }), + /switched-account/ + ); +}); diff --git a/tests/background-grok-state-module.test.js b/tests/background-grok-state-module.test.js new file mode 100644 index 0000000..2eb3674 --- /dev/null +++ b/tests/background-grok-state-module.test.js @@ -0,0 +1,158 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadGrokStateApi() { + const source = fs.readFileSync('flows/grok/background/state.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundGrokState;`)(globalScope); +} + +test('grok state view projects canonical runtime state into legacy flat read fields', () => { + const api = loadGrokStateApi(); + const view = api.buildStateView({ + runtimeState: { + flowState: { + openai: { + preserved: true, + }, + grok: { + session: { + registerTabId: 42, + pageState: 'profile_entry', + pageUrl: 'https://accounts.x.ai/sign-up', + }, + register: { + email: 'USER@EXAMPLE.COM', + firstName: 'Ada', + lastName: 'Lovelace', + password: 'Secret123!', + verificationRequestedAt: 1000, + verificationCode: 'ABC123', + status: 'verified', + completedAt: 2000, + }, + sso: { + currentCookie: 'cookie-a', + cookies: ['cookie-a', 'cookie-b', 'cookie-a'], + extractedAt: 3000, + }, + }, + }, + }, + }); + + assert.equal(view.grokRegisterTabId, 42); + assert.equal(view.grokPageState, 'profile_entry'); + assert.equal(view.grokEmail, 'user@example.com'); + assert.equal(view.grokFirstName, 'Ada'); + assert.equal(view.grokLastName, 'Lovelace'); + assert.equal(view.grokPassword, 'Secret123!'); + assert.equal(view.grokVerificationRequestedAt, 1000); + assert.equal(view.grokVerificationCode, 'ABC123'); + assert.equal(view.grokRegisterStatus, 'verified'); + assert.equal(view.grokCompletedAt, 2000); + assert.equal(view.grokSsoCookie, 'cookie-a'); + assert.deepEqual(view.grokSsoCookies, ['cookie-a', 'cookie-b']); + assert.equal(view.grokSsoExtractedAt, 3000); + assert.equal(view.runtimeState.flowState.openai.preserved, true); + assert.equal(view.runtimeState.flowState.grok.register.email, 'user@example.com'); + assert.equal(view.flowState.grok.sso.currentCookie, 'cookie-a'); + assert.equal(view.flows.grok.sso.cookies.length, 2); +}); + +test('grok completion payloads update canonical runtime state and flat compatibility fields', () => { + const api = loadGrokStateApi(); + const patch = api.applyNodeCompletionPayload({}, { + grokEmail: 'GROK@EXAMPLE.COM', + grokVerificationRequestedAt: 123, + grokSsoCookie: 'cookie-z', + grokSsoCookies: ['cookie-z', 'cookie-z', 'cookie-y'], + grokCompletedAt: 456, + }); + + assert.equal(patch.grokEmail, 'grok@example.com'); + assert.equal(patch.grokVerificationRequestedAt, 123); + assert.equal(patch.grokSsoCookie, 'cookie-z'); + assert.deepEqual(patch.grokSsoCookies, ['cookie-z', 'cookie-y']); + assert.equal(patch.grokCompletedAt, 456); + assert.equal(patch.grokSsoExtractedAt, 456); + assert.equal(patch.runtimeState.flowState.grok.register.email, 'grok@example.com'); + assert.equal(patch.runtimeState.flowState.grok.sso.currentCookie, 'cookie-z'); +}); + +test('grok fresh keep-state reset preserves SSO cookies but clears registration runtime', () => { + const api = loadGrokStateApi(); + const patch = api.buildFreshKeepState({ + runtimeState: { + flowState: { + grok: { + session: { + registerTabId: 42, + pageState: 'profile_entry', + }, + register: { + email: 'grok@example.com', + status: 'completed', + completedAt: 1000, + }, + sso: { + currentCookie: 'cookie-a', + cookies: ['cookie-a', 'cookie-b'], + extractedAt: 2000, + }, + }, + }, + }, + }); + + assert.equal(patch.grokRegisterTabId, null); + assert.equal(patch.grokPageState, ''); + assert.equal(patch.grokEmail, ''); + assert.equal(patch.grokRegisterStatus, ''); + assert.equal(patch.grokCompletedAt, 0); + assert.equal(patch.grokSsoCookie, 'cookie-a'); + assert.deepEqual(patch.grokSsoCookies, ['cookie-a', 'cookie-b']); + assert.equal(patch.grokSsoExtractedAt, 2000); + assert.equal(patch.runtimeState.flowState.grok.register.email, ''); + assert.equal(patch.runtimeState.flowState.grok.sso.currentCookie, 'cookie-a'); +}); + +test('grok downstream reset clears only the state owned by the restarted tail', () => { + const api = loadGrokStateApi(); + const currentState = { + runtimeState: { + flowState: { + grok: { + session: { + registerTabId: 7, + pageState: 'signed_in', + pageUrl: 'https://grok.com/', + lastError: 'old-error', + }, + register: { + email: 'grok@example.com', + status: 'completed', + completedAt: 1000, + }, + sso: { + currentCookie: 'cookie-a', + cookies: ['cookie-a'], + extractedAt: 2000, + }, + }, + }, + }, + }; + + const profilePatch = api.buildDownstreamResetPatch('grok-submit-profile', currentState); + assert.equal(profilePatch.grokEmail, 'grok@example.com'); + assert.equal(profilePatch.grokRegisterStatus, 'completed'); + assert.equal(profilePatch.grokSsoCookie, ''); + assert.deepEqual(profilePatch.grokSsoCookies, []); + + const emailPatch = api.buildDownstreamResetPatch('grok-submit-email', currentState); + assert.equal(emailPatch.grokEmail, ''); + assert.equal(emailPatch.grokRegisterStatus, ''); + assert.equal(emailPatch.grokRegisterTabId, 7); +}); diff --git a/tests/background-kiro-desktop-authorize-runner-module.test.js b/tests/background-kiro-desktop-authorize-runner-module.test.js index 9e811c1..4e06d13 100644 --- a/tests/background-kiro-desktop-authorize-runner-module.test.js +++ b/tests/background-kiro-desktop-authorize-runner-module.test.js @@ -91,6 +91,18 @@ test('kiro desktop authorize runner uses a shared 3-minute page-load timeout bud assert.match(source, /finalizeDesktopAuthorizeCallback/); }); +test('kiro desktop authorize runner delegates OTP mail polling to the shared flow mail service', () => { + const source = fs.readFileSync('flows/kiro/background/desktop-authorize-runner.js', 'utf8'); + assert.match(source, /pollFlowVerificationCode/); + assert.doesNotMatch(source, /buildDesktopOtpPollPayload/); + assert.doesNotMatch(source, /pollHotmailVerificationCode/); + assert.doesNotMatch(source, /pollLuckmailVerificationCode/); + assert.doesNotMatch(source, /pollCloudflareTempEmailVerificationCode/); + assert.doesNotMatch(source, /pollCloudMailVerificationCode/); + assert.doesNotMatch(source, /pollYydsMailVerificationCode/); + assert.doesNotMatch(source, /sendToMailContentScriptResilient/); +}); + test('background wires the Kiro register injector into desktop authorization runner', () => { const source = fs.readFileSync('background.js', 'utf8'); const start = source.indexOf('const kiroDesktopAuthorizeRunner = self.MultiPageBackgroundKiroDesktopAuthorizeRunner?.createKiroDesktopAuthorizeRunner({'); diff --git a/tests/background-kiro-register-runner-module.test.js b/tests/background-kiro-register-runner-module.test.js index a28ee9c..c764dd0 100644 --- a/tests/background-kiro-register-runner-module.test.js +++ b/tests/background-kiro-register-runner-module.test.js @@ -37,6 +37,18 @@ test('kiro register runner uses a shared 3-minute page-load timeout budget', () assert.match(source, /onRetryableError: buildKiroRetryRecovery\(tabId, \{\s*\.\.\.options,\s*timeoutBudget,/); }); +test('kiro register runner delegates verification mail polling to the shared flow mail service', () => { + const source = fs.readFileSync('flows/kiro/background/register-runner.js', 'utf8'); + assert.match(source, /pollFlowVerificationCode/); + assert.doesNotMatch(source, /buildKiroVerificationPollPayload/); + assert.doesNotMatch(source, /pollHotmailVerificationCode/); + assert.doesNotMatch(source, /pollLuckmailVerificationCode/); + assert.doesNotMatch(source, /pollCloudflareTempEmailVerificationCode/); + assert.doesNotMatch(source, /pollCloudMailVerificationCode/); + assert.doesNotMatch(source, /pollYydsMailVerificationCode/); + assert.doesNotMatch(source, /sendToMailContentScriptResilient/); +}); + test('kiro register consent step treats Kiro Web signed-in page as completion', () => { const source = fs.readFileSync('flows/kiro/background/register-runner.js', 'utf8'); assert.match(source, /readKiroRegisterPageState\(tabId, \{/); diff --git a/tests/background-mail-rule-registry-module.test.js b/tests/background-mail-rule-registry-module.test.js index f63dac8..facd548 100644 --- a/tests/background-mail-rule-registry-module.test.js +++ b/tests/background-mail-rule-registry-module.test.js @@ -2,10 +2,13 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -test('background imports mail rule registry and OpenAI mail rules modules', () => { +test('background imports mail rule registry and flow mail rules modules', () => { const source = fs.readFileSync('background.js', 'utf8'); assert.match(source, /background\/mail-rule-registry\.js/); assert.match(source, /flows\/openai\/mail-rules\.js/); + assert.match(source, /flows\/kiro\/mail-rules\.js/); + assert.match(source, /flows\/grok\/mail-rules\.js/); + assert.match(source, /background\/flow-mail-polling\.js/); }); test('mail rule registry exposes canonical OpenAI verification poll payloads', () => { @@ -133,3 +136,185 @@ test('mail rule registry rejects unknown active flow ids instead of silently usi /未找到 flow=site-a 的邮件轮询规则构造器/ ); }); + +test('mail rule registry exposes Kiro AWS verification poll payloads by node', () => { + const stateSource = fs.readFileSync('flows/kiro/background/state.js', 'utf8'); + const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8'); + const kiroSource = fs.readFileSync('flows/kiro/mail-rules.js', 'utf8'); + const globalScope = {}; + new Function('self', `${stateSource}; ${registrySource}; ${kiroSource}; return self;`)(globalScope); + + const kiroMailRules = globalScope.MultiPageKiroMailRules.createKiroMailRules({ + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 16000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 17, + }); + const registry = globalScope.MultiPageBackgroundMailRuleRegistry.createMailRuleRegistry({ + defaultFlowId: 'openai', + flowBuilders: { + kiro: kiroMailRules, + }, + }); + const baseState = { + activeFlowId: 'kiro', + mailProvider: '2925', + mail2925Mode: 'receive', + runtimeState: { + flowState: { + kiro: { + register: { + email: 'kiro-user@example.com', + }, + }, + }, + }, + }; + + assert.deepEqual( + registry.buildVerificationPollPayloadForNode( + 'kiro-submit-verification-code', + baseState, + { filterAfterTimestamp: 12345 } + ), + { + flowId: 'kiro', + ruleId: 'kiro-submit-verification-code', + nodeId: 'kiro-submit-verification-code', + step: 4, + artifactType: 'code', + codePatterns: [ + { + source: '(?:verification\\s*code|验证码|Your code is|code is)[::\\s]*(\\d{6})', + flags: 'gi', + }, + { + source: '^\\s*(\\d{6})\\s*$', + flags: 'gm', + }, + { + source: '>\\s*(\\d{6})\\s*<', + flags: 'g', + }, + ], + filterAfterTimestamp: 12345, + requiredKeywords: ['verification', '验证码', 'code', 'aws'], + senderFilters: [ + 'no-reply@signin.aws', + 'no-reply@login.awsapps.com', + 'noreply@amazon.com', + 'account-update@amazon.com', + 'no-reply@aws.amazon.com', + 'noreply@aws.amazon.com', + 'aws', + ], + subjectFilters: ['aws builder id', 'verification', '验证码', 'code', 'aws'], + targetEmail: 'kiro-user@example.com', + targetEmailHints: ['kiro-user@example.com'], + mail2925MatchTargetEmail: true, + maxAttempts: 17, + intervalMs: 16000, + } + ); + + const desktopPayload = registry.buildVerificationPollPayloadForNode( + 'kiro-complete-desktop-authorize', + { + ...baseState, + mailProvider: 'luckmail-api', + mail2925Mode: 'provide', + } + ); + assert.equal(desktopPayload.ruleId, 'kiro-complete-desktop-authorize'); + assert.equal(desktopPayload.step, 8); + assert.equal(desktopPayload.mail2925MatchTargetEmail, false); + assert.equal(desktopPayload.maxAttempts, 3); + assert.equal(desktopPayload.intervalMs, 15000); +}); + +test('mail rule registry exposes Grok xAI verification poll payloads by node', () => { + const stateSource = fs.readFileSync('flows/grok/background/state.js', 'utf8'); + const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8'); + const grokSource = fs.readFileSync('flows/grok/mail-rules.js', 'utf8'); + const globalScope = {}; + new Function('self', `${stateSource}; ${registrySource}; ${grokSource}; return self;`)(globalScope); + + const grokMailRules = globalScope.MultiPageGrokMailRules.createGrokMailRules({ + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 16000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 17, + }); + const registry = globalScope.MultiPageBackgroundMailRuleRegistry.createMailRuleRegistry({ + defaultFlowId: 'openai', + flowBuilders: { + grok: grokMailRules, + }, + }); + + assert.deepEqual( + registry.buildVerificationPollPayloadForNode( + 'grok-submit-verification-code', + { + activeFlowId: 'grok', + mailProvider: '2925', + mail2925Mode: 'receive', + runtimeState: { + flowState: { + grok: { + register: { + email: 'grok-user@example.com', + }, + }, + }, + }, + }, + { filterAfterTimestamp: 12345 } + ), + { + flowId: 'grok', + ruleId: 'grok-submit-verification-code', + nodeId: 'grok-submit-verification-code', + step: 3, + artifactType: 'code', + codePatterns: [ + { + source: '\\b([A-Z0-9]{3}-[A-Z0-9]{3})\\b', + flags: 'gi', + }, + { + source: '(?:verification\\s*code|confirmation\\s*code|code\\s*is)[::\\s]*(\\d{6})', + flags: 'gi', + }, + { + source: '(?:验证码|代码|确认码)[::\\s为]+(\\d{6})', + flags: 'gi', + }, + { + source: '(? { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = { console }; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + let called = false; + const router = api.createMessageRouter({ + clearGrokSsoCookies: async () => { + called = true; + return { + ok: true, + state: { + grokSsoCookie: '', + grokSsoCookies: [], + runtimeState: { + flowState: { + grok: { + sso: { + currentCookie: '', + cookies: [], + extractedAt: 0, + }, + }, + }, + }, + }, + }; + }, + }); + + const response = await router.handleMessage({ + type: 'CLEAR_GROK_SSO_COOKIES', + source: 'sidepanel', + payload: {}, + }); + + assert.equal(called, true); + assert.equal(response.ok, true); + assert.deepStrictEqual(response.state.grokSsoCookies, []); + assert.equal(response.state.runtimeState.flowState.grok.sso.currentCookie, ''); +}); + test('SAVE_SETTING syncs canonical kiro settingsState back into session state', async () => { const source = fs.readFileSync('background/message-router.js', 'utf8'); const globalScope = { console }; diff --git a/tests/background-step-registry.test.js b/tests/background-step-registry.test.js index aca3ac2..a3cf0c9 100644 --- a/tests/background-step-registry.test.js +++ b/tests/background-step-registry.test.js @@ -17,11 +17,14 @@ test('background imports node registry and wires the rebuilt Kiro executors', () assert.match(source, /flows\/kiro\/background\/desktop-client\.js/); assert.match(source, /flows\/kiro\/background\/desktop-authorize-runner\.js/); assert.match(source, /flows\/kiro\/background\/publisher-kiro-rs\.js/); + assert.match(source, /flows\/grok\/background\/state\.js/); + assert.match(source, /flows\/grok\/background\/register-runner\.js/); assert.doesNotMatch(source, /background\/steps\/kiro-device-auth\.js/); assert.match(source, /const kiroRegisterRunner = self\.MultiPageBackgroundKiroRegisterRunner\?\.createKiroRegisterRunner\(/); assert.match(source, /const kiroDesktopAuthorizeRunner = self\.MultiPageBackgroundKiroDesktopAuthorizeRunner\?\.createKiroDesktopAuthorizeRunner\(/); assert.match(source, /const kiroPublisher = self\.MultiPageBackgroundKiroPublisherKiroRs\?\.createKiroRsPublisher\(/); + assert.match(source, /const grokRegisterRunner = self\.MultiPageBackgroundGrokRegisterRunner\?\.createGrokRegisterRunner\(/); assert.match(source, /'kiro-open-register-page': \(state\) => kiroRegisterRunner\.executeKiroOpenRegisterPage\(state\)/); assert.match(source, /'kiro-submit-email': \(state\) => kiroRegisterRunner\.executeKiroSubmitEmail\(state\)/); @@ -32,11 +35,20 @@ test('background imports node registry and wires the rebuilt Kiro executors', () assert.match(source, /'kiro-start-desktop-authorize': \(state\) => kiroDesktopAuthorizeRunner\.executeKiroStartDesktopAuthorize\(state\)/); assert.match(source, /'kiro-complete-desktop-authorize': \(state\) => kiroDesktopAuthorizeRunner\.executeKiroCompleteDesktopAuthorize\(state\)/); assert.match(source, /'kiro-upload-credential': \(state\) => kiroPublisher\.executeKiroUploadCredential\(state\)/); + assert.match(source, /'grok-open-signup-page': \(state\) => grokRegisterRunner\.executeGrokOpenSignupPage\(state\)/); + assert.match(source, /'grok-submit-email': \(state\) => grokRegisterRunner\.executeGrokSubmitEmail\(state\)/); + assert.match(source, /'grok-submit-verification-code': \(state\) => grokRegisterRunner\.executeGrokSubmitVerificationCode\(state\)/); + assert.match(source, /'grok-submit-profile': \(state\) => grokRegisterRunner\.executeGrokSubmitProfile\(state\)/); + assert.match(source, /'grok-extract-sso-cookie': \(state\) => grokRegisterRunner\.executeGrokExtractSsoCookie\(state\)/); assert.match( source, /'kiro-open-register-page',[\s\S]*'kiro-submit-email',[\s\S]*'kiro-submit-name',[\s\S]*'kiro-submit-verification-code',[\s\S]*'kiro-submit-password',[\s\S]*'kiro-complete-register-consent',[\s\S]*'kiro-start-desktop-authorize',[\s\S]*'kiro-complete-desktop-authorize',[\s\S]*'kiro-upload-credential'/ ); + assert.match( + source, + /'grok-open-signup-page',[\s\S]*'grok-submit-email',[\s\S]*'grok-submit-verification-code',[\s\S]*'grok-submit-profile',[\s\S]*'grok-extract-sso-cookie'/ + ); }); test('GoPay approve executor receives debugger click and manual OTP helpers', () => { diff --git a/tests/contribution-registry.test.js b/tests/contribution-registry.test.js index dfed168..bbaabda 100644 --- a/tests/contribution-registry.test.js +++ b/tests/contribution-registry.test.js @@ -69,9 +69,23 @@ test('contribution registry resolves the combined tutorial entry per flow', () = ); }); +test('contribution registry keeps Grok SSO export flow out of published contribution checks', () => { + const api = loadApi(); + + assert.equal(api.getContributionTutorialEntry('grok'), null); + assert.deepEqual(api.getContributionAdapterIds('grok'), []); + assert.deepEqual(api.getPublishedContributionFlowIds(['openai', 'kiro', 'grok']), ['openai', 'kiro']); + assert.throws( + () => api.assertPublishedFlowsHaveContributionAdapters(['grok']), + /缺少账号贡献适配器:grok/ + ); +}); + test('contribution registry fails fast when a published flow has no adapter', () => { const api = loadApi(); + assert.deepEqual(api.getPublishedContributionFlowIds(), ['openai', 'kiro']); + assert.equal(api.assertPublishedFlowsHaveContributionAdapters(), true); assert.equal(api.assertPublishedFlowsHaveContributionAdapters(['openai', 'kiro']), true); assert.throws( () => api.assertPublishedFlowsHaveContributionAdapters(['openai', 'missing-flow']), diff --git a/tests/flow-capabilities-module.test.js b/tests/flow-capabilities-module.test.js index fa73b35..b6131de 100644 --- a/tests/flow-capabilities-module.test.js +++ b/tests/flow-capabilities-module.test.js @@ -98,6 +98,36 @@ test('flow capability registry exposes Kiro as an independent flow with its own ); }); +test('flow capability registry exposes Grok as an independent SSO flow without OpenAI-only modes', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry(); + + const capabilityState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'grok', + targetId: 'webchat2api', + signupMethod: 'phone', + plusModeEnabled: true, + phoneVerificationEnabled: true, + accountContributionEnabled: true, + }, + }); + + assert.equal(capabilityState.activeFlowId, 'grok'); + assert.equal(capabilityState.canShowPhoneSettings, false); + assert.equal(capabilityState.canShowPlusSettings, false); + assert.equal(capabilityState.canShowContributionMode, false); + assert.equal(capabilityState.canShowLuckmail, false); + assert.equal(capabilityState.effectiveSignupMethod, 'email'); + assert.equal(capabilityState.effectiveTargetId, 'webchat2api'); + assert.deepEqual(capabilityState.supportedTargetIds, ['webchat2api']); + assert.deepEqual(capabilityState.flowCapabilities.contributionAdapterIds, []); + assert.deepEqual( + capabilityState.visibleGroupIds, + ['grok-runtime-status', 'grok-target-webchat2api', 'service-account', 'service-email', 'service-proxy'] + ); +}); + test('flow capability registry exposes shared auto-run validation for phone locks and target support', () => { const api = loadApi(); const registry = api.createFlowCapabilityRegistry({ diff --git a/tests/flow-registry-settings-schema.test.js b/tests/flow-registry-settings-schema.test.js index 0d9809f..0c9169c 100644 --- a/tests/flow-registry-settings-schema.test.js +++ b/tests/flow-registry-settings-schema.test.js @@ -16,12 +16,14 @@ function loadApis() { test('flow registry exposes canonical flow and target metadata', () => { const { flowRegistry } = loadApis(); - assert.deepEqual(flowRegistry.getRegisteredFlowIds(), ['openai', 'kiro']); + assert.deepEqual(flowRegistry.getRegisteredFlowIds(), ['openai', 'kiro', 'grok']); assert.equal(flowRegistry.normalizeFlowId('kiro'), 'kiro'); + assert.equal(flowRegistry.normalizeFlowId('grok'), 'grok'); assert.equal(flowRegistry.normalizeFlowId('unknown'), 'openai'); assert.equal(flowRegistry.getFlowLabel('openai'), 'Codex / OpenAI'); assert.equal(flowRegistry.normalizeTargetId('openai', 'sub2api'), 'sub2api'); assert.equal(flowRegistry.normalizeTargetId('kiro', 'anything-else'), 'kiro-rs'); + assert.equal(flowRegistry.normalizeTargetId('grok', 'anything-else'), 'webchat2api'); assert.deepEqual( flowRegistry.getVisibleGroupIds('openai', 'cpa'), ['openai-plus', 'openai-phone', 'openai-oauth', 'openai-step6', 'openai-target-cpa', 'service-account', 'service-email', 'service-proxy'] @@ -30,10 +32,18 @@ test('flow registry exposes canonical flow and target metadata', () => { flowRegistry.getVisibleGroupIds('kiro', 'kiro-rs'), ['kiro-runtime-status', 'kiro-target-kiro-rs', 'service-account', 'service-email', 'service-proxy'] ); + assert.deepEqual( + flowRegistry.getVisibleGroupIds('grok', 'webchat2api'), + ['grok-runtime-status', 'grok-target-webchat2api', 'service-account', 'service-email', 'service-proxy'] + ); assert.deepEqual( flowRegistry.getTargetOptions('openai').map((entry) => entry.id), ['cpa', 'sub2api', 'codex2api'] ); + assert.deepEqual( + flowRegistry.getTargetOptions('grok').map((entry) => entry.id), + ['webchat2api'] + ); assert.deepEqual( flowRegistry.getSettingsGroupDefinition('openai-plus')?.rowIds, ['row-plus-mode', 'row-plus-account-access-strategy', 'row-plus-payment-method'] @@ -41,6 +51,8 @@ test('flow registry exposes canonical flow and target metadata', () => { assert.equal(flowRegistry.getPublicationTargetDefinition('kiro', 'kiro-rs')?.label, 'kiro.rs'); assert.equal(flowRegistry.getFlowCapabilities('openai').supportsAccountContribution, true); assert.equal(flowRegistry.getFlowCapabilities('kiro').supportsAccountContribution, true); + assert.equal(flowRegistry.getFlowCapabilities('grok').supportsAccountContribution, false); + assert.deepEqual(flowRegistry.getFlowCapabilities('grok').supportedTargetIds, ['webchat2api']); assert.deepEqual( flowRegistry.getFlowCapabilities('openai').contributionAdapterIds, ['openai-oauth', 'openai-codex-file', 'openai-sub2api-file'] @@ -49,6 +61,7 @@ test('flow registry exposes canonical flow and target metadata', () => { flowRegistry.getFlowCapabilities('kiro').contributionAdapterIds, ['kiro-builder-id'] ); + assert.deepEqual(flowRegistry.getFlowCapabilities('grok').contributionAdapterIds, []); }); test('settings schema normalizes view input into canonical nested namespaces', () => { @@ -68,6 +81,7 @@ test('settings schema normalizes view input into canonical nested namespaces', ( stepExecutionRangeByFlow: { openai: { enabled: true, fromStep: 2, toStep: 9 }, kiro: { enabled: true, fromStep: 1, toStep: 9 }, + grok: { enabled: true, fromStep: 2, toStep: 4 }, }, }); @@ -78,6 +92,7 @@ test('settings schema normalizes view input into canonical nested namespaces', ( assert.equal(normalized.flows.openai.selectedTargetId, 'cpa'); assert.equal(normalized.flows.openai.plus.plusAccountAccessStrategy, 'sub2api_codex_session'); assert.equal(normalized.flows.kiro.selectedTargetId, 'kiro-rs'); + assert.equal(normalized.flows.grok.selectedTargetId, 'webchat2api'); assert.equal(normalized.flows.kiro.targets['kiro-rs'].baseUrl, 'https://kiro.example.com/admin'); assert.equal(normalized.flows.kiro.targets['kiro-rs'].apiKey, 'secret-key'); assert.deepEqual(normalized.flows.kiro.autoRun.stepExecutionRange, { @@ -85,6 +100,11 @@ test('settings schema normalizes view input into canonical nested namespaces', ( fromStep: 1, toStep: 9, }); + assert.deepEqual(normalized.flows.grok.autoRun.stepExecutionRange, { + enabled: true, + fromStep: 2, + toStep: 4, + }); }); test('settings schema lets explicit flat step range override stale canonical range', () => { @@ -130,6 +150,11 @@ test('settings schema can project canonical state into a read view without legac assert.equal(view.plusAccountAccessStrategy, 'sub2api_codex_session'); assert.equal(view.settingsSchemaVersion, 5); assert.equal(view.settingsState.activeFlowId, 'kiro'); + assert.deepEqual(view.stepExecutionRangeByFlow.grok, { + enabled: false, + fromStep: 1, + toStep: 5, + }); }); test('settings schema preserves CPA session strategy in canonical state and read view', () => { @@ -143,3 +168,99 @@ test('settings schema preserves CPA session strategy in canonical state and read assert.equal(normalized.flows.openai.plus.plusAccountAccessStrategy, 'cpa_codex_session'); assert.equal(view.plusAccountAccessStrategy, 'cpa_codex_session'); }); + +test('settings schema preserves registered custom flow settings without openai/kiro hardcoding', () => { + const { settingsSchema } = loadApis(); + const customFlowRegistry = { + DEFAULT_FLOW_ID: 'openai', + getRegisteredFlowIds: () => ['openai', 'kiro', 'sample'], + getDefaultTargetId(flowId) { + return flowId === 'sample' ? 'sample-target' : (flowId === 'kiro' ? 'kiro-rs' : 'cpa'); + }, + getFlowDefinition(flowId) { + if (flowId !== 'sample') { + return null; + } + return { + id: 'sample', + defaultTargetId: 'sample-target', + settingsDefaults: { + targets: { + 'sample-target': { + endpoint: 'https://sample.example.com', + }, + }, + autoRun: { + stepExecutionRange: { enabled: false, fromStep: 1, toStep: 3 }, + }, + }, + }; + }, + getTargetDefinitions(flowId) { + if (flowId === 'sample') { + return { + 'sample-target': { id: 'sample-target', label: 'Sample Target' }, + }; + } + if (flowId === 'kiro') { + return { + 'kiro-rs': { id: 'kiro-rs', label: 'kiro.rs' }, + }; + } + return { + cpa: { id: 'cpa', label: 'CPA' }, + sub2api: { id: 'sub2api', label: 'SUB2API' }, + codex2api: { id: 'codex2api', label: 'Codex2API' }, + }; + }, + normalizeFlowId(value = '', fallback = 'openai') { + const normalized = String(value || '').trim().toLowerCase(); + return ['openai', 'kiro', 'sample'].includes(normalized) + ? normalized + : (['openai', 'kiro', 'sample'].includes(fallback) ? fallback : 'openai'); + }, + normalizeTargetId(flowId, targetId = '', fallback = '') { + const targets = Object.keys(customFlowRegistry.getTargetDefinitions(flowId)); + const normalized = String(targetId || '').trim().toLowerCase(); + if (targets.includes(normalized)) { + return normalized; + } + if (targets.includes(fallback)) { + return fallback; + } + return customFlowRegistry.getDefaultTargetId(flowId); + }, + }; + const schema = settingsSchema.createSettingsSchema({ flowRegistry: customFlowRegistry }); + + const normalized = schema.normalizeSettingsState({ + activeFlowId: 'sample', + targetId: 'sample-target', + settingsState: { + flows: { + sample: { + selectedTargetId: 'sample-target', + targets: { + 'sample-target': { + endpoint: 'https://custom.example.com', + }, + }, + autoRun: { + stepExecutionRange: { enabled: true, fromStep: 2, toStep: 3 }, + }, + }, + }, + }, + }); + const view = schema.buildSettingsView(normalized); + + assert.equal(normalized.activeFlowId, 'sample'); + assert.equal(normalized.flows.sample.selectedTargetId, 'sample-target'); + assert.equal(normalized.flows.sample.targets['sample-target'].endpoint, 'https://custom.example.com'); + assert.deepEqual(view.stepExecutionRangeByFlow.sample, { + enabled: true, + fromStep: 2, + toStep: 3, + }); + assert.equal(schema.getSelectedTargetId(normalized, 'sample'), 'sample-target'); +}); diff --git a/tests/grok-runner.test.js b/tests/grok-runner.test.js new file mode 100644 index 0000000..1b5d652 --- /dev/null +++ b/tests/grok-runner.test.js @@ -0,0 +1,185 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadGrokRunnerApi() { + const source = fs.readFileSync('flows/grok/background/register-runner.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundGrokRegisterRunner;`)(globalScope); +} + +function getGrokRuntime(state = {}) { + return state?.runtimeState?.flowState?.grok || {}; +} + +test('grok runner delegates verification polling to the shared flow mail service', () => { + const source = fs.readFileSync('flows/grok/background/register-runner.js', 'utf8'); + assert.match(source, /pollFlowVerificationCode/); + assert.doesNotMatch(source, /buildGrokVerificationPollPayload/); + assert.doesNotMatch(source, /pollHotmailVerificationCode/); + assert.doesNotMatch(source, /pollLuckmailVerificationCode/); + assert.doesNotMatch(source, /pollCloudflareTempEmailVerificationCode/); + assert.doesNotMatch(source, /pollCloudMailVerificationCode/); + assert.doesNotMatch(source, /pollYydsMailVerificationCode/); + assert.doesNotMatch(source, /sendToMailContentScriptResilient/); +}); + +test('grok content script does not patch global MouseEvent prototypes', () => { + const source = fs.readFileSync('flows/grok/content/register-page.js', 'utf8'); + assert.doesNotMatch(source, /MouseEvent\.prototype/); + assert.doesNotMatch(source, /Object\.defineProperty\(MouseEvent/); + assert.match(source, /screenX:/); + assert.match(source, /screenY:/); +}); + +test('grok verification runner polls by flow node and submits normalized code', async () => { + const api = loadGrokRunnerApi(); + const calls = []; + let completedPayload = null; + let currentState = { + activeFlowId: 'grok', + mailProvider: '2925', + grokRegisterTabId: 101, + grokEmail: 'grok-user@example.com', + grokVerificationRequestedAt: 1000000, + runtimeState: { + flowState: { + grok: { + session: { + registerTabId: 101, + }, + register: { + email: 'grok-user@example.com', + verificationRequestedAt: 1000000, + }, + }, + }, + }, + }; + const runner = api.createGrokRegisterRunner({ + addLog: async () => {}, + chrome: { + tabs: { + get: async (tabId) => ({ id: tabId }), + update: async () => {}, + }, + }, + completeNodeFromBackground: async (_nodeId, payload) => { + completedPayload = payload; + }, + ensureContentScriptReadyOnTab: async () => {}, + getState: async () => currentState, + getTabId: async () => 101, + isTabAlive: async () => true, + pollFlowVerificationCode: async (options) => { + calls.push({ type: 'poll', options }); + return { code: 'ABC-123', messageId: 'mail-001' }; + }, + registerTab: async () => {}, + sendToContentScriptResilient: async (sourceId, message) => { + calls.push({ type: 'send', sourceId, message }); + return { submitted: true, state: 'profile_entry', url: 'https://accounts.x.ai/profile' }; + }, + setState: async (patch) => { + currentState = { ...currentState, ...patch }; + }, + waitForTabStableComplete: async () => {}, + }); + + await runner.executeGrokSubmitVerificationCode({ nodeId: 'grok-submit-verification-code', ...currentState }); + + const pollCall = calls.find((entry) => entry.type === 'poll'); + assert.equal(pollCall.options.flowId, 'grok'); + assert.equal(pollCall.options.nodeId, 'grok-submit-verification-code'); + assert.equal(pollCall.options.step, 3); + assert.equal(pollCall.options.logStep, 3); + assert.equal(pollCall.options.filterAfterTimestamp, 400000); + assert.equal(pollCall.options.state.activeFlowId, 'grok'); + assert.equal(pollCall.options.state.visibleStep, 3); + + const sendCall = calls.find((entry) => entry.type === 'send'); + assert.equal(sendCall.sourceId, 'grok-register-page'); + assert.equal(sendCall.message.type, 'EXECUTE_NODE'); + assert.equal(sendCall.message.nodeId, 'grok-submit-verification-code'); + assert.deepEqual(sendCall.message.payload, { code: 'ABC123' }); + + assert.equal(completedPayload.grokVerificationCode, 'ABC123'); + assert.equal(completedPayload.grokVerificationRawCode, 'ABC-123'); + assert.equal(completedPayload.grokVerificationMessageId, 'mail-001'); + assert.equal(getGrokRuntime(completedPayload).register.verificationCode, 'ABC123'); + assert.equal(getGrokRuntime(completedPayload).register.status, 'verified'); +}); + +test('grok SSO extraction accumulates unique cookies without logging the secret value', async () => { + const api = loadGrokRunnerApi(); + const logs = []; + let completedPayload = null; + let markUsedPayload = null; + let currentState = { + activeFlowId: 'grok', + grokRegisterTabId: 202, + grokSsoCookies: ['existing-cookie', 'new-cookie'], + runtimeState: { + flowState: { + grok: { + session: { + registerTabId: 202, + }, + sso: { + cookies: ['existing-cookie', 'new-cookie'], + }, + }, + }, + }, + }; + const runner = api.createGrokRegisterRunner({ + addLog: async (message, level) => { + logs.push({ message, level }); + }, + chrome: { + cookies: { + get: async () => ({ value: 'new-cookie' }), + }, + tabs: { + get: async (tabId) => ({ id: tabId }), + update: async () => {}, + }, + }, + completeNodeFromBackground: async (_nodeId, payload) => { + completedPayload = payload; + }, + ensureContentScriptReadyOnTab: async () => {}, + getState: async () => currentState, + getTabId: async () => 202, + isTabAlive: async () => true, + markCurrentRegistrationAccountUsed: async (state) => { + markUsedPayload = state; + }, + registerTab: async () => {}, + sendToContentScriptResilient: async () => { + throw new Error('content fallback should not be used when chrome.cookies finds sso'); + }, + setState: async (patch) => { + currentState = { ...currentState, ...patch }; + }, + sleepWithStop: async () => {}, + waitForTabStableComplete: async () => {}, + }); + + await runner.executeGrokExtractSsoCookie({ nodeId: 'grok-extract-sso-cookie', ...currentState }); + + assert.equal(completedPayload.grokSsoCookie, 'new-cookie'); + assert.deepEqual(completedPayload.grokSsoCookies, ['existing-cookie', 'new-cookie']); + assert.equal(getGrokRuntime(completedPayload).sso.currentCookie, 'new-cookie'); + assert.deepEqual(getGrokRuntime(completedPayload).sso.cookies, ['existing-cookie', 'new-cookie']); + assert.equal(markUsedPayload.grokSsoCookie, 'new-cookie'); + assert.equal(logs.some(({ message }) => message.includes('new-cookie')), false); +}); + +test('grok register runner requires background node completion dependency', () => { + const api = loadGrokRunnerApi(); + assert.throws( + () => api.createGrokRegisterRunner({}), + /requires completeNodeFromBackground/ + ); +}); diff --git a/tests/helpers/script-bundles.js b/tests/helpers/script-bundles.js index 632294e..1efc685 100644 --- a/tests/helpers/script-bundles.js +++ b/tests/helpers/script-bundles.js @@ -3,12 +3,14 @@ const fs = require('node:fs'); const FLOW_DEFINITION_FILES = Object.freeze([ 'flows/openai/index.js', 'flows/kiro/index.js', + 'flows/grok/index.js', 'flows/index.js', ]); const FLOW_WORKFLOW_FILES = Object.freeze([ 'flows/openai/workflow.js', 'flows/kiro/workflow.js', + 'flows/grok/workflow.js', ]); function readBundle(files = []) { diff --git a/tests/sidepanel-flow-source-registry.test.js b/tests/sidepanel-flow-source-registry.test.js index 67cf0e6..f248545 100644 --- a/tests/sidepanel-flow-source-registry.test.js +++ b/tests/sidepanel-flow-source-registry.test.js @@ -36,6 +36,7 @@ function extractFunction(source, name) { test('sidepanel html exposes flow selector and kiro source fields', () => { [ 'id="select-flow"', + '', 'id="label-source-selector"', 'id="row-step6-cookie-settings"', 'id="row-kiro-rs-url"', @@ -46,9 +47,90 @@ test('sidepanel html exposes flow selector and kiro source fields', () => { 'id="row-kiro-web-status"', 'id="row-kiro-login-url"', 'id="row-kiro-upload-status"', + 'id="row-grok-register-status"', + 'id="row-grok-sso-status"', + 'id="row-grok-sso-settings"', + 'id="btn-copy-grok-sso"', + 'id="btn-export-grok-sso"', + 'id="btn-clear-grok-sso"', + '', + '', ].forEach((snippet) => { assert.match(sidepanelHtml, new RegExp(snippet.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); }); + assert.ok( + sidepanelHtml.indexOf('') + < sidepanelHtml.indexOf('') + ); + assert.ok( + sidepanelHtml.indexOf('') + < sidepanelHtml.indexOf('') + ); +}); + +test('sidepanel Grok SSO clear action goes through background message instead of direct storage writes', () => { + const clearButtonIndex = sidepanelSource.indexOf("btnClearGrokSso?.addEventListener('click'"); + assert.notEqual(clearButtonIndex, -1); + const nextManagerIndex = sidepanelSource.indexOf('const hotmailManager', clearButtonIndex); + assert.notEqual(nextManagerIndex, -1); + const block = sidepanelSource.slice(clearButtonIndex, nextManagerIndex); + + assert.match(block, /type:\s*'CLEAR_GROK_SSO_COOKIES'/); + assert.match(block, /chrome\.runtime\.sendMessage/); + assert.doesNotMatch(block, /chrome\.storage/); + assert.doesNotMatch(block, /storage\.local\.set/); +}); + +test('sidepanel renders Grok SSO status from canonical runtime state', () => { + const bundle = [ + extractFunction(sidepanelSource, 'getGrokRuntimeState'), + extractFunction(sidepanelSource, 'normalizeGrokSsoCookies'), + extractFunction(sidepanelSource, 'getGrokRegisterStatusLabel'), + extractFunction(sidepanelSource, 'renderGrokRuntimeState'), + ].join('\n'); + + const api = new Function(` +let latestState = {}; +const displayGrokRegisterStatus = { textContent: '' }; +const displayGrokSsoStatus = { textContent: '' }; +const displayGrokSsoCookie = { textContent: '', title: '' }; +const buttons = []; +const btnCopyGrokSso = { disabled: false }; +const btnExportGrokSso = { disabled: false }; +const btnClearGrokSso = { disabled: false }; +${bundle} +return { + displayGrokRegisterStatus, + displayGrokSsoStatus, + displayGrokSsoCookie, + btnCopyGrokSso, + btnExportGrokSso, + btnClearGrokSso, + renderGrokRuntimeState, +}; +`)(); + + api.renderGrokRuntimeState({ + runtimeState: { + flowState: { + grok: { + register: { status: 'completed' }, + sso: { + currentCookie: '1234567890abcdef', + cookies: ['1234567890abcdef', 'second-cookie'], + extractedAt: 0, + }, + }, + }, + }, + }); + + assert.equal(api.displayGrokRegisterStatus.textContent, '已完成'); + assert.match(api.displayGrokSsoStatus.textContent, /^已提取 2 条/); + assert.equal(api.displayGrokSsoCookie.textContent, '12345678...abcdef'); + assert.equal(api.btnCopyGrokSso.disabled, false); + assert.equal(api.btnExportGrokSso.disabled, false); + assert.equal(api.btnClearGrokSso.disabled, false); }); test('sidepanel Kiro GitHub button opens the configured fork', () => { diff --git a/tests/sidepanel-icloud-provider.test.js b/tests/sidepanel-icloud-provider.test.js index 1d61c3c..ead2abb 100644 --- a/tests/sidepanel-icloud-provider.test.js +++ b/tests/sidepanel-icloud-provider.test.js @@ -525,6 +525,7 @@ function updateAccountRunHistorySettingsUI() {} function updatePhoneVerificationSettingsUI() {} function updatePanelModeUI() {} function updateMailProviderUI() { calls.push({ target: selectIcloudTargetMailboxType.value, provider: selectIcloudForwardMailProvider.value }); } +function renderGrokRuntimeState() {} function renderSub2ApiGroupOptions() {} function isLuckmailProvider() { return false; } function updateButtonStates() {} diff --git a/tests/source-registry-module.test.js b/tests/source-registry-module.test.js index 7518426..74ea244 100644 --- a/tests/source-registry-module.test.js +++ b/tests/source-registry-module.test.js @@ -15,6 +15,11 @@ test('background imports shared source registry module', () => { assert.match(source, /core\/flow-kernel\/settings-schema\.js/); assert.match(source, /core\/flow-kernel\/source-registry\.js/); assert.match(source, /shared\/kiro-timeouts\.js/); + assert.match(source, /flows\/grok\/index\.js/); + assert.match(source, /flows\/grok\/workflow\.js/); + assert.match(source, /flows\/grok\/background\/state\.js/); + assert.match(source, /flows\/grok\/background\/register-runner\.js/); + assert.match(source, /flows\/grok\/mail-rules\.js/); }); test('manifest loads shared source registry before content utils in static bundles', () => { @@ -41,15 +46,38 @@ test('manifest no longer ships a static Kiro content bundle', () => { assert.equal(hasStaticKiroBundle, false); }); +test('manifest loads Grok flow definition in static bundles but not Grok content runtime', () => { + const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); + for (const entry of manifest.content_scripts || []) { + const scripts = Array.isArray(entry.js) ? entry.js : []; + if (!scripts.includes('flows/index.js')) continue; + assert.ok(scripts.includes('flows/kiro/index.js')); + assert.ok(scripts.includes('flows/grok/index.js')); + assert.ok( + scripts.indexOf('flows/kiro/index.js') < scripts.indexOf('flows/grok/index.js'), + 'Kiro definition should load before Grok definition' + ); + assert.ok( + scripts.indexOf('flows/grok/index.js') < scripts.indexOf('flows/index.js'), + 'Grok definition must load before flows/index.js' + ); + assert.equal(scripts.includes('flows/grok/content/register-page.js'), false); + } +}); + test('background injects shared Kiro timeout module before Kiro content scripts', () => { const source = fs.readFileSync('background.js', 'utf8'); assert.match( source, - /const KIRO_REGISTER_INJECT_FILES = \['flows\/openai\/index\.js', 'flows\/kiro\/index\.js', 'flows\/index\.js', 'core\/flow-kernel\/flow-registry\.js', 'core\/flow-kernel\/source-registry\.js', 'shared\/kiro-timeouts\.js', 'content\/utils\.js', 'flows\/kiro\/content\/register-page\.js'\];/ + /const KIRO_REGISTER_INJECT_FILES = \['flows\/openai\/index\.js', 'flows\/kiro\/index\.js', 'flows\/grok\/index\.js', 'flows\/index\.js', 'core\/flow-kernel\/flow-registry\.js', 'core\/flow-kernel\/source-registry\.js', 'shared\/kiro-timeouts\.js', 'content\/utils\.js', 'flows\/kiro\/content\/register-page\.js'\];/ ); assert.match( source, - /const KIRO_DESKTOP_AUTHORIZE_INJECT_FILES = \['flows\/openai\/index\.js', 'flows\/kiro\/index\.js', 'flows\/index\.js', 'core\/flow-kernel\/flow-registry\.js', 'core\/flow-kernel\/source-registry\.js', 'shared\/kiro-timeouts\.js', 'content\/utils\.js', 'flows\/kiro\/content\/desktop-authorize-page\.js'\];/ + /const KIRO_DESKTOP_AUTHORIZE_INJECT_FILES = \['flows\/openai\/index\.js', 'flows\/kiro\/index\.js', 'flows\/grok\/index\.js', 'flows\/index\.js', 'core\/flow-kernel\/flow-registry\.js', 'core\/flow-kernel\/source-registry\.js', 'shared\/kiro-timeouts\.js', 'content\/utils\.js', 'flows\/kiro\/content\/desktop-authorize-page\.js'\];/ + ); + assert.match( + source, + /const GROK_REGISTER_INJECT_FILES = \['flows\/openai\/index\.js', 'flows\/kiro\/index\.js', 'flows\/grok\/index\.js', 'flows\/index\.js', 'core\/flow-kernel\/flow-registry\.js', 'core\/flow-kernel\/source-registry\.js', 'content\/utils\.js', 'flows\/grok\/content\/register-page\.js'\];/ ); }); @@ -83,6 +111,20 @@ test('shared source registry exposes canonical Kiro sources and drivers', () => }), 'kiro-desktop-authorize' ); + assert.equal( + registry.detectSourceFromLocation({ + url: 'https://accounts.x.ai/sign-up', + hostname: 'accounts.x.ai', + }), + 'grok-register-page' + ); + assert.equal( + registry.detectSourceFromLocation({ + url: 'https://grok.com/', + hostname: 'grok.com', + }), + 'grok-register-page' + ); assert.equal( registry.detectSourceFromLocation({ url: 'https://example.com/', @@ -115,6 +157,22 @@ test('shared source registry exposes canonical Kiro sources and drivers', () => ), true ); + assert.equal( + registry.matchesSourceUrlFamily( + 'grok-register-page', + 'https://accounts.x.ai/sign-up', + 'https://grok.com/' + ), + true + ); + assert.equal( + registry.matchesSourceUrlFamily( + 'grok-register-page', + 'https://grok.com/', + 'https://accounts.x.ai/sign-up' + ), + true + ); assert.equal( registry.matchesSourceUrlFamily( 'kiro-desktop-authorize', @@ -137,4 +195,6 @@ test('shared source registry exposes canonical Kiro sources and drivers', () => assert.equal(registry.driverAcceptsCommand('flows/kiro/background/register-runner', 'kiro-open-register-page'), true); assert.equal(registry.driverAcceptsCommand('flows/kiro/background/desktop-authorize-runner', 'kiro-start-desktop-authorize'), true); assert.equal(registry.driverAcceptsCommand('flows/kiro/background/publisher-kiro-rs', 'kiro-upload-credential'), true); + assert.equal(registry.driverAcceptsCommand('flows/grok/content/register-page', 'grok-submit-profile'), true); + assert.equal(registry.driverAcceptsCommand('flows/grok/background/register-runner', 'grok-extract-sso-cookie'), true); }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index 180ddf4..ee5a466 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -23,6 +23,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', () const goPaySteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gopay' }); const gpcSteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gpc-helper' }); const kiroSteps = api.getSteps({ activeFlowId: 'kiro' }); + const grokSteps = api.getSteps({ activeFlowId: 'grok' }); assert.equal(Array.isArray(steps), true); assert.equal(steps.length, 11); @@ -162,8 +163,9 @@ test('step definitions module exposes ordered normal and Plus step metadata', () assert.equal(api.getLastStepId({ plusModeEnabled: true, signupMethod: 'phone', phoneSignupReloginAfterBindEmailEnabled: true }), 19); assert.equal(api.hasFlow('openai'), true); assert.equal(api.hasFlow('kiro'), true); + assert.equal(api.hasFlow('grok'), true); assert.equal(api.hasFlow('site-a'), false); - assert.deepStrictEqual(api.getRegisteredFlowIds(), ['openai', 'kiro']); + assert.deepStrictEqual(api.getRegisteredFlowIds(), ['openai', 'kiro', 'grok']); assert.deepStrictEqual(api.getSteps({ activeFlowId: 'site-a' }), []); assert.equal(api.getStepById(2, { activeFlowId: 'site-a' }), null); assert.deepStrictEqual( @@ -210,6 +212,36 @@ test('step definitions module exposes ordered normal and Plus step metadata', () [], ] ); + assert.deepStrictEqual( + grokSteps.map((step) => step.key), + [ + 'grok-open-signup-page', + 'grok-submit-email', + 'grok-submit-verification-code', + 'grok-submit-profile', + 'grok-extract-sso-cookie', + ] + ); + assert.equal(grokSteps.every((step) => step.flowId === 'grok'), true); + assert.equal(grokSteps[0].driverId, 'flows/grok/background/register-runner'); + assert.equal(grokSteps[0].sourceId, 'grok-register-page'); + assert.equal(grokSteps[2].mailRuleId, 'grok-submit-verification-code'); + assert.deepStrictEqual( + grokSteps.map((step) => step.title), + ['打开 Grok 注册页', '获取邮箱并继续', '获取验证码并继续', '填写资料并继续', '提取 SSO Cookie'] + ); + assert.deepStrictEqual(api.getStepIds({ activeFlowId: 'grok' }), [1, 2, 3, 4, 5]); + assert.equal(api.getLastStepId({ activeFlowId: 'grok' }), 5); + assert.deepStrictEqual( + api.getNodes({ activeFlowId: 'grok' }).map((node) => node.next), + [ + ['grok-submit-email'], + ['grok-submit-verification-code'], + ['grok-submit-profile'], + ['grok-extract-sso-cookie'], + [], + ] + ); assert.equal(plusSteps[6].title, '创建 Plus Checkout'); assert.equal(plusSteps[8].title, 'PayPal 登录与授权'); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index c12025e..90bb7d0 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -10,10 +10,11 @@ ## 1. 项目目标 -这是一个 Chrome 扩展,用于承载多套注册与认证自动化 flow。当前已注册两条主 flow: +这是一个 Chrome 扩展,用于承载多套注册与认证自动化 flow。当前已注册三条主 flow: - `openai`:OpenAI / ChatGPT OAuth 注册、登录、平台绑定与 Plus 扩展链路 - `kiro`:AWS Builder ID 注册页 + 桌面授权 + `kiro.rs` 凭据上传链路 +- `grok`:Grok / xAI 注册页自动化 + `webchat2api` SSO Cookie 本地导出链路 它的核心价值不是“打开一个页面点几个按钮”,而是把下面这些环节串成一条完整可恢复的自动化链路: @@ -28,7 +29,7 @@ - 自动确认 OAuth 同意页 - 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API -同时,这套扩展现在不再把“来源、步骤、侧边栏显隐、运行态字段”硬编码在单一 OpenAI 链路里,而是统一挂到 `flow registry + source registry + settings schema + step definitions` 这一层。Kiro 这类新 flow 可以复用共享邮箱服务与 IP 代理服务,但不会复用 OpenAI 的接码、Plus、平台绑定和贡献模式逻辑。 +同时,这套扩展现在不再把“来源、步骤、侧边栏显隐、运行态字段”硬编码在单一 OpenAI 链路里,而是统一挂到 `flow registry + source registry + settings schema + step definitions` 这一层。Kiro / Grok 这类新 flow 可以复用共享邮箱服务与 IP 代理服务,但不会复用 OpenAI 的接码、Plus、平台绑定和贡献模式逻辑。 ## 2. 核心运行参与者 @@ -43,8 +44,8 @@ - 向后台发送命令 - 接收后台广播并更新 UI - 动态渲染步骤列表 -- 维护 `activeFlowId + targetId` 的双层选择;`openai` flow 继续把 `panelMode` 归一为 legacy integration target,`kiro` flow 则使用独立的 `flows.kiro.targetId` -- 按当前 flow capability 决定显隐分组;Kiro flow 只显示来源下拉、`kiro.rs URL / API Key`、共享邮箱服务、共享 IP 代理与 Kiro 运行状态,不显示 OpenAI 接码 / Plus / 平台绑定配置 +- 维护 `activeFlowId + targetId` 的双层选择;`openai` flow 继续把 `panelMode` 归一为 legacy integration target,`kiro` / `grok` flow 使用各自的 `flows..targetId` +- 按当前 flow capability 决定显隐分组;Kiro flow 只显示来源下拉、`kiro.rs URL / API Key`、共享邮箱服务、共享 IP 代理与 Kiro 运行状态;Grok flow 只显示 `webchat2api` SSO 导出状态、共享账号密码、共享邮箱服务和共享 IP 代理;二者都不显示 OpenAI 接码 / Plus / 平台绑定配置 - 管理顶部“贡献”按钮与贡献模式主面板;贡献模式本身是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` 来源 - 在贡献模式下复用同一套主自动流程启动,并在面板内展示贡献链路的 `OAUTH / 回调 / 总状态` 三块实时状态 - 在顶部“贡献/使用”按钮下方展示一个非强制的内容更新轻提示;提示来源于 `flowpilot.qlhazycoder.top` 的公开公告 / 教程摘要,用户关闭后仅对当前 `promptVersion` 静默,下次内容版本变化后会重新出现 @@ -150,13 +151,13 @@ - `runtimeState`:当前运行中的 node、nodeStatuses、activeRunId 等核心执行态 - `sharedState`:automation window、tab registry、最近来源 URL 这类跨 flow 共享运行态 - `serviceState`:代理、邮箱等共享服务运行态 -- `flowState`:OpenAI / Kiro 各自的 flow 私有运行态 +- `flowState`:OpenAI / Kiro / Grok 各自的 flow 私有运行态 这两个模块的作用,是把“当前该跑哪些节点”“当前 state 应该怎么看”“前后台恢复时如何合并 patch”从 `background.js` 主入口里抽离出来,避免再次退回到旧的平铺 step 状态机。 ### 3.4 步骤注册 -[data/step-definitions.js](./data/step-definitions.js) 提供共享步骤元数据,并按当前 `activeFlowId` 输出 flow-specific workflow。`openai` flow 会继续按 `plusPaymentMethod / signupMethod / plusAccountAccessStrategy` 动态解析步骤标题与节点集合:当 Plus 模式为邮箱注册且来源支持时,原本的 OAuth 尾链可以被替换成 `sub2api-session-import` 或 `cpa-session-import`;`kiro` flow 则输出固定的 9 节点注册/桌面授权/上传 workflow。 +[data/step-definitions.js](./data/step-definitions.js) 提供共享步骤元数据,并按当前 `activeFlowId` 输出 flow-specific workflow。`openai` flow 会继续按 `plusPaymentMethod / signupMethod / plusAccountAccessStrategy` 动态解析步骤标题与节点集合:当 Plus 模式为邮箱注册且来源支持时,原本的 OAuth 尾链可以被替换成 `sub2api-session-import` 或 `cpa-session-import`;`kiro` flow 输出固定的 9 节点注册/桌面授权/上传 workflow;`grok` flow 输出固定的 5 节点注册与 SSO Cookie 提取 workflow。 [core/flow-kernel/step-registry.js](./core/flow-kernel/step-registry.js) 负责把“workflow node 元数据”映射到“步骤执行器”,同时保留 `flowId / nodeId / displayOrder / executeKey` 等稳定节点元数据。 这意味着: @@ -168,7 +169,7 @@ ### 3.4.1 验证码规则注册 -[background/mail-rule-registry.js](./background/mail-rule-registry.js) 负责按当前 `activeFlowId` 选择验证码规则构造器。[flows/openai/mail-rules.js](./flows/openai/mail-rules.js) 则提供 OpenAI flow 当前使用的注册验证码 / 登录验证码规则。 +[background/mail-rule-registry.js](./background/mail-rule-registry.js) 负责按当前 `activeFlowId` 选择验证码规则构造器。[flows/openai/mail-rules.js](./flows/openai/mail-rules.js) 提供 OpenAI flow 当前使用的注册验证码 / 登录验证码规则,[flows/kiro/mail-rules.js](./flows/kiro/mail-rules.js) 提供 Kiro / AWS Builder ID 注册验证码与桌面授权验证码规则,[flows/grok/mail-rules.js](./flows/grok/mail-rules.js) 提供 xAI / Grok 注册验证码规则。 这层统一产出: @@ -180,7 +181,8 @@ 它的职责边界是: - `background/verification-flow.js` 只消费统一规则 payload,不再在每个 provider 或内容脚本里各自硬写一套 OpenAI 验证码过滤条件。 -- “邮件从哪里读”由 provider 决定,“哪封邮件算命中验证码”优先由规则注册层决定。 +- `background/flow-mail-polling.js` 负责共享的 provider 分派、iCloud / 2925 会话准备、网页邮箱标签页打开与内容脚本轮询;flow runner 只提供 `flowId / nodeId / step / filterAfterTimestamp` 这类业务上下文。 +- “邮件从哪里读”由公共轮询调度层和 provider 决定,“哪封邮件算命中验证码”优先由规则注册层决定。 - 新 flow 如需接入邮箱验证码,应优先新增自己的 mail rules builder,而不是继续改写 OpenAI 规则。 ### 3.5 日志步骤号链路 @@ -214,6 +216,7 @@ - `contributionLastPollAt` - OAuth 链接 - Kiro 运行态:使用 `kiroRuntime.session / register / webAuth / desktopAuth / upload` 命名空间,保存 Kiro 官方登录入口、Builder ID 注册页状态、Kiro Web 登录态摘要、桌面授权 PKCE 凭据、上传状态和当前 Kiro 会话进度 +- Grok 运行态:权威状态位于 `runtimeState.flowState.grok`,包含 `session / register / sso`;顶层 `grokRegisterTabId / grokPageState / grokEmail / grokPassword / grokSsoCookie / grokSsoCookies` 只作为兼容投影,不应当再形成第二套状态机 - 当前轮冻结后的注册方式 `resolvedSignupMethod` - 当前统一账号标识 `accountIdentifierType / accountIdentifier` - 当前邮箱 / 密码 @@ -762,9 +765,32 @@ Kiro flow 的目标不是复用 OpenAI 注册链,而是独立完成“Kiro 官 - Kiro 只复用共享账户密码、共享邮箱服务和共享 IP 代理服务,不复用 OpenAI 接码 / Plus / 平台绑定 / 贡献模式链路 - Kiro 自动运行走通用 linear node runner,但执行器、运行态、页面驱动和上传器全部独立在 `background/kiro/*` 与 `content/kiro/*` - Kiro 运行态统一落在 `kiroRuntime` 命名空间,不再散落为多个平铺字段 +- Kiro 注册验证码与桌面授权验证码只在 runner 内计算业务时间窗,具体 AWS 邮件匹配规则由 `flows/kiro/mail-rules.js` 提供,provider 分派统一走 `background/flow-mail-polling.js` - `kiro.rs` 上传固定发送 BuilderId profileArn `arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX`,machineId 固定按 `sha256("KotlinNativeAPI/" + refreshToken)` 生成 - Kiro 注册页覆盖 `app.kiro.dev` 与 AWS Builder ID 页面;桌面授权页只绑定 AWS authorize 页面,二者由后台按 source 动态注入,避免同域 AWS 页面被错误归到另一条 Kiro 子链路 +## 6.3 Grok Flow 链路 + +Grok flow 的目标是完成 Grok / xAI 注册页自动化,并把 `sso` Cookie 作为 `webchat2api` 可用的本地导出产物。它不是 OpenAI 目标来源,也不是公开贡献 / 发布 flow。 + +链路如下: + +1. sidepanel 把当前 flow 切到 `grok`,target 固定为 `webchat2api` +2. 步骤 1 打开 Grok / xAI 注册入口,并清理本轮注册页运行态 +3. 步骤 2 通过共享邮箱服务生成或复用注册邮箱,并提交到 xAI 登录/注册页 +4. 步骤 3 调用 `background/flow-mail-polling.js`,由 `flows/grok/mail-rules.js` 构造 xAI / Grok 验证码规则并轮询邮箱验证码,再交给 Grok 内容脚本提交 +5. 步骤 4 填写资料和密码,推进注册完成页状态 +6. 步骤 5 先通过 `chrome.cookies.get` 从 `x.ai / grok.com / accounts.x.ai` 查找 `sso` Cookie,必要时 fallback 到 Grok 内容脚本读取 `document.cookie`;提取到的值保存到 `runtimeState.flowState.grok.sso` 并投影到 `grokSsoCookie / grokSsoCookies` +7. sidepanel 只展示掩码后的 SSO 值,复制和导出才使用完整值;清空动作必须发 `CLEAR_GROK_SSO_COOKIES` 给后台,由 `flows/grok/background/state.js` 构造 patch,不能在 sidepanel 直接写 `chrome.storage` + +这条链路的关键边界是: + +- Grok 只复用共享账户密码、共享邮箱服务和共享 IP 代理服务,不复用 OpenAI 接码 / Plus / 平台绑定 / 贡献模式链路 +- Grok 自动运行走通用 linear node runner,执行器、运行态、页面驱动和邮件规则全部放在 `flows/grok/*` +- Grok 内容脚本不能全局 patch 浏览器原型;点击事件需要的坐标只能在局部事件参数里补齐 +- Grok SSO Cookie 是本地导出产物,`flows/grok/index.js` 的 `publicationTargets` 保持为空,`supportsAccountContribution` 保持为 `false` +- `shared/contribution-registry.js` 的默认发布贡献校验只覆盖声明了发布目标或支持贡献的 flow;Grok 不应被自动纳入贡献 adapter 缺失检查。如果未来要把 Grok 改成正式发布 flow,必须先新增明确的 publication target 与 contribution adapter,再打开对应能力 + ## 7. 邮箱与 provider 链路 ### 7.1 共享邮箱生成补充 @@ -840,8 +866,11 @@ Kiro flow 的目标不是复用 OpenAI 注册链,而是独立完成“Kiro 官 - `background/registration-email-state.js` 统一维护当前注册邮箱、上一份比较基线邮箱、来源与更新时间,并提供 Duck 生成前解析比较基线、Step 8 清空当前邮箱但保留历史基线,以及 Step 8 `add-email` 写入邮箱时保留手机号身份的共享工具。 -- `background/mail-rule-registry.js` + `flows/openai/mail-rules.js` - 统一构造 Step 4 / Step 8 的验证码规则 payload,区分注册验证码与登录/绑定验证码,并把 `senderFilters / subjectFilters / codePatterns / targetEmailHints / mail2925MatchTargetEmail` 收敛到同一层。 +- `background/mail-rule-registry.js` + `flows/*/mail-rules.js` + 统一构造各 flow 的验证码规则 payload,区分 OpenAI 注册/登录验证码、Kiro 注册/桌面授权验证码与 Grok 注册验证码,并把 `senderFilters / subjectFilters / codePatterns / targetEmailHints / mail2925MatchTargetEmail` 收敛到 flow mail rules 层。 + +- `background/flow-mail-polling.js` + 统一执行 flow 邮箱验证码轮询调度:读取当前邮箱配置,调用 `mailRuleRegistry.buildVerificationPollPayloadForNode`,分派 Hotmail / LuckMail / Cloudflare Temp Email / Cloud Mail / YYDS Mail 这类 API provider,并为 iCloud、2925 和普通网页邮箱准备会话与内容脚本轮询。Kiro 与后续新 flow 不应在各自 runner 内复制 provider 分派逻辑。 - `background/generated-email-helpers.js` 在原有 Duck / Cloudflare / iCloud / Cloudflare Temp Email 生成链路之外,新增 Gmail / 2925 的共享生成接入;Duck 生成前会按“侧栏当前可见邮箱 -> `registrationEmailState.current` -> `registrationEmailState.previous`”的顺序解析比较基线。 @@ -1131,6 +1160,7 @@ Hide My Email 获取与管理链路: - 如果当前 `Mail = 自定义邮箱` 且配置了 `customMailProviderPool`,会先按当前目标轮次把号池中的对应邮箱写回运行态 - 如果当前生成方式是 `custom-pool`,会先按当前目标轮次把邮箱池中的对应邮箱写回运行态 - fresh-attempt reset 会保留 `stepExecutionRangeByFlow`,避免重置运行态时丢失用户设置的执行窗口 + - Grok fresh-attempt reset 会清理注册页 runtime,但保留已提取的 `grokSsoCookies`,避免用户导出前被下一轮准备动作清空 5. 执行 `runAutoSequenceFromStep` - 如果当前 `activeFlowId !== openai`,后台会切到通用 linear node runner,按当前 flow 的 workflow node 顺序执行,并复用统一的完成状态与 idle watchdog - 如果 `stepExecutionRangeByFlow` 已启用,自动运行只会遍历允许范围内的 workflow node;范围外节点会被跳过,`getFirstUnfinishedNodeId` 与保存进度判断也只统计允许节点 diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md index 1b56777..2d608d7 100644 --- a/项目开发规范(AI协作).md +++ b/项目开发规范(AI协作).md @@ -171,6 +171,7 @@ - `resolvedSignupMethod` 是当前轮冻结结果,不等同于用户此刻 UI 上选择的 `signupMethod`。 - 强制绑定邮箱后重登用邮箱身份,只能通过单次执行参数覆盖登录身份,不能持久改写 `signupMethod`。 - flow 能力不足时必须在步骤定义层或启动校验层处理,不能等执行到不存在的节点后才报错。 +- 本地导出型 flow、发布型 flow、贡献型 flow 必须显式区分:只有声明了 `publicationTargets` 或明确支持贡献能力的 flow 才能进入发布/贡献 adapter 校验;例如 Grok / `webchat2api` SSO Cookie 导出属于本地导出 flow,不能因为有 target 就默认接入贡献模式。 ### 1.7 日志步骤号原则 @@ -285,6 +286,7 @@ - 贡献流程的后台公开 OAuth 状态机应优先收敛到独立模块,例如 `background/contribution-oauth.js` - 贡献模式的侧栏按钮、状态展示和轮询调度应优先收敛到独立 manager,例如 `sidepanel/contribution-mode.js` - 如果服务端当前返回“无需手动提交 callback”,扩展端必须把它当兼容成功态处理,不能简单按 HTTP 非 200 直接视为失败 +- 如果新增 flow 只是本地导出产物,例如 Cookie、JSON、会话文件、手动复制值,必须保持 `publicationTargets` 为空并关闭 `supportsAccountContribution`;如果后续产品决定发布到远端服务,必须先设计 publication target、贡献 adapter、敏感字段脱敏和测试,再打开能力开关。 ### 3.4.1 iCloud Hide My Email 维护补充 @@ -495,6 +497,7 @@ npm test 15. 如果创建了 `docs/md/` 方案文件,我有没有确认它是否应被提交? 16. 如果改动涉及 flow、步骤、模式切换,我有没有同时检查 `getSteps / getNodes / getWorkflow`、后台 registry、sidepanel `workflowNodes` 和 node 状态? 17. 我有没有新增任何用数字步骤判断业务含义的代码?如果有,是否能改为 `key / nodeId`? +18. 如果新增或调整 flow target,我有没有确认它到底是本地导出、正式发布还是贡献 flow,并同步检查 `publicationTargets / supportsAccountContribution / contributionAdapterIds / shared/contribution-registry.js`? ## 9. 完成标准 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 6e32bd4..af6564e 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -21,7 +21,7 @@ - `.gitignore`:定义仓库忽略规则,当前忽略 `docs/md/`、`.github/`、`_metadata/`、`.vscode/` 等目录。 - `LICENSE`:项目许可证文件。 - `README.md`:面向使用者的精简说明文档,主要介绍项目用途、功能范围、快速开始与文档入口;不再承载过多技术实现细节。 -- `background.js`:扩展后台 Service Worker 入口壳,负责模块装配、初始化、全局常量、少量保留的领域函数与运行入口;当前已按 `activeFlowId` 装配 flow-aware 的步骤定义、执行注册表、自动运行与状态同步,`openai` flow 继续承接 `oauthFlowTimeoutEnabled` 与 `stepExecutionRangeByFlow`,`kiro` flow 则走独立的“注册页 1-6 步 -> 桌面授权 7-8 步 -> `kiro.rs` 上传 9 步”链路。 +- `background.js`:扩展后台 Service Worker 入口壳,负责模块装配、初始化、全局常量、少量保留的领域函数与运行入口;当前已按 `activeFlowId` 装配 flow-aware 的步骤定义、执行注册表、自动运行与状态同步,`openai` flow 继续承接 `oauthFlowTimeoutEnabled` 与 `stepExecutionRangeByFlow`,`kiro` flow 走独立的“注册页 1-6 步 -> 桌面授权 7-8 步 -> `kiro.rs` 上传 9 步”链路,`grok` flow 走独立的“注册页 1-4 步 -> SSO Cookie 提取 5 步”链路。 - `cloudmail-utils.js`:Cloud Mail / SkyMail 相关的纯工具函数,负责 API 地址、域名、鉴权头、邮件列表响应与邮件正文归一化。 - `cloudflare-temp-email-utils.js`:Cloudflare Temp Email 相关的纯工具函数,负责 URL、域名、邮件内容与 MIME 数据归一化。 - `gopay-utils.js`:GoPay / GPC Plus 支付相关纯工具函数,负责支付方式归一化、GoPay 手机号/OTP/PIN 归一化、GPC API 地址归一化、API Key 请求头、任务创建/查询/OTP/PIN/停止 URL 与余额响应解析。 @@ -59,11 +59,14 @@ - `flows/kiro/background/desktop-client.js`:Kiro 桌面授权协议层,负责桌面 OIDC client 注册、PKCE 参数生成、授权地址组装、callback 参数校验和授权码换 token。 - `flows/kiro/background/desktop-authorize-runner.js`:Kiro 桌面授权执行器,负责步骤 7~8 的标签页打开、授权页轮询、localhost callback 捕获,以及桌面授权完成后的凭据落库。 - `flows/kiro/background/publisher-kiro-rs.js`:Kiro 发布器,负责 `kiro.rs` 地址归一化、连通性探测、BuilderId profileArn 固定映射、machineId 计算、上传 payload 构建与最终凭据上传。 +- `flows/grok/background/state.js`:Grok 独立运行态模型与状态工具,负责 `runtimeState.flowState.grok.session / register / sso` 的默认值、归一化、兼容字段投影、节点完成回写、下游重置和 fresh-attempt keep-state 构建。 +- `flows/grok/background/register-runner.js`:Grok 注册页执行器,负责步骤 1~5 的编排、Grok/xAI 注册标签页管理、邮箱提交、验证码轮询提交、资料/密码提交、SSO Cookie 提取、去重保存与账号记录收尾。 - `background/ip-proxy-core.js`:IP 代理核心模块,负责代理条目解析、账号/接口代理池运行态、PAC 应用与清除、代理鉴权回填、出口探测、失败时的目标站点 fail-close 防漏规则,以及自动运行成功阈值后的代理切换支撑。 - `background/ip-proxy-provider-711proxy.js`:711Proxy provider 规则模块,负责从账号串中识别和写回 `region / session / sessTime` 等参数,并在固定账号模式下把侧栏配置转换为最终生效的代理账号。 - `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;日志条目统一写入结构化 `step / stepKey`,sidepanel 只读取该元数据渲染步骤标签,不再从日志正文解析步骤号;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。 - `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。 - `background/mail-rule-registry.js`:flow-aware 邮件规则注册表,负责按 `activeFlowId` 选择验证码规则构造器,并统一输出注册/登录验证码轮询 payload。 +- `background/flow-mail-polling.js`:flow-aware 邮件轮询调度层,负责读取共享邮箱配置、调用当前 flow 的 mail rules、分派 Hotmail / LuckMail / Cloudflare Temp Email / Cloud Mail / YYDS Mail API provider、准备 iCloud / 2925 / 网页邮箱会话,并统一通过邮箱内容脚本轮询验证码。 - `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;`LOG / STEP_COMPLETE / STEP_ERROR` 会把消息里的真实 `step` 继续传给结构化日志,跳过登录验证码这类派生日志也按当前步骤 key 写入;当前额外接入 PayPal 账号池的新增与切换、2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息;手动改邮箱、手动取邮箱等入口也统一经这里落到 `setEmailState`,同步维护注册邮箱运行态;当侧栏关闭 `oauthFlowTimeoutEnabled` 时,会立即清空已存在的 OAuth 总预算 deadline;手动 `EXECUTE_NODE` 会先按 `stepExecutionRangeByFlow` 校验当前节点是否允许执行。 - `background/registration-email-state.js`:注册邮箱运行态共享模块,负责维护 `registrationEmailState = { current, previous, source, updatedAt }`,并提供“当前邮箱清空时保留上一比较基线”“Duck 生成前解析比较基线”“Step 8 `add-email` 写入邮箱时按需保留手机号身份”的统一工具。 - `core/flow-kernel/runtime-state.js`:运行态视图与 patch 构造模块,负责把会话字段归一到 `runtimeState / sharedState / serviceState / flowState` 结构,并维护 flow-aware 的 session patch、节点状态默认值与兼容视图输出。 @@ -152,6 +155,11 @@ ## `flows/` - `flows/openai/mail-rules.js`:OpenAI flow 的验证码邮件规则定义,负责按注册/登录节点输出 code pattern、关键词、目标邮箱提示、2925 receive 弱匹配与轮询参数。 +- `flows/kiro/mail-rules.js`:Kiro flow 的 AWS Builder ID 邮件规则定义,负责按注册验证码节点与桌面授权验证码节点输出 AWS 发件人、主题、关键词、code pattern、目标邮箱提示、2925 receive 弱匹配与轮询参数。 +- `flows/grok/index.js`:Grok flow 定义,声明 `webchat2api` target、Grok 注册页 runtime source、Grok 专属设置分组、能力边界与本地 SSO 导出属性;该 flow 当前没有 publication target,也不支持贡献 adapter。 +- `flows/grok/workflow.js`:Grok workflow 定义,固定输出打开注册页、提交邮箱、提交验证码、提交资料、提取 SSO Cookie 五个节点。 +- `flows/grok/mail-rules.js`:Grok flow 的 xAI / Grok 验证码邮件规则定义,负责输出发件人、主题、关键词、`ABC-123` 与 6 位码 pattern、目标邮箱提示、2925 receive 弱匹配与轮询参数。 +- `flows/grok/content/register-page.js`:Grok 注册页内容脚本,负责五个 Grok 节点的 DOM 操作、页面状态读取与局部点击事件坐标补齐,不修改全局浏览器原型。 ## `icons/` @@ -178,9 +186,10 @@ ## `shared/` - `core/flow-kernel/flow-capabilities.js`:flow 能力矩阵归一化模块,负责根据 `activeFlowId` 与来源解析 sidepanel 的显隐能力、来源作用域、贡献模式边界、步骤范围 UI 作用域,以及 Plus 模式下 `账号接入策略` 的可选项、可编辑性与最终生效值。 -- `core/flow-kernel/flow-registry.js`:flow/source/settings group 总注册表,定义 `openai / kiro` 两套 flow、各自 target/runtime source、可见分组、driver 定义,以及默认 target 和默认 `kiro.rs` 配置。 +- `core/flow-kernel/flow-registry.js`:flow/source/settings group 总注册表,定义 `openai / kiro / grok` 三套 flow、各自 target/runtime source、可见分组、driver 定义,以及默认 target 和默认 `kiro.rs` 配置。 - `shared/kiro-timeouts.js`:Kiro 共享超时常量与归一化工具,负责注册页/桌面授权链路复用的页面加载超时配置。 -- `core/flow-kernel/settings-schema.js`:统一设置 schema 与归一化层,负责把持久配置收敛到 `settingsState.services.*` 与 `settingsState.flows.*` 结构,并维护 `activeFlowId`、OpenAI 的 integration target、Kiro 的 `flows.kiro.targetId / targets` 与 `stepExecutionRangeByFlow`。 +- `shared/contribution-registry.js`:贡献 adapter 与教程入口注册表,负责 OpenAI / Kiro 贡献 adapter、教程入口和发布贡献 flow 校验;默认发布校验只覆盖声明了 publication target 或支持贡献的 flow,Grok 这类本地 SSO 导出 flow 不自动纳入贡献 adapter 缺失检查。 +- `core/flow-kernel/settings-schema.js`:统一设置 schema 与归一化层,负责把持久配置收敛到 `settingsState.services.*` 与 `settingsState.flows.*` 结构,并维护 `activeFlowId`、OpenAI 的 integration target、Kiro/Grok 的 `flows..targetId / targets` 与 `stepExecutionRangeByFlow`。 - `core/flow-kernel/source-registry.js`:运行时来源注册表,负责合并 flow runtime source 与共享 mail source,统一 source family、driver、URL 归属和 cleanup owner 判定。 ## `sidepanel/` @@ -270,7 +279,9 @@ - `tests/background-mail2925-relogin-wait.test.js`:测试 2925 强制重登入口 URL、长登录超时与前后固定等待窗口。 - `tests/background-mail2925-session-module.test.js`:测试 2925 会话模块已接入且导出工厂,并覆盖命中上限后“禁用 24 小时 + 切下一个号 + 自动登录”的核心链路。 - `tests/background-mail2925-signup-flow.test.js`:测试注册页辅助层在 2925 provider 下,会先确保账号池里已分配可用账号,再继续生成别名邮箱。 -- `tests/background-mail-rule-registry-module.test.js`:测试邮件规则注册表与 OpenAI mail rules 模块接入,并覆盖验证码轮询 payload 构造。 +- `tests/background-flow-mail-polling-module.test.js`:测试 flow-aware 邮件轮询调度层对 API provider、网页邮箱 provider、2925 会话准备和 2925 上限恢复的共享分派行为。 +- `tests/background-mail-rule-registry-module.test.js`:测试邮件规则注册表与 OpenAI / Kiro mail rules 模块接入,并覆盖各 flow 验证码轮询 payload 构造。 +- `tests/background-grok-state-module.test.js`:测试 Grok 运行态 helper 的默认值、兼容字段投影、节点完成回写、下游重置、fresh-attempt keep-state 与 SSO Cookie 保留规则。 - `tests/background-message-router-module.test.js`:测试消息路由模块已接入且导出工厂。 - `tests/background-message-router-plus-final-step.test.js`:测试消息路由会按 Plus 当前最终节点追加成功记录,不再写死步骤 10。 - `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑,并覆盖邮箱身份写入时清理旧手机号注册运行态。 @@ -358,6 +369,7 @@ - `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线、更新提示气泡接线,以及相关脚本加载顺序。 - `tests/sidepanel-account-records-manager.test.js`:测试侧边栏账号记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。 - `tests/contribution-content-update-service.test.js`:测试贡献内容更新服务对公开摘要接口的归一化、版本提取与失败回退缓存逻辑。 +- `tests/contribution-registry.test.js`:测试贡献 adapter 注册、教程入口解析、默认发布贡献 flow 校验,以及 Grok 本地 SSO 导出 flow 不被自动纳入发布贡献检查。 - `tests/flow-capabilities-module.test.js`:测试 flow 能力矩阵模块对来源作用域、显隐能力和贡献模式边界的归一化。 - `tests/flow-registry-settings-schema.test.js`:测试 flow registry 与 settings schema 之间的默认值、来源映射和 flow-specific 配置结构保持一致。 - `tests/sidepanel-custom-email-pool.test.js`:测试侧边栏自定义邮箱池、自定义邮箱服务号池的 HTML 接线,以及邮箱池长度与自动轮数之间的联动规则。 @@ -365,6 +377,7 @@ - `tests/sidepanel-contribution-mode-flow-scope.test.js`:测试贡献模式只在 `openai` flow 内生效,不会污染 Kiro flow 的来源与面板作用域。 - `tests/sidepanel-contribution-update-hint.test.js`:测试顶部贡献更新轻提示在公告、教程和托管 auto-run 提醒并存时的文案合成。 - `tests/sidepanel-flow-source-registry.test.js`:测试 sidepanel 的 flow/source 选择器、Kiro 来源切换与显隐分组接线。 +- `tests/grok-runner.test.js`:测试 Grok 注册页执行器对邮箱提交、验证码轮询、资料提交、SSO Cookie 提取、账号记录收尾和完成状态持久化的关键链路。 - `tests/sidepanel-auto-run-content-refresh.test.js`:测试点击“自动”时会先冻结当前轮数并刷新贡献内容更新摘要,且刷新失败或启动前状态回灌不会阻塞或改写自动流程启动目标。 - `tests/sidepanel-auto-run-risk-warning.test.js`:测试自动运行在高轮数且未配置节点轮询时会弹出风险提醒。 - `tests/sidepanel-cloudflare-temp-email-random-subdomain.test.js`:测试 Cloudflare Temp Email 独立区域、随机子域名开关、域名列表恢复与提示文案联动。