From 04f89620be6608a3dca03ce3a6dd497753ce698e Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Wed, 13 May 2026 00:52:10 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/local-customizations-maintenance.md | 2 + docs/多注册流程侧边栏能力矩阵.md | 258 +++++++++++++++++++++++ docs/多注册流程来源与驱动注册设计.md | 246 +++++++++++++++++++++ docs/多注册流程架构边界.md | 124 +++++++++++ docs/多注册流程状态迁移设计.md | 248 ++++++++++++++++++++++ docs/多注册流程邮件分层设计.md | 222 +++++++++++++++++++ 项目完整链路说明.md | 8 +- 项目文件结构说明.md | 18 +- 8 files changed, 1116 insertions(+), 10 deletions(-) create mode 100644 docs/多注册流程侧边栏能力矩阵.md create mode 100644 docs/多注册流程来源与驱动注册设计.md create mode 100644 docs/多注册流程架构边界.md create mode 100644 docs/多注册流程状态迁移设计.md create mode 100644 docs/多注册流程邮件分层设计.md diff --git a/docs/local-customizations-maintenance.md b/docs/local-customizations-maintenance.md index 927bde6..9273bf3 100644 --- a/docs/local-customizations-maintenance.md +++ b/docs/local-customizations-maintenance.md @@ -338,6 +338,8 @@ OAuth 登录流程打开新标签页,避免复用旧页面导致状态污染 ## 8. 接码平台多平台适配:HeroSMS + 5sim +边界说明:接码平台多平台适配当前只属于 OpenAI 注册 / OAuth 链路,不作为多注册 flow 架构的 core 通用服务。后续新增的注册项目默认不接入接码;只有出现第二个真实项目也需要短信生命周期时,再按实际复用点抽象 provider 层。 + ### 目标 手机号验证 Step 9 支持按平台切换接码能力: diff --git a/docs/多注册流程侧边栏能力矩阵.md b/docs/多注册流程侧边栏能力矩阵.md new file mode 100644 index 0000000..088d8e6 --- /dev/null +++ b/docs/多注册流程侧边栏能力矩阵.md @@ -0,0 +1,258 @@ +# 多注册流程侧边栏能力矩阵 + +本文解决的是一个很容易被低估的问题:sidepanel 现在不只是“渲染 UI”,它还承担了大量业务约束。如果未来要支持多个 flow,不能只做动态渲染,还必须把“哪些能力允许显示、允许切换、允许启动”做成统一能力矩阵。 + +## 1. 当前源码里的真实问题 + +当前手机号注册能力至少同时受这几组条件约束: + +- `phoneVerificationEnabled` +- `plusModeEnabled` +- `contributionMode` +- `panelMode === 'cpa'` 时还会触发额外提醒 + +而这些约束现在分散在多个地方: + +- `background.js`:`canUsePhoneSignup` +- `background/message-router.js`:同类判断 +- `sidepanel/sidepanel.js`:`canSelectPhoneSignupMethod` +- `sidepanel/sidepanel.js`:`shouldWarnCpaPhoneSignup` + +这说明 sidepanel 当前既在做展示,也在做策略判定,而且判定逻辑有重复。 + +## 2. 设计目标 + +后续 sidepanel 至少要拆成三层能力判断: + +1. `flowCapabilities` +2. `panelCapabilities` +3. `runtimeLocks` + +最终所有显示 / 禁用 / 启动校验都只读这三层组合结果。 + +## 3. 三层能力定义 + +### 3.1 `flowCapabilities` + +表示“当前 flow 业务上支持什么”。 + +示例: + +```js +flowCapabilities.openai = { + supportsEmailSignup: true, + supportsPhoneSignup: true, + supportsPhoneVerificationSettings: true, + supportsPlusMode: true, + supportsContributionMode: true, + supportsPlatformBinding: ['cpa', 'sub2api', 'codex2api'], + supportsLuckmail: true, + supportsOauthTimeoutBudget: true, + stepDefinitionMode: 'openai-dynamic', +}; + +flowCapabilities.siteA = { + supportsEmailSignup: true, + supportsPhoneSignup: false, + supportsPhoneVerificationSettings: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsPlatformBinding: ['siteA-panel'], + supportsLuckmail: false, + supportsOauthTimeoutBudget: false, + stepDefinitionMode: 'siteA', +}; +``` + +原则: + +- 没有真实需求,不要默认给新 flow 打开手机号、Plus、LuckMail +- 新 flow 默认从最小能力集合开始 + +### 3.2 `panelCapabilities` + +表示“当前面板来源允许什么交互”。 + +示例: + +```js +panelCapabilities = { + cpa: { + supportsPhoneSignup: true, + requiresPhoneSignupWarning: true, + }, + sub2api: { + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }, + codex2api: { + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }, +}; +``` + +注意:`contributionMode` 不是新的 `panelMode`,它是独立运行态锁。 + +### 3.3 `runtimeLocks` + +表示“当前这一次运行或当前 UI 状态是否允许操作”。 + +示例: + +```js +runtimeLocks = { + autoRunLocked: false, + contributionMode: false, + plusModeEnabled: false, + phoneVerificationEnabled: true, + settingsMenuLocked: false, +}; +``` + +它决定的是: + +- 现在能不能切换注册方式 +- 能不能改 flow +- 能不能导入导出配置 +- 能不能打开某些配置区 + +## 4. 统一选择器 + +建议提供一个中心 selector: + +```js +resolveSidepanelCapabilities({ + activeFlowId, + panelMode, + state, +}); +``` + +输出: + +```js +{ + effectiveSignupMethods: ['email', 'phone'], + canShowPhoneSettings: true, + canSelectPhoneSignup: true, + shouldWarnCpaPhoneSignup: true, + canShowPlusSettings: true, + canShowLuckmail: true, + canExportSettings: true, + canSwitchFlow: false, + stepDefinitionOptions: {}, +} +``` + +以后 sidepanel 与 background 都只消费这份结果,不再各写一套判断。 + +## 5. OpenAI 当前矩阵 + +### 5.1 仅看 flow 能力 + +OpenAI 当前业务上支持: + +- 邮箱注册 +- 手机号注册 +- OAuth 登录 +- 平台回调绑定 +- Plus +- LuckMail +- 接码配置 + +### 5.2 加上 runtime lock 后的真实结果 + +OpenAI 的手机号注册只有在以下条件同时成立时才可选: + +- `flowCapabilities.openai.supportsPhoneSignup === true` +- `phoneVerificationEnabled === true` +- `plusModeEnabled === false` +- `contributionMode === false` + +也就是说,手机号注册不是一个“单纯 UI 开关”,而是能力矩阵结果。 + +### 5.3 加上 panel 约束后的额外行为 + +当满足手机号注册可用,且当前 `panelMode === 'cpa'` 时: + +- 不阻止 +- 但要提示风险 + +这类“允许但要提醒”的逻辑也必须归到能力矩阵里,而不是散落在点击事件中。 + +## 6. 步骤列表也属于能力矩阵的一部分 + +当前步骤定义主要受: + +- `plusModeEnabled` +- `signupMethod` + +影响,并通过 `data/step-definitions.js` 动态切换。 + +多 flow 后必须升级成: + +```js +getStepDefinitions({ + activeFlowId, + signupVariant, + panelMode, + plusModeEnabled, + contributionMode, +}); +``` + +也就是说,步骤列表不再是全局 `10 步 / 13 步` 二选一,而是 flow-aware 的定义。 + +## 7. Sidepanel 分层职责 + +### 7.1 展示层 + +只负责: + +- show / hide +- enabled / disabled +- 文案与提示 + +### 7.2 能力判定层 + +只负责: + +- `resolveSidepanelCapabilities()` +- `validateAutoRunStart()` +- `validateModeSwitch()` + +### 7.3 后台复核层 + +后台在收到启动、切换注册方式、切换 flow 等消息时,仍要复核一遍同一套能力判断,避免 sidepanel 以外的入口绕过约束。 + +## 8. 新 flow 的默认能力策略 + +后续新增 flow 时,默认按以下最小集合开始: + +- 仅邮箱注册 +- 无手机号注册 +- 无 Plus +- 无 LuckMail +- 无贡献模式 +- 无 OpenAI 平台回调面板 + +只有真实需求出现时,再逐项打开 capability。 + +## 9. 迁移顺序 + +1. 提炼 `flowCapabilitiesRegistry` +2. 提炼 `panelCapabilitiesRegistry` +3. 新增 `resolveSidepanelCapabilities()` 与 `validateAutoRunStart()` +4. 让 `sidepanel.js`、`background.js`、`message-router.js` 共用同一套 selector +5. 再把步骤定义切换也改成 flow-aware + +## 10. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- sidepanel 不只是渲染层,还承接业务约束 +- 手机号注册能力由多个开关共同决定,当前逻辑分散且重复 +- `contributionMode` 与 `panelMode` 容易混淆 +- 多 flow 之后,步骤列表也必须纳入能力矩阵,而不是继续全局写死 + diff --git a/docs/多注册流程来源与驱动注册设计.md b/docs/多注册流程来源与驱动注册设计.md new file mode 100644 index 0000000..82a5f4c --- /dev/null +++ b/docs/多注册流程来源与驱动注册设计.md @@ -0,0 +1,246 @@ +# 多注册流程来源与驱动注册设计 + +本文解决的是另一个会在第二个 flow 接入时立刻爆炸的问题:当前项目虽然有 `tab-runtime`,但“来源 source 是谁、该注入什么脚本、URL family 怎么判定、localhost callback 应该清谁”仍然带着明显的 OpenAI 单流程假设。 + +## 1. 当前源码里的真实耦合 + +### 1.1 `signup-page` 其实不是抽象 source,而是 OpenAI Auth 标签页别名 + +目前这些地方都把 OpenAI Auth / Consent / Add-phone 页面统称为 `signup-page`: + +- `content/utils.js` 的 `detectScriptSource()` +- `background/navigation-utils.js` 的 `matchesSourceUrlFamily()` +- `background/tab-runtime.js` 的注册表、冲突清理、callback 清理 + +这说明当前的 source 体系不是“可扩展的来源注册表”,而是“OpenAI 时代遗留的 source 命名”。 + +### 1.2 `content/utils.js` 的默认回退不安全 + +当前 `detectScriptSource()` 在无法识别页面时会回退成 `vps-panel`。这对单流程时期问题不大,但多 flow 下会出现两个风险: + +1. 新 flow 页面被误识别成 `vps-panel` +2. `PING / READY / COMPLETE` 日志和标签注册打到错误 source + +### 1.3 `tab-runtime` 的 localhost cleanup 直接写死 `registry['signup-page']` + +`closeLocalhostCallbackTabs()` 现在默认把匹配 callback 的残留 tab 从 `signup-page` 名下清掉。将来只要第二个 flow 也有 callback,或者 callback 不是来自 OpenAI Auth 页,这里就会清错或漏清。 + +### 1.4 manifest 的静态注入列表目前基本围绕 OpenAI + +`manifest.json` 里的静态内容脚本匹配域名,目前只有 OpenAI Auth 页是明确的 flow 页面入口。第二个 flow 如果沿用这套做法,后续只会不断在 manifest 里继续加分散规则,缺少统一 source 定义中心。 + +## 2. 设计目标 + +后续必须拆成两层注册表: + +1. `sourceRegistry`:管理“页面来源与标签页生命周期” +2. `driverRegistry`:管理“这个来源页面由哪个 content driver 负责,支持哪些命令” + +这两层不要再混成一个 `signup-page` 字符串。 + +## 3. `sourceRegistry` 设计 + +建议 source 用稳定 ID,而不是模糊业务词。 + +```js +sourceRegistry = { + 'openai-auth': { + flowId: 'openai', + kind: 'flow-page', + label: 'OpenAI 认证页', + hostPatterns: [ + 'https://auth.openai.com/*', + 'https://auth0.openai.com/*', + 'https://accounts.openai.com/*', + ], + familyMatcher: 'openai-auth-family', + injectFiles: [ + 'content/activation-utils.js', + 'content/utils.js', + 'content/operation-delay.js', + 'content/auth-page-recovery.js', + 'content/phone-country-utils.js', + 'content/phone-auth.js', + 'content/signup-page.js', + ], + cleanupScopes: ['oauth-localhost-callback'], + }, + + 'mail-gmail': { + flowId: null, + kind: 'mail-provider', + label: 'Gmail', + hostPatterns: ['https://mail.google.com/*'], + familyMatcher: 'gmail-family', + injectFiles: ['content/activation-utils.js', 'content/utils.js', 'content/gmail-mail.js'], + }, + + 'panel-sub2api': { + flowId: 'openai', + kind: 'panel-page', + label: 'SUB2API 后台', + dynamicOnly: true, + familyMatcher: 'sub2api-panel-family', + injectFiles: ['content/utils.js', 'content/sub2api-panel.js'], + }, +}; +``` + +### 3.1 source 里至少要有这些字段 + +- `id` +- `flowId` +- `kind` +- `label` +- `hostPatterns` +- `familyMatcher` +- `injectFiles` +- `readyPolicy` +- `cleanupScopes` +- `tabReusePolicy` + +### 3.2 source 不等于 flow + +一个 flow 会有多个 source: + +- `openai-entry` +- `openai-auth` +- `openai-plus-checkout` +- `openai-paypal` +- `openai-gopay` +- `panel-sub2api` + +同样,一个 source 也可能不属于具体 flow,例如: + +- `mail-gmail` +- `mail-163` +- `mail-2925` +- `mail-inbucket` + +## 4. `driverRegistry` 设计 + +`driverRegistry` 解决的是“页面上能做什么”,不是“标签页如何复用”。 + +```js +driverRegistry = { + 'openai-auth': { + sourceId: 'openai-auth', + driverId: 'content/signup-page', + commands: [ + 'OPEN_SIGNUP', + 'SUBMIT_SIGNUP_IDENTIFIER', + 'SUBMIT_PASSWORD', + 'SUBMIT_PROFILE', + 'SUBMIT_LOGIN_CODE', + 'SUBMIT_PHONE_CODE', + 'DETECT_AUTH_STATE', + ], + }, + + 'mail-gmail': { + sourceId: 'mail-gmail', + driverId: 'content/gmail-mail', + commands: ['POLL_MAILBOX', 'OPEN_MESSAGE', 'DELETE_MESSAGE'], + }, +}; +``` + +这样做的好处是: + +- `tab-runtime` 只关心 source +- workflow / mail service 只关心 driver capability +- 后续换内容脚本实现时,不需要改 tab 生命周期代码 + +## 5. `signup-page` 的迁移策略 + +`signup-page` 不能直接继续作为未来通用 source 名字。 + +建议迁移方式: + +1. 新增正式 source:`openai-auth` +2. 在 `sourceRegistry.aliases` 里临时保留: + - `signup-page -> openai-auth` +3. `getSourceLabel()`、`matchesSourceUrlFamily()`、`ensureContentScriptReadyOnTab()` 全部优先读注册表 +4. 等 OpenAI 全链路迁完后,再删除 `signup-page` 别名 + +## 6. localhost callback cleanup 怎么改 + +当前 callback 清理逻辑最大的问题是“只知道 callback URL,不知道 callback 属于哪个 flow source”。 + +建议改成: + +```js +callbackRegistry = { + 'oauth-localhost-callback': { + ownerSourceId: 'openai-auth', + matcher: isLocalhostOAuthCallbackUrl, + clearOwnerTabOnClose: true, + }, +}; +``` + +`closeLocalhostCallbackTabs()` 的输入要升级为: + +- `cleanupScope` +- `callbackUrl` +- `ownerSourceId` + +而不是默认拿 `signup-page`。 + +## 7. `content/utils.js` 的改法 + +### 7.1 禁止默认回退成 `vps-panel` + +新的规则应该是: + +- 有 `window.__MULTIPAGE_SOURCE` 时,以注入 source 为准 +- 没有显式 source 时,只在静态 host map 里做白名单匹配 +- 仍无法识别时返回 `unknown-source` + +`unknown-source` 不参与 tab 注册,不自动 `reportReady()`,只记录诊断日志。 + +### 7.2 child frame ready 规则也要转到注册表 + +当前 `shouldReportReadyForFrame()` 里写死了多个 source 名称。后续应改成 source metadata: + +- `readyPolicy: 'top-frame-only'` +- `readyPolicy: 'allow-child-frame'` + +## 8. manifest / 动态注入策略 + +原则上以后由 `sourceRegistry` 作为单一事实来源,manifest 只是实现载体。 + +建议约定: + +- 稳定公共页面可继续走静态 `content_scripts` +- 变动大、域名多、只在运行期打开的页面改走 `chrome.scripting.executeScript` +- 每个 source 明确自己的 `hostPatterns` 和 `injectFiles` +- 新增 flow 时,不允许直接在业务代码里散写 `injectFiles = [...]` + +## 9. 与网络策略的关系 + +`sourceRegistry` 只负责页面来源,不负责代理策略;但每个 flow 应同时提供自己的 `networkProfile`,至少包含: + +- `guardDomains` +- `probeEndpoints` +- `failCloseDomains` + +否则 `ip-proxy-core` 仍会继续写死 `chatgpt.com / openai.com`。 + +## 10. 第一阶段迁移顺序 + +1. 引入 `sourceRegistry / driverRegistry` 与旧 source alias。 +2. 把 `getSourceLabel()`、`matchesSourceUrlFamily()`、`ensureContentScriptReadyOnTab()` 改成读注册表。 +3. 去掉 `detectScriptSource()` 的 `vps-panel` 默认回退。 +4. 改 `closeLocalhostCallbackTabs()`,不再写死 `signup-page`。 +5. 第二个 flow 接入时,严格要求先注册 source 和 driver,再写步骤逻辑。 + +## 11. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- `signup-page` 实际是 OpenAI source,不是通用注册页 +- source URL family / content ready / localhost callback cleanup 没有统一注册表 +- manifest 与动态注入没有统一入口 +- `content/utils.js` 的默认 source 回退会污染未来 flow + diff --git a/docs/多注册流程架构边界.md b/docs/多注册流程架构边界.md new file mode 100644 index 0000000..894b606 --- /dev/null +++ b/docs/多注册流程架构边界.md @@ -0,0 +1,124 @@ +# 多注册流程架构边界 + +本文记录后续从“OpenAI 注册扩展”升级到“多注册流程扩展”时的模块边界。当前结论是:需要引入多 flow 架构,但不要把所有能力都提前抽成通用服务。 + +## 1. 总体判断 + +后续新增注册项目时,不应继续在现有 OpenAI 步骤里堆 `if site === ...`。不同网站的注册入口、验证码、资料页、风控页、回调页和成功判定都可能完全不同,应按独立 flow 处理。 + +目标形态: + +```txt +core/ + flow-registry + workflow-engine + runtime-state + tab-runtime + logging + account-artifacts + email + mail-code-polling + network-proxy + +flows/ + openai/ + workflow.js + content-driver.js + mail-rules.js + network-profile.js + phone-verification-flow.js + phone-sms-providers/ + + site-a/ + workflow.js + content-driver.js + mail-rules.js + network-profile.js +``` + +第一阶段不需要立刻调整成上述目录,只需要按这个边界重构,避免新增项目继续污染 OpenAI 专用逻辑。 + +## 2. 必须通用化的部分 + +- `activeFlowId / runId / nodeId` 状态模型:替代只适合单流程的 `currentStep / stepStatuses`,同时避免与现有外部接口里的远端 `flow_id` 语义冲突。 +- Workflow engine:负责节点执行、跳转、停止、恢复、重试和超时,不理解具体网站页面。 +- Flow registry:负责注册 OpenAI、后续网站 A、网站 B 等独立流程。 +- Tab runtime:标签页注册、自动化窗口锁定、脚本注入、消息超时、页面 ready 检查。 +- Logging / status:结构化日志、节点状态、运行进度、停止和失败展示。 +- Account artifact store:记录账号产物、身份、凭据、回调结果和失败信息。 +- Email identity:邮箱生成、邮箱池、别名、转发收件目标等身份能力。 +- Mail polling:邮件验证码、magic link、激活链接的轮询框架;具体过滤和提取规则由 flow 提供。 +- IP proxy / network policy:代理应用、切换、出口检测、防泄漏;目标域名和探测 URL 由 flow 提供。 +- Sidepanel dynamic renderer:按 flow capability 显示配置区、步骤列表和运行状态。 +- Settings schema:通用配置与 flow 私有配置分层导入导出。 +- Error taxonomy / recovery policy:通用层只处理执行框架错误,业务错误由 flow 自己分类。 + +## 3. 暂不通用化的部分 + +以下能力当前只属于 OpenAI flow,不进入 core: + +- 手机接码:HeroSMS / 5sim / NexSMS、取号、复用、轮询、换号、释放、手机号注册和后置 add-phone。 +- LuckMail:当前 `project_code = openai` 的购邮、复用、已用/保留/禁用管理。 +- OpenAI / ChatGPT 页面 DOM 操作。 +- OpenAI OAuth 授权、localhost callback、CPA / SUB2API / Codex2API 绑定。 +- ChatGPT Plus、PayPal、GoPay、GPC 相关支付流程。 +- OpenAI 的验证码邮件过滤规则、登录状态判断、add-phone fatal 判断。 +- OpenAI 专属账号产物字段,如 Plus checkout、OAuth state、平台验证字段。 + +原因:当前除了 OpenAI,新项目暂不需要手机接码。过早把接码抽成通用服务会让新 flow 被迫理解手机号订单、短信平台、号码复用和页面 resend 细节,增加维护成本。 + +## 4. 手机接码边界 + +手机接码先保持为 OpenAI 专属能力。 + +当前这些模块在逻辑上归属 `flows/openai`,即使物理路径暂时还在旧目录: + +- `background/phone-verification-flow.js` +- `phone-sms/providers/hero-sms.js` +- `phone-sms/providers/five-sim.js` +- `phone-sms/providers/registry.js` +- `content/phone-auth.js` +- `content/phone-country-utils.js` +- sidepanel 中的接码配置区 +- 相关测试:`tests/phone-verification-flow.test.js`、`tests/five-sim-provider.test.js`、`tests/sidepanel-phone-verification-settings.test.js` + +后续新增 flow 默认 `sms: false`,不显示接码配置,不加载接码步骤,也不要求 workflow engine 理解短信订单。 + +如果未来第二个真实 flow 也需要接码,再按事实抽象,优先抽 provider API,而不是直接抽完整手机号验证编排: + +```js +flow.phoneVerification = { + acquirePolicy, + submitPhoneNumber, + submitCode, + classifyPhoneError, + recoverAfterTimeout, +} +``` + +只有两个以上 flow 共享相同短信供应商生命周期时,才考虑把 `phone-sms/providers/*` 下沉到 `core/services/sms`。 + +## 5. 迁移顺序 + +1. 引入 `activeFlowId / runId / currentNodeId / nodeStatuses / flowContext`,先兼容 OpenAI 旧步骤。 +2. 把邮箱、邮件轮询、代理、tab runtime、日志和账号记录逐步参数化,去掉 OpenAI 常量。 +3. 建立 `flows/openai` 边界,把 OpenAI 步骤、content driver、OAuth、Plus 和接码逻辑收拢进去。 +4. 用第二个真实注册项目验证 flow registry 和 workflow engine,而不是靠假想接口设计过度抽象。 +5. 稳定后清理旧 `step` 数字兼容层。 + +## 6. 新 flow 接入原则 + +新增注册项目时,只允许新增该 flow 的 workflow、content driver、mail rules、network profile 和必要的私有配置。不要直接修改 OpenAI 步骤,也不要把 OpenAI 的手机号验证、OAuth、Plus 支付逻辑提升为通用能力。 + +通用层只负责“怎么运行一个 flow”,具体 flow 负责“这个网站怎么注册”。 + +## 7. 配套设计文档 + +本边界文档只回答“什么该进 core、什么暂时不要抽”。要真正开工,还需要同时遵守下面四份配套设计: + +- `docs/多注册流程状态迁移设计.md`:解决 `DEFAULT_STATE` 扁平模型、旧 step 兼容、自动运行/日志/账号记录如何迁移。 +- `docs/多注册流程来源与驱动注册设计.md`:解决 `signup-page`、source family、内容脚本注入和 localhost callback cleanup 的注册表设计。 +- `docs/多注册流程邮件分层设计.md`:解决 provider driver 与 flow mail rules 的边界,以及 LuckMail 暂不通用化的问题。 +- `docs/多注册流程侧边栏能力矩阵.md`:解决 sidepanel 展示层与业务约束层混在一起的问题。 + +后续新 flow 设计如果与这四份文档冲突,以“先修正文档边界,再动代码”为准,不允许直接在现有 OpenAI 逻辑上叠条件分支。 diff --git a/docs/多注册流程状态迁移设计.md b/docs/多注册流程状态迁移设计.md new file mode 100644 index 0000000..fbce6e4 --- /dev/null +++ b/docs/多注册流程状态迁移设计.md @@ -0,0 +1,248 @@ +# 多注册流程状态迁移设计 + +本文是 `docs/多注册流程架构边界.md` 的落地补充,专门解决“现有扁平状态怎么迁到多 flow 架构”这个最容易把旧逻辑拖垮的问题。 + +## 1. 先说结论 + +当前项目不能直接在 `DEFAULT_STATE` 上继续堆字段。要支持多个完全不同的注册流程,必须先把“运行态”“持久配置”“共享服务状态”“flow 私有状态”分层,再保留一层 OpenAI 旧步骤兼容视图。 + +这次迁移的目标不是一步把全部字段搬完,而是先建立新的状态主模型,让旧代码通过 adapter 继续运行。 + +## 2. 当前源码里的真实问题 + +### 2.1 `DEFAULT_STATE` 仍然是单流程扁平模型 + +`background.js` 里的 `DEFAULT_STATE` 目前把这些不同层次的状态混在一起: + +- 流程进度:`currentStep`、`stepStatuses` +- 通用运行态:`automationWindowId`、`tabRegistry`、`logs` +- OpenAI OAuth / 平台回调:`oauthUrl`、`localhostUrl`、`sub2apiSessionId`、`codex2apiSessionId` +- OpenAI Plus:`plusCheckoutTabId`、`plusCheckoutUrl`、`plusBillingAddress` +- OpenAI 接码:`currentPhoneActivation`、`signupPhoneActivation`、`reusablePhoneActivation` +- OpenAI LuckMail:`currentLuckmailPurchase` +- 共享邮箱运行态:`registrationEmailState` + +这会带来两个直接后果: + +1. 新 flow 一接进来,就只能继续往全局 state 加字段。 +2. 任何 reset / retry / stop 都容易误清理别的业务域状态。 + +### 2.2 旧步骤数字模型已经渗透到多个模块 + +当前不只是 `background.js` 依赖 `currentStep / stepStatuses`,下列模块也在按“Step 1~13”理解流程: + +- `background/auto-run-controller.js` +- `background/logging-status.js` +- `background/account-run-history.js` + +所以新状态模型不能只改 `background.js`,必须给自动运行、日志、账号记录保留兼容出口。 + +### 2.3 `registrationEmailState` 名字看起来通用,内部却仍带 OpenAI 身份假设 + +`background/registration-email-state.js` 已经把邮箱运行态单独抽出来了,这是好事;但它的 `preserveAccountIdentity` 现在默认保留的是“手机号身份”,也就是: + +- `accountIdentifierType = phone` +- `signupPhoneNumber` +- `signupPhoneActivation` +- `signupPhoneCompletedActivation` + +这对 OpenAI 的 `add-email` 分支是对的,但对未来可能出现的“用户名主身份”“邀请码主身份”“钱包地址主身份”并不通用。 + +## 3. 目标状态分层 + +建议先建立“概念上的标准状态模型”,物理存储可以分阶段迁移。 + +```js +chrome.storage.session.runtimeState = { + activeFlowId: 'openai', + activeRunId: 'run_20260512_xxx', + currentNodeId: 'submit-signup-email', + nodeStatuses: { + 'open-chatgpt': 'completed', + 'submit-signup-email': 'running', + }, + + shared: { + automationWindowId: 123, + tabRegistry: {}, + sourceLastUrls: {}, + logs: [], + autoRun: {}, + accountArtifacts: {}, + }, + + services: { + mail: {}, + proxy: {}, + accountPools: {}, + }, + + flows: { + openai: { + auth: {}, + platformBinding: {}, + plus: {}, + phoneVerification: {}, + luckmail: {}, + identity: {}, + }, + }, + + legacyStepCompat: { + currentStep: 2, + stepStatuses: { 1: 'completed', 2: 'running' }, + }, +}; + +chrome.storage.local.settings = { + schemaVersion: 2, + + shared: { + autoRunDefaults: {}, + logging: {}, + }, + + services: { + mailProviders: {}, + ipProxy: {}, + hotmailPool: {}, + mail2925Pool: {}, + icloud: {}, + customEmailPool: {}, + }, + + flows: { + openai: { + signupMethod: 'email', + phoneVerificationEnabled: false, + plusModeEnabled: false, + plusPaymentMethod: 'paypal', + sub2api: {}, + codex2api: {}, + luckmail: {}, + smsProviders: {}, + }, + }, +}; +``` + +## 4. 四层状态边界 + +### 4.1 共享运行态 `runtimeState.shared` + +只放“运行任何 flow 都需要”的内容: + +- `activeFlowId / activeRunId / currentNodeId / nodeStatuses` +- `automationWindowId` +- `tabRegistry` +- `sourceLastUrls` +- `logs` +- 自动运行轮次、暂停、计时器计划 +- 账号产物总览,如最终身份、最终邮箱、最终手机号、完成时间 + +这里不要再放 OpenAI 特有概念,例如 `plusCheckoutTabId`、`currentPhoneActivation`。 + +### 4.2 共享服务状态 `runtimeState.services` + +放“可被多个 flow 复用的服务运行态”,而不是具体网站流程状态: + +- 邮箱 provider 当前会话 +- 2925 / Hotmail / iCloud / Cloud Mail 等共享邮箱服务上下文 +- IP 代理当前应用结果、探测结果、池游标 +- 可复用的共享账号池游标 + +注意:`LuckMail` 目前不进这一层,它仍属于 OpenAI flow 私有能力。 + +### 4.3 flow 私有运行态 `runtimeState.flows[flowId]` + +每个 flow 自己保管自己的业务运行态。当前 OpenAI 至少应拆成: + +- `auth`:`oauthUrl`、`localhostUrl` +- `platformBinding`:`sub2api*`、`codex2api*` +- `plus`:`plusCheckoutTabId`、`plusCheckoutUrl`、`plusBillingAddress` +- `phoneVerification`:`currentPhoneActivation`、`signupPhoneActivation`、`reusablePhoneActivation` +- `luckmail`:`currentLuckmailPurchase`、`currentLuckmailMailCursor` +- `identity`:OpenAI 特有的登录方式冻结结果、`step8VerificationTargetEmail` 等 + +### 4.4 持久配置 `settings` + +持久配置必须按“共享 / 服务 / flow 私有”拆开,不再把所有配置都平铺到同一个 namespace。 + +特别注意: + +- `signupMethod` 先保留在 `flows.openai`,不要急着抽成全局通用枚举。 +- `plusModeEnabled`、`plusPaymentMethod`、`gopay*`、`paypal*` 都属于 OpenAI flow 私有配置。 +- `phoneVerificationEnabled` 当前也属于 OpenAI flow 私有配置。 + +## 5. 旧步骤兼容层怎么做 + +迁移阶段仍要保留 `currentStep / stepStatuses`,但它们不再是主数据,只是 `legacyStepCompat` 的派生视图。 + +建议规则: + +1. 新的 canonical 状态只写 `activeFlowId / currentNodeId / nodeStatuses` +2. OpenAI flow 通过 node-to-step 映射生成 `legacyStepCompat` +3. `getState()` 对旧模块仍返回: + - `currentStep` + - `stepStatuses` + - OpenAI 旧字段镜像 +4. `setStepStatus(step, status)` 内部先更新 node,再回写 legacy step 视图 + +这样可以保证: + +- `background/auto-run-controller.js` 先不重写也能跑 +- `background/logging-status.js` 先不重写也能继续渲染 +- `background/account-run-history.js` 仍能按旧语义落盘 + +## 6. 账号记录与自动运行的兼容策略 + +### 6.1 自动运行 + +`background/auto-run-controller.js` 当前按数字步骤推断进度、失败点和恢复点。迁移阶段要增加一层统一 selector: + +- `getActiveRunProgress(state)` +- `getLegacyStepCompat(state)` +- `inferResumeNode(state, flowId)` + +第一阶段可以继续让 OpenAI flow 通过旧 step 恢复;第二个 flow 接入时再真正改成 node-based resume。 + +### 6.2 账号记录 + +`background/account-run-history.js` 不能只记“失败在第几步”,还要开始记录: + +- `activeFlowId` +- `activeRunId` +- `currentNodeId` +- `nodeStatuses` 摘要 +- `legacyFailedStep`(仅 OpenAI 兼容) + +否则未来多 flow 下,单看 `step7_failed` 已经没有意义。 + +## 7. 命名约束 + +仓库里已经存在别的 `flow_id` 语义,用在 GPC / GoPay 远端任务里。为了避免冲突: + +- 扩展内部运行态统一使用 `activeFlowId` +- 当前轮标识统一使用 `activeRunId` +- 节点标识统一使用 `currentNodeId` +- 不新增裸字段 `flowId` + +`flow_id` 只保留给外部接口 payload 兼容使用。 + +## 8. 第一阶段迁移顺序 + +1. 新增状态 selector / patch helper,不改业务步骤行为。 +2. 给 OpenAI flow 建立 `flowState.openai` 命名空间,同时保留旧字段镜像。 +3. 把日志、自动运行、账号记录改为优先读 selector,不直接拼 `stepStatuses`。 +4. 把新加字段一律放进 `shared / services / flows.openai`,禁止继续向 `DEFAULT_STATE` 顶层加 OpenAI 字段。 +5. 第二个 flow 接入前,再清理旧 step 镜像的写路径。 + +## 9. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- `DEFAULT_STATE` 扁平模型没有命名空间 +- 旧步骤状态与新 node 状态如何并存 +- 自动运行 / 日志 / 账号记录如何不被状态迁移打断 +- `registrationEmailState` 当前仍隐含“手机号是唯一可保留主身份”的假设 + diff --git a/docs/多注册流程邮件分层设计.md b/docs/多注册流程邮件分层设计.md new file mode 100644 index 0000000..2dc28c3 --- /dev/null +++ b/docs/多注册流程邮件分层设计.md @@ -0,0 +1,222 @@ +# 多注册流程邮件分层设计 + +本文专门解决“邮件轮询虽然看起来像公共能力,但实际 OpenAI 规则已经写进 provider 脚本和后台轮询里”这个问题。 + +## 1. 先说结论 + +邮件相关能力必须拆成两层,不能再只写一句“mail polling 通用化”: + +1. `mail provider driver` +2. `flow mail rules` + +前者负责“怎么从 Gmail / 163 / 2925 / Inbucket / API 邮箱里拿到邮件”,后者负责“哪封邮件算当前 flow 需要的验证码 / magic link / 激活链接”。 + +## 2. 当前源码里的真实耦合 + +### 2.1 后台轮询 payload 已经带着 OpenAI 过滤条件 + +`background/verification-flow.js` 当前在 `getVerificationPollPayload()` 里直接写死: + +- `senderFilters: ['openai', 'noreply', 'verify', 'auth', ...]` +- `subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', ...]` + +这不是 provider 通用逻辑,而是 OpenAI flow 规则。 + +### 2.2 内容脚本也已经写死 OpenAI / ChatGPT 正则 + +当前多个 provider 脚本里直接内嵌了 OpenAI / ChatGPT 识别: + +- `content/gmail-mail.js` +- `content/mail-163.js` +- `content/mail-2925.js` +- `content/inbucket-mail.js` + +例如: + +- `openai` +- `chatgpt` +- `verification` +- `log-in code` +- `strictChatGPTCodeOnly` + +这意味着现在不是“通用 provider + OpenAI 规则”,而是“OpenAI 规则渗透进 provider 实现本身”。 + +### 2.3 LuckMail 不能直接算进共享邮件层 + +LuckMail 当前还有一个额外边界:它不只是“邮箱提供商”,还绑定了 OpenAI 项目购邮与复用语义。 + +当前事实包括: + +- `DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'` +- sidepanel 明确只展示 `openai` 项目的 LuckMail 邮箱 + +所以 LuckMail 当前应留在 OpenAI flow 内,不直接并入通用 `mailProviders`。 + +## 3. 目标分层 + +## 3.1 第一层:`mail provider driver` + +这一层只负责 provider 访问与消息归一化,不理解具体网站业务。 + +职责包括: + +- 打开 provider 页面或请求 provider API +- 等待页面 ready / 会话可用 +- 列邮件、开邮件、取正文、删邮件 +- 会话级去重 +- 输出统一格式的消息对象 + +建议输出结构: + +```js +{ + providerId: 'gmail', + messageId: 'abc123', + receivedAt: 1710000000000, + sender: 'OpenAI ', + subject: 'Your code is 123456', + previewText: 'Your code is 123456', + normalizedText: 'your code is 123456', + html: '...', + links: ['https://...'], + targetHints: ['user@example.com'], + metadata: {}, +} +``` + +这一层不要再写: + +- “这是不是 OpenAI 邮件” +- “是不是 ChatGPT 登录码” +- “这个 code 是否必须 6 位” + +## 3.2 第二层:`flow mail rules` + +这一层由具体 flow 定义: + +- 要找的邮件类型 +- 过滤条件 +- 选择规则 +- 提取规则 +- 命中后的消费动作 + +建议接口: + +```js +flow.mailRules = { + signupCode: { + buildQuery(state) {}, + matchMessage(message, state) {}, + extractArtifact(message, state) {}, + afterConsume(message, provider, state) {}, + }, + loginCode: { + buildQuery(state) {}, + matchMessage(message, state) {}, + extractArtifact(message, state) {}, + }, +}; +``` + +其中: + +- `buildQuery` 负责时间窗、目标邮箱、允许的发件人线索 +- `matchMessage` 负责判定这是不是当前 flow 的邮件 +- `extractArtifact` 负责提取 `code / magic-link / activation-link` +- `afterConsume` 负责删除邮件、记录 cursor、保存已试 code + +## 4. 建议的共享 mail service 接口 + +```js +mailService.poll({ + providerId: 'gmail', + rule: flow.mailRules.signupCode, + state, + maxAttempts: 5, + intervalMs: 3000, +}); +``` + +内部流程: + +1. provider driver 按 query 拉取候选消息 +2. rule 决定哪封命中 +3. rule 提取 artifact +4. service 返回统一结果 + +返回值示例: + +```js +{ + ok: true, + artifact: { + type: 'code', + value: '123456', + }, + message: { ...normalizedMessage }, +} +``` + +## 5. OpenAI flow 当前应该拆出的规则 + +OpenAI 至少需要单独拆出这些规则文件: + +- `flows/openai/mail-rules/signup-code.js` +- `flows/openai/mail-rules/login-code.js` +- `flows/openai/mail-rules/add-email-code.js` + +它们负责承接现在散落在这些地方的规则: + +- `verification-flow.js` 中的 sender / subject filters +- `gmail-mail.js` / `mail-163.js` / `mail-2925.js` / `inbucket-mail.js` 中的 OpenAI / ChatGPT 正则 +- `strictChatGPTCodeOnly` +- Step 4 与 Step 8 不同的时间窗和目标邮箱逻辑 + +## 6. provider driver 的拆分建议 + +### 6.1 可以进入共享层的 provider + +这些 provider 的访问方式本身有共享价值,可以做成共享 mail driver: + +- Gmail +- 163 / 126 / 163 VIP +- 2925 +- Inbucket +- iCloud Mail +- Hotmail helper / Graph Mail +- Cloud Mail / SkyMail + +### 6.2 暂时不进入共享层的 provider + +- LuckMail:当前仍是 OpenAI 项目购邮与验证码轮询能力的一部分 + +如果未来第二个 flow 也明确复用 LuckMail,且复用的是“同一套 project / token / 生命周期”,再考虑把它抽出来。 + +## 7. `registrationEmailState` 与邮件层的关系 + +`registrationEmailState` 负责“当前流程邮箱身份”,不是“provider 拉信规则”。 + +所以后续边界应是: + +- `registrationEmailState`:运行态身份记录 +- `mail provider driver`:拉信能力 +- `flow mail rules`:判定与提取 + +不要再在 `registrationEmailState` 或 provider driver 里夹带 OpenAI `add-email` 业务判断。 + +## 8. 迁移顺序 + +1. 先把 `background/verification-flow.js` 里的查询构造迁到 `flows/openai/mail-rules/*` +2. 再把各 provider 脚本里的 OpenAI / ChatGPT 正则搬出到 rule 层 +3. 保留 provider 里的“消息抓取 / 页面操作 / 删除策略” +4. 等第二个 flow 接入时,只新增它自己的 `mail-rules`,不改 Gmail / 163 / 2925 driver + +## 9. 本文对应解决的缺口 + +本文主要补齐以下缺口: + +- “Mail polling 通用化”表述过于粗糙,没有说明 provider 与 rule 两层 +- provider 内容脚本里已经嵌入 OpenAI 规则 +- `strictChatGPTCodeOnly` 这类开关的归属边界不清 +- LuckMail 当前其实还是 OpenAI flow 私有邮件能力,不是共享 provider + diff --git a/项目完整链路说明.md b/项目完整链路说明.md index ac1c021..39ac559 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -478,7 +478,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 - 打开邮箱页或 API 轮询入口 - 轮询登录验证码并回填 4. 当前是真实 `phone-verification` 页: - - 才复用手机号验证码共享流程,重新激活同一号码并轮询短信验证码 + - 才复用 OpenAI 专属手机号验证码流程,重新激活同一号码并轮询短信验证码 - 在当前 `phone-verification` 页回填短信验证码 5. 如果登录验证码提交后页面进入 `add-phone / 手机号页`,则邮箱注册模式下交给后续 OAuth 后置手机号验证链路;手机号注册模式不把 `signupPhone*` 身份误当成官方 add-phone 订单 6. 如遇邮箱轮询类失败或显式的 `STEP8_RESTART_STEP7` 恢复错误,则按有限次数回到 Step 7 重试;若仍停在验证码页、手机验证码页或 add-email 页,则优先在当前链路重试 @@ -503,8 +503,8 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 流程: 1. 监听 localhost callback -2. 准备 OAuth 同意页;如果页面已进入 `add-phone / phone-verification`,先切入手机号验证共享流程 -3. 手机号验证共享流程会按当前 sidepanel 中保存的接码平台 `phoneSmsProvider` 选择 HeroSMS 或 5sim,并使用对应平台的 API Key、国家/地区、价格上限申请或复用号码 +2. 准备 OAuth 同意页;如果页面已进入 `add-phone / phone-verification`,先切入 OpenAI 专属手机号验证流程 +3. OpenAI 手机号验证流程会按当前 sidepanel 中保存的接码平台 `phoneSmsProvider` 选择 HeroSMS / 5sim / NexSMS,并使用对应平台的 API Key、国家/地区、价格上限申请或复用号码 4. 后台提交号码后,会按 `currentPhoneActivation.provider` 分发后续查码、完成、取消、ban、reuse:HeroSMS 走原 `getStatus / setStatus` 逻辑,5sim 走 `/v1/user/check|finish|cancel|ban|reuse` 5. 验证码被拒绝或长时间收不到短信时,会按平台决定重发、换号、ban/取消当前激活,必要时把自动流拉回 Step 7 6. 手机号验证完成后,如果认证页偶发进入资料页,内容脚本会复用 Step 5 的姓名生日填写逻辑补齐资料,再继续等待 OAuth 同意页 @@ -514,7 +514,7 @@ IP 代理模块在同步、切换、Change、出口探测和自动运行成功 补充: -- HeroSMS 和 5sim 都归入接码平台适配层;默认平台是 HeroSMS,5sim 产品码固定为 `openai`,operator 默认 `any`。 +- HeroSMS / 5sim / NexSMS 都归入 OpenAI 接码平台适配层;默认平台是 HeroSMS,5sim 产品码固定为 `openai`,operator 默认 `any`。该适配层暂不进入多注册 flow 的 core 通用服务,后续只有第二个真实 flow 也需要接码时再按实际复用点抽象。 - 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。 - 5sim 的国家/地区使用字符串 slug(当前开放 `indonesia / thailand / vietnam`),HeroSMS 仍使用数字国家 ID(当前开放印度尼西亚/泰国/越南)。 - 如果同一个号码在重发短信后仍收不到验证码,后台会按当前平台取消或 ban 激活并换号,而不是把当前号码无限重试下去。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 015105f..b7482be 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -58,7 +58,7 @@ - `background/registration-email-state.js`:注册邮箱运行态共享模块,负责维护 `registrationEmailState = { current, previous, source, updatedAt }`,并提供“当前邮箱清空时保留上一比较基线”“Duck 生成前解析比较基线”“Step 8 `add-email` 写入邮箱时按需保留手机号身份”的统一工具。 - `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。 - `background/paypal-account-store.js`:PayPal 账号池持久化模块,负责保存账号列表、切换当前账号,并把当前选中账号同步回兼容字段 `paypalEmail / paypalPassword`。 -- `background/phone-verification-flow.js`:手机号验证码共享流程模块,负责复用 HeroSMS / 5sim / NexSMS 的取号、复用、轮询、重发、换号与收尾能力;既承接 OAuth 后置 `add-phone / phone-verification` 页面,也承接手机号注册 Step 4 和 Step 8 中真实出现的 `phone-verification` 页面,并保持 `signupPhone*` 运行态与 `currentPhoneActivation` 隔离;后置 add-phone 成功后会把已绑定手机号写入运行态 `phoneNumber`,供账号记录并入同一轮;提交后置手机号验证码时会一并透传可复用的资料页 payload,便于认证页偶发跳回资料页时继续补齐;该流程由调用方传入当前可见步骤号,手机号日志不会再固定显示普通模式 Step 9。 +- `background/phone-verification-flow.js`:OpenAI 专属手机号验证码流程模块,负责复用 HeroSMS / 5sim / NexSMS 的取号、复用、轮询、重发、换号与收尾能力;既承接 OpenAI OAuth 后置 `add-phone / phone-verification` 页面,也承接 OpenAI 手机号注册 Step 4 和 Step 8 中真实出现的 `phone-verification` 页面,并保持 `signupPhone*` 运行态与 `currentPhoneActivation` 隔离;后续多注册 flow 架构中,该模块应收拢到 `flows/openai` 边界内,不作为 core 通用接码服务提前抽象;后置 add-phone 成功后会把已绑定手机号写入运行态 `phoneNumber`,供账号记录并入同一轮;提交后置手机号验证码时会一并透传可复用的资料页 payload,便于认证页偶发跳回资料页时继续补齐;该流程由调用方传入当前可见步骤号,手机号日志不会再固定显示普通模式 Step 9。 - `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。 - `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、Step 2 打开入口页后的加载完成与额外 3 秒稳定等待、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号;若邮箱生成器在生成阶段已经写回运行态,这里不会再重复覆盖 `registrationEmailState`,但仍会在 Step 8 `add-email` 的手机号身份场景下通过共享持久化按需保留手机号身份。 - `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;内容脚本恢复等待日志支持 `logStep / logStepKey`,用于保持 Plus 复用步骤的日志标签正确;当前等待标签完成、等待标签完成并短暂稳定、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。 @@ -69,7 +69,7 @@ - `background/steps/wait-registration-success.js`:步骤 6 实现,负责注册资料提交后等待 20 秒,让注册成功状态和页面跳转稳定;默认不清理 cookies,只有侧栏第六步 `清 Cookies` 开关开启时才会在等待结束后清理 ChatGPT / OpenAI 相关 cookies。 - `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成;等待 callback 期间会动态检查 OAuth 总预算开关,关闭后仅保留本地回调等待超时。 - `background/steps/create-plus-checkout.js`:Plus 模式第 6 步实现,负责在已登录 ChatGPT 页面创建 PayPal / GoPay checkout session,或在 GPC 模式下读取 accessToken 并通过 `https://gpc.qlhazycoder.top/api/gp/tasks` 创建队列任务,记录 checkout / GPC task 运行态。 -- `background/steps/fetch-login-code.js`:步骤 8 实现,负责按真实认证页状态分发登录后续流程:邮箱验证码页走邮箱轮询、验证码回填与回退控制;真实 `phone-verification` 页才复用手机号验证码共享流程;手机号注册登录后若进入 `add-email`,会先按共享邮箱状态持久化规则生成/解析邮箱并提交绑定,在不丢失当前手机号身份的前提下再进入邮箱验证码轮询;邮箱分支对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱,当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录;命中 `email_in_use` 时会仅清空当前邮箱并保留上一轮比较基线,再在当前 Step 8 内重开 `add-email` 获取新邮箱,`max_check_attempts` 也只重启当前步骤恢复链。 +- `background/steps/fetch-login-code.js`:步骤 8 实现,负责按真实认证页状态分发登录后续流程:邮箱验证码页走邮箱轮询、验证码回填与回退控制;真实 `phone-verification` 页才复用 OpenAI 专属手机号验证码流程;手机号注册登录后若进入 `add-email`,会先按共享邮箱状态持久化规则生成/解析邮箱并提交绑定,在不丢失当前手机号身份的前提下再进入邮箱验证码轮询;邮箱分支对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱,当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录;命中 `email_in_use` 时会仅清空当前邮箱并保留上一轮比较基线,再在当前 Step 8 内重开 `add-email` 获取新邮箱,`max_check_attempts` 也只重启当前步骤恢复链。 - `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口;邮箱注册继续走邮箱验证码,手机号注册改走短信验证码;当 provider 为 2925 时,会在邮箱轮询前先确保当前 2925 账号已自动登录。 - `background/steps/fill-plus-checkout.js`:Plus 模式第 7 步实现,负责驱动 PayPal / GoPay checkout 页面选择付款方式、填写账单地址、提交订阅并等待跳转;GPC 模式则轮询队列任务,按远端 `api_waiting_for` 提交 OTP / PIN,并在任务失败、过期或取消时清理运行态。 - `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交;当前已改为身份中立,手机号注册遇到无密码页时会按页面状态自动跳过。 @@ -112,14 +112,20 @@ - `docs/仓库协作者AI分析PR与合并标准流程.md`:仓库协作者进行 AI 分析 PR 与合并时的流程说明。 - `docs/ip-proxy-module.md`:IP Proxy 模块说明,记录模块定位、后台/侧栏代码结构、当前功能范围、711Proxy 参数联动、操作方式、发布策略与已知限制。 +- `docs/local-customizations-maintenance.md`:记录本地相对上游长期保留的定制能力、关键代码位置,以及后续同步上游时的检查点。 +- `docs/公告书写标准.md`:公告类文档的书写标准与维护约束。 - `docs/使用教程.md`:旧版完整使用教程,保留尚未完全拆分的 HeroSMS、IP 代理、GoPay 等补充章节。 - `docs/使用教程/使用教程.md`:分部分使用教程总索引。 - `docs/使用教程/使用教程书写模板.md`:后续维护分部分教程时使用的书写与拆分模板。 - `docs/使用教程/分部分/*.md`:按主题拆分后的使用教程正文。 +- `docs/错误重试分层策略.md`:记录错误分类、恢复边界与重试策略分层,避免不同模块各自定义失败语义。 +- `docs/多注册流程架构边界.md`:后续从 OpenAI 单注册流程升级到多注册 flow 时的架构边界说明,明确 core 通用能力、flow 私有能力,以及手机接码暂归 OpenAI flow、不提前抽成通用服务的决策。 +- `docs/多注册流程状态迁移设计.md`:多注册 flow 重构时的状态迁移设计,明确共享运行态、服务运行态、flow 私有状态与旧 step 兼容层。 +- `docs/多注册流程来源与驱动注册设计.md`:多注册 flow 下 source registry、driver registry、内容脚本注入与 localhost callback 清理的设计说明。 +- `docs/多注册流程邮件分层设计.md`:多注册 flow 下邮件 provider driver 与 flow mail rules 的分层设计说明。 +- `docs/多注册流程侧边栏能力矩阵.md`:多注册 flow 下 sidepanel 的 flow capability、panel capability 与 runtime lock 设计说明。 - `docs/images/交流群.jpg`:README 中展示的官网 / QQ 交流群入口图片。 -- `docs/refactor/2026-04-16-architecture-refactor-plan.md`:本轮重构阶段的结构设计与迁移计划记录。 -- `docs/superpowers/plans/2026-04-10-hotmail-oauth-mail-pool.md`:Hotmail OAuth 邮箱池实现计划文档。 -- `docs/superpowers/specs/2026-04-10-hotmail-oauth-design.md`:Hotmail OAuth + Graph Mail 的设计规格文档。 +- `docs/images/微信.png`:README 与文档中使用的微信相关展示图片。 ## `icons/` @@ -217,7 +223,7 @@ - `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。 - `tests/phone-auth-country-match.test.js`:测试认证页手机号国家选择在页面本地化时,仍能用 HeroSMS 的英文国家名匹配对应国家选项。 - `tests/phone-country-utils.test.js`:测试手机号国家/区号共享工具的区号提取、最长前缀解析、国家别名匹配,以及 manifest 与后台动态注入顺序。 -- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 +- `tests/phone-verification-flow.test.js`:测试 OpenAI 专属手机号验证流程对 HeroSMS / 5sim / NexSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 - `tests/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。 - `tests/paypal-flow-content.test.js`:测试 PayPal 内容脚本对可见邮箱/密码输入框的识别,并覆盖邮箱页即使已预填相同账号也会先清空再重填后继续下一步。 - `tests/gpc-sms-helper-script.test.js`:测试 GPC macOS 本地短信 helper 在非 macOS 环境下会提示平台与 iPhone 短信转发要求。 From a85ed0ce8994c59f19c1b71957cf6f0e105830bc Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Wed, 13 May 2026 04:46:56 +0800 Subject: [PATCH 2/7] feat: lay groundwork for multi-flow registries --- background.js | 180 ++++++- background/logging-status.js | 10 + background/mail-rule-registry.js | 48 ++ background/message-router.js | 24 +- background/navigation-utils.js | 7 +- background/runtime-state.js | 484 ++++++++++++++++++ background/tab-runtime.js | 158 ++++-- background/verification-flow.js | 4 + content/utils.js | 22 +- flows/openai/mail-rules.js | 69 +++ manifest.json | 5 + shared/flow-capabilities.js | 242 +++++++++ shared/source-registry.js | 433 ++++++++++++++++ sidepanel/sidepanel.html | 1 + sidepanel/sidepanel.js | 458 +++++++++++++++-- tests/background-luckmail.test.js | 3 + ...ckground-mail-rule-registry-module.test.js | 93 ++++ .../background-message-router-module.test.js | 36 ++ ...background-navigation-utils-module.test.js | 1 + tests/background-runtime-state-module.test.js | 163 ++++++ .../background-signup-method-settings.test.js | 45 ++ tests/background-tab-runtime-module.test.js | 70 +++ tests/content-utils.test.js | 34 ++ tests/flow-capabilities-module.test.js | 72 +++ ...epanel-phone-verification-settings.test.js | 52 ++ tests/sidepanel-plus-payment-method.test.js | 60 ++- tests/source-registry-module.test.js | 56 ++ tests/verification-flow-polling.test.js | 37 ++ 28 files changed, 2775 insertions(+), 92 deletions(-) create mode 100644 background/mail-rule-registry.js create mode 100644 background/runtime-state.js create mode 100644 flows/openai/mail-rules.js create mode 100644 shared/flow-capabilities.js create mode 100644 shared/source-registry.js create mode 100644 tests/background-mail-rule-registry-module.test.js create mode 100644 tests/background-runtime-state-module.test.js create mode 100644 tests/flow-capabilities-module.test.js create mode 100644 tests/source-registry-module.test.js diff --git a/background.js b/background.js index b65b5d1..e6017a3 100644 --- a/background.js +++ b/background.js @@ -1,6 +1,8 @@ // background.js — Service Worker: orchestration, state, tab management, message routing importScripts( + 'shared/source-registry.js', + 'shared/flow-capabilities.js', 'managed-alias-utils.js', 'mail2925-utils.js', 'paypal-utils.js', @@ -17,8 +19,11 @@ importScripts( 'background/ip-proxy-core.js', 'background/panel-bridge.js', 'background/registration-email-state.js', + 'background/runtime-state.js', 'background/generated-email-helpers.js', 'background/signup-flow-helpers.js', + 'background/mail-rule-registry.js', + 'flows/openai/mail-rules.js', 'background/message-router.js', 'background/verification-flow.js', 'background/auto-run-controller.js', @@ -80,6 +85,8 @@ const STEP_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite))) .sort((left, right) => left - right); +const DEFAULT_ACTIVE_FLOW_ID = 'openai'; +const DEFAULT_STEP_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite) @@ -205,6 +212,11 @@ const { isRecoverableStep9AuthFailure, } = self.MultiPageActivationUtils; const registrationEmailStateHelpers = self.MultiPageRegistrationEmailState?.createRegistrationEmailStateHelpers?.() || null; +const runtimeStateHelpers = self.MultiPageBackgroundRuntimeState?.createRuntimeStateHelpers?.({ + DEFAULT_ACTIVE_FLOW_ID, + defaultStepStatuses: DEFAULT_STEP_STATUSES, + getStepDefinitionForState: (step, state) => getStepDefinitionForState(step, state), +}) || null; const DEFAULT_REGISTRATION_EMAIL_STATE = registrationEmailStateHelpers?.DEFAULT_REGISTRATION_EMAIL_STATE || { current: '', previous: '', @@ -269,6 +281,20 @@ function getPreservedPhoneIdentity(state = {}) { return null; } +function buildStateViewWithRuntimeState(state = {}) { + if (runtimeStateHelpers?.buildStateView) { + return runtimeStateHelpers.buildStateView(state); + } + return state; +} + +function buildStatePatchWithRuntimeState(currentState = {}, updates = {}) { + if (runtimeStateHelpers?.buildSessionStatePatch) { + return runtimeStateHelpers.buildSessionStatePatch(currentState, updates); + } + return updates; +} + function statePatchHasChanges(state = {}, patch = {}) { return Object.keys(patch).some((key) => JSON.stringify(state?.[key] ?? null) !== JSON.stringify(patch[key] ?? null)); } @@ -844,8 +870,13 @@ const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings'; const STEP6_REGISTRATION_SUCCESS_WAIT_MS = 20000; const DEFAULT_STATE = { + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + activeRunId: '', + currentNodeId: '', + nodeStatuses: {}, currentStep: 0, // 当前流程执行到的步骤编号。 - stepStatuses: Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])), + stepStatuses: { ...DEFAULT_STEP_STATUSES }, + runtimeState: runtimeStateHelpers?.buildDefaultRuntimeState?.() || null, ...CONTRIBUTION_RUNTIME_DEFAULTS, oauthUrl: null, // 运行时抓取到的 OAuth 地址,不要手动预填。 resolvedSignupMethod: null, // 当前自动轮次冻结后的实际注册方式。 @@ -1320,7 +1351,49 @@ function normalizeSignupMethod(value = '') { : 'email'; } +function getFlowCapabilityRegistry() { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (typeof flowCapabilityRegistry !== 'undefined' && flowCapabilityRegistry) { + return flowCapabilityRegistry; + } + return rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; +} + +function resolveCurrentFlowCapabilities(state = {}, options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.resolveSidepanelCapabilities) { + return null; + } + return registry.resolveSidepanelCapabilities({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + function canUsePhoneSignup(state = {}) { + const capabilityState = typeof resolveCurrentFlowCapabilities === 'function' + ? resolveCurrentFlowCapabilities(state) + : (() => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId, + panelMode: state?.panelMode, + signupMethod: state?.signupMethod, + state, + }) + : null; + })(); + if (capabilityState && typeof capabilityState.canUsePhoneSignup === 'boolean') { + return capabilityState.canUsePhoneSignup; + } return Boolean(state?.phoneVerificationEnabled) && !Boolean(state?.plusModeEnabled) && !Boolean(state?.contributionMode); @@ -1332,9 +1405,26 @@ function resolveSignupMethod(state = {}) { return normalizeSignupMethod(frozenMethod); } const method = normalizeSignupMethod(state?.signupMethod); - return method === SIGNUP_METHOD_PHONE && canUsePhoneSignup(state) - ? SIGNUP_METHOD_PHONE - : SIGNUP_METHOD_EMAIL; + const capabilityState = typeof resolveCurrentFlowCapabilities === 'function' + ? resolveCurrentFlowCapabilities(state, { signupMethod: method }) + : (() => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId, + panelMode: state?.panelMode, + signupMethod: method, + state, + }) + : null; + })(); + if (capabilityState?.effectiveSignupMethod) { + return normalizeSignupMethod(capabilityState.effectiveSignupMethod); + } + return method === SIGNUP_METHOD_PHONE && canUsePhoneSignup(state) ? SIGNUP_METHOD_PHONE : SIGNUP_METHOD_EMAIL; } async function ensureResolvedSignupMethodForRun(options = {}) { @@ -2762,7 +2852,9 @@ function buildPersistentSettingsPayload(input = {}, options = {}) { }; if (Object.prototype.hasOwnProperty.call(payload, 'phoneVerificationEnabled') || Object.prototype.hasOwnProperty.call(payload, 'plusModeEnabled') - || Object.prototype.hasOwnProperty.call(payload, 'signupMethod')) { + || Object.prototype.hasOwnProperty.call(payload, 'signupMethod') + || Object.prototype.hasOwnProperty.call(payload, 'panelMode') + || Object.prototype.hasOwnProperty.call(payload, 'activeFlowId')) { payload.signupMethod = resolveSignupMethod(nextSignupConstraintState); } if (payload.ipProxyServiceProfiles) { @@ -2838,7 +2930,13 @@ async function getState() { getPersistedAliasState(), accountRunHistoryHelpers?.getPersistedAccountRunHistory?.() || [], ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state, accountRunHistory }; + return buildStateViewWithRuntimeState({ + ...DEFAULT_STATE, + ...persistedSettings, + ...persistedAliasState, + ...state, + accountRunHistory, + }); } async function initializeSessionStorageAccess() { @@ -2857,19 +2955,24 @@ async function initializeSessionStorageAccess() { async function setState(updates) { console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200)); if (Object.keys(updates || {}).length > 0) { - await chrome.storage.session.set(updates); + const currentSessionState = await chrome.storage.session.get(null); + const sessionUpdates = buildStatePatchWithRuntimeState({ + ...DEFAULT_STATE, + ...currentSessionState, + }, updates); + await chrome.storage.session.set(sessionUpdates); const persistentAliasUpdates = {}; - if (Object.prototype.hasOwnProperty.call(updates, 'manualAliasUsage')) { - persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(updates.manualAliasUsage); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'manualAliasUsage')) { + persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(sessionUpdates.manualAliasUsage); } - if (Object.prototype.hasOwnProperty.call(updates, 'preservedAliases')) { - persistentAliasUpdates.preservedAliases = normalizeBooleanMap(updates.preservedAliases); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'preservedAliases')) { + persistentAliasUpdates.preservedAliases = normalizeBooleanMap(sessionUpdates.preservedAliases); } - if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCache')) { - persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(updates.icloudAliasCache); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'icloudAliasCache')) { + persistentAliasUpdates.icloudAliasCache = normalizeIcloudAliasCacheList(sessionUpdates.icloudAliasCache); } - if (Object.prototype.hasOwnProperty.call(updates, 'icloudAliasCacheAt')) { - persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(updates.icloudAliasCacheAt) || 0); + if (Object.prototype.hasOwnProperty.call(sessionUpdates, 'icloudAliasCacheAt')) { + persistentAliasUpdates.icloudAliasCacheAt = Math.max(0, Number(sessionUpdates.icloudAliasCacheAt) || 0); } if (Object.keys(persistentAliasUpdates).length > 0) { await chrome.storage.local.set(persistentAliasUpdates); @@ -3455,7 +3558,7 @@ async function resetState() { ? prev.freeReusablePhoneActivation : null; await chrome.storage.session.clear(); - await chrome.storage.session.set({ + const resetPayload = buildStatePatchWithRuntimeState({}, { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, @@ -3485,6 +3588,7 @@ async function resetState() { ? Number(prev.automationWindowId) : null, }); + await chrome.storage.session.set(resetPayload); } /** @@ -7210,7 +7314,7 @@ function isSignupEntryHost(hostname = '') { if (typeof navigationUtils !== 'undefined' && navigationUtils?.isSignupEntryHost) { return navigationUtils.isSignupEntryHost(hostname); } - return ['chatgpt.com', 'chat.openai.com'].includes(hostname); + return ['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(hostname); } function isLikelyLoggedInChatgptHomeUrl(rawUrl) { @@ -7294,6 +7398,7 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { if (!candidate) return false; const reference = parseUrlSafely(referenceUrl); switch (source) { + case 'openai-auth': case 'signup-page': return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); case 'duck-mail': @@ -7304,6 +7409,8 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { return is163MailHost(candidate.hostname); case 'gmail-mail': return candidate.hostname === 'mail.google.com'; + case 'icloud-mail': + return candidate.hostname === 'www.icloud.com' || candidate.hostname === 'www.icloud.com.cn'; case 'inbucket-mail': return Boolean(reference) && candidate.origin === reference.origin && candidate.pathname.startsWith('/m/'); case 'mail-2925': @@ -7314,11 +7421,24 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { return Boolean(reference) && candidate.origin === reference.origin && (candidate.pathname.startsWith('/admin/accounts') || candidate.pathname.startsWith('/login') || candidate.pathname === '/'); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && (candidate.pathname.startsWith('/admin/accounts') || candidate.pathname === '/admin' || candidate.pathname === '/'); default: return false; } } +function sourcesMatch(leftSource, rightSource) { + if (sourceRegistry?.resolveCanonicalSource) { + const left = sourceRegistry.resolveCanonicalSource(leftSource); + const right = sourceRegistry.resolveCanonicalSource(rightSource); + return Boolean(left && right && left === right); + } + return String(leftSource || '').trim() === String(rightSource || '').trim(); +} + async function rememberSourceLastUrl(source, url) { return tabRuntime.rememberSourceLastUrl(source, url); } @@ -7399,7 +7519,7 @@ async function ensureContentScriptReadyOnTab(source, tabId, options = {}) { function isContentScriptReadyPong(source, pong) { if (!pong?.ok) return false; - if (pong.source && pong.source !== source) return false; + if (pong.source && !sourcesMatch(pong.source, source)) return false; if (source === 'plus-checkout') { return Boolean(pong.plusCheckoutReady); } @@ -7590,11 +7710,13 @@ function getSourceLabel(source) { return loggingStatus.getSourceLabel(source); } const labels = { + 'openai-auth': '认证页', 'gmail-mail': 'Gmail 邮箱', 'sidepanel': '侧边栏', 'signup-page': '认证页', 'vps-panel': 'CPA 面板', 'sub2api-panel': 'SUB2API 后台', + 'codex2api-panel': 'Codex2API 后台', 'qq-mail': 'QQ 邮箱', 'mail-163': '163 邮箱', 'mail-2925': '2925 邮箱', @@ -7606,6 +7728,8 @@ function getSourceLabel(source) { 'cloudmail': 'Cloud Mail', 'plus-checkout': 'Plus Checkout', 'paypal-flow': 'PayPal 授权页', + 'gopay-flow': 'GoPay 授权页', + 'unknown-source': '未知来源', }; return labels[source] || source || '未知来源'; } @@ -7659,10 +7783,16 @@ function getStepFetchNetworkRetryPolicy(step) { }; } +const sourceRegistry = self.MultiPageSourceRegistry?.createSourceRegistry?.() || null; +const flowCapabilityRegistry = self.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, +}) || null; + const navigationUtils = self.MultiPageBackgroundNavigationUtils?.createNavigationUtils({ DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, + sourceRegistry, }); const loggingStatus = self.MultiPageBackgroundLoggingStatus?.createLoggingStatus({ @@ -7672,6 +7802,7 @@ const loggingStatus = self.MultiPageBackgroundLoggingStatus?.createLoggingStatus isRecoverableStep9AuthFailure, LOG_PREFIX, setState, + sourceRegistry, STOP_ERROR_MESSAGE, }); @@ -7684,6 +7815,7 @@ const tabRuntime = self.MultiPageBackgroundTabRuntime?.createTabRuntime({ isRetryableContentScriptTransportError, LOG_PREFIX, matchesSourceUrlFamily, + sourceRegistry, setState, sleepWithStop, STOP_ERROR_MESSAGE, @@ -11251,8 +11383,20 @@ const signupFlowHelpers = self.MultiPageSignupFlowHelpers?.createSignupFlowHelpe waitForTabStableComplete, waitForTabUrlMatch, }); +const openAiMailRules = self.MultiPageOpenAiMailRules?.createOpenAiMailRules({ + getHotmailVerificationRequestTimestamp, + MAIL_2925_VERIFICATION_INTERVAL_MS, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS, +}); +const mailRuleRegistry = self.MultiPageBackgroundMailRuleRegistry?.createMailRuleRegistry({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, + flowBuilders: { + openai: openAiMailRules, + }, +}); const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.createVerificationFlowHelpers({ addLog, + buildVerificationPollPayload: mailRuleRegistry?.buildVerificationPollPayload, chrome, closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, diff --git a/background/logging-status.js b/background/logging-status.js index b86afa8..f92cd39 100644 --- a/background/logging-status.js +++ b/background/logging-status.js @@ -9,16 +9,22 @@ isRecoverableStep9AuthFailure, LOG_PREFIX, setState, + sourceRegistry = null, STOP_ERROR_MESSAGE, } = deps; function getSourceLabel(source) { + if (sourceRegistry?.getSourceLabel) { + return sourceRegistry.getSourceLabel(source); + } const labels = { + 'openai-auth': '认证页', 'gmail-mail': 'Gmail 邮箱', 'sidepanel': '侧边栏', 'signup-page': '认证页', 'vps-panel': 'CPA 面板', 'sub2api-panel': 'SUB2API 后台', + 'codex2api-panel': 'Codex2API 后台', 'qq-mail': 'QQ 邮箱', 'mail-163': '163 邮箱', 'mail-2925': '2925 邮箱', @@ -28,6 +34,10 @@ 'luckmail-api': 'LuckMail(API 购邮)', 'cloudflare-temp-email': 'Cloudflare Temp Email', 'cloudmail': 'Cloud Mail', + 'plus-checkout': 'Plus Checkout', + 'paypal-flow': 'PayPal 授权页', + 'gopay-flow': 'GoPay 授权页', + 'unknown-source': '未知来源', }; return labels[source] || source || '未知来源'; } diff --git a/background/mail-rule-registry.js b/background/mail-rule-registry.js new file mode 100644 index 0000000..be9848f --- /dev/null +++ b/background/mail-rule-registry.js @@ -0,0 +1,48 @@ +(function attachBackgroundMailRuleRegistry(root, factory) { + root.MultiPageBackgroundMailRuleRegistry = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundMailRuleRegistryModule() { + function createMailRuleRegistry(deps = {}) { + const { + defaultFlowId = 'openai', + flowBuilders = {}, + } = deps; + + function resolveFlowId(state = {}) { + const activeFlowId = String(state?.activeFlowId || '').trim(); + return activeFlowId || String(defaultFlowId || '').trim() || 'openai'; + } + + function getFlowBuilder(flowId) { + const normalizedFlowId = String(flowId || '').trim(); + return normalizedFlowId ? flowBuilders[normalizedFlowId] || null : null; + } + + function getVerificationMailRule(step, state = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder || typeof flowBuilder.getRuleDefinition !== 'function') { + throw new Error(`未找到 flow=${flowId} 的邮件规则定义。`); + } + return flowBuilder.getRuleDefinition(step, state); + } + + function buildVerificationPollPayload(step, state = {}, overrides = {}) { + const flowId = resolveFlowId(state); + const flowBuilder = getFlowBuilder(flowId); + if (!flowBuilder || typeof flowBuilder.buildVerificationPollPayload !== 'function') { + throw new Error(`未找到 flow=${flowId} 的邮件轮询规则构造器。`); + } + return flowBuilder.buildVerificationPollPayload(step, state, overrides); + } + + return { + buildVerificationPollPayload, + getVerificationMailRule, + resolveFlowId, + }; + } + + return { + createMailRuleRegistry, + }; +}); diff --git a/background/message-router.js b/background/message-router.js index f3127a0..25ed7b4 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -51,11 +51,27 @@ getStepIdsForState, getLastStepIdForState, normalizeSignupMethod = (value = '') => String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email', - canUsePhoneSignup = (state = {}) => Boolean(state?.phoneVerificationEnabled) - && !Boolean(state?.plusModeEnabled) - && !Boolean(state?.contributionMode), + canUsePhoneSignup = (state = {}) => { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (capabilityRegistry?.canUsePhoneSignup) { + return capabilityRegistry.canUsePhoneSignup(state); + } + return Boolean(state?.phoneVerificationEnabled) + && !Boolean(state?.plusModeEnabled) + && !Boolean(state?.contributionMode); + }, resolveSignupMethod = (state = {}) => { const method = normalizeSignupMethod(state?.signupMethod); + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (capabilityRegistry?.resolveSignupMethod) { + return capabilityRegistry.resolveSignupMethod(state, method); + } return method === 'phone' && canUsePhoneSignup(state) ? 'phone' : 'email'; }, getTabId, @@ -999,6 +1015,8 @@ Object.prototype.hasOwnProperty.call(updates, 'phoneVerificationEnabled') || Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled') || Object.prototype.hasOwnProperty.call(updates, 'signupMethod') + || Object.prototype.hasOwnProperty.call(updates, 'panelMode') + || Object.prototype.hasOwnProperty.call(updates, 'activeFlowId') ) { updates.signupMethod = resolveSignupMethod(nextSignupState); } diff --git a/background/navigation-utils.js b/background/navigation-utils.js index 9077502..2353973 100644 --- a/background/navigation-utils.js +++ b/background/navigation-utils.js @@ -6,6 +6,7 @@ DEFAULT_CODEX2API_URL, DEFAULT_SUB2API_URL, normalizeLocalCpaStep9Mode, + sourceRegistry = null, } = deps; function parseUrlSafely(rawUrl) { @@ -66,7 +67,7 @@ } function isSignupEntryHost(hostname = '') { - return ['chatgpt.com', 'chat.openai.com'].includes(hostname); + return ['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(hostname); } function isSignupPasswordPageUrl(rawUrl) { @@ -117,12 +118,16 @@ } function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { + if (sourceRegistry?.matchesSourceUrlFamily) { + return sourceRegistry.matchesSourceUrlFamily(source, candidateUrl, referenceUrl); + } const candidate = parseUrlSafely(candidateUrl); if (!candidate) return false; const reference = parseUrlSafely(referenceUrl); switch (source) { + case 'openai-auth': case 'signup-page': return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); case 'duck-mail': diff --git a/background/runtime-state.js b/background/runtime-state.js new file mode 100644 index 0000000..cfacddd --- /dev/null +++ b/background/runtime-state.js @@ -0,0 +1,484 @@ +(function attachBackgroundRuntimeState(root, factory) { + root.MultiPageBackgroundRuntimeState = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundRuntimeStateModule() { + function createRuntimeStateHelpers(deps = {}) { + const { + DEFAULT_ACTIVE_FLOW_ID = 'openai', + defaultStepStatuses = {}, + getStepDefinitionForState = null, + } = deps; + + const RUNTIME_SHARED_FIELDS = Object.freeze([ + 'automationWindowId', + 'tabRegistry', + 'sourceLastUrls', + 'flowStartTime', + ]); + const RUNTIME_PROXY_FIELDS = Object.freeze([ + 'ipProxyApiPool', + 'ipProxyApiCurrentIndex', + 'ipProxyApiCurrent', + 'ipProxyAccountPool', + 'ipProxyAccountCurrentIndex', + 'ipProxyAccountCurrent', + 'ipProxyPool', + 'ipProxyCurrentIndex', + 'ipProxyCurrent', + ]); + const OPENAI_FLOW_FIELD_GROUPS = Object.freeze({ + auth: Object.freeze([ + 'oauthUrl', + 'localhostUrl', + ]), + platformBinding: Object.freeze([ + 'cpaOAuthState', + 'cpaManagementOrigin', + 'sub2apiSessionId', + 'sub2apiOAuthState', + 'sub2apiGroupId', + 'sub2apiDraftName', + 'sub2apiProxyId', + 'sub2apiGroupIds', + 'codex2apiSessionId', + 'codex2apiOAuthState', + ]), + plus: Object.freeze([ + 'plusCheckoutTabId', + 'plusCheckoutUrl', + 'plusCheckoutCountry', + 'plusCheckoutCurrency', + 'plusCheckoutSource', + 'plusBillingCountryText', + 'plusBillingAddress', + 'plusPaypalApprovedAt', + 'plusGoPayApprovedAt', + 'plusReturnUrl', + 'plusManualConfirmationPending', + 'plusManualConfirmationRequestId', + 'plusManualConfirmationStep', + 'plusManualConfirmationMethod', + 'plusManualConfirmationTitle', + 'plusManualConfirmationMessage', + 'gopayHelperReferenceId', + 'gopayHelperGoPayGuid', + 'gopayHelperRedirectUrl', + 'gopayHelperNextAction', + 'gopayHelperFlowId', + 'gopayHelperChallengeId', + 'gopayHelperStartPayload', + 'gopayHelperTaskId', + 'gopayHelperTaskStatus', + 'gopayHelperStatusText', + 'gopayHelperRemoteStage', + 'gopayHelperApiWaitingFor', + 'gopayHelperApiInputDeadlineAt', + 'gopayHelperApiInputWaitSeconds', + 'gopayHelperLastInputError', + 'gopayHelperOtpInvalidCount', + 'gopayHelperFailureStage', + 'gopayHelperFailureDetail', + 'gopayHelperTaskPayload', + 'gopayHelperOrderCreatedAt', + 'gopayHelperTaskProgressSignature', + 'gopayHelperTaskProgressAt', + 'gopayHelperTaskProgressTaskId', + 'gopayHelperPinPayload', + 'gopayHelperResolvedOtp', + 'gopayHelperOtpRequestId', + 'gopayHelperOtpReferenceId', + ]), + phoneVerification: Object.freeze([ + 'currentPhoneActivation', + 'phoneNumber', + 'currentPhoneVerificationCode', + 'currentPhoneVerificationCountdownEndsAt', + 'currentPhoneVerificationCountdownWindowIndex', + 'currentPhoneVerificationCountdownWindowTotal', + 'reusablePhoneActivation', + 'freeReusablePhoneActivation', + 'phoneReusableActivationPool', + 'signupPhoneNumber', + 'signupPhoneActivation', + 'signupPhoneCompletedActivation', + 'signupPhoneVerificationRequestedAt', + 'signupPhoneVerificationPurpose', + ]), + luckmail: Object.freeze([ + 'currentLuckmailPurchase', + 'currentLuckmailMailCursor', + ]), + identity: Object.freeze([ + 'resolvedSignupMethod', + 'accountIdentifierType', + 'accountIdentifier', + 'registrationEmailState', + 'email', + 'password', + 'lastEmailTimestamp', + 'lastSignupCode', + 'lastLoginCode', + 'step8VerificationTargetEmail', + ]), + }); + + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + + function cloneValue(value) { + if (Array.isArray(value)) { + return value.map((item) => cloneValue(item)); + } + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, cloneValue(entryValue)]) + ); + } + return value; + } + + function normalizePlainObject(value) { + return isPlainObject(value) ? value : {}; + } + + function normalizeFlowId(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return normalized || DEFAULT_ACTIVE_FLOW_ID; + } + + function normalizeRunId(value = '') { + return String(value || '').trim(); + } + + function normalizeStepNumber(value) { + const step = Math.floor(Number(value) || 0); + return step > 0 ? step : 0; + } + + function normalizeNodeStatus(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return 'pending'; + } + return normalized; + } + + function buildDefaultStepStatuses() { + return Object.fromEntries( + Object.entries(normalizePlainObject(defaultStepStatuses)).map(([key, value]) => [ + String(key), + normalizeNodeStatus(value), + ]) + ); + } + + function normalizeStepStatuses(value) { + const base = buildDefaultStepStatuses(); + if (!isPlainObject(value)) { + return base; + } + + const next = { ...base }; + for (const [key, status] of Object.entries(value)) { + const step = normalizeStepNumber(key); + if (!step) continue; + next[String(step)] = normalizeNodeStatus(status); + } + return next; + } + + function normalizeLegacyStepCompat(value = {}, state = {}) { + const candidate = normalizePlainObject(value); + const currentStep = normalizeStepNumber( + Object.prototype.hasOwnProperty.call(state, 'currentStep') + ? state.currentStep + : candidate.currentStep + ); + const stepStatuses = normalizeStepStatuses( + Object.prototype.hasOwnProperty.call(state, 'stepStatuses') + ? state.stepStatuses + : candidate.stepStatuses + ); + + return { + currentStep, + stepStatuses, + }; + } + + function resolveStepKey(step, state = {}) { + const numericStep = normalizeStepNumber(step); + if (!numericStep || typeof getStepDefinitionForState !== 'function') { + return ''; + } + return String(getStepDefinitionForState(numericStep, state)?.key || '').trim(); + } + + function normalizeNodeStatuses(value, state = {}, legacyStepCompat = null) { + if (isPlainObject(value) && Object.keys(value).length > 0) { + return Object.fromEntries( + Object.entries(value) + .map(([key, status]) => [String(key || '').trim(), normalizeNodeStatus(status)]) + .filter(([key]) => Boolean(key)) + ); + } + + const compat = legacyStepCompat || normalizeLegacyStepCompat({}, state); + const next = {}; + for (const [stepKey, status] of Object.entries(compat.stepStatuses || {})) { + const step = normalizeStepNumber(stepKey); + if (!step) continue; + const nodeKey = resolveStepKey(step, state) || `step-${step}`; + next[nodeKey] = normalizeNodeStatus(status); + } + return next; + } + + function pickDefinedFields(state = {}, fields = []) { + const next = {}; + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(state, field)) { + next[field] = cloneValue(state[field]); + } + } + return next; + } + + function buildSharedState(baseValue = {}, state = {}) { + return { + ...cloneValue(normalizePlainObject(baseValue)), + ...pickDefinedFields(state, RUNTIME_SHARED_FIELDS), + }; + } + + function buildServiceState(baseValue = {}, state = {}) { + const base = cloneValue(normalizePlainObject(baseValue)); + return { + ...base, + proxy: { + ...cloneValue(normalizePlainObject(base.proxy)), + ...pickDefinedFields(state, RUNTIME_PROXY_FIELDS), + }, + }; + } + + function flattenOpenAiFlowState(flowState = {}) { + const openaiState = normalizePlainObject(flowState.openai); + const next = {}; + for (const [groupKey, fields] of Object.entries(OPENAI_FLOW_FIELD_GROUPS)) { + const group = normalizePlainObject(openaiState[groupKey]); + for (const field of fields) { + if (Object.prototype.hasOwnProperty.call(group, field)) { + next[field] = cloneValue(group[field]); + } + } + } + return next; + } + + function buildOpenAiFlowState(baseValue = {}, state = {}) { + const baseFlowState = cloneValue(normalizePlainObject(baseValue)); + const baseOpenAi = cloneValue(normalizePlainObject(baseFlowState.openai)); + const openaiState = { + ...baseOpenAi, + }; + + for (const [groupKey, fields] of Object.entries(OPENAI_FLOW_FIELD_GROUPS)) { + openaiState[groupKey] = { + ...cloneValue(normalizePlainObject(baseOpenAi[groupKey])), + ...pickDefinedFields(state, fields), + }; + } + + return { + ...baseFlowState, + openai: openaiState, + }; + } + + function buildRuntimeStateDefault() { + return { + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + activeRunId: '', + currentNodeId: '', + nodeStatuses: {}, + sharedState: {}, + serviceState: { + proxy: {}, + }, + flowState: { + openai: { + auth: {}, + platformBinding: {}, + plus: {}, + phoneVerification: {}, + luckmail: {}, + identity: {}, + }, + }, + legacyStepCompat: { + currentStep: 0, + stepStatuses: buildDefaultStepStatuses(), + }, + }; + } + + function ensureRuntimeState(state = {}) { + const baseRuntimeState = { + ...buildRuntimeStateDefault(), + ...cloneValue(normalizePlainObject(state.runtimeState)), + }; + const activeFlowId = normalizeFlowId( + Object.prototype.hasOwnProperty.call(state, 'activeFlowId') + ? state.activeFlowId + : baseRuntimeState.activeFlowId + ); + const legacyStepCompat = normalizeLegacyStepCompat(baseRuntimeState.legacyStepCompat, state); + const currentNodeId = String( + Object.prototype.hasOwnProperty.call(state, 'currentNodeId') + ? state.currentNodeId + : (baseRuntimeState.currentNodeId || resolveStepKey(legacyStepCompat.currentStep, state)) + ).trim(); + const nodeStatuses = normalizeNodeStatuses( + Object.prototype.hasOwnProperty.call(state, 'nodeStatuses') + ? state.nodeStatuses + : baseRuntimeState.nodeStatuses, + state, + legacyStepCompat + ); + + return { + ...baseRuntimeState, + activeFlowId, + activeRunId: normalizeRunId( + Object.prototype.hasOwnProperty.call(state, 'activeRunId') + ? state.activeRunId + : baseRuntimeState.activeRunId + ), + currentNodeId, + nodeStatuses, + sharedState: buildSharedState(baseRuntimeState.sharedState, state), + serviceState: buildServiceState(baseRuntimeState.serviceState, state), + flowState: buildOpenAiFlowState(baseRuntimeState.flowState, state), + legacyStepCompat, + }; + } + + function buildFlattenedUpdates(updates = {}) { + const next = { + ...updates, + }; + const runtimeState = normalizePlainObject(updates.runtimeState); + const legacyStepCompat = normalizePlainObject(updates.legacyStepCompat); + const sharedState = normalizePlainObject(updates.sharedState); + const serviceState = normalizePlainObject(updates.serviceState); + const flowState = normalizePlainObject(updates.flowState); + + if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeFlowId')) { + next.activeFlowId = runtimeState.activeFlowId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'activeRunId')) { + next.activeRunId = runtimeState.activeRunId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'currentNodeId')) { + next.currentNodeId = runtimeState.currentNodeId; + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'nodeStatuses')) { + next.nodeStatuses = cloneValue(runtimeState.nodeStatuses); + } + if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'currentStep')) { + next.currentStep = legacyStepCompat.currentStep; + } + if (Object.prototype.hasOwnProperty.call(legacyStepCompat, 'stepStatuses')) { + next.stepStatuses = cloneValue(legacyStepCompat.stepStatuses); + } + if (Object.prototype.hasOwnProperty.call(runtimeState, 'legacyStepCompat')) { + const compatCandidate = normalizePlainObject(runtimeState.legacyStepCompat); + if (Object.prototype.hasOwnProperty.call(compatCandidate, 'currentStep')) { + next.currentStep = compatCandidate.currentStep; + } + if (Object.prototype.hasOwnProperty.call(compatCandidate, 'stepStatuses')) { + next.stepStatuses = cloneValue(compatCandidate.stepStatuses); + } + } + + Object.assign(next, pickDefinedFields(sharedState, RUNTIME_SHARED_FIELDS)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'sharedState')) { + Object.assign( + next, + pickDefinedFields(normalizePlainObject(runtimeState.sharedState), RUNTIME_SHARED_FIELDS) + ); + } + + const serviceProxy = normalizePlainObject(serviceState.proxy); + Object.assign(next, pickDefinedFields(serviceProxy, RUNTIME_PROXY_FIELDS)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'serviceState')) { + const runtimeServiceState = normalizePlainObject(runtimeState.serviceState); + Object.assign( + next, + pickDefinedFields(normalizePlainObject(runtimeServiceState.proxy), RUNTIME_PROXY_FIELDS) + ); + } + + Object.assign(next, flattenOpenAiFlowState(flowState)); + if (Object.prototype.hasOwnProperty.call(runtimeState, 'flowState')) { + Object.assign(next, flattenOpenAiFlowState(normalizePlainObject(runtimeState.flowState))); + } + + return next; + } + + function buildStateView(state = {}) { + const runtimeState = ensureRuntimeState(state); + return { + ...state, + activeFlowId: runtimeState.activeFlowId, + activeRunId: runtimeState.activeRunId, + currentNodeId: runtimeState.currentNodeId, + nodeStatuses: cloneValue(runtimeState.nodeStatuses), + flowState: cloneValue(runtimeState.flowState), + sharedState: cloneValue(runtimeState.sharedState), + serviceState: cloneValue(runtimeState.serviceState), + legacyStepCompat: cloneValue(runtimeState.legacyStepCompat), + currentStep: runtimeState.legacyStepCompat.currentStep, + stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), + runtimeState, + }; + } + + function buildSessionStatePatch(currentState = {}, updates = {}) { + const flattenedUpdates = buildFlattenedUpdates(updates); + const nextState = { + ...currentState, + ...flattenedUpdates, + }; + const runtimeState = ensureRuntimeState(nextState); + + return { + ...flattenedUpdates, + activeFlowId: runtimeState.activeFlowId, + activeRunId: runtimeState.activeRunId, + currentNodeId: runtimeState.currentNodeId, + nodeStatuses: cloneValue(runtimeState.nodeStatuses), + currentStep: runtimeState.legacyStepCompat.currentStep, + stepStatuses: cloneValue(runtimeState.legacyStepCompat.stepStatuses), + runtimeState, + }; + } + + return { + DEFAULT_ACTIVE_FLOW_ID, + OPENAI_FLOW_FIELD_GROUPS, + RUNTIME_PROXY_FIELDS, + RUNTIME_SHARED_FIELDS, + buildDefaultRuntimeState: buildRuntimeStateDefault, + buildSessionStatePatch, + buildStateView, + ensureRuntimeState, + }; + } + + return { + createRuntimeStateHelpers, + }; +}); diff --git a/background/tab-runtime.js b/background/tab-runtime.js index f08f1a3..b689715 100644 --- a/background/tab-runtime.js +++ b/background/tab-runtime.js @@ -11,6 +11,7 @@ isRetryableContentScriptTransportError, LOG_PREFIX, matchesSourceUrlFamily, + sourceRegistry = null, setState, sleepWithStop, STOP_ERROR_MESSAGE, @@ -19,6 +20,65 @@ const pendingCommands = new Map(); + function resolveCanonicalSource(source) { + if (sourceRegistry?.resolveCanonicalSource) { + return sourceRegistry.resolveCanonicalSource(source); + } + return String(source || '').trim(); + } + + function getSourceKeys(source) { + if (sourceRegistry?.getSourceKeys) { + const registryKeys = sourceRegistry.getSourceKeys(source); + if (Array.isArray(registryKeys) && registryKeys.length) { + return registryKeys; + } + } + const normalized = String(source || '').trim(); + return normalized ? [normalized] : []; + } + + function getSourceCommandKey(source) { + const keys = getSourceKeys(source); + return keys[0] || String(source || '').trim(); + } + + function sourcesMatch(leftSource, rightSource) { + const left = resolveCanonicalSource(leftSource); + const right = resolveCanonicalSource(rightSource); + return Boolean(left && right && left === right); + } + + function getSourceMapValue(record, source) { + const map = record && typeof record === 'object' ? record : {}; + for (const key of getSourceKeys(source)) { + if (Object.prototype.hasOwnProperty.call(map, key)) { + return map[key]; + } + } + return undefined; + } + + function setSourceMapValue(record, source, value) { + const nextRecord = { ...(record || {}) }; + const keys = getSourceKeys(source); + const canonicalKey = keys[0] || String(source || '').trim(); + for (const key of keys.slice(1)) { + delete nextRecord[key]; + } + if (canonicalKey) { + nextRecord[canonicalKey] = value; + } + return nextRecord; + } + + function getCleanupOwnerSource(cleanupScope) { + if (sourceRegistry?.getCleanupOwnerSource) { + return sourceRegistry.getCleanupOwnerSource(cleanupScope); + } + return cleanupScope === 'oauth-localhost-callback' ? 'signup-page' : ''; + } + function normalizeAutomationWindowId(value) { if (value === null || value === undefined || value === '') { return null; @@ -170,7 +230,7 @@ } async function registerTab(source, tabId) { - const registry = await getTabRegistry(); + let registry = await getTabRegistry(); let windowId = null; if (chrome?.tabs?.get && Number.isInteger(tabId)) { const tab = await chrome.tabs.get(tabId).catch(() => null); @@ -180,42 +240,42 @@ } windowId = normalizeAutomationWindowId(tab?.windowId); } - registry[source] = { + registry = setSourceMapValue(registry, source, { tabId, ready: true, ...(windowId !== null ? { windowId } : {}), - }; + }); await setState({ tabRegistry: registry }); console.log(LOG_PREFIX, `Tab registered: ${source} -> ${tabId}`); } async function isTabAlive(source) { - const registry = await getTabRegistry(); - const entry = registry[source]; + let registry = await getTabRegistry(); + const entry = getSourceMapValue(registry, source); if (!entry) return false; try { const tab = await chrome.tabs.get(entry.tabId); if (!(await isTabInAutomationWindow(tab))) { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return false; } return true; } catch { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return false; } } async function getTabId(source) { - const registry = await getTabRegistry(); - const tabId = registry[source]?.tabId || null; + let registry = await getTabRegistry(); + const tabId = getSourceMapValue(registry, source)?.tabId || null; if (!Number.isInteger(tabId)) { return null; } if (!(await isTabInAutomationWindow(tabId))) { - registry[source] = null; + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); return null; } @@ -225,8 +285,7 @@ async function rememberSourceLastUrl(source, url) { if (!source || !url) return; const state = await getState(); - const sourceLastUrls = { ...(state.sourceLastUrls || {}) }; - sourceLastUrls[source] = url; + const sourceLastUrls = setSourceMapValue(state.sourceLastUrls, source, url); await setState({ sourceLastUrls }); } @@ -234,7 +293,7 @@ const { excludeTabIds = [] } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const state = await getState(); - const lastUrl = state.sourceLastUrls?.[source]; + const lastUrl = getSourceMapValue(state.sourceLastUrls, source); const referenceUrls = [currentUrl, lastUrl].filter(Boolean); if (!referenceUrls.length) return; @@ -249,9 +308,10 @@ await chrome.tabs.remove(matchedIds).catch(() => { }); - const registry = await getTabRegistry(); - if (registry[source]?.tabId && matchedIds.includes(registry[source].tabId)) { - registry[source] = null; + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); + if (sourceEntry?.tabId && matchedIds.includes(sourceEntry.tabId)) { + registry = setSourceMapValue(registry, source, null); await setState({ tabRegistry: registry }); } @@ -274,7 +334,7 @@ async function closeLocalhostCallbackTabs(callbackUrl, options = {}) { if (!isLocalhostOAuthCallbackUrl(callbackUrl)) return 0; - const { excludeTabIds = [] } = options; + const { excludeTabIds = [], ownerSource = getCleanupOwnerSource('oauth-localhost-callback') } = options; const excluded = new Set(excludeTabIds.filter((id) => Number.isInteger(id))); const tabs = await queryTabsInAutomationWindow({}); const matchedIds = tabs @@ -286,9 +346,10 @@ await chrome.tabs.remove(matchedIds).catch(() => { }); - const registry = await getTabRegistry(); - if (registry['signup-page']?.tabId && matchedIds.includes(registry['signup-page'].tabId)) { - registry['signup-page'] = null; + let registry = await getTabRegistry(); + const ownerEntry = getSourceMapValue(registry, ownerSource); + if (ownerEntry?.tabId && matchedIds.includes(ownerEntry.tabId)) { + registry = setSourceMapValue(registry, ownerSource, null); await setState({ tabRegistry: registry }); } @@ -468,7 +529,7 @@ while (Date.now() - start < timeoutMs) { attempt += 1; const pong = await pingContentScriptOnTab(tabId); - if (pong?.ok && (!pong.source || pong.source === source)) { + if (pong?.ok && (!pong.source || sourcesMatch(pong.source, source))) { console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`); await registerTab(source, tabId); return; @@ -478,9 +539,13 @@ throw new Error(`${getSourceLabel(source)} 内容脚本未就绪,且未提供可用的注入文件。`); } - const registry = await getTabRegistry(); - if (registry[source]) { - registry[source].ready = false; + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); await setState({ tabRegistry: registry }); } @@ -505,7 +570,7 @@ } const pongAfterInject = await pingContentScriptOnTab(tabId); - if (pongAfterInject?.ok && (!pongAfterInject.source || pongAfterInject.source === source)) { + if (pongAfterInject?.ok && (!pongAfterInject.source || sourcesMatch(pongAfterInject.source, source))) { console.log(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ready after inject ${source} tab=${tabId} on attempt ${attempt} after ${Date.now() - start}ms`); await registerTab(source, tabId); return; @@ -609,14 +674,16 @@ function queueCommand(source, message, timeout = 15000) { return new Promise((resolve, reject) => { + const commandKey = getSourceCommandKey(source); const timer = setTimeout(() => { - pendingCommands.delete(source); + pendingCommands.delete(commandKey); reject(new Error(`Content script on ${source} did not respond in ${timeout / 1000}s. Try refreshing the tab and retry.`)); }, timeout); - pendingCommands.set(source, { + pendingCommands.set(commandKey, { message, resolve, reject, + source, timer, responseTimeoutMs: timeout, }); @@ -625,11 +692,16 @@ } function flushCommand(source, tabId) { - const pending = pendingCommands.get(source); + const pending = pendingCommands.get(getSourceCommandKey(source)); if (pending) { clearTimeout(pending.timer); - pendingCommands.delete(source); - sendTabMessageWithTimeout(tabId, source, pending.message, pending.responseTimeoutMs).then(pending.resolve).catch(pending.reject); + pendingCommands.delete(getSourceCommandKey(source)); + sendTabMessageWithTimeout( + tabId, + pending.source || source, + pending.message, + pending.responseTimeoutMs + ).then(pending.resolve).catch(pending.reject); console.log(LOG_PREFIX, `Flushed queued command to ${source} (tab ${tabId})`); } } @@ -677,18 +749,29 @@ const sameUrl = currentTab.url === url; const shouldReloadOnReuse = sameUrl && options.reloadIfSameUrl; - const registry = await getTabRegistry(); + let registry = await getTabRegistry(); + const sourceEntry = getSourceMapValue(registry, source); if (sameUrl) { await chrome.tabs.update(tabId, { active: true }); if (shouldReloadOnReuse) { - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); await chrome.tabs.reload(tabId); await waitForTabUpdateComplete(tabId); } if (options.inject) { - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); if (options.injectSource) { await chrome.scripting.executeScript({ @@ -710,7 +793,12 @@ return tabId; } - if (registry[source]) registry[source].ready = false; + if (sourceEntry) { + registry = setSourceMapValue(registry, source, { + ...sourceEntry, + ready: false, + }); + } await setState({ tabRegistry: registry }); await chrome.tabs.update(tabId, { url, active: true }); @@ -765,7 +853,7 @@ throwIfStopped(); const { responseTimeoutMs = getContentScriptResponseTimeoutMs(message) } = options; const registry = await getTabRegistry(); - const entry = registry[source]; + const entry = getSourceMapValue(registry, source); if (!entry || !entry.ready) { throwIfStopped(); diff --git a/background/verification-flow.js b/background/verification-flow.js index 25fcb3d..dcfb066 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -7,6 +7,7 @@ function createVerificationFlowHelpers(deps = {}) { const { addLog: rawAddLog = async () => {}, + buildVerificationPollPayload: externalBuildVerificationPollPayload = null, chrome, closeConflictingTabsForSource, CLOUDFLARE_TEMP_EMAIL_PROVIDER, @@ -408,6 +409,9 @@ } function getVerificationPollPayload(step, state, overrides = {}) { + if (typeof externalBuildVerificationPollPayload === 'function') { + return externalBuildVerificationPollPayload(step, state, overrides); + } const is2925Provider = state?.mailProvider === '2925'; const mail2925MatchTargetEmail = is2925Provider && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; diff --git a/content/utils.js b/content/utils.js index 4a35f3e..8e749c3 100644 --- a/content/utils.js +++ b/content/utils.js @@ -7,8 +7,16 @@ function detectScriptSource({ url = '', hostname = '', } = {}) { + const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.(); + if (sourceRegistry?.detectSourceFromLocation) { + return sourceRegistry.detectSourceFromLocation({ + injectedSource, + url, + hostname, + }); + } if (injectedSource) return injectedSource; - if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page'; + if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'openai-auth'; if (hostname === 'mail.qq.com' || hostname === 'wx.mail.qq.com') return 'qq-mail'; if ( hostname === 'mail.163.com' @@ -22,8 +30,7 @@ function detectScriptSource({ if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail'; if (url.includes('chatgpt.com')) return 'chatgpt'; if (url.includes("2925.com")) return "mail-2925"; - // VPS panel — detected dynamically since URL is configurable - return 'vps-panel'; + return 'unknown-source'; } const SCRIPT_SOURCE = (() => { @@ -294,6 +301,10 @@ function log(message, level = 'info', options = {}) { * Report that this content script is loaded and ready. */ function reportReady() { + if (getRuntimeScriptSource() === 'unknown-source') { + console.warn(LOG_PREFIX, 'skip CONTENT_SCRIPT_READY for unknown source'); + return; + } console.log(LOG_PREFIX, '内容脚本已就绪'); const message = { type: 'CONTENT_SCRIPT_READY', @@ -439,6 +450,10 @@ async function humanPause(min = 250, max = 850) { } function shouldReportReadyForFrame(source, isChildFrame) { + const sourceRegistry = globalThis?.MultiPageSourceRegistry?.createSourceRegistry?.(); + if (sourceRegistry?.shouldReportReadyForFrame) { + return sourceRegistry.shouldReportReadyForFrame(source, isChildFrame); + } if (!isChildFrame) return true; return ![ 'qq-mail', @@ -447,6 +462,7 @@ function shouldReportReadyForFrame(source, isChildFrame) { 'mail-2925', 'inbucket-mail', 'plus-checkout', + 'unknown-source', ].includes(source); } diff --git a/flows/openai/mail-rules.js b/flows/openai/mail-rules.js new file mode 100644 index 0000000..940e80e --- /dev/null +++ b/flows/openai/mail-rules.js @@ -0,0 +1,69 @@ +(function attachOpenAiMailRules(root, factory) { + root.MultiPageOpenAiMailRules = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createOpenAiMailRulesModule() { + const SIGNUP_CODE_RULE_ID = 'openai-signup-code'; + const LOGIN_CODE_RULE_ID = 'openai-login-code'; + + function createOpenAiMailRules(deps = {}) { + const { + getHotmailVerificationRequestTimestamp = () => 0, + MAIL_2925_VERIFICATION_INTERVAL_MS = 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS = 15, + } = deps; + + function isMail2925Provider(state = {}) { + return String(state?.mailProvider || '').trim().toLowerCase() === '2925'; + } + + function shouldMatchMail2925TargetEmail(state = {}) { + return isMail2925Provider(state) + && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; + } + + function getRuleDefinition(step, state = {}) { + const normalizedStep = Number(step) === 4 ? 4 : 8; + const mail2925Provider = isMail2925Provider(state); + const signupStep = normalizedStep === 4; + + return { + flowId: 'openai', + ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID, + step: normalizedStep, + artifactType: 'code', + filterAfterTimestamp: mail2925Provider + ? 0 + : getHotmailVerificationRequestTimestamp(normalizedStep, state), + senderFilters: signupStep + ? ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'] + : ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], + subjectFilters: signupStep + ? ['verify', 'verification', 'code', '验证码', 'confirm'] + : ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], + targetEmail: signupStep + ? state?.email + : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email), + mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), + maxAttempts: mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, + intervalMs: mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, + }; + } + + function buildVerificationPollPayload(step, state = {}, overrides = {}) { + return { + ...getRuleDefinition(step, state), + ...(overrides || {}), + }; + } + + return { + buildVerificationPollPayload, + getRuleDefinition, + }; + } + + return { + LOGIN_CODE_RULE_ID, + SIGNUP_CODE_RULE_ID, + createOpenAiMailRules, + }; +}); diff --git a/manifest.json b/manifest.json index f79b796..2d53725 100644 --- a/manifest.json +++ b/manifest.json @@ -49,6 +49,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/operation-delay.js", "content/auth-page-recovery.js", @@ -65,6 +66,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/qq-mail.js" ], @@ -81,6 +83,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/mail-163.js" ], @@ -94,6 +97,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/icloud-mail.js" ], @@ -106,6 +110,7 @@ ], "js": [ "content/activation-utils.js", + "shared/source-registry.js", "content/utils.js", "content/operation-delay.js", "content/duck-mail.js" diff --git a/shared/flow-capabilities.js b/shared/flow-capabilities.js new file mode 100644 index 0000000..8946645 --- /dev/null +++ b/shared/flow-capabilities.js @@ -0,0 +1,242 @@ +(function attachMultiPageFlowCapabilities(root, factory) { + root.MultiPageFlowCapabilities = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createFlowCapabilitiesModule() { + const DEFAULT_FLOW_ID = 'openai'; + const DEFAULT_PANEL_MODE = 'cpa'; + const SIGNUP_METHOD_EMAIL = 'email'; + const SIGNUP_METHOD_PHONE = 'phone'; + const VALID_PANEL_MODES = Object.freeze(['cpa', 'sub2api', 'codex2api']); + + const DEFAULT_FLOW_CAPABILITIES = Object.freeze({ + supportsEmailSignup: true, + supportsPhoneSignup: false, + supportsPhoneVerificationSettings: false, + supportsPlusMode: false, + supportsContributionMode: false, + supportsPlatformBinding: [], + supportsLuckmail: false, + supportsOauthTimeoutBudget: false, + canSwitchFlow: false, + stepDefinitionMode: 'default', + }); + + const FLOW_CAPABILITIES = Object.freeze({ + openai: Object.freeze({ + ...DEFAULT_FLOW_CAPABILITIES, + supportsPhoneSignup: true, + supportsPhoneVerificationSettings: true, + supportsPlusMode: true, + supportsContributionMode: true, + supportsPlatformBinding: ['cpa', 'sub2api', 'codex2api'], + supportsLuckmail: true, + supportsOauthTimeoutBudget: true, + stepDefinitionMode: 'openai-dynamic', + }), + }); + + const DEFAULT_PANEL_CAPABILITIES = Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }); + + const PANEL_CAPABILITIES = Object.freeze({ + cpa: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: true, + }), + sub2api: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }), + codex2api: Object.freeze({ + supportsPhoneSignup: true, + requiresPhoneSignupWarning: false, + }), + }); + + function normalizeFlowId(value = '', fallback = DEFAULT_FLOW_ID) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return fallbackValue || DEFAULT_FLOW_ID; + } + + function normalizePanelMode(value = '', fallback = DEFAULT_PANEL_MODE) { + const normalized = String(value || '').trim().toLowerCase(); + if (VALID_PANEL_MODES.includes(normalized)) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return VALID_PANEL_MODES.includes(fallbackValue) ? fallbackValue : DEFAULT_PANEL_MODE; + } + + function normalizeSignupMethod(value = '') { + return String(value || '').trim().toLowerCase() === SIGNUP_METHOD_PHONE + ? SIGNUP_METHOD_PHONE + : SIGNUP_METHOD_EMAIL; + } + + function normalizePanelModeList(values = []) { + if (!Array.isArray(values)) { + return []; + } + const seen = new Set(); + const normalized = []; + values.forEach((value) => { + const mode = normalizePanelMode(value, ''); + if (!mode || seen.has(mode)) { + return; + } + seen.add(mode); + normalized.push(mode); + }); + return normalized; + } + + function createFlowCapabilityRegistry(deps = {}) { + const { + defaultFlowCapabilities = DEFAULT_FLOW_CAPABILITIES, + defaultFlowId = DEFAULT_FLOW_ID, + defaultPanelCapabilities = DEFAULT_PANEL_CAPABILITIES, + flowCapabilities = FLOW_CAPABILITIES, + panelCapabilities = PANEL_CAPABILITIES, + } = deps; + + function getFlowCapabilities(flowId) { + const normalizedFlowId = normalizeFlowId(flowId, defaultFlowId); + const entry = flowCapabilities[normalizedFlowId] || null; + return { + ...defaultFlowCapabilities, + ...(entry || {}), + supportsPlatformBinding: normalizePanelModeList(entry?.supportsPlatformBinding || defaultFlowCapabilities.supportsPlatformBinding), + }; + } + + function getPanelCapabilities(panelMode) { + const normalizedPanelMode = normalizePanelMode(panelMode); + return { + ...defaultPanelCapabilities, + ...(panelCapabilities[normalizedPanelMode] || {}), + }; + } + + function resolveSidepanelCapabilities(options = {}) { + const state = options?.state || {}; + const activeFlowId = normalizeFlowId( + options?.activeFlowId ?? state?.activeFlowId, + defaultFlowId + ); + const flowState = getFlowCapabilities(activeFlowId); + const requestedPanelMode = normalizePanelMode( + options?.panelMode ?? state?.panelMode, + DEFAULT_PANEL_MODE + ); + const supportedPanelModes = normalizePanelModeList(flowState.supportsPlatformBinding); + const panelModeSupported = supportedPanelModes.length === 0 + ? true + : supportedPanelModes.includes(requestedPanelMode); + const effectivePanelMode = panelModeSupported + ? requestedPanelMode + : supportedPanelModes[0]; + const panelState = getPanelCapabilities(effectivePanelMode); + const runtimeLocks = { + autoRunLocked: Boolean(options?.autoRunLocked ?? state?.autoRunLocked), + contributionMode: flowState.supportsContributionMode && Boolean(state?.contributionMode), + phoneVerificationEnabled: flowState.supportsPhoneVerificationSettings && Boolean(state?.phoneVerificationEnabled), + plusModeEnabled: flowState.supportsPlusMode && Boolean(state?.plusModeEnabled), + settingsMenuLocked: Boolean(options?.settingsMenuLocked ?? state?.settingsMenuLocked), + }; + const effectiveSignupMethods = []; + if (flowState.supportsEmailSignup !== false) { + effectiveSignupMethods.push(SIGNUP_METHOD_EMAIL); + } + const canSelectPhoneSignup = Boolean(flowState.supportsPhoneSignup) + && Boolean(panelState.supportsPhoneSignup) + && runtimeLocks.phoneVerificationEnabled + && !runtimeLocks.plusModeEnabled + && !runtimeLocks.contributionMode; + if (canSelectPhoneSignup) { + effectiveSignupMethods.push(SIGNUP_METHOD_PHONE); + } + if (!effectiveSignupMethods.length) { + effectiveSignupMethods.push(SIGNUP_METHOD_EMAIL); + } + const requestedSignupMethod = normalizeSignupMethod( + options?.signupMethod ?? state?.signupMethod + ); + const effectiveSignupMethod = requestedSignupMethod === SIGNUP_METHOD_PHONE && canSelectPhoneSignup + ? SIGNUP_METHOD_PHONE + : (effectiveSignupMethods.includes(SIGNUP_METHOD_EMAIL) + ? SIGNUP_METHOD_EMAIL + : effectiveSignupMethods[0]); + + return { + activeFlowId, + canShowContributionMode: Boolean(flowState.supportsContributionMode), + canShowLuckmail: Boolean(flowState.supportsLuckmail), + canShowPhoneSettings: Boolean(flowState.supportsPhoneVerificationSettings), + canShowPlusSettings: Boolean(flowState.supportsPlusMode), + canSwitchFlow: Boolean(flowState.canSwitchFlow), + canUsePhoneSignup: canSelectPhoneSignup, + canUseSelectedPanelMode: panelModeSupported, + effectivePanelMode, + effectiveSignupMethod, + effectiveSignupMethods, + flowCapabilities: flowState, + panelCapabilities: panelState, + panelMode: effectivePanelMode, + requestedPanelMode, + requestedSignupMethod, + runtimeLocks, + shouldWarnCpaPhoneSignup: effectiveSignupMethod === SIGNUP_METHOD_PHONE + && Boolean(panelState.requiresPhoneSignupWarning), + stepDefinitionOptions: { + activeFlowId, + panelMode: effectivePanelMode, + plusModeEnabled: runtimeLocks.plusModeEnabled, + signupMethod: effectiveSignupMethod, + }, + supportedPanelModes, + }; + } + + function canUsePhoneSignup(state = {}) { + return resolveSidepanelCapabilities({ state }).canUsePhoneSignup; + } + + function resolveSignupMethod(state = {}, signupMethod = undefined) { + return resolveSidepanelCapabilities({ + signupMethod, + state, + }).effectiveSignupMethod; + } + + return { + canUsePhoneSignup, + getFlowCapabilities, + getPanelCapabilities, + normalizeFlowId, + normalizePanelMode, + normalizeSignupMethod, + resolveSidepanelCapabilities, + resolveSignupMethod, + }; + } + + return { + createFlowCapabilityRegistry, + DEFAULT_FLOW_CAPABILITIES, + DEFAULT_FLOW_ID, + DEFAULT_PANEL_CAPABILITIES, + DEFAULT_PANEL_MODE, + FLOW_CAPABILITIES, + PANEL_CAPABILITIES, + SIGNUP_METHOD_EMAIL, + SIGNUP_METHOD_PHONE, + normalizeFlowId, + normalizePanelMode, + normalizeSignupMethod, + }; +}); diff --git a/shared/source-registry.js b/shared/source-registry.js new file mode 100644 index 0000000..6d7ce66 --- /dev/null +++ b/shared/source-registry.js @@ -0,0 +1,433 @@ +(function attachMultiPageSourceRegistry(root, factory) { + root.MultiPageSourceRegistry = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createSourceRegistryModule() { + const SOURCE_ALIASES = Object.freeze({ + 'signup-page': 'openai-auth', + }); + + const SOURCE_DEFINITIONS = Object.freeze({ + 'openai-auth': { + flowId: 'openai', + kind: 'flow-page', + label: '认证页', + readyPolicy: 'allow-child-frame', + family: 'openai-auth-family', + driverId: 'content/signup-page', + cleanupScopes: ['oauth-localhost-callback'], + }, + chatgpt: { + flowId: 'openai', + kind: 'flow-entry', + label: 'ChatGPT 首页', + readyPolicy: 'allow-child-frame', + family: 'chatgpt-entry-family', + driverId: null, + cleanupScopes: [], + }, + 'qq-mail': { + flowId: null, + kind: 'mail-provider', + label: 'QQ 邮箱', + readyPolicy: 'top-frame-only', + family: 'qq-mail-family', + driverId: 'content/qq-mail', + cleanupScopes: [], + }, + 'mail-163': { + flowId: null, + kind: 'mail-provider', + label: '163 邮箱', + readyPolicy: 'top-frame-only', + family: 'mail-163-family', + driverId: 'content/mail-163', + cleanupScopes: [], + }, + 'gmail-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Gmail 邮箱', + readyPolicy: 'top-frame-only', + family: 'gmail-mail-family', + driverId: 'content/gmail-mail', + cleanupScopes: [], + }, + 'icloud-mail': { + flowId: null, + kind: 'mail-provider', + label: 'iCloud 邮箱', + readyPolicy: 'allow-child-frame', + family: 'icloud-mail-family', + driverId: 'content/icloud-mail', + cleanupScopes: [], + }, + 'inbucket-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Inbucket 邮箱', + readyPolicy: 'top-frame-only', + family: 'inbucket-mail-family', + driverId: 'content/inbucket-mail', + cleanupScopes: [], + }, + 'mail-2925': { + flowId: null, + kind: 'mail-provider', + label: '2925 邮箱', + readyPolicy: 'top-frame-only', + family: 'mail-2925-family', + driverId: 'content/mail-2925', + cleanupScopes: [], + }, + 'duck-mail': { + flowId: null, + kind: 'mail-provider', + label: 'Duck 邮箱', + readyPolicy: 'allow-child-frame', + family: 'duck-mail-family', + driverId: 'content/duck-mail', + cleanupScopes: [], + }, + 'vps-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'CPA 面板', + readyPolicy: 'allow-child-frame', + family: 'vps-panel-family', + driverId: 'content/vps-panel', + cleanupScopes: [], + }, + 'sub2api-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'SUB2API 后台', + readyPolicy: 'allow-child-frame', + family: 'sub2api-panel-family', + driverId: 'content/sub2api-panel', + cleanupScopes: [], + }, + 'codex2api-panel': { + flowId: 'openai', + kind: 'panel-page', + label: 'Codex2API 后台', + readyPolicy: 'allow-child-frame', + family: 'codex2api-panel-family', + driverId: 'content/sub2api-panel', + cleanupScopes: [], + }, + 'plus-checkout': { + flowId: 'openai', + kind: 'flow-page', + label: 'Plus Checkout', + readyPolicy: 'top-frame-only', + family: 'plus-checkout-family', + driverId: 'content/plus-checkout', + cleanupScopes: [], + }, + 'paypal-flow': { + flowId: 'openai', + kind: 'flow-page', + label: 'PayPal 授权页', + readyPolicy: 'allow-child-frame', + family: 'paypal-flow-family', + driverId: 'content/paypal-flow', + cleanupScopes: [], + }, + 'gopay-flow': { + flowId: 'openai', + kind: 'flow-page', + label: 'GoPay 授权页', + readyPolicy: 'allow-child-frame', + family: 'gopay-flow-family', + driverId: 'content/gopay-flow', + cleanupScopes: [], + }, + 'unknown-source': { + flowId: null, + kind: 'unknown', + label: '未知来源', + readyPolicy: 'disabled', + family: 'unknown-family', + driverId: null, + cleanupScopes: [], + }, + }); + + const DRIVER_DEFINITIONS = Object.freeze({ + 'content/signup-page': { + sourceId: 'openai-auth', + commands: [ + 'OPEN_SIGNUP', + 'SUBMIT_SIGNUP_IDENTIFIER', + 'SUBMIT_PASSWORD', + 'SUBMIT_PROFILE', + 'SUBMIT_LOGIN_CODE', + 'SUBMIT_PHONE_CODE', + 'DETECT_AUTH_STATE', + ], + }, + 'content/qq-mail': { + sourceId: 'qq-mail', + commands: ['POLL_EMAIL'], + }, + 'content/mail-163': { + sourceId: 'mail-163', + commands: ['POLL_EMAIL'], + }, + 'content/gmail-mail': { + sourceId: 'gmail-mail', + commands: ['POLL_EMAIL'], + }, + 'content/icloud-mail': { + sourceId: 'icloud-mail', + commands: ['POLL_EMAIL'], + }, + 'content/mail-2925': { + sourceId: 'mail-2925', + commands: ['POLL_EMAIL'], + }, + 'content/duck-mail': { + sourceId: 'duck-mail', + commands: ['FETCH_ALIAS_EMAIL'], + }, + 'content/sub2api-panel': { + sourceId: 'sub2api-panel', + commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL', 'VERIFY_PLATFORM'], + }, + 'content/vps-panel': { + sourceId: 'vps-panel', + commands: ['OPEN_PANEL', 'FETCH_OAUTH_URL'], + }, + 'content/plus-checkout': { + sourceId: 'plus-checkout', + commands: ['CREATE_CHECKOUT', 'FILL_CHECKOUT'], + }, + 'content/paypal-flow': { + sourceId: 'paypal-flow', + commands: ['APPROVE_PAYPAL'], + }, + 'content/gopay-flow': { + sourceId: 'gopay-flow', + commands: ['APPROVE_GOPAY'], + }, + }); + + const CLEANUP_SCOPE_OWNERS = Object.freeze({ + 'oauth-localhost-callback': 'openai-auth', + }); + + const AUTH_PAGE_HOSTS = new Set(['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com']); + const ENTRY_PAGE_HOSTS = new Set(['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com']); + const CHILD_FRAME_BLOCKED_SOURCES = new Set([ + 'qq-mail', + 'mail-163', + 'gmail-mail', + 'mail-2925', + 'inbucket-mail', + 'plus-checkout', + ]); + + function createSourceRegistry() { + function parseUrlSafely(rawUrl) { + if (!rawUrl) return null; + try { + return new URL(rawUrl); + } catch { + return null; + } + } + + function normalizeSourceId(source) { + return String(source || '').trim(); + } + + function resolveCanonicalSource(source) { + const normalized = normalizeSourceId(source); + if (!normalized) return ''; + return SOURCE_ALIASES[normalized] || normalized; + } + + function getAliasKeysForCanonicalSource(source) { + const canonical = resolveCanonicalSource(source); + return Object.keys(SOURCE_ALIASES).filter((alias) => SOURCE_ALIASES[alias] === canonical); + } + + function getSourceKeys(source) { + const normalized = normalizeSourceId(source); + const canonical = resolveCanonicalSource(normalized); + return Array.from(new Set([ + canonical, + ...getAliasKeysForCanonicalSource(canonical), + normalized, + ].filter(Boolean))); + } + + function getSourceMeta(source) { + const canonical = resolveCanonicalSource(source); + const definition = SOURCE_DEFINITIONS[canonical]; + if (!definition) { + return null; + } + return { + id: canonical, + aliases: getAliasKeysForCanonicalSource(canonical), + ...definition, + }; + } + + function getSourceLabel(source) { + return getSourceMeta(source)?.label || normalizeSourceId(source) || '未知来源'; + } + + function getDriverIdForSource(source) { + return getSourceMeta(source)?.driverId || null; + } + + function getDriverMeta(sourceOrDriverId) { + const directDriverId = normalizeSourceId(sourceOrDriverId); + const driverId = Object.prototype.hasOwnProperty.call(DRIVER_DEFINITIONS, directDriverId) + ? directDriverId + : getDriverIdForSource(sourceOrDriverId); + if (!driverId || !Object.prototype.hasOwnProperty.call(DRIVER_DEFINITIONS, driverId)) { + return null; + } + return { + id: driverId, + ...DRIVER_DEFINITIONS[driverId], + }; + } + + function isSignupPageHost(hostname = '') { + return AUTH_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); + } + + function isSignupEntryHost(hostname = '') { + return ENTRY_PAGE_HOSTS.has(String(hostname || '').toLowerCase()); + } + + function is163MailHost(hostname = '') { + const normalized = String(hostname || '').toLowerCase(); + return normalized === 'mail.163.com' + || normalized.endsWith('.mail.163.com') + || normalized === 'mail.126.com' + || normalized.endsWith('.mail.126.com') + || normalized === 'webmail.vip.163.com'; + } + + function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { + const candidate = parseUrlSafely(candidateUrl); + if (!candidate) return false; + + const canonical = resolveCanonicalSource(source); + const reference = parseUrlSafely(referenceUrl); + + switch (canonical) { + case 'openai-auth': + return isSignupPageHost(candidate.hostname) || isSignupEntryHost(candidate.hostname); + case 'chatgpt': + return isSignupEntryHost(candidate.hostname); + case 'duck-mail': + return candidate.hostname === 'duckduckgo.com' && candidate.pathname.startsWith('/email/'); + case 'qq-mail': + return candidate.hostname === 'mail.qq.com' || candidate.hostname === 'wx.mail.qq.com'; + case 'mail-163': + return is163MailHost(candidate.hostname); + case 'gmail-mail': + return candidate.hostname === 'mail.google.com'; + case 'icloud-mail': + return candidate.hostname === 'www.icloud.com' + || candidate.hostname === 'www.icloud.com.cn'; + case 'inbucket-mail': + return Boolean(reference) + && candidate.origin === reference.origin + && candidate.pathname.startsWith('/m/'); + case 'mail-2925': + return candidate.hostname === '2925.com' || candidate.hostname === 'www.2925.com'; + case 'vps-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && candidate.pathname === reference.pathname; + case 'sub2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname.startsWith('/login') + || candidate.pathname === '/' + ); + case 'codex2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname === '/admin' + || candidate.pathname === '/' + ); + case 'plus-checkout': + return candidate.hostname === 'chatgpt.com' + && candidate.pathname.startsWith('/checkout/'); + case 'paypal-flow': + return candidate.hostname.endsWith('paypal.com'); + case 'gopay-flow': + return /gopay|gojek/i.test(candidate.hostname); + default: + return false; + } + } + + function detectSourceFromLocation({ + injectedSource, + url = '', + hostname = '', + } = {}) { + if (injectedSource) return resolveCanonicalSource(injectedSource); + + const normalizedHostname = String(hostname || '').toLowerCase(); + const normalizedUrl = String(url || ''); + + if (isSignupPageHost(normalizedHostname)) return 'openai-auth'; + if (normalizedHostname === 'mail.qq.com' || normalizedHostname === 'wx.mail.qq.com') return 'qq-mail'; + if (is163MailHost(normalizedHostname)) return 'mail-163'; + if (normalizedHostname === 'mail.google.com') return 'gmail-mail'; + if (normalizedHostname === 'www.icloud.com' || normalizedHostname === 'www.icloud.com.cn') return 'icloud-mail'; + if (normalizedUrl.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail'; + if (normalizedUrl.includes('2925.com')) return 'mail-2925'; + if (isSignupEntryHost(normalizedHostname)) return 'chatgpt'; + return 'unknown-source'; + } + + function shouldReportReadyForFrame(source, isChildFrame) { + const canonical = resolveCanonicalSource(source); + const readyPolicy = getSourceMeta(canonical)?.readyPolicy || 'allow-child-frame'; + if (readyPolicy === 'disabled') return false; + if (!isChildFrame) return true; + if (readyPolicy === 'top-frame-only') return false; + if (CHILD_FRAME_BLOCKED_SOURCES.has(canonical)) return false; + return true; + } + + function getCleanupOwnerSource(cleanupScope) { + return resolveCanonicalSource(CLEANUP_SCOPE_OWNERS[String(cleanupScope || '').trim()] || ''); + } + + return { + detectSourceFromLocation, + getCleanupOwnerSource, + getDriverIdForSource, + getDriverMeta, + getSourceKeys, + getSourceLabel, + getSourceMeta, + is163MailHost, + isSignupEntryHost, + isSignupPageHost, + matchesSourceUrlFamily, + parseUrlSafely, + resolveCanonicalSource, + shouldReportReadyForFrame, + }; + } + + return { + createSourceRegistry, + }; +}); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index f26087c..9b70f74 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -1732,6 +1732,7 @@ + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 7e7c5b3..99453c1 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -517,6 +517,7 @@ const DEFAULT_PLUS_PAYMENT_METHOD = PLUS_PAYMENT_METHOD_PAYPAL; const SIGNUP_METHOD_EMAIL = 'email'; const SIGNUP_METHOD_PHONE = 'phone'; const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL; +const DEFAULT_ACTIVE_FLOW_ID = 'openai'; let currentPlusModeEnabled = false; let currentPlusPaymentMethod = DEFAULT_PLUS_PAYMENT_METHOD; let currentSignupMethod = DEFAULT_SIGNUP_METHOD; @@ -1931,6 +1932,39 @@ function shouldWarnCpaPhoneSignup(signupMethod = null, panelMode = null) { ) ); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: typeof latestState !== 'undefined' ? latestState?.activeFlowId : '', + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + panelMode: resolvedPanelMode, + signupMethod: resolvedSignupMethod, + }, + }) + : null; + })(); + + if (capabilityState && typeof capabilityState.shouldWarnCpaPhoneSignup === 'boolean') { + return capabilityState.shouldWarnCpaPhoneSignup && !isCpaPhoneSignupPromptDismissed(); + } + return resolvedSignupMethod === SIGNUP_METHOD_PHONE && resolvedPanelMode === 'cpa' && !isCpaPhoneSignupPromptDismissed(); @@ -3470,6 +3504,57 @@ function collectSettingsPayload() { ? normalizeSignupMethod(latestState?.signupMethod) : (String(latestState?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email')) ); + const normalizePanelModeSafe = typeof normalizePanelMode === 'function' + ? normalizePanelMode + : ((value = '') => { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'sub2api' || normalized === 'codex2api' ? normalized : 'cpa'; + }); + const rawPanelMode = normalizePanelModeSafe(selectPanelMode?.value || latestState?.panelMode || 'cpa'); + const rawPlusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled); + const rawPhoneVerificationEnabled = Boolean(inputPhoneVerificationEnabled?.checked); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: rawPanelMode, + signupMethod: selectedSignupMethod, + state: { + ...(latestState || {}), + panelMode: rawPanelMode, + plusModeEnabled: rawPlusModeEnabled, + phoneVerificationEnabled: rawPhoneVerificationEnabled, + signupMethod: selectedSignupMethod, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: latestState?.activeFlowId, + panelMode: rawPanelMode, + signupMethod: selectedSignupMethod, + state: { + ...(latestState || {}), + panelMode: rawPanelMode, + plusModeEnabled: rawPlusModeEnabled, + phoneVerificationEnabled: rawPhoneVerificationEnabled, + signupMethod: selectedSignupMethod, + }, + }) + : null; + })(); + const effectivePanelMode = capabilityState?.effectivePanelMode || capabilityState?.panelMode || rawPanelMode; + const effectivePlusModeEnabled = capabilityState + ? Boolean(capabilityState.runtimeLocks?.plusModeEnabled) + : rawPlusModeEnabled; + const effectivePhoneVerificationEnabled = capabilityState + ? Boolean(capabilityState.runtimeLocks?.phoneVerificationEnabled) + : rawPhoneVerificationEnabled; + const effectiveSignupMethod = capabilityState?.effectiveSignupMethod || selectedSignupMethod; const plusPaymentMethod = getSelectedPlusPaymentMethod(); const normalizeGpcHelperPhoneModeSafe = typeof normalizeGpcHelperPhoneModeValue === 'function' ? normalizeGpcHelperPhoneModeValue @@ -3527,7 +3612,7 @@ function collectSettingsPayload() { }); return { ...(contributionModeEnabled ? {} : { - panelMode: selectPanelMode.value, + panelMode: effectivePanelMode, }), vpsUrl: inputVpsUrl.value.trim(), vpsPassword: inputVpsPassword.value, @@ -3565,9 +3650,7 @@ function collectSettingsPayload() { ipProxyRegion: currentIpProxyServiceProfile.region, codex2apiUrl: inputCodex2ApiUrl.value.trim(), codex2apiAdminKey: inputCodex2ApiAdminKey.value.trim(), - plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled - ? Boolean(inputPlusModeEnabled.checked) - : Boolean(latestState?.plusModeEnabled), + plusModeEnabled: effectivePlusModeEnabled, plusPaymentMethod, paypalEmail: String(currentPayPalAccount?.email || latestState?.paypalEmail || '').trim(), paypalPassword: String(currentPayPalAccount?.password || latestState?.paypalPassword || ''), @@ -3682,8 +3765,8 @@ function collectSettingsPayload() { oauthFlowTimeoutEnabled: typeof inputOAuthFlowTimeoutEnabled !== 'undefined' && inputOAuthFlowTimeoutEnabled ? Boolean(inputOAuthFlowTimeoutEnabled.checked) : true, - phoneVerificationEnabled: Boolean(inputPhoneVerificationEnabled?.checked), - signupMethod: selectedSignupMethod, + phoneVerificationEnabled: effectivePhoneVerificationEnabled, + signupMethod: effectiveSignupMethod, phoneSmsProvider: phoneSmsProviderValue, phoneSmsProviderOrder: phoneSmsProviderOrderValue, verificationResendCount: normalizeVerificationResendCount( @@ -7215,11 +7298,66 @@ function normalizePanelMode(value = '') { return 'cpa'; } +let flowCapabilityRegistry = null; + +function getFlowCapabilityRegistry() { + if (flowCapabilityRegistry) { + return flowCapabilityRegistry; + } + const rootScope = typeof window !== 'undefined' ? window : globalThis; + flowCapabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: DEFAULT_ACTIVE_FLOW_ID, + }) || null; + return flowCapabilityRegistry; +} + +function resolveCurrentSidepanelCapabilities(options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.resolveSidepanelCapabilities) { + return null; + } + const state = { + ...(latestState || {}), + ...(options?.state || {}), + }; + return registry.resolveSidepanelCapabilities({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + +function resolveStepDefinitionCapabilityState(state = latestState, options = {}) { + const nextState = { + ...(state || {}), + ...(options?.state || {}), + }; + const capabilityState = resolveCurrentSidepanelCapabilities({ + activeFlowId: options?.activeFlowId ?? nextState?.activeFlowId, + panelMode: options?.panelMode ?? nextState?.panelMode, + signupMethod: options?.signupMethod ?? nextState?.signupMethod, + state: nextState, + }); + return { + capabilityState, + plusModeEnabled: capabilityState + ? Boolean(capabilityState.runtimeLocks?.plusModeEnabled) + : Boolean(nextState?.plusModeEnabled), + signupMethod: capabilityState?.effectiveSignupMethod + || normalizeSignupMethod((options?.signupMethod ?? nextState?.signupMethod) || DEFAULT_SIGNUP_METHOD), + }; +} + function getSelectedPanelMode() { const selectedValue = typeof selectPanelMode !== 'undefined' && selectPanelMode ? selectPanelMode.value : (typeof latestState !== 'undefined' ? latestState?.panelMode : ''); - return normalizePanelMode(selectedValue || 'cpa'); + const resolvedPanelMode = normalizePanelMode(selectedValue || 'cpa'); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ panelMode: resolvedPanelMode }) + : null; + return capabilityState?.effectivePanelMode || capabilityState?.panelMode || resolvedPanelMode; } function getSelectedSignupMethod() { @@ -7244,6 +7382,37 @@ function canSelectPhoneSignupMethod() { ? Boolean(inputPlusModeEnabled.checked) : Boolean(latestState?.plusModeEnabled); const contributionModeEnabled = Boolean(latestState?.contributionMode); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + phoneVerificationEnabled: phoneEnabled, + plusModeEnabled, + contributionMode: contributionModeEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: typeof latestState !== 'undefined' ? latestState?.activeFlowId : '', + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : (latestState?.panelMode || 'cpa'), + state: { + ...(typeof latestState !== 'undefined' ? latestState : {}), + phoneVerificationEnabled: phoneEnabled, + plusModeEnabled, + contributionMode: contributionModeEnabled, + }, + }) + : null; + })(); + if (capabilityState && typeof capabilityState.canSelectPhoneSignup === 'boolean') { + return capabilityState.canSelectPhoneSignup; + } return phoneEnabled && !plusModeEnabled && !contributionModeEnabled; } @@ -7295,22 +7464,68 @@ function updateSignupMethodUI(options = {}) { } } }); - syncStepDefinitionsForMode( - typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled - ? Boolean(inputPlusModeEnabled.checked) - : Boolean(latestState?.plusModeEnabled), - { - plusPaymentMethod: getSelectedPlusPaymentMethod(latestState), + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled), signupMethod: selectedMethod, - } - ); + }, { + signupMethod: selectedMethod, + }) + : { + plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled), + signupMethod: selectedMethod, + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { + plusPaymentMethod: getSelectedPlusPaymentMethod(latestState), + signupMethod: selectedMethod, + }); if (typeof syncSignupPhoneInputFromState === 'function') { syncSignupPhoneInputFromState(latestState); } } function updatePhoneVerificationSettingsUI() { - const enabled = Boolean(inputPhoneVerificationEnabled?.checked); + const rawEnabled = Boolean(inputPhoneVerificationEnabled?.checked); + const rawPlusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + signupMethod: typeof getSelectedSignupMethod === 'function' ? getSelectedSignupMethod() : latestState?.signupMethod, + state: { + ...(latestState || {}), + phoneVerificationEnabled: rawEnabled, + plusModeEnabled: rawPlusModeEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: latestState?.activeFlowId, + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : (latestState?.panelMode || 'cpa'), + signupMethod: typeof getSelectedSignupMethod === 'function' ? getSelectedSignupMethod() : latestState?.signupMethod, + state: { + ...(latestState || {}), + phoneVerificationEnabled: rawEnabled, + plusModeEnabled: rawPlusModeEnabled, + }, + }) + : null; + })(); + const canShowPhoneSettings = capabilityState + ? Boolean(capabilityState.canShowPhoneSettings) + : true; + const enabled = canShowPhoneSettings && rawEnabled; const showSettings = enabled && phoneVerificationSectionExpanded; const normalizeProvider = typeof normalizePhoneSmsProviderValue === 'function' ? normalizePhoneSmsProviderValue @@ -7333,10 +7548,10 @@ function updatePhoneVerificationSettingsUI() { const fiveSimProvider = provider === fiveSimProviderValue; const nexSmsProvider = provider === nexSmsProviderValue; if (rowPhoneVerificationEnabled) { - rowPhoneVerificationEnabled.style.display = ''; + rowPhoneVerificationEnabled.style.display = canShowPhoneSettings ? '' : 'none'; } if (rowHeroSmsPlatform) { - rowHeroSmsPlatform.style.display = ''; + rowHeroSmsPlatform.style.display = canShowPhoneSettings ? '' : 'none'; } updateSignupMethodUI(); if (btnTogglePhoneVerificationSection) { @@ -7447,9 +7662,37 @@ function updatePlusModeUI() { const gopayValue = typeof PLUS_PAYMENT_METHOD_GOPAY !== 'undefined' ? PLUS_PAYMENT_METHOD_GOPAY : 'gopay'; const gpcValue = typeof PLUS_PAYMENT_METHOD_GPC_HELPER !== 'undefined' ? PLUS_PAYMENT_METHOD_GPC_HELPER : 'gpc-helper'; const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : paypalValue; - const enabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + const rawEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled ? Boolean(inputPlusModeEnabled.checked) : false; + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + state: { + ...(latestState || {}), + plusModeEnabled: rawEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: latestState?.activeFlowId, + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : (latestState?.panelMode || 'cpa'), + state: { + ...(latestState || {}), + plusModeEnabled: rawEnabled, + }, + }) + : null; + })(); + const supportsPlusMode = capabilityState + ? Boolean(capabilityState.canShowPlusSettings) + : true; + const enabled = supportsPlusMode && rawEnabled; const method = enabled ? getSelectedPlusPaymentMethod() : defaultMethod; const gpcPhoneMode = normalizeGpcHelperPhoneModeValue( typeof selectGpcHelperPhoneMode !== 'undefined' && selectGpcHelperPhoneMode @@ -7476,6 +7719,9 @@ function updatePlusModeUI() { const canShowGpcModeSelector = gpcRowsVisible; const localSmsControlsVisible = gpcRowsVisible && !isGpcAutoMode; const effectiveLocalSmsEnabled = !isGpcAutoMode && localSmsEnabled; + if (typeof rowPlusMode !== 'undefined' && rowPlusMode) { + rowPlusMode.style.display = supportsPlusMode ? '' : 'none'; + } if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) { selectPlusPaymentMethod.value = method; if (selectPlusPaymentMethod.style) { @@ -7657,7 +7903,39 @@ function syncSignupPhoneInputFromState(state = latestState) { const selectedMethod = typeof normalizeSignupMethod === 'function' ? normalizeSignupMethod(rawSignupMethod) : (String(rawSignupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'); - rowSignupPhone.style.display = phoneVerificationEnabled + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: state?.panelMode || latestState?.panelMode, + signupMethod: selectedMethod, + state: { + ...(latestState || {}), + ...(state || {}), + phoneVerificationEnabled, + }, + }) + : (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + return registry?.resolveSidepanelCapabilities + ? registry.resolveSidepanelCapabilities({ + activeFlowId: state?.activeFlowId || latestState?.activeFlowId, + panelMode: state?.panelMode || latestState?.panelMode, + signupMethod: selectedMethod, + state: { + ...(latestState || {}), + ...(state || {}), + phoneVerificationEnabled, + }, + }) + : null; + })(); + const canShowPhoneSettings = capabilityState + ? Boolean(capabilityState.canShowPhoneSettings) + : true; + rowSignupPhone.style.display = canShowPhoneSettings + && phoneVerificationEnabled && (selectedMethod === 'phone' || Boolean(signupPhone) || Boolean(getSignupPhoneInputValue()) || signupPhoneInputDirty) ? '' : 'none'; @@ -8285,9 +8563,17 @@ function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOr function applySettingsState(state) { if (typeof syncStepDefinitionsForMode === 'function') { - syncStepDefinitionsForMode(Boolean(state?.plusModeEnabled), { + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState(state, { + signupMethod: state?.signupMethod, + }) + : { + plusModeEnabled: Boolean(state?.plusModeEnabled), + signupMethod: normalizeSignupMethod(state?.signupMethod || DEFAULT_SIGNUP_METHOD), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { plusPaymentMethod: state?.plusPaymentMethod, - signupMethod: state?.signupMethod, + signupMethod: stepDefinitionState.signupMethod, }); } const fallbackIpProxyService = '711proxy'; @@ -9776,6 +10062,30 @@ function updateMailProviderUI() { const icloudHostPreferenceValue = typeof selectIcloudHostPreference !== 'undefined' ? selectIcloudHostPreference?.value : latestState?.icloudHostPreference; + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + state: latestState || {}, + }) + : null; + const canShowLuckmail = capabilityState + ? Boolean(capabilityState.canShowLuckmail) + : true; + const mailProviderOptions = Array.from(selectMailProvider?.options || []); + mailProviderOptions.forEach((option) => { + if (!option) { + return; + } + if (String(option.value || '').trim().toLowerCase() === 'luckmail-api') { + option.hidden = !canShowLuckmail; + } + }); + if (!canShowLuckmail && String(selectMailProvider?.value || '').trim().toLowerCase() === 'luckmail-api') { + const fallbackOption = mailProviderOptions.find((option) => option && !option.hidden); + if (fallbackOption) { + selectMailProvider.value = String(fallbackOption.value || '').trim(); + } + } const use2925 = selectMailProvider.value === '2925'; const useGmail = selectMailProvider.value === GMAIL_PROVIDER; const useMail2925 = selectMailProvider.value === '2925'; @@ -9806,7 +10116,7 @@ function updateMailProviderUI() { const useGeneratedAlias = usesGeneratedAliasMailProvider(selectMailProvider.value, mail2925Mode, selectedGenerator); const useInbucket = selectMailProvider.value === 'inbucket'; const useHotmail = selectMailProvider.value === 'hotmail-api'; - const useLuckmail = isLuckmailProvider(); + const useLuckmail = canShowLuckmail && isLuckmailProvider(); const useCustomEmail = isCustomMailProvider(); const useCustomMailProviderPool = useCustomEmail && usesCustomMailProviderPool(selectMailProvider.value); const useIcloudProvider = isIcloudMailProvider(); @@ -10228,7 +10538,39 @@ async function handleDeleteSub2ApiGroup(groupName) { } function updatePanelModeUI() { - const panelMode = getSelectedPanelMode(); + const rawPanelMode = normalizePanelMode(selectPanelMode?.value || latestState?.panelMode || 'cpa'); + const capabilityState = typeof resolveCurrentSidepanelCapabilities === 'function' + ? resolveCurrentSidepanelCapabilities({ + panelMode: rawPanelMode, + state: { + ...(latestState || {}), + panelMode: rawPanelMode, + }, + }) + : null; + const supportedPanelModes = Array.isArray(capabilityState?.supportedPanelModes) + ? capabilityState.supportedPanelModes + : []; + if (selectPanelMode?.options && supportedPanelModes.length) { + Array.from(selectPanelMode.options).forEach((option) => { + if (!option) { + return; + } + const optionMode = normalizePanelMode(option.value || ''); + const enabled = supportedPanelModes.includes(optionMode); + option.disabled = !enabled; + option.hidden = !enabled; + }); + } else if (selectPanelMode?.options) { + Array.from(selectPanelMode.options).forEach((option) => { + if (!option) { + return; + } + option.disabled = false; + option.hidden = false; + }); + } + const panelMode = capabilityState?.effectivePanelMode || capabilityState?.panelMode || getSelectedPanelMode(); if (selectPanelMode) { selectPanelMode.value = panelMode; } @@ -11799,7 +12141,22 @@ inputPassword.addEventListener('blur', () => { inputPlusModeEnabled?.addEventListener('change', () => { updatePlusModeUI(); updateSignupMethodUI({ notify: true }); - syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled.checked), getSelectedPlusPaymentMethod(), { render: true }); + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: Boolean(inputPlusModeEnabled.checked), + signupMethod: getSelectedSignupMethod(), + }, { + signupMethod: getSelectedSignupMethod(), + }) + : { + plusModeEnabled: Boolean(inputPlusModeEnabled.checked), + signupMethod: getSelectedSignupMethod(), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, getSelectedPlusPaymentMethod(), { + render: true, + signupMethod: stepDefinitionState.signupMethod, + }); markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); }); @@ -11811,7 +12168,22 @@ inputOperationDelayEnabled?.addEventListener('change', () => { selectPlusPaymentMethod?.addEventListener('change', () => { selectPlusPaymentMethod.value = normalizePlusPaymentMethod(selectPlusPaymentMethod.value); updatePlusModeUI(); - syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled?.checked), selectPlusPaymentMethod.value, { render: true }); + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }, { + signupMethod: getSelectedSignupMethod(), + }) + : { + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, selectPlusPaymentMethod.value, { + render: true, + signupMethod: stepDefinitionState.signupMethod, + }); markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); }); @@ -11873,9 +12245,22 @@ btnGpcHelperBalance?.addEventListener('click', async () => { selectPlusPaymentMethod?.addEventListener('change', () => { updatePlusModeUI(); - syncStepDefinitionsForMode(Boolean(inputPlusModeEnabled?.checked), { + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState({ + ...(latestState || {}), + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }, { + signupMethod: getSelectedSignupMethod(), + }) + : { + plusModeEnabled: Boolean(inputPlusModeEnabled?.checked), + signupMethod: getSelectedSignupMethod(), + }; + syncStepDefinitionsForMode(stepDefinitionState.plusModeEnabled, { render: true, plusPaymentMethod: selectPlusPaymentMethod.value, + signupMethod: stepDefinitionState.signupMethod, }); markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); @@ -12011,7 +12396,9 @@ checkboxAutoDeleteIcloud?.addEventListener('change', () => { selectPanelMode.addEventListener('change', async () => { const previousPanelMode = normalizePanelMode(latestState?.panelMode || 'cpa'); - const nextPanelMode = normalizePanelMode(selectPanelMode.value); + const rawNextPanelMode = normalizePanelMode(selectPanelMode.value); + selectPanelMode.value = rawNextPanelMode; + const nextPanelMode = getSelectedPanelMode(); selectPanelMode.value = nextPanelMode; const confirmed = await confirmCpaPhoneSignupIfNeeded({ signupMethod: getSelectedSignupMethod(), @@ -13845,10 +14232,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { || message.payload.gopayHelperOtpChannel !== undefined || message.payload.gopayHelperLocalSmsHelperEnabled !== undefined ) { + const stepDefinitionState = typeof resolveStepDefinitionCapabilityState === 'function' + ? resolveStepDefinitionCapabilityState(latestState, { + signupMethod: latestState?.signupMethod, + }) + : { + plusModeEnabled: Boolean(latestState?.plusModeEnabled), + signupMethod: normalizeSignupMethod(latestState?.signupMethod || DEFAULT_SIGNUP_METHOD), + }; syncStepDefinitionsForMode( - Boolean(latestState?.plusModeEnabled), + stepDefinitionState.plusModeEnabled, latestState?.plusPaymentMethod, - { render: true } + { + render: true, + signupMethod: stepDefinitionState.signupMethod, + } ); updatePlusModeUI(); updateSignupMethodUI({ notify: true }); diff --git a/tests/background-luckmail.test.js b/tests/background-luckmail.test.js index 669cd28..e9deb50 100644 --- a/tests/background-luckmail.test.js +++ b/tests/background-luckmail.test.js @@ -731,6 +731,9 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c 'async function getPersistedAliasState() {', ' return {};', '}', + 'function buildStatePatchWithRuntimeState(_currentState, updates) {', + ' return updates;', + '}', 'const chrome = {', ' storage: {', ' session: {', diff --git a/tests/background-mail-rule-registry-module.test.js b/tests/background-mail-rule-registry-module.test.js new file mode 100644 index 0000000..d6cc6c0 --- /dev/null +++ b/tests/background-mail-rule-registry-module.test.js @@ -0,0 +1,93 @@ +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', () => { + const source = fs.readFileSync('background.js', 'utf8'); + assert.match(source, /background\/mail-rule-registry\.js/); + assert.match(source, /flows\/openai\/mail-rules\.js/); +}); + +test('mail rule registry exposes canonical OpenAI verification poll payloads', () => { + const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8'); + const openAiSource = fs.readFileSync('flows/openai/mail-rules.js', 'utf8'); + const registryApi = new Function('self', `${registrySource}; return self.MultiPageBackgroundMailRuleRegistry;`)({}); + const openAiApi = new Function('self', `${openAiSource}; return self.MultiPageOpenAiMailRules;`)({}); + + const openAiMailRules = openAiApi.createOpenAiMailRules({ + getHotmailVerificationRequestTimestamp: (step) => (step === 4 ? 123 : 456), + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + }); + const registry = registryApi.createMailRuleRegistry({ + defaultFlowId: 'openai', + flowBuilders: { + openai: openAiMailRules, + }, + }); + + assert.deepEqual( + registry.buildVerificationPollPayload( + 4, + { + activeFlowId: 'openai', + email: 'user@example.com', + mailProvider: '2925', + mail2925Mode: 'receive', + }, + { excludeCodes: ['111111'] } + ), + { + flowId: 'openai', + ruleId: 'openai-signup-code', + step: 4, + artifactType: 'code', + filterAfterTimestamp: 0, + senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], + subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], + targetEmail: 'user@example.com', + mail2925MatchTargetEmail: true, + maxAttempts: 15, + intervalMs: 15000, + excludeCodes: ['111111'], + } + ); + + assert.deepEqual( + registry.buildVerificationPollPayload(8, { + activeFlowId: 'openai', + email: 'user@example.com', + step8VerificationTargetEmail: 'login@example.com', + }), + { + flowId: 'openai', + ruleId: 'openai-login-code', + step: 8, + artifactType: 'code', + filterAfterTimestamp: 456, + senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], + subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], + targetEmail: 'login@example.com', + mail2925MatchTargetEmail: false, + maxAttempts: 5, + intervalMs: 3000, + } + ); +}); + +test('mail rule registry rejects unknown active flow ids instead of silently using OpenAI rules', () => { + const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8'); + const registryApi = new Function('self', `${registrySource}; return self.MultiPageBackgroundMailRuleRegistry;`)({}); + const registry = registryApi.createMailRuleRegistry({ + defaultFlowId: 'openai', + flowBuilders: {}, + }); + + assert.throws( + () => registry.buildVerificationPollPayload(4, { + activeFlowId: 'site-a', + email: 'user@example.com', + }), + /未找到 flow=site-a 的邮件轮询规则构造器/ + ); +}); diff --git a/tests/background-message-router-module.test.js b/tests/background-message-router-module.test.js index fef97fd..f1a2da0 100644 --- a/tests/background-message-router-module.test.js +++ b/tests/background-message-router-module.test.js @@ -162,3 +162,39 @@ test('SAVE_SETTING broadcasts operation delay setting without background success assert.deepStrictEqual(broadcasts.at(-1), { operationDelayEnabled: false }); assert.equal(logs.length, 0); }); + +test('SAVE_SETTING re-resolves signup method when panel mode changes', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = { console }; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + let state = { + signupMethod: 'phone', + phoneVerificationEnabled: true, + plusModeEnabled: false, + panelMode: 'sub2api', + }; + + const router = api.createMessageRouter({ + addLog: async () => {}, + buildLuckmailSessionSettingsPayload: () => ({}), + buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'panelMode') + ? { panelMode: input.panelMode } + : {}, + broadcastDataUpdate: () => {}, + getState: async () => ({ ...state }), + resolveSignupMethod: (nextState = {}) => nextState.panelMode === 'cpa' ? 'email' : 'phone', + setPersistentSettings: async () => {}, + setState: async (updates) => { + state = { ...state, ...updates }; + }, + }); + + const response = await router.handleMessage({ + type: 'SAVE_SETTING', + payload: { panelMode: 'cpa' }, + }); + + assert.equal(response.ok, true); + assert.equal(state.panelMode, 'cpa'); + assert.equal(state.signupMethod, 'email'); +}); diff --git a/tests/background-navigation-utils-module.test.js b/tests/background-navigation-utils-module.test.js index eee918b..cce18ed 100644 --- a/tests/background-navigation-utils-module.test.js +++ b/tests/background-navigation-utils-module.test.js @@ -29,6 +29,7 @@ test('navigation utils recognize signup password pages for email and phone signu assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/create-account/password'), true); assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in/password'), true); assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in'), false); + assert.equal(utils.isSignupEntryHost('www.chatgpt.com'), true); }); test('navigation utils treat 126 mail hosts as part of the shared NetEase mail family', () => { diff --git a/tests/background-runtime-state-module.test.js b/tests/background-runtime-state-module.test.js new file mode 100644 index 0000000..df67866 --- /dev/null +++ b/tests/background-runtime-state-module.test.js @@ -0,0 +1,163 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function loadRuntimeStateApi() { + const source = fs.readFileSync('background/runtime-state.js', 'utf8'); + const globalScope = {}; + return new Function('self', `${source}; return self.MultiPageBackgroundRuntimeState;`)(globalScope); +} + +test('background imports runtime-state module and wires state view helpers', () => { + const source = fs.readFileSync('background.js', 'utf8'); + assert.match(source, /background\/runtime-state\.js/); + assert.match(source, /createRuntimeStateHelpers/); + assert.match(source, /buildStateViewWithRuntimeState/); + assert.match(source, /buildStatePatchWithRuntimeState/); + assert.match(source, /runtimeState:/); +}); + +test('runtime-state module exposes a factory', () => { + const api = loadRuntimeStateApi(); + assert.equal(typeof api?.createRuntimeStateHelpers, 'function'); +}); + +test('runtime-state view derives canonical flow metadata from legacy step state', () => { + const api = loadRuntimeStateApi(); + const helpers = api.createRuntimeStateHelpers({ + DEFAULT_ACTIVE_FLOW_ID: 'openai', + defaultStepStatuses: { + 1: 'pending', + 2: 'pending', + 10: 'pending', + }, + getStepDefinitionForState(step) { + return { + 1: { id: 1, key: 'open-chatgpt' }, + 2: { id: 2, key: 'submit-signup-email' }, + 10: { id: 10, key: 'oauth-login' }, + }[Number(step)] || null; + }, + }); + + const view = helpers.buildStateView({ + currentStep: 2, + stepStatuses: { + 1: 'completed', + 2: 'running', + }, + oauthUrl: 'https://auth.example.com/start', + plusCheckoutTabId: 88, + currentPhoneActivation: { + activationId: 'active-1', + phoneNumber: '+447700900123', + }, + tabRegistry: { + 'signup-page': { tabId: 12 }, + }, + sourceLastUrls: { + 'signup-page': 'https://auth.example.com/start', + }, + flowStartTime: 12345, + }); + + assert.equal(view.activeFlowId, 'openai'); + assert.equal(view.currentNodeId, 'submit-signup-email'); + assert.deepStrictEqual(view.legacyStepCompat, { + currentStep: 2, + stepStatuses: { + 1: 'completed', + 2: 'running', + 10: 'pending', + }, + }); + assert.deepStrictEqual(view.nodeStatuses, { + 'open-chatgpt': 'completed', + 'submit-signup-email': 'running', + 'oauth-login': 'pending', + }); + assert.equal(view.runtimeState.flowState.openai.auth.oauthUrl, 'https://auth.example.com/start'); + assert.equal(view.runtimeState.flowState.openai.plus.plusCheckoutTabId, 88); + assert.deepStrictEqual(view.runtimeState.flowState.openai.phoneVerification.currentPhoneActivation, { + activationId: 'active-1', + phoneNumber: '+447700900123', + }); + assert.deepStrictEqual(view.sharedState, { + tabRegistry: { + 'signup-page': { tabId: 12 }, + }, + sourceLastUrls: { + 'signup-page': 'https://auth.example.com/start', + }, + flowStartTime: 12345, + }); +}); + +test('runtime-state patch accepts nested flow updates while keeping legacy compatibility fields in sync', () => { + const api = loadRuntimeStateApi(); + const helpers = api.createRuntimeStateHelpers({ + DEFAULT_ACTIVE_FLOW_ID: 'openai', + defaultStepStatuses: { + 1: 'pending', + 2: 'pending', + 10: 'pending', + }, + getStepDefinitionForState(step) { + return { + 1: { id: 1, key: 'open-chatgpt' }, + 2: { id: 2, key: 'submit-signup-email' }, + 10: { id: 10, key: 'oauth-login' }, + }[Number(step)] || null; + }, + }); + + const patch = helpers.buildSessionStatePatch({ + currentStep: 1, + stepStatuses: { + 1: 'running', + 2: 'pending', + 10: 'pending', + }, + oauthUrl: 'https://old.example.com/start', + }, { + runtimeState: { + activeRunId: 'run-001', + flowState: { + openai: { + auth: { + oauthUrl: 'https://new.example.com/start', + }, + plus: { + plusCheckoutTabId: 99, + }, + }, + }, + legacyStepCompat: { + currentStep: 10, + stepStatuses: { + 1: 'completed', + 10: 'running', + }, + }, + }, + }); + + assert.equal(patch.activeFlowId, 'openai'); + assert.equal(patch.activeRunId, 'run-001'); + assert.equal(patch.currentNodeId, 'oauth-login'); + assert.equal(patch.oauthUrl, 'https://new.example.com/start'); + assert.equal(patch.plusCheckoutTabId, 99); + assert.equal(patch.currentStep, 10); + assert.deepStrictEqual(patch.stepStatuses, { + 1: 'completed', + 2: 'pending', + 10: 'running', + }); + assert.deepStrictEqual(patch.nodeStatuses, { + 'open-chatgpt': 'completed', + 'submit-signup-email': 'pending', + 'oauth-login': 'running', + }); + assert.equal(patch.runtimeState.flowState.openai.auth.oauthUrl, 'https://new.example.com/start'); + assert.equal(patch.runtimeState.flowState.openai.plus.plusCheckoutTabId, 99); +}); diff --git a/tests/background-signup-method-settings.test.js b/tests/background-signup-method-settings.test.js index 25d61c1..3af827a 100644 --- a/tests/background-signup-method-settings.test.js +++ b/tests/background-signup-method-settings.test.js @@ -95,6 +95,51 @@ return { assert.equal(api.logs.some((entry) => /固定为邮箱注册/.test(entry.message)), true); }); +test('signup method resolution respects the shared flow capability registry when available', () => { + const api = new Function(` +const self = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + resolveSidepanelCapabilities({ state = {}, signupMethod = 'email' } = {}) { + const phoneAllowed = String(state?.activeFlowId || '').trim().toLowerCase() === 'openai'; + return { + canUsePhoneSignup: phoneAllowed, + effectiveSignupMethod: signupMethod === 'phone' && phoneAllowed ? 'phone' : 'email', + }; + }, + }; + }, + }, +}; +const SIGNUP_METHOD_EMAIL = 'email'; +const SIGNUP_METHOD_PHONE = 'phone'; +${extractFunction('normalizeSignupMethod')} +${extractFunction('canUsePhoneSignup')} +${extractFunction('resolveSignupMethod')} +return { + canUsePhoneSignup, + resolveSignupMethod, +}; +`)(); + + assert.equal(api.canUsePhoneSignup({ + activeFlowId: 'site-a', + phoneVerificationEnabled: true, + signupMethod: 'phone', + }), false); + assert.equal(api.resolveSignupMethod({ + activeFlowId: 'site-a', + phoneVerificationEnabled: true, + signupMethod: 'phone', + }), 'email'); + assert.equal(api.resolveSignupMethod({ + activeFlowId: 'openai', + phoneVerificationEnabled: true, + signupMethod: 'phone', + }), 'phone'); +}); + test('background step definitions resolve titles from the frozen signup method', () => { const api = new Function(` const captured = []; diff --git a/tests/background-tab-runtime-module.test.js b/tests/background-tab-runtime-module.test.js index b1186bd..6a31394 100644 --- a/tests/background-tab-runtime-module.test.js +++ b/tests/background-tab-runtime-module.test.js @@ -16,6 +16,76 @@ test('tab runtime module exposes a factory', () => { assert.equal(typeof api?.createTabRuntime, 'function'); }); +test('tab runtime accepts canonical openai-auth readiness for queued signup-page commands', async () => { + const runtimeSource = fs.readFileSync('background/tab-runtime.js', 'utf8'); + const registrySource = fs.readFileSync('shared/source-registry.js', 'utf8'); + const runtimeApi = new Function('self', `${runtimeSource}; return self.MultiPageBackgroundTabRuntime;`)({}); + const registryApi = new Function('self', `${registrySource}; return self.MultiPageSourceRegistry;`)({}); + const sourceRegistry = registryApi.createSourceRegistry(); + + const sentMessages = []; + let currentState = { + tabRegistry: { + 'signup-page': { tabId: 91, ready: true }, + }, + sourceLastUrls: { + 'signup-page': 'https://auth.openai.com/authorize', + }, + }; + + const runtime = runtimeApi.createTabRuntime({ + LOG_PREFIX: '[test]', + addLog: async () => {}, + chrome: { + tabs: { + get: async (tabId) => ({ + id: tabId, + windowId: 1, + url: 'https://auth.openai.com/authorize', + status: 'complete', + }), + query: async () => [], + sendMessage: async (tabId, message) => { + if (message.type === 'PING') { + return { ok: true, source: 'openai-auth' }; + } + sentMessages.push({ tabId, message }); + return { ok: true }; + }, + }, + }, + getSourceLabel: (source) => source || 'unknown', + getState: async () => currentState, + matchesSourceUrlFamily: (source, candidateUrl, referenceUrl) => ( + sourceRegistry.matchesSourceUrlFamily(source, candidateUrl, referenceUrl) + ), + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sourceRegistry, + throwIfStopped: () => {}, + }); + + assert.equal(await runtime.getTabId('openai-auth'), 91); + + currentState = { + tabRegistry: {}, + sourceLastUrls: {}, + }; + + const queued = runtime.queueCommand('signup-page', { type: 'STEP2_TEST' }, 1000); + runtime.flushCommand('openai-auth', 55); + await assert.doesNotReject(queued); + assert.deepEqual(sentMessages, [{ tabId: 55, message: { type: 'STEP2_TEST' } }]); + + await runtime.ensureContentScriptReadyOnTab('signup-page', 77, { + timeoutMs: 100, + }); + + assert.deepEqual(currentState.tabRegistry['openai-auth'], { tabId: 77, ready: true, windowId: 1 }); + assert.equal(Object.prototype.hasOwnProperty.call(currentState.tabRegistry, 'signup-page'), false); +}); + test('tab runtime caps per-attempt response timeout to the remaining resilient timeout budget', () => { const source = fs.readFileSync('background/tab-runtime.js', 'utf8'); const globalScope = {}; diff --git a/tests/content-utils.test.js b/tests/content-utils.test.js index 8703fdd..44353c8 100644 --- a/tests/content-utils.test.js +++ b/tests/content-utils.test.js @@ -81,6 +81,38 @@ return { detectScriptSource }; ); }); +test('detectScriptSource maps OpenAI auth hosts to canonical openai-auth source', () => { + const bundle = [extractFunction('detectScriptSource')].join('\n'); + const api = new Function(` +${bundle} +return { detectScriptSource }; +`)(); + + assert.equal( + api.detectScriptSource({ + url: 'https://auth.openai.com/create-account', + hostname: 'auth.openai.com', + }), + 'openai-auth' + ); +}); + +test('detectScriptSource returns unknown-source for unrecognized pages', () => { + const bundle = [extractFunction('detectScriptSource')].join('\n'); + const api = new Function(` +${bundle} +return { detectScriptSource }; +`)(); + + assert.equal( + api.detectScriptSource({ + url: 'https://example.com/', + hostname: 'example.com', + }), + 'unknown-source' + ); +}); + test('shouldReportReadyForFrame suppresses noisy plus checkout child frame ready logs', () => { const bundle = [extractFunction('shouldReportReadyForFrame')].join('\n'); const api = new Function(` @@ -91,6 +123,8 @@ return { shouldReportReadyForFrame }; assert.equal(api.shouldReportReadyForFrame('plus-checkout', true), false); assert.equal(api.shouldReportReadyForFrame('plus-checkout', false), true); assert.equal(api.shouldReportReadyForFrame('paypal-flow', true), true); + assert.equal(api.shouldReportReadyForFrame('unknown-source', false), true); + assert.equal(api.shouldReportReadyForFrame('unknown-source', true), false); }); test('getRuntimeScriptSource follows injected source overrides after utils is already loaded', () => { diff --git a/tests/flow-capabilities-module.test.js b/tests/flow-capabilities-module.test.js new file mode 100644 index 0000000..e6d8125 --- /dev/null +++ b/tests/flow-capabilities-module.test.js @@ -0,0 +1,72 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('shared/flow-capabilities.js', 'utf8'); + +function loadApi() { + const scope = {}; + return new Function('self', `${source}; return self.MultiPageFlowCapabilities;`)(scope); +} + +test('flow capability registry keeps OpenAI phone signup available only when runtime locks allow it', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry(); + + const enabledState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'openai', + panelMode: 'cpa', + phoneVerificationEnabled: true, + plusModeEnabled: false, + contributionMode: false, + signupMethod: 'phone', + }, + }); + + assert.equal(enabledState.canUsePhoneSignup, true); + assert.equal(enabledState.effectiveSignupMethod, 'phone'); + assert.equal(enabledState.shouldWarnCpaPhoneSignup, true); + assert.deepEqual(enabledState.effectiveSignupMethods, ['email', 'phone']); + + const plusLockedState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'openai', + panelMode: 'sub2api', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: false, + signupMethod: 'phone', + }, + }); + + assert.equal(plusLockedState.canUsePhoneSignup, false); + assert.equal(plusLockedState.effectiveSignupMethod, 'email'); + assert.equal(plusLockedState.shouldWarnCpaPhoneSignup, false); + assert.deepEqual(plusLockedState.effectiveSignupMethods, ['email']); +}); + +test('flow capability registry defaults unknown flows to minimal non-phone capabilities', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry(); + + const capabilityState = registry.resolveSidepanelCapabilities({ + state: { + activeFlowId: 'site-a', + panelMode: 'codex2api', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: true, + signupMethod: 'phone', + }, + }); + + assert.equal(capabilityState.activeFlowId, 'site-a'); + assert.equal(capabilityState.canShowPhoneSettings, false); + assert.equal(capabilityState.canShowPlusSettings, false); + assert.equal(capabilityState.canShowLuckmail, false); + assert.equal(capabilityState.canUsePhoneSignup, false); + assert.equal(capabilityState.effectiveSignupMethod, 'email'); + assert.equal(capabilityState.panelMode, 'codex2api'); + assert.deepEqual(capabilityState.supportedPanelModes, []); +}); diff --git a/tests/sidepanel-phone-verification-settings.test.js b/tests/sidepanel-phone-verification-settings.test.js index 6488369..774983e 100644 --- a/tests/sidepanel-phone-verification-settings.test.js +++ b/tests/sidepanel-phone-verification-settings.test.js @@ -294,6 +294,58 @@ return { assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), false); }); +test('sidepanel phone signup gating can follow the shared flow capability registry', () => { + const bundle = [ + extractFunction('normalizeSignupMethod'), + extractFunction('normalizePanelMode'), + extractFunction('canSelectPhoneSignupMethod'), + extractFunction('shouldWarnCpaPhoneSignup'), + ].join('\n'); + + const api = new Function(` +const window = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + resolveSidepanelCapabilities({ state = {}, panelMode = 'cpa', signupMethod = 'email' } = {}) { + const phoneAllowed = String(state?.activeFlowId || '').trim().toLowerCase() === 'openai'; + return { + canSelectPhoneSignup: phoneAllowed, + shouldWarnCpaPhoneSignup: phoneAllowed && signupMethod === 'phone' && panelMode === 'cpa', + }; + }, + }; + }, + }, +}; +let latestState = { + activeFlowId: 'site-a', + contributionMode: false, + panelMode: 'cpa', +}; +const inputPhoneVerificationEnabled = { checked: true }; +const inputPlusModeEnabled = { checked: false }; +function getSelectedPanelMode() { return 'cpa'; } +function getSelectedSignupMethod() { return 'phone'; } +function isCpaPhoneSignupPromptDismissed() { return false; } +${bundle} +return { + canSelectPhoneSignupMethod, + shouldWarnCpaPhoneSignup, + setFlow(flowId) { + latestState.activeFlowId = flowId; + }, +}; +`)(); + + assert.equal(api.canSelectPhoneSignupMethod(), false); + assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), false); + + api.setFlow('openai'); + assert.equal(api.canSelectPhoneSignupMethod(), true); + assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), true); +}); + test('manual step 3 uses phone identity without requiring registration email', () => { const api = new Function(` let latestState = { signupMethod: 'phone', phoneVerificationEnabled: true, signupPhoneNumber: '+441111111111', accountIdentifierType: 'phone', accountIdentifier: '+441111111111' }; diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js index ded1b45..1a936be 100644 --- a/tests/sidepanel-plus-payment-method.test.js +++ b/tests/sidepanel-plus-payment-method.test.js @@ -124,8 +124,8 @@ test('sidepanel signup method UI syncs shared step definitions with the selected test('sidepanel applies restored signup method when rebuilding shared step definitions on load', () => { const source = extractFunction('applySettingsState'); - assert.match(source, /syncStepDefinitionsForMode\(Boolean\(state\?\.plusModeEnabled\),\s*\{/); - assert.match(source, /signupMethod:\s*state\?\.signupMethod/); + assert.match(source, /resolveStepDefinitionCapabilityState\(state/); + assert.match(source, /signupMethod:\s*stepDefinitionState\.signupMethod/); }); test('sidepanel Plus UI hides PayPal account selector while GoPay is selected', () => { @@ -165,6 +165,62 @@ return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount }; assert.equal(api.rowPayPalAccount.style.display, ''); }); +test('sidepanel Plus UI can hide Plus controls when the shared flow capability registry disables them', () => { + const bundle = [ + extractFunction('normalizePlusPaymentMethod'), + extractFunction('getSelectedPlusPaymentMethod'), + extractFunction('normalizeGpcHelperPhoneModeValue'), + extractFunction('getGpcHelperAutoModeEnabled'), + extractFunction('normalizeGpcAutoModePermissionValue'), + extractFunction('getGpcAutoModePermissionFromPayload'), + extractFunction('shouldPreserveSelectedGpcAutoMode'), + extractFunction('hasGpcAutoModePermissionField'), + extractFunction('isGpcAutoModePermissionDenied'), + extractFunction('normalizeGpcOtpChannelValue'), + extractFunction('updatePlusModeUI'), + ].join('\n'); + + const api = new Function(` +const window = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + resolveSidepanelCapabilities() { + return { + canShowPlusSettings: false, + runtimeLocks: { plusModeEnabled: false }, + }; + }, + }; + }, + }, +}; +let latestState = { plusPaymentMethod: 'paypal' }; +const inputPlusModeEnabled = { checked: true }; +const rowPlusMode = { style: { display: '' } }; +const selectPlusPaymentMethod = { value: 'paypal', style: { display: '' } }; +const rowPlusPaymentMethod = { style: { display: '' } }; +const rowPayPalAccount = { style: { display: '' } }; +const GPC_HELPER_PHONE_MODE_AUTO = 'auto'; +const GPC_HELPER_PHONE_MODE_MANUAL = 'manual'; +${bundle} +return { + rowPlusMode, + rowPlusPaymentMethod, + rowPayPalAccount, + selectPlusPaymentMethod, + updatePlusModeUI, +}; +`)(); + + api.updatePlusModeUI(); + + assert.equal(api.rowPlusMode.style.display, 'none'); + assert.equal(api.rowPlusPaymentMethod.style.display, 'none'); + assert.equal(api.rowPayPalAccount.style.display, 'none'); + assert.equal(api.selectPlusPaymentMethod.style.display, 'none'); +}); + test('sidepanel step definitions keep GPC helper mode distinct', () => { const bundle = [ extractFunction('normalizeSignupMethod'), diff --git a/tests/source-registry-module.test.js b/tests/source-registry-module.test.js new file mode 100644 index 0000000..ae6d47c --- /dev/null +++ b/tests/source-registry-module.test.js @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('background imports shared source registry module', () => { + const source = fs.readFileSync('background.js', 'utf8'); + assert.match(source, /shared\/source-registry\.js/); +}); + +test('manifest loads shared source registry before content utils in static bundles', () => { + 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('content/utils.js')) continue; + assert.ok(scripts.includes('shared/source-registry.js')); + assert.ok( + scripts.indexOf('shared/source-registry.js') < scripts.indexOf('content/utils.js'), + 'shared/source-registry.js must load before content/utils.js' + ); + } +}); + +test('shared source registry exposes canonical source, alias, detection, and ready policies', () => { + const source = fs.readFileSync('shared/source-registry.js', 'utf8'); + const api = new Function('self', `${source}; return self.MultiPageSourceRegistry;`)({}); + const registry = api.createSourceRegistry(); + + assert.equal(registry.resolveCanonicalSource('signup-page'), 'openai-auth'); + assert.deepEqual(registry.getSourceKeys('signup-page'), ['openai-auth', 'signup-page']); + assert.equal(registry.getSourceLabel('openai-auth'), '认证页'); + assert.equal( + registry.matchesSourceUrlFamily( + 'openai-auth', + 'https://chatgpt.com/', + 'https://auth.openai.com/authorize?client_id=test' + ), + true + ); + assert.equal( + registry.detectSourceFromLocation({ + url: 'https://auth.openai.com/create-account', + hostname: 'auth.openai.com', + }), + 'openai-auth' + ); + assert.equal( + registry.detectSourceFromLocation({ + url: 'https://example.com/', + hostname: 'example.com', + }), + 'unknown-source' + ); + assert.equal(registry.shouldReportReadyForFrame('mail-163', true), false); + assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false); + assert.equal(registry.getCleanupOwnerSource('oauth-localhost-callback'), 'openai-auth'); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 14eedb8..9e5dc1e 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -9,6 +9,7 @@ const api = new Function('self', `${source}; return self.MultiPageBackgroundVeri function createVerificationFlowTestHelpers(overrides = {}) { return api.createVerificationFlowHelpers({ addLog: async () => {}, + buildVerificationPollPayload: null, chrome: { tabs: { update: async () => {}, @@ -43,6 +44,42 @@ function createVerificationFlowTestHelpers(overrides = {}) { }); } +test('verification flow prefers injected verification poll payload builder when provided', () => { + const helpers = createVerificationFlowTestHelpers({ + buildVerificationPollPayload: (step, state, overrides = {}) => ({ + flowId: state.activeFlowId || 'openai', + ruleId: 'custom-rule', + step, + senderFilters: ['custom-sender'], + subjectFilters: ['custom-subject'], + targetEmail: state.email, + maxAttempts: 9, + intervalMs: 4321, + ...overrides, + }), + }); + + assert.deepStrictEqual( + helpers.getVerificationPollPayload(4, { + activeFlowId: 'openai', + email: 'user@example.com', + }, { + excludeCodes: ['111111'], + }), + { + flowId: 'openai', + ruleId: 'custom-rule', + step: 4, + senderFilters: ['custom-sender'], + subjectFilters: ['custom-subject'], + targetEmail: 'user@example.com', + maxAttempts: 9, + intervalMs: 4321, + excludeCodes: ['111111'], + } + ); +}); + test('verification flow keeps 2925 polling cadence in the default payload', () => { const helpers = api.createVerificationFlowHelpers({ addLog: async () => {}, From d93cdfff9ee07966ccd92ae3cdf9f4ff4d5e6b98 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Wed, 13 May 2026 06:35:43 +0800 Subject: [PATCH 3/7] Refactor multi-flow mail capability boundaries --- background.js | 128 +++++++++++- background/message-router.js | 65 +++++- background/verification-flow.js | 29 ++- content/gmail-mail.js | 55 ++++- content/icloud-mail.js | 55 ++++- content/inbucket-mail.js | 70 ++++++- content/mail-163.js | 72 +++++-- content/mail-2925.js | 111 ++++++++-- content/qq-mail.js | 55 ++++- data/step-definitions.js | 116 ++++++++--- flows/openai/mail-rules.js | 48 ++++- hotmail-utils.js | 73 +++++-- microsoft-email.js | 89 +++++--- scripts/hotmail_helper.py | 82 ++++---- shared/flow-capabilities.js | 195 ++++++++++++++++++ sidepanel/sidepanel.js | 45 ++++ tests/auto-run-timer-session-guard.test.js | 106 ++++++++++ tests/background-contribution-mode.test.js | 59 ++++++ ...tmail-local-helper-rule-forwarding.test.js | 28 +++ ...ckground-mail-rule-registry-module.test.js | 32 +++ .../background-message-router-module.test.js | 64 ++++++ ...nd-settings-import-mode-validation.test.js | 132 ++++++++++++ .../background-signup-method-settings.test.js | 1 + tests/flow-capabilities-module.test.js | 88 ++++++++ tests/hotmail-utils.test.js | 33 +++ tests/icloud-mail-content.test.js | 31 +++ tests/mail-163-content.test.js | 29 +++ tests/mail-2925-content.test.js | 62 +++++- tests/microsoft-email.test.js | 30 +++ tests/qq-mail-content.test.js | 139 +++++++++++++ ...sidepanel-auto-run-content-refresh.test.js | 101 +++++++++ tests/sidepanel-plus-payment-method.test.js | 4 +- tests/step-definitions-module.test.js | 6 + 33 files changed, 2035 insertions(+), 198 deletions(-) create mode 100644 tests/background-hotmail-local-helper-rule-forwarding.test.js create mode 100644 tests/background-settings-import-mode-validation.test.js create mode 100644 tests/qq-mail-content.test.js diff --git a/background.js b/background.js index e6017a3..8529b90 100644 --- a/background.js +++ b/background.js @@ -61,21 +61,30 @@ importScripts( 'content/activation-utils.js' ); -const NORMAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled: false }) || []; +const DEFAULT_ACTIVE_FLOW_ID = 'openai'; +const NORMAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, + plusModeEnabled: false, +}) || []; const PLUS_PAYPAL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, plusPaymentMethod: 'paypal', }) || NORMAL_STEP_DEFINITIONS; const PLUS_GOPAY_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, plusPaymentMethod: 'gopay', }) || PLUS_PAYPAL_STEP_DEFINITIONS; const PLUS_GPC_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', }) || PLUS_GOPAY_STEP_DEFINITIONS; const PLUS_STEP_DEFINITIONS = PLUS_PAYPAL_STEP_DEFINITIONS; -const ALL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getAllSteps?.() || [ +const ALL_STEP_DEFINITIONS = self.MultiPageStepDefinitions?.getAllSteps?.({ + activeFlowId: DEFAULT_ACTIVE_FLOW_ID, +}) || [ ...NORMAL_STEP_DEFINITIONS, ...PLUS_PAYPAL_STEP_DEFINITIONS, ...PLUS_GOPAY_STEP_DEFINITIONS, @@ -85,7 +94,6 @@ const STEP_IDS = Array.from(new Set(ALL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) .filter(Number.isFinite))) .sort((left, right) => left - right); -const DEFAULT_ACTIVE_FLOW_ID = 'openai'; const DEFAULT_STEP_STATUSES = Object.fromEntries(STEP_IDS.map((stepId) => [stepId, 'pending'])); const NORMAL_STEP_IDS = NORMAL_STEP_DEFINITIONS .map((definition) => Number(definition?.id)) @@ -597,11 +605,21 @@ function getSignupMethodForStepDefinitions(state = {}) { function getStepDefinitionsForState(state = {}) { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.MultiPageStepDefinitions?.getSteps) { - return rootScope.MultiPageStepDefinitions.getSteps({ + const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai'; + const activeFlowId = String(state?.activeFlowId || '').trim().toLowerCase() || defaultFlowId; + const definitions = rootScope.MultiPageStepDefinitions.getSteps({ + activeFlowId, plusModeEnabled: isPlusModeState(state), plusPaymentMethod: normalizePlusPaymentMethod(state?.plusPaymentMethod), signupMethod: getSignupMethodForStepDefinitions(state), }); + if (Array.isArray(definitions)) { + return definitions; + } + } + const activeFlowId = String(state?.activeFlowId || '').trim().toLowerCase(); + if (activeFlowId && activeFlowId !== DEFAULT_ACTIVE_FLOW_ID) { + return []; } if (!isPlusModeState(state)) { return NORMAL_STEP_DEFINITIONS; @@ -633,10 +651,19 @@ function getStepIdsForState(state = {}) { function getLastStepIdForState(state = {}) { const ids = getStepIdsForState(state); - return ids[ids.length - 1] || 10; + if (ids.length) { + return ids[ids.length - 1]; + } + return String(state?.activeFlowId || '').trim().toLowerCase() === DEFAULT_ACTIVE_FLOW_ID ? 10 : 0; } function getAuthChainStartStepId(state = {}) { + const authStepId = typeof getStepIdByKeyForState === 'function' + ? getStepIdByKeyForState('oauth-login', state) + : null; + if (Number.isInteger(authStepId) && authStepId > 0) { + return authStepId; + } return isPlusModeState(state) ? 10 : FINAL_OAUTH_CHAIN_START_STEP; } @@ -1374,6 +1401,38 @@ function resolveCurrentFlowCapabilities(state = {}, options = {}) { }); } +function validateAutoRunStartState(state = {}, options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.validateAutoRunStart) { + return { ok: true, errors: [] }; + } + return registry.validateAutoRunStart({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + +function validateModeSwitchState(state = {}, options = {}) { + const registry = getFlowCapabilityRegistry(); + if (!registry?.validateModeSwitch) { + return { + ok: true, + changedKeys: Array.isArray(options?.changedKeys) ? options.changedKeys : [], + errors: [], + normalizedUpdates: {}, + }; + } + return registry.validateModeSwitch({ + activeFlowId: options?.activeFlowId ?? state?.activeFlowId, + changedKeys: options?.changedKeys, + panelMode: options?.panelMode ?? state?.panelMode, + signupMethod: options?.signupMethod ?? state?.signupMethod, + state, + }); +} + function canUsePhoneSignup(state = {}) { const capabilityState = typeof resolveCurrentFlowCapabilities === 'function' ? resolveCurrentFlowCapabilities(state) @@ -3029,6 +3088,30 @@ async function importSettingsBundle(configBundle) { fillDefaults: true, requireKnownKeys: true, }); + const importModeValidation = validateModeSwitchState({ + ...state, + ...importedSettings, + resolvedSignupMethod: null, + }, { + changedKeys: Object.keys(importedSettings), + }); + if (importModeValidation?.normalizedUpdates && Object.keys(importModeValidation.normalizedUpdates).length > 0) { + Object.assign(importedSettings, importModeValidation.normalizedUpdates); + } + if ( + Object.prototype.hasOwnProperty.call(importedSettings, 'phoneVerificationEnabled') + || Object.prototype.hasOwnProperty.call(importedSettings, 'plusModeEnabled') + || Object.prototype.hasOwnProperty.call(importedSettings, 'signupMethod') + || Object.prototype.hasOwnProperty.call(importedSettings, 'panelMode') + || Object.prototype.hasOwnProperty.call(importedSettings, 'activeFlowId') + || Object.prototype.hasOwnProperty.call(importedSettings, 'contributionMode') + ) { + importedSettings.signupMethod = resolveSignupMethod({ + ...state, + ...importedSettings, + resolvedSignupMethod: null, + }); + } await setPersistentSettings(importedSettings); @@ -4151,6 +4234,8 @@ async function requestHotmailLocalCode(account, pollPayload = {}) { top: 5, senderFilters: pollPayload.senderFilters || [], subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], excludeCodes: pollPayload.excludeCodes || [], filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0, }), @@ -4427,6 +4512,8 @@ async function pollHotmailVerificationCode(step, state, pollPayload = {}) { afterTimestamp: pollPayload.filterAfterTimestamp || 0, senderFilters: pollPayload.senderFilters || [], subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], excludeCodes: pollPayload.excludeCodes || [], }); const match = matchResult.match; @@ -5609,6 +5696,8 @@ async function pollCloudflareTempEmailVerificationCode(step, state, pollPayload afterTimestamp: pollPayload.filterAfterTimestamp || 0, senderFilters: pollPayload.senderFilters || [], subjectFilters: pollPayload.subjectFilters || [], + requiredKeywords: pollPayload.requiredKeywords || [], + codePatterns: pollPayload.codePatterns || [], excludeCodes: pollPayload.excludeCodes || [], }); const match = matchResult.match; @@ -8680,6 +8769,30 @@ async function launchAutoRunTimerPlan(trigger = 'alarm', options = {}) { return false; } + if (plan.kind === AUTO_RUN_TIMER_KIND_SCHEDULED_START) { + const autoRunStartValidation = typeof validateAutoRunStartState === 'function' + ? validateAutoRunStartState(state, { state }) + : { ok: true, errors: [] }; + if (autoRunStartValidation?.ok === false) { + const validationMessage = autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'; + await clearAutoRunTimerAlarm(); + await broadcastAutoRunStatus('idle', { + currentRun: 0, + totalRuns: 1, + attemptRun: 0, + }, { + autoRunRoundSummaries: [], + autoRunTimerPlan: null, + scheduledAutoRunPlan: null, + }); + await addLog(`自动运行计划已取消:${validationMessage}`, 'error'); + if (trigger === 'manual') { + throw new Error(validationMessage); + } + return false; + } + } + await clearAutoRunTimerAlarm(); if (plan.autoRunSessionId && !isCurrentAutoRunSessionId(plan.autoRunSessionId)) { return false; @@ -11814,6 +11927,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter normalizeSignupMethod, canUsePhoneSignup, resolveSignupMethod, + validateAutoRunStart: validateAutoRunStartState, getTabId, getStopRequested: () => stopRequested, handleCloudflareSecurityBlocked, @@ -11902,6 +12016,10 @@ const plusGoPayStepRegistry = buildStepRegistry(PLUS_GOPAY_STEP_DEFINITIONS); const plusGpcStepRegistry = buildStepRegistry(PLUS_GPC_STEP_DEFINITIONS); function getStepRegistryForState(state = {}) { + const activeFlowId = String(state?.activeFlowId || DEFAULT_ACTIVE_FLOW_ID).trim().toLowerCase() || DEFAULT_ACTIVE_FLOW_ID; + if (activeFlowId !== DEFAULT_ACTIVE_FLOW_ID) { + throw new Error(`当前尚未注册 flow=${activeFlowId} 的步骤执行器。`); + } if (!isPlusModeState(state)) { return normalStepRegistry; } diff --git a/background/message-router.js b/background/message-router.js index 25ed7b4..336aae9 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -74,6 +74,44 @@ } return method === 'phone' && canUsePhoneSignup(state) ? 'phone' : 'email'; }, + validateAutoRunStart = (state = {}, options = {}) => { + const validationState = options?.state || state; + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (!capabilityRegistry?.validateAutoRunStart) { + return { ok: true, errors: [] }; + } + return capabilityRegistry.validateAutoRunStart({ + activeFlowId: options?.activeFlowId ?? validationState?.activeFlowId, + panelMode: options?.panelMode ?? validationState?.panelMode, + signupMethod: options?.signupMethod ?? validationState?.signupMethod, + state: validationState, + }); + }, + validateModeSwitch = (state = {}, options = {}) => { + const validationState = options?.state || state; + const rootScope = typeof self !== 'undefined' ? self : globalThis; + const capabilityRegistry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: 'openai', + }) || null; + if (!capabilityRegistry?.validateModeSwitch) { + return { + ok: true, + changedKeys: Array.isArray(options?.changedKeys) ? options.changedKeys : [], + errors: [], + normalizedUpdates: {}, + }; + } + return capabilityRegistry.validateModeSwitch({ + activeFlowId: options?.activeFlowId ?? validationState?.activeFlowId, + changedKeys: options?.changedKeys, + panelMode: options?.panelMode ?? validationState?.panelMode, + signupMethod: options?.signupMethod ?? validationState?.signupMethod, + state: validationState, + }); + }, getTabId, getStopRequested, handleAutoRunLoopUnhandledError, @@ -908,6 +946,10 @@ } } const state = await getState(); + const autoRunStartValidation = validateAutoRunStart(state, { state }); + if (autoRunStartValidation?.ok === false) { + throw new Error(autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'); + } if (getPendingAutoRunTimerPlan(state)) { throw new Error('已有自动运行倒计时计划,请先取消或立即开始。'); } @@ -935,6 +977,11 @@ }); } } + const state = await getState(); + const autoRunStartValidation = validateAutoRunStart(state, { state }); + if (autoRunStartValidation?.ok === false) { + throw new Error(autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'); + } const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1); return await scheduleAutoRun(totalRuns, { delayMinutes: message.payload?.delayMinutes, @@ -1006,6 +1053,16 @@ const currentState = await getState(); const updates = buildPersistentSettingsPayload(message.payload || {}); const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {}); + const modeValidation = validateModeSwitch({ + ...currentState, + ...updates, + resolvedSignupMethod: null, + }, { + changedKeys: Object.keys(updates), + }); + if (modeValidation?.normalizedUpdates && Object.keys(modeValidation.normalizedUpdates).length > 0) { + Object.assign(updates, modeValidation.normalizedUpdates); + } const nextSignupState = { ...currentState, ...updates, @@ -1017,6 +1074,7 @@ || Object.prototype.hasOwnProperty.call(updates, 'signupMethod') || Object.prototype.hasOwnProperty.call(updates, 'panelMode') || Object.prototype.hasOwnProperty.call(updates, 'activeFlowId') + || Object.prototype.hasOwnProperty.call(updates, 'contributionMode') ) { updates.signupMethod = resolveSignupMethod(nextSignupState); } @@ -1117,7 +1175,12 @@ ); await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info'); } - return { ok: true, state: await getState(), proxyRouting }; + return { + ok: true, + modeValidation, + proxyRouting, + state: await getState(), + }; } case 'REFRESH_GPC_CARD_BALANCE': { diff --git a/background/verification-flow.js b/background/verification-flow.js index dcfb066..2e4dd7f 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -412,27 +412,22 @@ if (typeof externalBuildVerificationPollPayload === 'function') { return externalBuildVerificationPollPayload(step, state, overrides); } + const normalizedStep = Number(step) === 4 ? 4 : 8; const is2925Provider = state?.mailProvider === '2925'; const mail2925MatchTargetEmail = is2925Provider && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive'; - if (step === 4) { - return { - filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(4, state), - senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], - subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], - targetEmail: state.email, - mail2925MatchTargetEmail, - maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, - intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, - ...overrides, - }; - } - return { - filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(8, state), - senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], - subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], - targetEmail: String(state?.step8VerificationTargetEmail || '').trim() || state.email, + flowId: String(state?.activeFlowId || '').trim(), + step: normalizedStep, + filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(normalizedStep, state), + senderFilters: [], + subjectFilters: [], + requiredKeywords: [], + codePatterns: [], + targetEmail: normalizedStep === 4 + ? state.email + : (String(state?.step8VerificationTargetEmail || '').trim() || state.email), + targetEmailHints: [], mail2925MatchTargetEmail, maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, diff --git a/content/gmail-mail.js b/content/gmail-mail.js index 3444d6e..f9eb84c 100644 --- a/content/gmail-mail.js +++ b/content/gmail-mail.js @@ -98,6 +98,39 @@ function getTargetEmailMatchState(text, targetEmail) { return { matches: false, hasExplicitEmail: false }; } +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + const MONTH_INDEX_MAP = { jan: 0, feb: 1, @@ -165,16 +198,17 @@ function parseGmailTimestampText(rawText) { return null; } -function extractVerificationCode(text) { +function extractVerificationCode(text, options = {}) { const normalized = String(text || ''); + const matchedByRule = extractCodeByRulePatterns(normalized, options?.codePatterns); + if (matchedByRule) { + return matchedByRule; + } const cnMatch = normalized.match(/(?:验证码|代码)[^0-9]{0,16}(\d{6})/i); if (cnMatch) return cnMatch[1]; - const openAiLoginMatch = normalized.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (openAiLoginMatch) return openAiLoginMatch[1]; - - const enMatch = normalized.match(/(?:verification\s+code|temporary\s+verification\s+code|your\s+chatgpt\s+code|code(?:\s+is)?)[^0-9]{0,16}(\d{6})/i); + const enMatch = normalized.match(/(?:verification\s+code|temporary\s+verification\s+code|log-?in\s+code|enter\s+this\s+code|code(?:\s+is)?)[^0-9]{0,24}(\d{6})/i); if (enMatch) return enMatch[1]; const plainMatch = normalized.match(/\b(\d{6})\b/); @@ -357,7 +391,7 @@ function collectThreadRows() { if ( row.matches('tr.zA') || row.querySelector('.bog, .y6, .y2, .afn, [data-thread-id], [data-legacy-thread-id], [data-legacy-last-message-id]') - || /openai|chatgpt|verify|verification|code|验证码/i.test(text) + || /verify|verification|code|验证码|log-?in/i.test(text) ) { rows.push(row); } @@ -545,6 +579,7 @@ async function openRowAndGetMessageText(row) { async function handlePollEmail(step, payload) { const { + codePatterns = [], senderFilters = [], subjectFilters = [], maxAttempts = 5, @@ -618,7 +653,9 @@ async function handlePollEmail(step, payload) { } const previewTargetState = getTargetEmailMatchState(preview.combinedText, targetEmail); - const previewCode = extractVerificationCode(preview.combinedText); + const previewCode = extractVerificationCode(preview.combinedText, { + codePatterns, + }); if (previewCode) { if (excludedCodeSet.has(previewCode)) { log(`步骤 ${step}:跳过排除的验证码:${previewCode}`, 'info'); @@ -644,7 +681,9 @@ async function handlePollEmail(step, payload) { const openedText = await openRowAndGetMessageText(row); const openedTargetState = getTargetEmailMatchState(openedText, targetEmail); - const bodyCode = extractVerificationCode(openedText); + const bodyCode = extractVerificationCode(openedText, { + codePatterns, + }); if (!bodyCode) { continue; } diff --git a/content/icloud-mail.js b/content/icloud-mail.js index 8f434ad..a3e5ab3 100644 --- a/content/icloud-mail.js +++ b/content/icloud-mail.js @@ -43,6 +43,39 @@ if (shouldHandlePollEmailInCurrentFrame) { return String(value || '').replace(/\s+/g, ' ').trim(); } + function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; + } + + function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; + } + function isVisibleElement(node) { return Boolean(node instanceof HTMLElement) && (Boolean(node.offsetParent) || getComputedStyle(node).position === 'fixed'); @@ -82,12 +115,15 @@ if (shouldHandlePollEmailInCurrentFrame) { ].join('::')).slice(0, 240); } - function extractVerificationCode(text) { + function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) return matchedByRule; + const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; @@ -276,7 +312,14 @@ if (shouldHandlePollEmailInCurrentFrame) { } async function handlePollEmail(step, payload) { - const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload; + const { + codePatterns = [], + senderFilters, + subjectFilters, + maxAttempts, + intervalMs, + excludeCodes = [], + } = payload; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); const FALLBACK_AFTER = 3; const pollSessionKey = normalizePollSessionKey(payload); @@ -327,7 +370,7 @@ if (shouldHandlePollEmailInCurrentFrame) { continue; } - let code = extractVerificationCode(meta.combinedText); + let code = extractVerificationCode(meta.combinedText, { codePatterns }); let opened = null; if (!code) { @@ -339,7 +382,7 @@ if (shouldHandlePollEmailInCurrentFrame) { if (!openedSenderMatch && !openedSubjectMatch && !senderMatch && !subjectMatch) { continue; } - code = extractVerificationCode(opened.combinedText); + code = extractVerificationCode(opened.combinedText, { codePatterns }); } if (!code) { diff --git a/content/inbucket-mail.js b/content/inbucket-mail.js index ade97f4..accdc5b 100644 --- a/content/inbucket-mail.js +++ b/content/inbucket-mail.js @@ -60,12 +60,55 @@ function normalizeText(value) { return (value || '').replace(/\s+/g, ' ').trim().toLowerCase(); } -function extractVerificationCode(text) { +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + +function matchesKeywordHints(text, keywords = []) { + const normalizedText = normalizeText(text); + const normalizedKeywords = Array.isArray(keywords) ? keywords : []; + return normalizedKeywords.some((keyword) => normalizedText.includes(normalizeText(keyword))); +} + +function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) { + return matchedByRule; + } const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; @@ -76,7 +119,7 @@ function extractVerificationCode(text) { return null; } -function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) { +function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail, options = {}) { const sender = normalizeText(mail.sender); const subject = normalizeText(mail.subject); const mailbox = normalizeText(mail.mailbox); @@ -87,8 +130,12 @@ function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) { const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase())); const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal); const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText); - const code = extractVerificationCode(mail.combinedText); - const keywordMatch = /openai|chatgpt|verify|verification|confirm|log-?in|验证码|代码/.test(combined); + const code = extractVerificationCode(mail.combinedText, { + codePatterns: options?.codePatterns, + }); + const keywordMatch = options?.requiredKeywords?.length + ? matchesKeywordHints(combined, options.requiredKeywords) + : /verify|verification|confirm|log-?in|验证码|代码/.test(combined); if (mailboxMatch) return { matched: true, mailboxMatch, code }; if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code }; @@ -170,6 +217,8 @@ async function deleteCurrentMailboxMessage(step) { async function handleMailboxPollEmail(step, payload) { const { + codePatterns = [], + requiredKeywords = [], senderFilters = [], subjectFilters = [], maxAttempts = 20, @@ -208,14 +257,19 @@ async function handleMailboxPollEmail(step, payload) { if (seenMailIds.has(mail.mailId)) continue; if (!useFallback && existingMailIds.has(mail.mailId)) continue; - const match = rowMatchesFilters(mail, senderFilters, subjectFilters, ''); + const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '', { + codePatterns, + requiredKeywords, + }); if (!match.matched) continue; candidates.push({ ...mail, code: match.code }); } for (const mail of candidates) { - const code = mail.code || extractVerificationCode(mail.combinedText); + const code = mail.code || extractVerificationCode(mail.combinedText, { + codePatterns, + }); if (!code) continue; if (excludedCodeSet.has(code)) { log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info'); diff --git a/content/mail-163.js b/content/mail-163.js index 3756a87..95a4bf4 100644 --- a/content/mail-163.js +++ b/content/mail-163.js @@ -73,6 +73,39 @@ function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + function getNetEaseMailLabel(hostname) { const currentHostname = String( hostname || (typeof location !== 'undefined' ? location.hostname : '') || '' @@ -129,7 +162,7 @@ function isLikelyMailItemNode(node) { return false; } - return /发件人|验证码|verification|chatgpt|openai|code|log-?in/i.test(summaryText); + return /发件人|验证码|verification|code|log-?in/i.test(summaryText); } function findMailItems() { @@ -406,7 +439,7 @@ function selectOpenedMailTextCandidate(item, candidates = [], options = {}) { if (sender && lower.includes(sender)) { return true; } - return Boolean(extractVerificationCode(candidate) && /chatgpt|openai|verification|验证码|log-?in\s+code/i.test(lower)); + return Boolean(extractVerificationCode(candidate, { codePatterns: options.codePatterns }) && /verification|验证码|log-?in\s+code|enter\s+this\s+code|code/i.test(lower)); }) || source[0] || ''; const filteredCandidates = candidates.filter((candidate) => !excludedSet.has(normalizeText(candidate))); @@ -443,9 +476,11 @@ async function returnToInbox() { return false; } -async function openMailAndGetMessageText(item) { +async function openMailAndGetMessageText(item, options = {}) { const beforeCandidates = collectOpenedMailTextCandidates(); - const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates); + const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates, { + codePatterns: options.codePatterns, + }); if (typeof simulateClick === 'function') { simulateClick(item); } else { @@ -456,6 +491,7 @@ async function openMailAndGetMessageText(item) { for (let i = 0; i < 24; i += 1) { await sleep(250); const candidate = readOpenedMailText(item, { + codePatterns: options.codePatterns, excludedTexts: beforeCandidates, allowExcludedFallback: false, }); @@ -463,7 +499,7 @@ async function openMailAndGetMessageText(item) { continue; } openedText = candidate; - if (extractVerificationCode(candidate)) { + if (extractVerificationCode(candidate, { codePatterns: options.codePatterns })) { break; } if (candidate !== beforeText && candidate.length > beforeText.length + 24) { @@ -488,7 +524,15 @@ function scheduleEmailCleanup(item, step) { // ============================================================ async function handlePollEmail(step, payload) { - const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [], filterAfterTimestamp = 0 } = payload; + const { + codePatterns = [], + senderFilters, + subjectFilters, + maxAttempts, + intervalMs, + excludeCodes = [], + filterAfterTimestamp = 0, + } = payload; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0); const mailLabel = getNetEaseMailLabel(); @@ -581,12 +625,12 @@ async function handlePollEmail(step, payload) { }); if (senderMatch || subjectMatch) { - let code = extractVerificationCode(combinedText); + let code = extractVerificationCode(combinedText, { codePatterns }); let codeSource = '邮件列表'; if (!code) { - const openedText = await openMailAndGetMessageText(item); - code = extractVerificationCode(openedText); + const openedText = await openMailAndGetMessageText(item, { codePatterns }); + code = extractVerificationCode(openedText, { codePatterns }); if (code) { codeSource = '邮件正文'; } @@ -716,12 +760,16 @@ async function refreshInbox() { // Verification Code Extraction // ============================================================ -function extractVerificationCode(text) { +function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) { + return matchedByRule; + } const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; diff --git a/content/mail-2925.js b/content/mail-2925.js index 13dfe4f..a897ffc 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -670,7 +670,56 @@ function matchesMailFilters(text, senderFilters, subjectFilters) { return senderMatch || subjectMatch; } -function extractStrictChatGPTVerificationCode(text) { +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + +function normalizeTargetEmailHints(hints = [], targetEmail = '') { + const normalizedHints = Array.isArray(hints) ? hints : []; + const collected = normalizedHints + .map((item) => String(item || '').trim().toLowerCase()) + .filter(Boolean); + const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); + if (normalizedTarget) { + collected.push(normalizedTarget); + const atIndex = normalizedTarget.indexOf('@'); + if (atIndex > 0) { + collected.push(`${normalizedTarget.slice(0, atIndex)}=${normalizedTarget.slice(atIndex + 1)}`); + } + } + return [...new Set(collected)]; +} + +function extractLegacyStrictVerificationCode(text) { const normalized = String(text || ''); const patterns = [ /your\s+(?:temporary\s+)?chatgpt\s+(?:(?:log-?in|login)\s+)?code\s+is[\s\S]{0,80}?(\d{6})/i, @@ -742,11 +791,16 @@ function findSafeStandaloneSixDigitCode(text) { return null; } -function extractVerificationCode(text, strictChatGPTCodeOnly = false) { - const strictCode = extractStrictChatGPTVerificationCode(text); - if (strictChatGPTCodeOnly) { - return strictCode; +function extractVerificationCode(text, options = {}) { + const legacyStrictMode = typeof options === 'boolean' ? options : false; + const strictMode = legacyStrictMode || Boolean(options?.strictMode); + const codePatterns = legacyStrictMode ? [] : options?.codePatterns; + const strictCode = extractLegacyStrictVerificationCode(text); + const matchedByRule = extractCodeByRulePatterns(text, codePatterns); + if (strictMode) { + return matchedByRule || strictCode; } + if (matchedByRule) return matchedByRule; if (strictCode) return strictCode; const normalized = String(text || ''); @@ -754,11 +808,8 @@ function extractVerificationCode(text, strictChatGPTCodeOnly = false) { const matchCn = normalized.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = normalized.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; - - const matchChatGPT = normalized.match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i); - if (matchChatGPT) return matchChatGPT[1]; + const matchLoginCode = normalized.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = normalized.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; @@ -771,9 +822,9 @@ function extractEmails(text = '') { return [...new Set(matches.map((item) => item.toLowerCase()))]; } -function extractForwardedTargetEmails(text = '') { +function extractForwardedTargetEmails(text = '', targetEmailHints = []) { const normalizedText = String(text || '').toLowerCase(); - const matches = normalizedText.match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@(?:tm\d*\.openai\.com|em\d+\.tm\.openai\.com)/gi) || []; + const matches = normalizedText.match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@[a-z0-9.-]+\.[a-z]{2,}/gi) || []; const decoded = matches .map((candidate) => { const match = String(candidate || '').match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@/i); @@ -783,7 +834,19 @@ function extractForwardedTargetEmails(text = '') { return `${match[1].toLowerCase()}@${match[2].toLowerCase()}`; }) .filter(Boolean); - return [...new Set(decoded)]; + const hinted = normalizeTargetEmailHints(targetEmailHints) + .filter((hint) => hint.includes('@') || hint.includes('=')) + .flatMap((hint) => { + if (hint.includes('@')) { + return normalizedText.includes(hint) ? [hint] : []; + } + const match = hint.match(/^([^=]+)=([^=]+)$/); + if (!match || !normalizedText.includes(hint)) { + return []; + } + return [`${match[1]}@${match[2]}`]; + }); + return [...new Set([...decoded, ...hinted])]; } function emailMatchesTarget(candidate, targetEmail) { @@ -796,19 +859,20 @@ function emailMatchesTarget(candidate, targetEmail) { return normalizedCandidate === normalizedTarget; } -function getTargetEmailMatchState(text, targetEmail) { +function getTargetEmailMatchState(text, targetEmail, options = {}) { const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); if (!normalizedTarget) { return { matches: true, hasExplicitEmail: false }; } const normalizedText = String(text || '').toLowerCase(); - if (normalizedText.includes(normalizedTarget)) { + const targetEmailHints = normalizeTargetEmailHints(options?.targetEmailHints, normalizedTarget); + if (targetEmailHints.some((hint) => normalizedText.includes(hint))) { return { matches: true, hasExplicitEmail: true }; } const extractedEmails = extractEmails(normalizedText); - const forwardedTargetEmails = extractForwardedTargetEmails(normalizedText); + const forwardedTargetEmails = extractForwardedTargetEmails(normalizedText, targetEmailHints); if (!extractedEmails.length) { return forwardedTargetEmails.length ? { @@ -1175,14 +1239,15 @@ async function ensureMail2925Session(payload = {}) { async function handlePollEmail(step, payload) { await ensureSeenCodesSession(step, payload); const { + codePatterns = [], senderFilters, subjectFilters, maxAttempts, intervalMs, filterAfterTimestamp = 0, excludeCodes = [], - strictChatGPTCodeOnly = false, targetEmail = '', + targetEmailHints = [], mail2925MatchTargetEmail = false, } = payload || {}; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); @@ -1249,21 +1314,25 @@ async function handlePollEmail(step, payload) { continue; } const previewTargetState = mail2925MatchTargetEmail - ? getTargetEmailMatchState(previewText, targetEmail) + ? getTargetEmailMatchState(previewText, targetEmail, { targetEmailHints }) : { matches: true, hasExplicitEmail: false }; if (mail2925MatchTargetEmail && previewTargetState.hasExplicitEmail && !previewTargetState.matches) { continue; } - const previewCode = extractVerificationCode(previewText, strictChatGPTCodeOnly); + const previewCode = extractVerificationCode(previewText, { + codePatterns, + }); const openedText = await openMailAndDeleteAfterRead(item, step); const openedTargetState = mail2925MatchTargetEmail - ? getTargetEmailMatchState(openedText, targetEmail) + ? getTargetEmailMatchState(openedText, targetEmail, { targetEmailHints }) : { matches: true, hasExplicitEmail: false }; if (mail2925MatchTargetEmail && openedTargetState.hasExplicitEmail && !openedTargetState.matches) { continue; } - const bodyCode = extractVerificationCode(openedText, strictChatGPTCodeOnly); + const bodyCode = extractVerificationCode(openedText, { + codePatterns, + }); const candidateCode = bodyCode || previewCode; if (!candidateCode) { diff --git a/content/qq-mail.js b/content/qq-mail.js index dc72ca1..73b9eba 100644 --- a/content/qq-mail.js +++ b/content/qq-mail.js @@ -50,12 +50,52 @@ function getCurrentMailIds() { return ids; } +function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; +} + +function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; +} + // ============================================================ // Email Polling // ============================================================ async function handlePollEmail(step, payload) { - const { senderFilters, subjectFilters, maxAttempts, intervalMs, excludeCodes = [] } = payload; + const { + codePatterns = [], + senderFilters, + subjectFilters, + maxAttempts, + intervalMs, + excludeCodes = [], + } = payload; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); log(`步骤 ${step}:开始轮询邮箱(最多 ${maxAttempts} 次,每 ${intervalMs / 1000} 秒一次)`); @@ -103,7 +143,9 @@ async function handlePollEmail(step, payload) { const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase())); if (senderMatch || subjectMatch) { - const code = extractVerificationCode(subject + ' ' + digest); + const code = extractVerificationCode(subject + ' ' + digest, { + codePatterns, + }); if (code) { if (excludedCodeSet.has(code)) { log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info'); @@ -169,13 +211,16 @@ async function refreshInbox() { // Verification Code Extraction // ============================================================ -function extractVerificationCode(text) { +function extractVerificationCode(text, options = {}) { + const matchedByRule = extractCodeByRulePatterns(text, options?.codePatterns); + if (matchedByRule) return matchedByRule; + // Pattern 1: Chinese format "代码为 370794" or "验证码...370794" const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = text.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = text.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; // Pattern 2: English format "code is 370794" or "code: 370794" const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); diff --git a/data/step-definitions.js b/data/step-definitions.js index dd87867..27584ff 100644 --- a/data/step-definitions.js +++ b/data/step-definitions.js @@ -1,6 +1,7 @@ (function attachStepDefinitions(root, factory) { root.MultiPageStepDefinitions = factory(); })(typeof self !== 'undefined' ? self : globalThis, function createStepDefinitionsModule() { + const DEFAULT_ACTIVE_FLOW_ID = 'openai'; const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal'; const PLUS_PAYMENT_METHOD_GOPAY = 'gopay'; const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper'; @@ -88,11 +89,20 @@ : SIGNUP_METHOD_EMAIL; } + function normalizeActiveFlowId(value = '', fallback = DEFAULT_ACTIVE_FLOW_ID) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized) { + return normalized; + } + const fallbackValue = String(fallback || '').trim().toLowerCase(); + return fallbackValue || DEFAULT_ACTIVE_FLOW_ID; + } + function getResolvedSignupMethod(options = {}) { return normalizeSignupMethod(options?.resolvedSignupMethod || options?.signupMethod); } - function getModeStepDefinitions(options = {}) { + function getOpenAiModeStepDefinitions(options = {}) { if (!isPlusModeEnabled(options)) { return NORMAL_STEP_DEFINITIONS; } @@ -103,20 +113,20 @@ return paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_GOPAY_STEP_DEFINITIONS : PLUS_PAYPAL_STEP_DEFINITIONS; } - function getPlusPaymentStepTitle(options = {}) { + function getOpenAiPlusPaymentStepTitle(options = {}) { if (!isPlusModeEnabled(options)) { return ''; } - const paymentStep = getModeStepDefinitions({ + const paymentStep = getOpenAiModeStepDefinitions({ ...options, plusModeEnabled: true, }).find((step) => step.key === PLUS_PAYMENT_STEP_KEY); return paymentStep?.title || ''; } - function getResolvedStepTitle(step = {}, options = {}) { + function getOpenAiResolvedStepTitle(step = {}, options = {}) { if (isPlusModeEnabled(options) && step.key === PLUS_PAYMENT_STEP_KEY) { - return getPlusPaymentStepTitle(options) || step.title; + return getOpenAiPlusPaymentStepTitle(options) || step.title; } const signupMethod = getResolvedSignupMethod(options); if (signupMethod === SIGNUP_METHOD_PHONE && PHONE_SIGNUP_TITLE_OVERRIDES[step.key]) { @@ -125,37 +135,83 @@ return step.title; } - function cloneSteps(steps = [], options = {}) { + const FLOW_DEFINITION_BUILDERS = Object.freeze({ + openai: { + getAllSteps() { + const keyed = new Map(); + for (const step of [ + ...NORMAL_STEP_DEFINITIONS, + ...PLUS_PAYPAL_STEP_DEFINITIONS, + ...PLUS_GOPAY_STEP_DEFINITIONS, + ...PLUS_GPC_STEP_DEFINITIONS, + ]) { + keyed.set(`${step.id}:${step.key}`, step); + } + return Array.from(keyed.values()).sort((left, right) => { + const leftOrder = Number.isFinite(left.order) ? left.order : left.id; + const rightOrder = Number.isFinite(right.order) ? right.order : right.id; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return left.id - right.id; + }); + }, + getModeStepDefinitions: getOpenAiModeStepDefinitions, + getPlusPaymentStepTitle: getOpenAiPlusPaymentStepTitle, + resolveStepTitle: getOpenAiResolvedStepTitle, + }, + }); + + function hasFlow(flowId) { + const normalizedFlowId = normalizeActiveFlowId(flowId, ''); + return Boolean(normalizedFlowId && FLOW_DEFINITION_BUILDERS[normalizedFlowId]); + } + + function getRegisteredFlowIds() { + return Object.keys(FLOW_DEFINITION_BUILDERS); + } + + function getFlowDefinitionBuilder(options = {}) { + const flowId = normalizeActiveFlowId(options?.activeFlowId, DEFAULT_ACTIVE_FLOW_ID); + return { + flowId, + builder: FLOW_DEFINITION_BUILDERS[flowId] || null, + }; + } + + function cloneSteps(steps = [], options = {}, flowId = DEFAULT_ACTIVE_FLOW_ID) { + const { builder } = getFlowDefinitionBuilder({ activeFlowId: flowId }); return steps.map((step) => ({ ...step, - title: getResolvedStepTitle(step, options), + flowId, + title: builder?.resolveStepTitle ? builder.resolveStepTitle(step, options) : step.title, })); } function getSteps(options = {}) { - return cloneSteps(getModeStepDefinitions(options), options); + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getModeStepDefinitions) { + return []; + } + return cloneSteps(builder.getModeStepDefinitions(options), options, flowId); } - function getAllSteps() { - const keyed = new Map(); - for (const step of [ - ...NORMAL_STEP_DEFINITIONS, - ...PLUS_PAYPAL_STEP_DEFINITIONS, - ...PLUS_GOPAY_STEP_DEFINITIONS, - ...PLUS_GPC_STEP_DEFINITIONS, - ]) { - keyed.set(`${step.id}:${step.key}`, step); + function getAllSteps(options = {}) { + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getAllSteps) { + return []; } - return cloneSteps(Array.from(keyed.values()).sort((left, right) => { - const leftOrder = Number.isFinite(left.order) ? left.order : left.id; - const rightOrder = Number.isFinite(right.order) ? right.order : right.id; - if (leftOrder !== rightOrder) return leftOrder - rightOrder; - return left.id - right.id; - })); + return cloneSteps(builder.getAllSteps(options), options, flowId); + } + + function getPlusPaymentStepTitle(options = {}) { + const { builder } = getFlowDefinitionBuilder(options); + if (!builder?.getPlusPaymentStepTitle) { + return ''; + } + return builder.getPlusPaymentStepTitle(options); } function getStepIds(options = {}) { - return getModeStepDefinitions(options) + return getSteps(options) .map((step) => Number(step.id)) .filter(Number.isFinite) .sort((left, right) => left - right); @@ -168,11 +224,16 @@ function getStepById(id, options = {}) { const numericId = Number(id); - const match = getModeStepDefinitions(options).find((step) => step.id === numericId); - return match ? cloneSteps([match], options)[0] : null; + const { flowId, builder } = getFlowDefinitionBuilder(options); + if (!builder?.getModeStepDefinitions) { + return null; + } + const match = builder.getModeStepDefinitions(options).find((step) => step.id === numericId); + return match ? cloneSteps([match], options, flowId)[0] : null; } return { + DEFAULT_ACTIVE_FLOW_ID, STEP_DEFINITIONS: NORMAL_STEP_DEFINITIONS, NORMAL_STEP_DEFINITIONS, PLUS_STEP_DEFINITIONS: PLUS_PAYPAL_STEP_DEFINITIONS, @@ -184,10 +245,13 @@ getAllSteps, getLastStepId, getPlusPaymentStepTitle, + getRegisteredFlowIds, getStepById, getStepIds, getSteps, + hasFlow, isPlusModeEnabled, + normalizeActiveFlowId, normalizePlusPaymentMethod, normalizeSignupMethod, }; diff --git a/flows/openai/mail-rules.js b/flows/openai/mail-rules.js index 940e80e..44dc4ca 100644 --- a/flows/openai/mail-rules.js +++ b/flows/openai/mail-rules.js @@ -3,6 +3,42 @@ })(typeof self !== 'undefined' ? self : globalThis, function createOpenAiMailRulesModule() { const SIGNUP_CODE_RULE_ID = 'openai-signup-code'; const LOGIN_CODE_RULE_ID = 'openai-login-code'; + const OPENAI_CODE_PATTERNS = Object.freeze([ + Object.freeze({ + source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', + flags: 'i', + }), + Object.freeze({ + source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})', + flags: 'i', + }), + Object.freeze({ + source: '(?:verification\\s+code|temporary\\s+verification\\s+code|your\\s+chatgpt\\s+code|code(?:\\s+is)?)[^0-9]{0,16}(\\d{6})', + flags: 'i', + }), + ]); + const OPENAI_REQUIRED_KEYWORDS = Object.freeze([ + 'openai', + 'chatgpt', + 'verify', + 'verification', + 'confirm', + '验证码', + '代码', + ]); + + function buildTargetEmailHints(targetEmail = '') { + const normalizedTarget = String(targetEmail || '').trim().toLowerCase(); + if (!normalizedTarget) { + return []; + } + const hints = [normalizedTarget]; + const atIndex = normalizedTarget.indexOf('@'); + if (atIndex > 0) { + hints.push(`${normalizedTarget.slice(0, atIndex)}=${normalizedTarget.slice(atIndex + 1)}`); + } + return [...new Set(hints)]; + } function createOpenAiMailRules(deps = {}) { const { @@ -24,24 +60,30 @@ const normalizedStep = Number(step) === 4 ? 4 : 8; const mail2925Provider = isMail2925Provider(state); const signupStep = normalizedStep === 4; + const targetEmail = signupStep + ? state?.email + : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email); return { flowId: 'openai', ruleId: signupStep ? SIGNUP_CODE_RULE_ID : LOGIN_CODE_RULE_ID, step: normalizedStep, artifactType: 'code', + codePatterns: OPENAI_CODE_PATTERNS, filterAfterTimestamp: mail2925Provider ? 0 : getHotmailVerificationRequestTimestamp(normalizedStep, state), + requiredKeywords: signupStep + ? OPENAI_REQUIRED_KEYWORDS + : [...OPENAI_REQUIRED_KEYWORDS, 'login'], senderFilters: signupStep ? ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'] : ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], subjectFilters: signupStep ? ['verify', 'verification', 'code', '验证码', 'confirm'] : ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], - targetEmail: signupStep - ? state?.email - : (String(state?.step8VerificationTargetEmail || '').trim() || state?.email), + targetEmail, + targetEmailHints: buildTargetEmailHints(targetEmail), mail2925MatchTargetEmail: shouldMatchMail2925TargetEmail(state), maxAttempts: mail2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, intervalMs: mail2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, diff --git a/hotmail-utils.js b/hotmail-utils.js index 3ff4778..aa93a99 100644 --- a/hotmail-utils.js +++ b/hotmail-utils.js @@ -32,13 +32,49 @@ : HOTMAIL_SERVICE_MODE_LOCAL; } - function extractVerificationCode(text) { + function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; + } + + function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; + } + + function extractVerificationCode(text, options = {}) { const source = String(text || ''); + const matchedByRule = extractCodeByRulePatterns(source, options?.codePatterns); + if (matchedByRule) return matchedByRule; + const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i); if (matchCn) return matchCn[1]; - const matchOpenAiLogin = source.match(/(?:chatgpt\s+log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); - if (matchOpenAiLogin) return matchOpenAiLogin[1]; + const matchLoginCode = source.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i); if (matchEn) return matchEn[1]; @@ -47,7 +83,7 @@ return matchStandalone ? matchStandalone[1] : null; } - function extractVerificationCodeFromMessage(message = {}) { + function extractVerificationCodeFromMessage(message = {}, options = {}) { const sender = firstNonEmptyString([ message?.from?.emailAddress?.address, message?.sender, @@ -55,7 +91,9 @@ ]); const subject = firstNonEmptyString([message?.subject]); const preview = firstNonEmptyString([message?.bodyPreview, message?.preview, message?.text]); - return extractVerificationCode([subject, preview, sender].filter(Boolean).join(' ')); + return extractVerificationCode([subject, preview, sender].filter(Boolean).join(' '), { + codePatterns: options?.codePatterns, + }); } function getLatestHotmailMessage(messages) { @@ -138,6 +176,10 @@ function messageMatchesFilters(message, filters = {}) { const senderFilters = (filters.senderFilters || []).map(normalizeText).filter(Boolean); const subjectFilters = (filters.subjectFilters || []).map(normalizeText).filter(Boolean); + const requiredKeywords = (filters.requiredKeywords || []).map(normalizeText).filter(Boolean); + const hasSenderFilters = senderFilters.length > 0; + const hasSubjectFilters = subjectFilters.length > 0; + const hasKeywordHints = requiredKeywords.length > 0; const afterTimestamp = normalizeTimestamp(filters.afterTimestamp); const receivedAt = normalizeTimestamp(message?.receivedDateTime); if (afterTimestamp && receivedAt && receivedAt < afterTimestamp) { @@ -148,20 +190,25 @@ const subject = normalizeText(message?.subject); const preview = String(message?.bodyPreview || ''); const combinedText = [subject, sender, preview].filter(Boolean).join(' '); - const code = extractVerificationCode(combinedText); + const code = extractVerificationCode(combinedText, { + codePatterns: filters.codePatterns, + }); const excludedCodes = new Set((filters.excludeCodes || []).filter(Boolean)); if (code && excludedCodes.has(code)) { return null; } - const senderMatch = senderFilters.length === 0 - ? true - : senderFilters.some((item) => sender.includes(item) || normalizeText(preview).includes(item)); - const subjectMatch = subjectFilters.length === 0 - ? true - : subjectFilters.some((item) => subject.includes(item) || normalizeText(preview).includes(item)); + const senderMatch = hasSenderFilters + ? senderFilters.some((item) => sender.includes(item) || normalizeText(preview).includes(item)) + : false; + const subjectMatch = hasSubjectFilters + ? subjectFilters.some((item) => subject.includes(item) || normalizeText(preview).includes(item)) + : false; + const keywordMatch = hasKeywordHints + ? requiredKeywords.some((item) => normalizeText(combinedText).includes(item)) + : false; - if (!senderMatch && !subjectMatch) { + if ((hasSenderFilters || hasSubjectFilters || hasKeywordHints) && !senderMatch && !subjectMatch && !keywordMatch) { return null; } diff --git a/microsoft-email.js b/microsoft-email.js index 131af23..83b784a 100644 --- a/microsoft-email.js +++ b/microsoft-email.js @@ -1,8 +1,4 @@ (function attachMicrosoftEmailHelpers(globalScope) { - const OPENAI_SENDER_PATTERNS = [ - /openai\.com/i, - /auth0\.openai\.com/i, - ]; const CODE_PATTERN = /\b(\d{6})\b/; const GRAPH_SCOPES = 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read'; const GRAPH_DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'; @@ -179,6 +175,57 @@ return String(value || '').trim().toLowerCase(); } + function normalizeRulePatternList(patterns = []) { + return Array.isArray(patterns) ? patterns : []; + } + + function extractCodeByRulePatterns(text, patterns = []) { + const normalizedText = String(text || ''); + for (const pattern of normalizeRulePatternList(patterns)) { + try { + const source = String(pattern?.source || '').trim(); + if (!source) { + continue; + } + const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); + const match = normalizedText.match(new RegExp(source, flags)); + if (!match) { + continue; + } + for (let index = 1; index < match.length; index += 1) { + const candidate = String(match[index] || '').trim(); + if (candidate) { + return candidate; + } + } + if (String(match[0] || '').trim()) { + return String(match[0] || '').trim(); + } + } catch (_) { + // Ignore invalid runtime rule patterns and continue with other candidates. + } + } + return null; + } + + function extractVerificationCode(text, options = {}) { + const source = String(text || ''); + const matchedByRule = extractCodeByRulePatterns(source, options?.codePatterns); + if (matchedByRule) return matchedByRule; + + const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i); + if (matchCn) return matchCn[1]; + + const matchLoginCode = source.match(/(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/i); + if (matchLoginCode) return matchLoginCode[1]; + + const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i); + if (matchEn) return matchEn[1]; + + const matchStandalone = source.match(CODE_PATTERN); + return matchStandalone?.[1] || ''; + } + function getMessageSender(message) { return String( message?.from?.emailAddress?.address @@ -203,22 +250,13 @@ .join('\n'); } - function isOpenAiMessage(message) { - const sender = getMessageSender(message); - if (OPENAI_SENDER_PATTERNS.some((pattern) => pattern.test(sender))) { - return true; - } - - const searchText = getMessageSearchText(message); - return OPENAI_SENDER_PATTERNS.some((pattern) => pattern.test(searchText)); - } - function extractVerificationCodeFromMessages(messages, options = {}) { const filterAfterTimestamp = Number(options.filterAfterTimestamp || 0) || 0; const senderFilters = (options.senderFilters || []).map(normalizeFilterValue).filter(Boolean); const subjectFilters = (options.subjectFilters || []).map(normalizeFilterValue).filter(Boolean); + const requiredKeywords = (options.requiredKeywords || []).map(normalizeFilterValue).filter(Boolean); const excludedCodes = new Set((options.excludeCodes || []).map((value) => String(value || '').trim()).filter(Boolean)); - const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0; + const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0 || requiredKeywords.length > 0; const sortedMessages = (Array.isArray(messages) ? messages : []) .map((raw) => normalizeMessage(raw, raw?.mailbox)) @@ -234,23 +272,23 @@ const subject = normalizeFilterValue(message?.subject); const preview = normalizeFilterValue(message?.bodyPreview); const searchText = normalizeFilterValue(getMessageSearchText(message)); - const codeMatch = getMessageSearchText(message).match(CODE_PATTERN); - const code = codeMatch?.[1] || ''; + const code = extractVerificationCode(getMessageSearchText(message), { + codePatterns: options.codePatterns, + }); if (!code || excludedCodes.has(code)) { continue; } - if (!hasExplicitFilters && !isOpenAiMessage(message)) { - continue; - } - const senderMatched = senderFilters.length === 0 - ? true + ? false : senderFilters.some((filter) => sender.includes(filter) || preview.includes(filter) || searchText.includes(filter)); const subjectMatched = subjectFilters.length === 0 - ? true + ? false : subjectFilters.some((filter) => subject.includes(filter) || preview.includes(filter) || searchText.includes(filter)); - if (!senderMatched && !subjectMatched) { + const keywordMatched = requiredKeywords.length === 0 + ? false + : requiredKeywords.some((filter) => preview.includes(filter) || searchText.includes(filter)); + if (hasExplicitFilters && !senderMatched && !subjectMatched && !keywordMatched) { continue; } @@ -401,6 +439,8 @@ filterAfterTimestamp, senderFilters, subjectFilters, + requiredKeywords: options.requiredKeywords, + codePatterns: options.codePatterns, excludeCodes, }); if (match) { @@ -437,7 +477,6 @@ fetchOutlookMessages, getMessageSender, getMessageTimestamp, - isOpenAiMessage, normalizeMailboxId, normalizeMailboxLabel, normalizeMessage, diff --git a/scripts/hotmail_helper.py b/scripts/hotmail_helper.py index 768b480..cfe45b3 100644 --- a/scripts/hotmail_helper.py +++ b/scripts/hotmail_helper.py @@ -122,12 +122,6 @@ def compact_text(value, limit=400): def log_info(message): print(f"[HotmailHelper] {message}", flush=True) - -def is_openai_sender(address): - sender = str(address or "").strip().lower() - return "openai" in sender - - def get_message_body_content(message): body = message.get("body") or {} if not isinstance(body, dict): @@ -135,36 +129,6 @@ def get_message_body_content(message): return str(body.get("content") or "").strip() -def log_openai_messages(messages, transport=""): - for message in messages or []: - sender_info = message.get("from", {}).get("emailAddress", {}) or {} - sender = str(sender_info.get("address") or "").strip() - if not is_openai_sender(sender): - continue - - sender_name = str(sender_info.get("name") or "").strip() - mailbox = str(message.get("mailbox") or "").strip() or "INBOX" - subject = str(message.get("subject") or "").strip() - transport_label = str(transport or "").strip() or "unknown" - base = ( - f"transport={transport_label} mailbox={mailbox} sender={sender} " - f"senderName={sender_name or '-'} subject={subject}" - ) - - log_info(f"openai mail received {base}") - - body_content = get_message_body_content(message) - if body_content: - log_info(f"openai mail full body start {base}") - print(body_content, flush=True) - log_info("openai mail full body end") - continue - - preview = str(message.get("bodyPreview") or "").strip() - if preview: - log_info(f"openai mail preview {base} preview={compact_text(preview, 1000)}") - - def get_proxy_debug_context(): names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"] parts = [] @@ -725,7 +689,6 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top): try: log_info(f"message collection start transport={transport_name}") result = collector(email_addr, client_id, refresh_token, mailboxes, top) - log_openai_messages(result.get("messages") or [], transport=transport_name) log_info( f"message collection success transport={transport_name} " f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}" @@ -739,10 +702,37 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top): raise RuntimeError(f"Message collection failed on all transports: {' | '.join(errors)}") -def extract_code(text): +def extract_code(text, code_patterns=None): source = str(text or "") + for pattern in code_patterns or []: + try: + source_pattern = str((pattern or {}).get("source") or "").strip() + if not source_pattern: + continue + flags = str((pattern or {}).get("flags") or "").lower() + re_flags = 0 + if "i" in flags: + re_flags |= re.IGNORECASE + if "m" in flags: + re_flags |= re.MULTILINE + if "s" in flags: + re_flags |= re.DOTALL + match = re.search(source_pattern, source, flags=re_flags) + if not match: + continue + if match.lastindex: + for group_index in range(1, match.lastindex + 1): + candidate = str(match.group(group_index) or "").strip() + if candidate: + return candidate + candidate = str(match.group(0) or "").strip() + if candidate: + return candidate + except re.error: + continue patterns = [ r"(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})", + r"(?:log-?in\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})", r"code(?:\s+is|[\s:])+(\d{6})", r"\b(\d{6})\b", ] @@ -753,9 +743,10 @@ def extract_code(text): return "" -def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp): +def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp, required_keywords=None, code_patterns=None): sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()] subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()] + required_keyword_hints = [str(item).strip().lower() for item in required_keywords or [] if str(item).strip()] excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()} def match_message(message, apply_time_filter): @@ -767,17 +758,18 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, subject = str(message.get("subject", "")) preview = str(message.get("bodyPreview", "")) combined = " ".join([sender, subject.lower(), preview.lower()]) - code = extract_code(" ".join([subject, preview, sender])) + code = extract_code(" ".join([subject, preview, sender]), code_patterns=code_patterns) if not code: body_content = get_message_body_content(message) if body_content: - code = extract_code(" ".join([subject, body_content, sender])) + code = extract_code(" ".join([subject, body_content, sender]), code_patterns=code_patterns) if not code or code in excluded: return None - sender_ok = not sender_keywords or any(keyword in combined for keyword in sender_keywords) - subject_ok = not subject_keywords or any(keyword in combined for keyword in subject_keywords) - if not sender_ok and not subject_ok: + sender_ok = bool(sender_keywords) and any(keyword in combined for keyword in sender_keywords) + subject_ok = bool(subject_keywords) and any(keyword in combined for keyword in subject_keywords) + keyword_ok = bool(required_keyword_hints) and any(keyword in combined for keyword in required_keyword_hints) + if (sender_keywords or subject_keywords or required_keyword_hints) and not sender_ok and not subject_ok and not keyword_ok: return None return {"code": code, "message": message} @@ -862,6 +854,8 @@ class HotmailHelperHandler(BaseHTTPRequestHandler): payload.get("subjectFilters") or [], payload.get("excludeCodes") or [], int(payload.get("filterAfterTimestamp") or 0), + payload.get("requiredKeywords") or [], + payload.get("codePatterns") or [], ) json_response(self, 200, { "ok": True, diff --git a/shared/flow-capabilities.js b/shared/flow-capabilities.js index 8946645..89274e1 100644 --- a/shared/flow-capabilities.js +++ b/shared/flow-capabilities.js @@ -38,6 +38,14 @@ supportsPhoneSignup: true, requiresPhoneSignupWarning: false, }); + const MODE_SWITCH_RELEVANT_KEYS = Object.freeze([ + 'activeFlowId', + 'contributionMode', + 'panelMode', + 'phoneVerificationEnabled', + 'plusModeEnabled', + 'signupMethod', + ]); const PANEL_CAPABILITIES = Object.freeze({ cpa: Object.freeze({ @@ -95,6 +103,17 @@ return normalized; } + function getPanelModeLabel(panelMode = '') { + const normalized = normalizePanelMode(panelMode); + if (normalized === 'sub2api') { + return 'SUB2API'; + } + if (normalized === 'codex2api') { + return 'Codex2API'; + } + return 'CPA'; + } + function createFlowCapabilityRegistry(deps = {}) { const { defaultFlowCapabilities = DEFAULT_FLOW_CAPABILITIES, @@ -122,6 +141,21 @@ }; } + function normalizeChangedKeys(values = []) { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const normalized = []; + list.forEach((value) => { + const key = String(value || '').trim(); + if (!key || seen.has(key)) { + return; + } + seen.add(key); + normalized.push(key); + }); + return normalized; + } + function resolveSidepanelCapabilities(options = {}) { const state = options?.state || {}; const activeFlowId = normalizeFlowId( @@ -202,6 +236,165 @@ }; } + function buildPhoneSignupValidationError(capabilityState = {}) { + const flowState = capabilityState.flowCapabilities || {}; + const panelState = capabilityState.panelCapabilities || {}; + const runtimeLocks = capabilityState.runtimeLocks || {}; + + if (!flowState.supportsPhoneSignup) { + return { + code: 'phone_signup_flow_unsupported', + message: '当前 flow 不支持手机号注册。', + }; + } + if (!panelState.supportsPhoneSignup) { + return { + code: 'phone_signup_panel_unsupported', + message: `当前面板模式 ${getPanelModeLabel(capabilityState.requestedPanelMode)} 不支持手机号注册。`, + }; + } + if (!runtimeLocks.phoneVerificationEnabled) { + return { + code: 'phone_signup_phone_verification_disabled', + message: '请先开启接码功能后再使用手机号注册。', + }; + } + if (runtimeLocks.plusModeEnabled) { + return { + code: 'phone_signup_plus_mode_locked', + message: 'Plus 模式开启时不能使用手机号注册。', + }; + } + if (runtimeLocks.contributionMode) { + return { + code: 'phone_signup_contribution_mode_locked', + message: '贡献模式开启时不能使用手机号注册。', + }; + } + return { + code: 'phone_signup_unavailable', + message: '当前设置暂不支持手机号注册。', + }; + } + + function validateAutoRunStart(options = {}) { + const state = options?.state || {}; + const capabilityState = resolveSidepanelCapabilities(options); + const errors = []; + + if ( + Array.isArray(capabilityState.supportedPanelModes) + && capabilityState.supportedPanelModes.length > 0 + && capabilityState.canUseSelectedPanelMode === false + ) { + errors.push({ + code: 'panel_mode_unsupported', + message: `当前 flow 不支持 ${getPanelModeLabel(capabilityState.requestedPanelMode)} 面板模式。`, + }); + } + + if (Boolean(state?.plusModeEnabled) && !capabilityState.flowCapabilities?.supportsPlusMode) { + errors.push({ + code: 'plus_mode_unsupported', + message: '当前 flow 不支持 Plus 模式。', + }); + } + + if (Boolean(state?.contributionMode) && !capabilityState.flowCapabilities?.supportsContributionMode) { + errors.push({ + code: 'contribution_mode_unsupported', + message: '当前 flow 不支持贡献模式。', + }); + } + + if ( + capabilityState.requestedSignupMethod === SIGNUP_METHOD_PHONE + && capabilityState.effectiveSignupMethod !== SIGNUP_METHOD_PHONE + ) { + errors.push(buildPhoneSignupValidationError(capabilityState)); + } + + return { + ok: errors.length === 0, + errors, + capabilityState, + }; + } + + function validateModeSwitch(options = {}) { + const state = options?.state || {}; + const changedKeys = normalizeChangedKeys( + options?.changedKeys !== undefined + ? options.changedKeys + : Object.keys(state || {}) + ); + const changedKeySet = new Set(changedKeys); + const capabilityState = resolveSidepanelCapabilities(options); + const errors = []; + const normalizedUpdates = {}; + const flowState = capabilityState.flowCapabilities || {}; + const requestedPhoneSignup = capabilityState.requestedSignupMethod === SIGNUP_METHOD_PHONE; + const shouldReconcileSignupMethod = MODE_SWITCH_RELEVANT_KEYS.some((key) => changedKeySet.has(key)); + + if ( + changedKeySet.has('panelMode') + && Array.isArray(capabilityState.supportedPanelModes) + && capabilityState.supportedPanelModes.length > 0 + && capabilityState.canUseSelectedPanelMode === false + ) { + normalizedUpdates.panelMode = capabilityState.effectivePanelMode; + errors.push({ + code: 'panel_mode_unsupported', + message: `当前 flow 不支持 ${getPanelModeLabel(capabilityState.requestedPanelMode)} 面板模式。`, + }); + } + + if (changedKeySet.has('plusModeEnabled') && Boolean(state?.plusModeEnabled) && !flowState.supportsPlusMode) { + normalizedUpdates.plusModeEnabled = false; + errors.push({ + code: 'plus_mode_unsupported', + message: '当前 flow 不支持 Plus 模式。', + }); + } + + if (changedKeySet.has('contributionMode') && Boolean(state?.contributionMode) && !flowState.supportsContributionMode) { + normalizedUpdates.contributionMode = false; + errors.push({ + code: 'contribution_mode_unsupported', + message: '当前 flow 不支持贡献模式。', + }); + } + + if ( + changedKeySet.has('phoneVerificationEnabled') + && Boolean(state?.phoneVerificationEnabled) + && !flowState.supportsPhoneVerificationSettings + ) { + normalizedUpdates.phoneVerificationEnabled = false; + errors.push({ + code: 'phone_verification_unsupported', + message: '当前 flow 不支持接码配置。', + }); + } + + if ( + shouldReconcileSignupMethod + && requestedPhoneSignup + && capabilityState.effectiveSignupMethod !== SIGNUP_METHOD_PHONE + ) { + normalizedUpdates.signupMethod = capabilityState.effectiveSignupMethod; + errors.push(buildPhoneSignupValidationError(capabilityState)); + } + + return { + ok: errors.length === 0, + changedKeys, + capabilityState, + errors, + normalizedUpdates, + }; + } + function canUsePhoneSignup(state = {}) { return resolveSidepanelCapabilities({ state }).canUsePhoneSignup; } @@ -222,6 +415,8 @@ normalizeSignupMethod, resolveSidepanelCapabilities, resolveSignupMethod, + validateAutoRunStart, + validateModeSwitch, }; } diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 99453c1..c71804e 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -789,6 +789,7 @@ function initPhoneVerificationSectionExpandedState() { } function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) { + const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID !== 'undefined' ? DEFAULT_ACTIVE_FLOW_ID : 'openai'; const defaultMethod = typeof DEFAULT_PLUS_PAYMENT_METHOD !== 'undefined' ? DEFAULT_PLUS_PAYMENT_METHOD : 'paypal'; const rawPaymentMethod = typeof options === 'string' ? options @@ -796,7 +797,11 @@ function getStepDefinitionsForMode(plusModeEnabled = false, options = {}) { const rawSignupMethod = typeof options === 'string' ? currentSignupMethod : (options.signupMethod || currentSignupMethod || DEFAULT_SIGNUP_METHOD); + const activeFlowId = typeof options === 'string' + ? ((typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId) + : (options.activeFlowId || (typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') || defaultFlowId); return (window.MultiPageStepDefinitions?.getSteps?.({ + activeFlowId: String(activeFlowId || '').trim().toLowerCase() || defaultFlowId, plusModeEnabled, plusPaymentMethod: normalizePlusPaymentMethod(rawPaymentMethod), signupMethod: normalizeSignupMethod(rawSignupMethod), @@ -830,6 +835,7 @@ function rebuildStepDefinitionState(plusModeEnabled = false, options = {}) { currentPlusPaymentMethod = normalizePlusPaymentMethod(rawPaymentMethod); currentSignupMethod = normalizeSignupMethod(rawSignupMethod); stepDefinitions = getStepDefinitionsForMode(currentPlusModeEnabled, { + activeFlowId: options?.activeFlowId, plusPaymentMethod: currentPlusPaymentMethod, signupMethod: currentSignupMethod, }); @@ -8524,6 +8530,7 @@ function renderStepsList() { } function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOrOptions = {}, maybeOptions = {}) { + const defaultFlowId = typeof DEFAULT_ACTIVE_FLOW_ID !== 'undefined' ? DEFAULT_ACTIVE_FLOW_ID : 'openai'; const nextPlusModeEnabled = Boolean(plusModeEnabled); const options = typeof plusPaymentMethodOrOptions === 'string' ? maybeOptions @@ -8533,9 +8540,15 @@ function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOr : (options.plusPaymentMethod || getSelectedPlusPaymentMethod(latestState)); const nextSignupMethod = normalizeSignupMethod(options.signupMethod || currentSignupMethod || DEFAULT_SIGNUP_METHOD); const nextPaymentMethod = normalizePlusPaymentMethod(rawPaymentMethod); + const nextActiveFlowId = String( + options.activeFlowId + || (typeof latestState !== 'undefined' ? latestState?.activeFlowId : '') + || defaultFlowId + ).trim().toLowerCase() || defaultFlowId; const rootScope = typeof window !== 'undefined' ? window : globalThis; const currentPaymentStep = stepDefinitions.find((step) => step.key === 'paypal-approve'); const nextPaymentTitle = rootScope.MultiPageStepDefinitions?.getPlusPaymentStepTitle?.({ + activeFlowId: nextActiveFlowId, plusModeEnabled: nextPlusModeEnabled, plusPaymentMethod: nextPaymentMethod, signupMethod: nextSignupMethod, @@ -8551,6 +8564,7 @@ function syncStepDefinitionsForMode(plusModeEnabled = false, plusPaymentMethodOr } rebuildStepDefinitionState(nextPlusModeEnabled, { + activeFlowId: nextActiveFlowId, plusPaymentMethod: nextPaymentMethod, signupMethod: nextSignupMethod, }); @@ -11834,6 +11848,37 @@ async function startAutoRunFromCurrentSettings() { if (typeof persistCurrentSettingsForAction === 'function') { await persistCurrentSettingsForAction(); } + const autoRunStartValidation = (() => { + const rootScope = typeof window !== 'undefined' ? window : globalThis; + const registry = rootScope.MultiPageFlowCapabilities?.createFlowCapabilityRegistry?.({ + defaultFlowId: typeof DEFAULT_ACTIVE_FLOW_ID === 'string' ? DEFAULT_ACTIVE_FLOW_ID : 'openai', + }) || null; + if (!registry?.validateAutoRunStart) { + return { ok: true, errors: [] }; + } + const validationState = { + ...(latestState || {}), + panelMode: typeof getSelectedPanelMode === 'function' ? getSelectedPanelMode() : latestState?.panelMode, + signupMethod: typeof getSelectedSignupMethod === 'function' ? getSelectedSignupMethod() : latestState?.signupMethod, + phoneVerificationEnabled: typeof inputPhoneVerificationEnabled !== 'undefined' && inputPhoneVerificationEnabled + ? Boolean(inputPhoneVerificationEnabled.checked) + : Boolean(latestState?.phoneVerificationEnabled), + plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(latestState?.plusModeEnabled), + contributionMode: Boolean(latestState?.contributionMode), + }; + return registry.validateAutoRunStart({ + activeFlowId: validationState.activeFlowId, + panelMode: validationState.panelMode, + signupMethod: validationState.signupMethod, + state: validationState, + }); + })(); + if (autoRunStartValidation?.ok === false) { + clearPendingAutoRunStartRunCount(); + throw new Error(autoRunStartValidation.errors?.[0]?.message || '当前设置不支持启动自动流程。'); + } if (!(await ensureGpcApiKeyReadyForStart())) { clearPendingAutoRunStartRunCount(); return false; diff --git a/tests/auto-run-timer-session-guard.test.js b/tests/auto-run-timer-session-guard.test.js index 1f74bf2..ab1e45b 100644 --- a/tests/auto-run-timer-session-guard.test.js +++ b/tests/auto-run-timer-session-guard.test.js @@ -145,3 +145,109 @@ return { assert.equal(snapshot.autoRunTotalRuns, 1); assert.equal(snapshot.autoRunAttemptRun, 0); }); + +test('launchAutoRunTimerPlan cancels an invalid scheduled start before restarting auto-run', async () => { + const api = new Function(` +const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start'; +const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds'; +const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry'; +const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3; + +let autoRunTimerLaunching = false; +let autoRunActive = false; +let autoRunCurrentRun = 0; +let autoRunTotalRuns = 1; +let autoRunAttemptRun = 0; +let autoRunSessionId = 0; + +const state = { + activeFlowId: 'site-a', + panelMode: 'cpa', + signupMethod: 'phone', + autoRunDelayEnabled: false, + autoRunTimerPlan: { + kind: AUTO_RUN_TIMER_KIND_SCHEDULED_START, + fireAt: Date.now() + 60_000, + totalRuns: 2, + autoRunSkipFailures: false, + autoRunSessionId: 0, + countdownTitle: '已计划自动运行', + countdownNote: '等待启动', + }, +}; + +let startCalls = 0; +let clearStopCalls = 0; +let clearAlarmCalls = 0; +const broadcasts = []; +const logs = []; + +async function getState() { + return { ...state }; +} + +function getPendingAutoRunTimerPlan() { + return state.autoRunTimerPlan; +} + +async function clearAutoRunTimerAlarm() { + clearAlarmCalls += 1; +} + +async function broadcastAutoRunStatus(phase, statusPayload, statePayload) { + broadcasts.push({ phase, statusPayload, statePayload }); +} +async function addLog(message, level) { + logs.push({ message, level }); +} +async function setAutoRunDelayEnabledState() {} +function serializeAutoRunRoundSummaries(totalRuns, summaries = []) { + return Array.isArray(summaries) ? summaries : []; +} +function clearStopRequest() { + clearStopCalls += 1; +} +function startAutoRunLoop() { + startCalls += 1; +} +function validateAutoRunStartState() { + return { + ok: false, + errors: [{ message: '当前 flow 不支持手机号注册。' }], + }; +} + +${helperBundle} + +return { + launchAutoRunTimerPlan, + snapshot() { + return { + startCalls, + clearStopCalls, + clearAlarmCalls, + broadcasts, + logs, + autoRunCurrentRun, + autoRunTotalRuns, + autoRunAttemptRun, + }; + }, +}; +`)(); + + const started = await api.launchAutoRunTimerPlan('alarm'); + const snapshot = api.snapshot(); + + assert.equal(started, false); + assert.equal(snapshot.startCalls, 0); + assert.equal(snapshot.clearStopCalls, 0); + assert.equal(snapshot.clearAlarmCalls, 1); + assert.equal(snapshot.broadcasts.length, 1); + assert.equal(snapshot.broadcasts[0].phase, 'idle'); + assert.match(snapshot.logs[0].message, /自动运行计划已取消:当前 flow 不支持手机号注册。/); + assert.equal(snapshot.logs[0].level, 'error'); + assert.equal(snapshot.autoRunCurrentRun, 0); + assert.equal(snapshot.autoRunTotalRuns, 1); + assert.equal(snapshot.autoRunAttemptRun, 0); +}); diff --git a/tests/background-contribution-mode.test.js b/tests/background-contribution-mode.test.js index ad1c3b7..afe84ee 100644 --- a/tests/background-contribution-mode.test.js +++ b/tests/background-contribution-mode.test.js @@ -351,6 +351,65 @@ test('message router re-syncs contribution mode before AUTO_RUN when sidepanel p ]); }); +test('message router blocks AUTO_RUN and SCHEDULE_AUTO_RUN when shared auto-run validation fails', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + + const calls = []; + const router = api.createMessageRouter({ + clearStopRequest: () => {}, + getPendingAutoRunTimerPlan: () => null, + getState: async () => ({ + activeFlowId: 'site-a', + panelMode: 'cpa', + signupMethod: 'phone', + stepStatuses: {}, + }), + normalizeRunCount: (value) => Number(value) || 1, + scheduleAutoRun: async () => { + calls.push({ type: 'scheduleAutoRun' }); + return { ok: true }; + }, + setState: async (updates) => { + calls.push({ type: 'setState', updates }); + }, + startAutoRunLoop: () => { + calls.push({ type: 'startAutoRunLoop' }); + }, + validateAutoRunStart: () => ({ + ok: false, + errors: [{ message: '当前 flow 不支持手机号注册。' }], + }), + }); + + await assert.rejects( + () => router.handleMessage({ + type: 'AUTO_RUN', + payload: { + totalRuns: 2, + autoRunSkipFailures: true, + mode: 'restart', + }, + }), + /当前 flow 不支持手机号注册。/ + ); + + await assert.rejects( + () => router.handleMessage({ + type: 'SCHEDULE_AUTO_RUN', + payload: { + totalRuns: 2, + delayMinutes: 5, + autoRunSkipFailures: false, + }, + }), + /当前 flow 不支持手机号注册。/ + ); + + assert.deepStrictEqual(calls, []); +}); + test('account run history snapshot sync is disabled in contribution mode', () => { const source = fs.readFileSync('background/account-run-history.js', 'utf8'); const globalScope = {}; diff --git a/tests/background-hotmail-local-helper-rule-forwarding.test.js b/tests/background-hotmail-local-helper-rule-forwarding.test.js new file mode 100644 index 0000000..d26ac41 --- /dev/null +++ b/tests/background-hotmail-local-helper-rule-forwarding.test.js @@ -0,0 +1,28 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +test('background forwards mail rule metadata to Hotmail helper and shared picker paths', () => { + const source = fs.readFileSync('background.js', 'utf8'); + + assert.match( + source, + /requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\]/, + 'Hotmail helper 请求体应转发 requiredKeywords' + ); + assert.match( + source, + /codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/, + 'Hotmail helper 请求体应转发 codePatterns' + ); + assert.match( + source, + /pickVerificationMessageWithTimeFallback\(fetchResult\.messages,\s*\{[\s\S]*requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\],[\s\S]*codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/, + 'Hotmail API 轮询应把 rule metadata 传给共享验证码筛选器' + ); + assert.match( + source, + /pickVerificationMessageWithTimeFallback\(messages,\s*\{[\s\S]*requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\],[\s\S]*codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/, + 'Cloudflare Temp Email 轮询也应复用同一套 rule metadata' + ); +}); diff --git a/tests/background-mail-rule-registry-module.test.js b/tests/background-mail-rule-registry-module.test.js index d6cc6c0..d487c42 100644 --- a/tests/background-mail-rule-registry-module.test.js +++ b/tests/background-mail-rule-registry-module.test.js @@ -42,10 +42,26 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', ( ruleId: 'openai-signup-code', step: 4, artifactType: 'code', + codePatterns: [ + { + source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', + flags: 'i', + }, + { + source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})', + flags: 'i', + }, + { + source: '(?:verification\\s+code|temporary\\s+verification\\s+code|your\\s+chatgpt\\s+code|code(?:\\s+is)?)[^0-9]{0,16}(\\d{6})', + flags: 'i', + }, + ], filterAfterTimestamp: 0, + requiredKeywords: ['openai', 'chatgpt', 'verify', 'verification', 'confirm', '验证码', '代码'], senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], targetEmail: 'user@example.com', + targetEmailHints: ['user@example.com', 'user=example.com'], mail2925MatchTargetEmail: true, maxAttempts: 15, intervalMs: 15000, @@ -64,10 +80,26 @@ test('mail rule registry exposes canonical OpenAI verification poll payloads', ( ruleId: 'openai-login-code', step: 8, artifactType: 'code', + codePatterns: [ + { + source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})', + flags: 'i', + }, + { + source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})', + flags: 'i', + }, + { + source: '(?:verification\\s+code|temporary\\s+verification\\s+code|your\\s+chatgpt\\s+code|code(?:\\s+is)?)[^0-9]{0,16}(\\d{6})', + flags: 'i', + }, + ], filterAfterTimestamp: 456, + requiredKeywords: ['openai', 'chatgpt', 'verify', 'verification', 'confirm', '验证码', '代码', 'login'], senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], targetEmail: 'login@example.com', + targetEmailHints: ['login@example.com', 'login=example.com'], mail2925MatchTargetEmail: false, maxAttempts: 5, intervalMs: 3000, diff --git a/tests/background-message-router-module.test.js b/tests/background-message-router-module.test.js index f1a2da0..46f2e0c 100644 --- a/tests/background-message-router-module.test.js +++ b/tests/background-message-router-module.test.js @@ -198,3 +198,67 @@ test('SAVE_SETTING re-resolves signup method when panel mode changes', async () assert.equal(state.panelMode, 'cpa'); assert.equal(state.signupMethod, 'email'); }); + +test('SAVE_SETTING applies shared mode-switch normalization before persisting incompatible capability flags', async () => { + const source = fs.readFileSync('background/message-router.js', 'utf8'); + const globalScope = { console }; + const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope); + const persistedPayloads = []; + let state = { + activeFlowId: 'site-a', + signupMethod: 'email', + phoneVerificationEnabled: false, + plusModeEnabled: false, + panelMode: 'cpa', + }; + + const router = api.createMessageRouter({ + addLog: async () => {}, + buildLuckmailSessionSettingsPayload: () => ({}), + buildPersistentSettingsPayload: (input = {}) => ({ + plusModeEnabled: Boolean(input.plusModeEnabled), + phoneVerificationEnabled: Boolean(input.phoneVerificationEnabled), + signupMethod: String(input.signupMethod || 'email'), + }), + broadcastDataUpdate: () => {}, + getState: async () => ({ ...state }), + resolveSignupMethod: (nextState = {}) => ( + Boolean(nextState.phoneVerificationEnabled) && Boolean(nextState.plusModeEnabled) ? 'phone' : 'email' + ), + setPersistentSettings: async (updates) => { + persistedPayloads.push({ ...updates }); + }, + setState: async (updates) => { + state = { ...state, ...updates }; + }, + validateModeSwitch: () => ({ + ok: false, + errors: [{ code: 'plus_mode_unsupported', message: '当前 flow 不支持 Plus 模式。' }], + normalizedUpdates: { + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }, + }), + }); + + const response = await router.handleMessage({ + type: 'SAVE_SETTING', + payload: { + plusModeEnabled: true, + phoneVerificationEnabled: true, + signupMethod: 'phone', + }, + }); + + assert.equal(response.ok, true); + assert.equal(state.plusModeEnabled, false); + assert.equal(state.phoneVerificationEnabled, false); + assert.equal(state.signupMethod, 'email'); + assert.deepEqual(persistedPayloads[0], { + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }); + assert.equal(response.modeValidation?.errors?.[0]?.code, 'plus_mode_unsupported'); +}); diff --git a/tests/background-settings-import-mode-validation.test.js b/tests/background-settings-import-mode-validation.test.js new file mode 100644 index 0000000..3348397 --- /dev/null +++ b/tests/background-settings-import-mode-validation.test.js @@ -0,0 +1,132 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') parenDepth += 1; + if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) signatureEnded = true; + } + if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + return source.slice(start, end); +} + +test('importSettingsBundle normalizes unsupported capability flags before persisting imported settings', async () => { + const api = new Function(` +const SETTINGS_EXPORT_SCHEMA_VERSION = 1; +const DEFAULT_REGISTRATION_EMAIL_STATE = { emailHistory: [] }; +let persistedUpdates = null; +let stateUpdates = null; +let broadcastPayload = null; +let currentState = { + activeFlowId: 'site-a', + panelMode: 'sub2api', + signupMethod: 'phone', + plusModeEnabled: false, + phoneVerificationEnabled: false, + stepStatuses: {}, +}; +async function ensureManualInteractionAllowed() { + return currentState; +} +function buildPersistentSettingsPayload(settings = {}) { + return { ...settings }; +} +function validateModeSwitchState() { + return { + ok: false, + errors: [{ code: 'panel_mode_unsupported', message: '当前 flow 不支持 SUB2API 面板模式。' }], + normalizedUpdates: { + panelMode: 'cpa', + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }, + }; +} +function resolveSignupMethod(state = {}) { + return String(state?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; +} +async function setPersistentSettings(updates) { + persistedUpdates = { ...updates }; +} +async function setState(updates) { + stateUpdates = { ...updates }; + currentState = { ...currentState, ...updates }; +} +function broadcastDataUpdate(payload) { + broadcastPayload = { ...payload }; +} +async function getState() { + return { ...currentState }; +} +${extractFunction('importSettingsBundle')} +return { + importSettingsBundle, + getPersistedUpdates: () => persistedUpdates, + getStateUpdates: () => stateUpdates, + getBroadcastPayload: () => broadcastPayload, +}; +`)(); + + const result = await api.importSettingsBundle({ + schemaVersion: 1, + settings: { + panelMode: 'sub2api', + plusModeEnabled: true, + phoneVerificationEnabled: true, + signupMethod: 'phone', + }, + }); + + assert.deepEqual(api.getPersistedUpdates(), { + panelMode: 'cpa', + plusModeEnabled: false, + phoneVerificationEnabled: false, + signupMethod: 'email', + }); + assert.equal(api.getStateUpdates().panelMode, 'cpa'); + assert.equal(api.getStateUpdates().plusModeEnabled, false); + assert.equal(api.getStateUpdates().phoneVerificationEnabled, false); + assert.equal(api.getStateUpdates().signupMethod, 'email'); + assert.equal(api.getBroadcastPayload().panelMode, 'cpa'); + assert.equal(api.getBroadcastPayload().signupMethod, 'email'); + assert.equal(result.signupMethod, 'email'); +}); diff --git a/tests/background-signup-method-settings.test.js b/tests/background-signup-method-settings.test.js index 3af827a..6688263 100644 --- a/tests/background-signup-method-settings.test.js +++ b/tests/background-signup-method-settings.test.js @@ -177,6 +177,7 @@ return { }); assert.deepEqual(api.getCaptured(), [{ + activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'phone', diff --git a/tests/flow-capabilities-module.test.js b/tests/flow-capabilities-module.test.js index e6d8125..d974976 100644 --- a/tests/flow-capabilities-module.test.js +++ b/tests/flow-capabilities-module.test.js @@ -70,3 +70,91 @@ test('flow capability registry defaults unknown flows to minimal non-phone capab assert.equal(capabilityState.panelMode, 'codex2api'); assert.deepEqual(capabilityState.supportedPanelModes, []); }); + +test('flow capability registry exposes shared auto-run validation for phone locks and panel support', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry({ + flowCapabilities: { + openai: api.FLOW_CAPABILITIES.openai, + 'site-a': { + ...api.DEFAULT_FLOW_CAPABILITIES, + supportsPlatformBinding: ['cpa'], + }, + }, + }); + + const plusLockedResult = registry.validateAutoRunStart({ + state: { + activeFlowId: 'openai', + panelMode: 'cpa', + signupMethod: 'phone', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: false, + }, + }); + + assert.equal(plusLockedResult.ok, false); + assert.equal(plusLockedResult.errors[0].code, 'phone_signup_plus_mode_locked'); + + const unsupportedPanelResult = registry.validateAutoRunStart({ + state: { + activeFlowId: 'site-a', + panelMode: 'sub2api', + signupMethod: 'email', + }, + }); + + assert.equal(unsupportedPanelResult.ok, false); + assert.equal(unsupportedPanelResult.errors[0].code, 'panel_mode_unsupported'); +}); + +test('flow capability registry normalizes unsupported mode switches back to the effective capability set', () => { + const api = loadApi(); + const registry = api.createFlowCapabilityRegistry({ + flowCapabilities: { + openai: api.FLOW_CAPABILITIES.openai, + 'site-a': { + ...api.DEFAULT_FLOW_CAPABILITIES, + supportsPlatformBinding: ['cpa'], + }, + }, + }); + + const validation = registry.validateModeSwitch({ + state: { + activeFlowId: 'site-a', + panelMode: 'sub2api', + signupMethod: 'phone', + phoneVerificationEnabled: true, + plusModeEnabled: true, + contributionMode: true, + }, + changedKeys: [ + 'panelMode', + 'signupMethod', + 'phoneVerificationEnabled', + 'plusModeEnabled', + 'contributionMode', + ], + }); + + assert.equal(validation.ok, false); + assert.deepEqual(validation.normalizedUpdates, { + panelMode: 'cpa', + signupMethod: 'email', + phoneVerificationEnabled: false, + plusModeEnabled: false, + contributionMode: false, + }); + assert.deepEqual( + validation.errors.map((entry) => entry.code), + [ + 'panel_mode_unsupported', + 'plus_mode_unsupported', + 'contribution_mode_unsupported', + 'phone_verification_unsupported', + 'phone_signup_flow_unsupported', + ] + ); +}); diff --git a/tests/hotmail-utils.test.js b/tests/hotmail-utils.test.js index 6744dd6..44275ff 100644 --- a/tests/hotmail-utils.test.js +++ b/tests/hotmail-utils.test.js @@ -185,6 +185,15 @@ test('extractVerificationCode returns first six-digit code from multilingual mai assert.equal(extractVerificationCode('No code here'), null); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + assert.equal( + extractVerificationCode('Mailbox notice: use pin A-778899 to continue.', { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '778899' + ); +}); + test('extractVerificationCodeFromMessage reads code from the latest message subject or preview', () => { assert.equal( extractVerificationCodeFromMessage({ @@ -214,6 +223,30 @@ test('extractVerificationCodeFromMessage reads code from the latest message subj ); }); +test('pickVerificationMessageWithTimeFallback supports required keyword hints and runtime code patterns', () => { + const messages = [ + { + id: 'mail-1', + subject: 'Security center', + from: { emailAddress: { address: 'alerts@example.com' } }, + bodyPreview: 'Use pin A-661122 to continue', + receivedDateTime: '2026-04-14T10:06:00.000Z', + }, + ]; + + const result = pickVerificationMessageWithTimeFallback(messages, { + afterTimestamp: 0, + senderFilters: [], + subjectFilters: [], + requiredKeywords: ['security'], + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + excludeCodes: [], + }); + + assert.equal(result.match?.code, '661122'); + assert.equal(result.usedTimeFallback, false); +}); + test('getHotmailListToggleLabel reflects expanded state and account count', () => { assert.equal(getHotmailListToggleLabel(false, 0), '展开列表'); assert.equal(getHotmailListToggleLabel(false, 7), '展开列表(7)'); diff --git a/tests/icloud-mail-content.test.js b/tests/icloud-mail-content.test.js index 9cfffbe..12223a1 100644 --- a/tests/icloud-mail-content.test.js +++ b/tests/icloud-mail-content.test.js @@ -54,6 +54,8 @@ function extractFunction(name) { test('readOpenedMailBody falls back to thread detail pane and extracts verification code', () => { const bundle = [ extractFunction('normalizeText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('getOpenedMailBodyRoot'), extractFunction('readOpenedMailBody'), @@ -83,6 +85,8 @@ return { readOpenedMailBody, extractVerificationCode }; test('extractVerificationCode matches the new suspicious log-in mail body', () => { const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -95,6 +99,27 @@ return { extractVerificationCode }; assert.equal(api.extractVerificationCode(bodyText), '982219'); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + const bodyText = 'Mailbox notice\nUse verification pin A-445566 to continue.'; + assert.equal( + api.extractVerificationCode(bodyText, { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '445566' + ); +}); + test('readOpenedMailBody ignores conversation list rows when no detail pane is open', () => { const bundle = [ extractFunction('normalizeText'), @@ -199,6 +224,8 @@ test('icloud poll session baseline is reused across calls and enables fallback a extractFunction('normalizeText'), extractFunction('getThreadItemMetadata'), extractFunction('buildItemSignature'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('normalizePollSessionKey'), extractFunction('getOrCreatePollSessionBaseline'), @@ -300,6 +327,8 @@ test('icloud step8 polling finds a visible first-row code immediately', async () extractFunction('normalizeText'), extractFunction('getThreadItemMetadata'), extractFunction('buildItemSignature'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('normalizePollSessionKey'), extractFunction('getOrCreatePollSessionBaseline'), @@ -378,6 +407,8 @@ test('icloud step8 visible first-row code still respects excluded codes', async extractFunction('normalizeText'), extractFunction('getThreadItemMetadata'), extractFunction('buildItemSignature'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), extractFunction('normalizePollSessionKey'), extractFunction('getOrCreatePollSessionBaseline'), diff --git a/tests/mail-163-content.test.js b/tests/mail-163-content.test.js index 8d8f384..7eafd80 100644 --- a/tests/mail-163-content.test.js +++ b/tests/mail-163-content.test.js @@ -166,6 +166,8 @@ test('readOpenedMailText prefers opened body content that contains the verificat extractFunction('collectOpenedMailTextCandidates'), extractFunction('selectOpenedMailTextCandidate'), extractFunction('readOpenedMailText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -216,6 +218,8 @@ test('openMailAndGetMessageText reads opened body text and returns to inbox', as extractFunction('readOpenedMailText'), extractFunction('returnToInbox'), extractFunction('openMailAndGetMessageText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -289,6 +293,8 @@ test('openMailAndGetMessageText ignores stale pre-open text that contains an old extractFunction('readOpenedMailText'), extractFunction('returnToInbox'), extractFunction('openMailAndGetMessageText'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -359,6 +365,8 @@ return { openMailAndGetMessageText, mailItem }; test('extractVerificationCode matches the new suspicious log-in mail body', () => { const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), extractFunction('extractVerificationCode'), ].join('\n'); @@ -371,6 +379,27 @@ return { extractVerificationCode }; assert.equal(api.extractVerificationCode(bodyText), '982219'); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + const bodyText = 'Security Center\nUse verification pin A-778899 to continue.'; + assert.equal( + api.extractVerificationCode(bodyText, { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '778899' + ); +}); + test('handlePollEmail ignores same-minute old snapshot mail before fallback', async () => { const bundle = [ extractFunction('normalizeText'), diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index 54ebb47..d3f3d70 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -319,6 +319,7 @@ return { test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => { const bundle = [ extractFunction('extractEmails'), + extractFunction('normalizeTargetEmailHints'), extractFunction('extractForwardedTargetEmails'), extractFunction('emailMatchesTarget'), extractFunction('getTargetEmailMatchState'), @@ -407,6 +408,34 @@ return { assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-2']); }); +test('getTargetEmailMatchState decodes generic forwarded bounce aliases without OpenAI-specific domains', () => { + const bundle = [ + extractFunction('extractEmails'), + extractFunction('normalizeTargetEmailHints'), + extractFunction('extractForwardedTargetEmails'), + extractFunction('emailMatchesTarget'), + extractFunction('getTargetEmailMatchState'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { getTargetEmailMatchState }; +`)(); + + const state = api.getTargetEmailMatchState( + 'Return-Path: ', + 'expected.user@example.com', + { + targetEmailHints: ['expected.user@example.com', 'expected.user=example.com'], + } + ); + + assert.deepEqual(state, { + matches: true, + hasExplicitEmail: true, + }); +}); + test('handlePollEmail only accepts 2925 mails inside the fixed lookback window', async () => { const bundle = [ extractFunction('normalizeMinuteTimestamp'), @@ -549,7 +578,9 @@ return { test('extractVerificationCode strict mode matches the new suspicious log-in mail body', () => { const bundle = [ - extractFunction('extractStrictChatGPTVerificationCode'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractLegacyStrictVerificationCode'), extractFunction('isLikelyCompactTimeValue'), extractFunction('isLikelyHeaderTimestampCode'), extractFunction('findSafeStandaloneSixDigitCode'), @@ -566,9 +597,36 @@ return { extractVerificationCode }; assert.equal(api.extractVerificationCode(bodyText, false), '982219'); }); +test('extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractLegacyStrictVerificationCode'), + extractFunction('isLikelyCompactTimeValue'), + extractFunction('isLikelyHeaderTimestampCode'), + extractFunction('findSafeStandaloneSixDigitCode'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + const bodyText = 'System alert\nUse verification pin A-556677 to continue.'; + assert.equal( + api.extractVerificationCode(bodyText, { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '556677' + ); +}); + test('extractVerificationCode ignores compact header time before fallback code', () => { const bundle = [ - extractFunction('extractStrictChatGPTVerificationCode'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractLegacyStrictVerificationCode'), extractFunction('isLikelyCompactTimeValue'), extractFunction('isLikelyHeaderTimestampCode'), extractFunction('findSafeStandaloneSixDigitCode'), diff --git a/tests/microsoft-email.test.js b/tests/microsoft-email.test.js index cf08547..3cf439f 100644 --- a/tests/microsoft-email.test.js +++ b/tests/microsoft-email.test.js @@ -64,6 +64,36 @@ test('extractVerificationCodeFromMessages 支持显式过滤条件并跳过排 }); }); +test('extractVerificationCodeFromMessages supports keyword hints and runtime code patterns without OpenAI-specific fallback', () => { + const result = extractVerificationCodeFromMessages([ + { + From: { EmailAddress: { Address: 'alerts@example.com' } }, + Subject: 'Security center', + BodyPreview: 'Use pin A-551199 to continue', + ReceivedDateTime: '2026-04-14T10:07:00.000Z', + Id: 'mail-1', + }, + { + From: { EmailAddress: { Address: 'news@example.com' } }, + Subject: 'Newsletter', + BodyPreview: 'pin A-000000', + ReceivedDateTime: '2026-04-14T10:06:00.000Z', + Id: 'mail-2', + }, + ], { + filterAfterTimestamp: 0, + senderFilters: [], + subjectFilters: [], + requiredKeywords: ['security'], + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + excludeCodes: [], + }); + + assert.equal(result.code, '551199'); + assert.equal(result.messageId, 'mail-1'); + assert.equal(result.sender, 'alerts@example.com'); +}); + test('normalizeMailboxId 将 Junk 归一为微软邮箱夹 ID', () => { assert.equal(normalizeMailboxId('INBOX'), 'inbox'); assert.equal(normalizeMailboxId('junk'), 'junkemail'); diff --git a/tests/qq-mail-content.test.js b/tests/qq-mail-content.test.js new file mode 100644 index 0000000..c72081d --- /dev/null +++ b/tests/qq-mail-content.test.js @@ -0,0 +1,139 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/qq-mail.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +test('qq extractVerificationCode supports runtime mail rule patterns', () => { + const bundle = [ + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + ].join('\n'); + + const api = new Function(` +${bundle} +return { extractVerificationCode }; +`)(); + + assert.equal( + api.extractVerificationCode('Mailbox notice: use pin A-441122 to continue.', { + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }), + '441122' + ); +}); + +test('qq handlePollEmail forwards runtime code patterns to new-mail matching', async () => { + const bundle = [ + extractFunction('getCurrentMailIds'), + extractFunction('normalizeRulePatternList'), + extractFunction('extractCodeByRulePatterns'), + extractFunction('extractVerificationCode'), + extractFunction('handlePollEmail'), + ].join('\n'); + + const api = new Function(` +let currentItems = []; +let refreshCount = 0; + +function createMailItem(mailId, sender, subject, digest) { + return { + getAttribute(name) { + if (name === 'data-mailid') return mailId; + return ''; + }, + querySelector(selector) { + if (selector === '.cmp-account-nick') return { textContent: sender }; + if (selector === '.mail-subject') return { textContent: subject }; + if (selector === '.mail-digest') return { textContent: digest }; + return null; + }, + }; +} + +const document = { + querySelectorAll(selector) { + if (selector === '.mail-list-page-item[data-mailid]') { + return currentItems; + } + return []; + }, +}; + +async function waitForElement() { + return true; +} +async function refreshInbox() { + refreshCount += 1; + if (refreshCount >= 1) { + currentItems = [ + createMailItem('mail-1', 'alerts@example.com', 'Security center', 'Use pin A-551188 to continue'), + ]; + } +} +async function sleep() {} +function log() {} + +${bundle} + +return { handlePollEmail }; +`)(); + + const result = await api.handlePollEmail(4, { + senderFilters: ['alerts'], + subjectFilters: ['security'], + maxAttempts: 2, + intervalMs: 1, + codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }], + }); + + assert.equal(result.code, '551188'); +}); diff --git a/tests/sidepanel-auto-run-content-refresh.test.js b/tests/sidepanel-auto-run-content-refresh.test.js index 950bd07..a7ffdf3 100644 --- a/tests/sidepanel-auto-run-content-refresh.test.js +++ b/tests/sidepanel-auto-run-content-refresh.test.js @@ -196,6 +196,107 @@ test('startAutoRunFromCurrentSettings freezes run count before async settings sy assert.equal(events[3].message.payload.totalRuns, 20); }); +test('startAutoRunFromCurrentSettings blocks when shared flow capability validation fails', async () => { + const bundle = [ + extractFunction('normalizePendingAutoRunStartRunCount'), + extractFunction('registerPendingAutoRunStartRunCount'), + extractFunction('clearPendingAutoRunStartRunCount'), + extractFunction('startAutoRunFromCurrentSettings'), + ].join('\n'); + + const api = new Function(` +const events = []; +const latestState = { + activeFlowId: 'site-a', + panelMode: 'cpa', + signupMethod: 'phone', + contributionMode: false, + phoneVerificationEnabled: true, +}; +const inputAutoSkipFailures = { checked: false }; +const inputContributionNickname = { value: 'tester' }; +const inputContributionQq = { value: '123456' }; +const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' }; +const inputAutoDelayEnabled = { checked: false }; +const inputAutoDelayMinutes = { value: '30' }; +const btnAutoRun = { disabled: false, innerHTML: '' }; +const inputRunCount = { disabled: false, value: '1' }; +const inputPhoneVerificationEnabled = { checked: true }; +const inputPlusModeEnabled = { checked: false }; +let runCountValue = 1; +let pendingAutoRunStartTotalRuns = 0; +let pendingAutoRunStartExpiresAt = 0; +const chrome = { + runtime: { + async sendMessage(message) { + events.push({ type: 'send', message }); + return { ok: true }; + }, + }, +}; +const console = { + warn(...args) { + events.push({ type: 'warn', args }); + }, +}; +const window = { + MultiPageFlowCapabilities: { + createFlowCapabilityRegistry() { + return { + validateAutoRunStart() { + return { + ok: false, + errors: [{ message: '当前 flow 不支持手机号注册。' }], + }; + }, + }; + }, + }, +}; +async function sendSidepanelMessage(message) { + return chrome.runtime.sendMessage(message); +} +async function persistCurrentSettingsForAction() { + events.push({ type: 'sync-settings' }); +} +function getRunCountValue() { return Math.max(1, Number(runCountValue) || 1); } +function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; } +function shouldOfferAutoModeChoice() { return false; } +async function openAutoStartChoiceDialog() { throw new Error('should not be called'); } +function getFirstUnfinishedStep() { return 1; } +function getRunningSteps() { return []; } +function shouldWarnAutoRunFallbackRisk() { return false; } +function isAutoRunFallbackRiskPromptDismissed() { return false; } +async function openAutoRunFallbackRiskConfirmModal() { throw new Error('should not be called'); } +function setAutoRunFallbackRiskPromptDismissed() {} +function normalizeAutoDelayMinutes(value) { return Number(value) || 30; } +async function refreshContributionContentHint() { + events.push({ type: 'refresh' }); + return null; +} +async function ensureGpcApiKeyReadyForStart() { + return true; +} +${bundle} +return { + startAutoRunFromCurrentSettings, + getEvents() { + return events; + }, +}; +`)(); + + await assert.rejects( + () => api.startAutoRunFromCurrentSettings(), + /当前 flow 不支持手机号注册。/ + ); + + assert.deepEqual( + api.getEvents().map((entry) => entry.type), + ['refresh', 'sync-settings'] + ); +}); + test('persistCurrentSettingsForAction forces a silent save even when settings are not marked dirty', async () => { const bundle = [ extractFunction('waitForSettingsSaveIdle'), diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js index 1a936be..6adba6f 100644 --- a/tests/sidepanel-plus-payment-method.test.js +++ b/tests/sidepanel-plus-payment-method.test.js @@ -106,7 +106,7 @@ return { assert.deepEqual(api.getStepIds(), [7]); assert.deepEqual(api.calls[0], { type: 'getSteps', - options: { plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email' }, + options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email' }, }); assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] }); }); @@ -266,7 +266,7 @@ return { assert.deepEqual(api.getStepIds(), [13]); assert.deepEqual(api.calls[0], { type: 'getSteps', - options: { plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email' }, + options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email' }, }); }); diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js index 4d0caba..af176b2 100644 --- a/tests/step-definitions-module.test.js +++ b/tests/step-definitions-module.test.js @@ -16,6 +16,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', () assert.equal(Array.isArray(steps), true); assert.equal(steps.length, 10); + assert.equal(steps.every((step) => step.flowId === 'openai'), true); assert.deepStrictEqual( steps.map((step) => step.order), steps.map((step) => step.order).slice().sort((left, right) => left - right) @@ -68,6 +69,11 @@ test('step definitions module exposes ordered normal and Plus step metadata', () assert.equal(api.getPlusPaymentStepTitle({ plusModeEnabled: true, plusPaymentMethod: 'gopay' }), ''); assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); assert.equal(api.getLastStepId({ plusModeEnabled: true }), 13); + assert.equal(api.hasFlow('openai'), true); + assert.equal(api.hasFlow('site-a'), false); + assert.deepStrictEqual(api.getRegisteredFlowIds(), ['openai']); + assert.deepStrictEqual(api.getSteps({ activeFlowId: 'site-a' }), []); + assert.equal(api.getStepById(2, { activeFlowId: 'site-a' }), null); assert.equal(plusSteps[5].title, '创建 Plus Checkout'); assert.equal(plusSteps[7].title, 'PayPal 登录与授权'); From ecba33f021273012d53f304c2ac8432477b3630a Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Wed, 13 May 2026 14:13:41 +0800 Subject: [PATCH 4/7] Fix sidepanel latestState bootstrap order --- sidepanel/sidepanel.js | 2 +- tests/sidepanel-plus-payment-method.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index c71804e..9e29c62 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -518,6 +518,7 @@ const SIGNUP_METHOD_EMAIL = 'email'; const SIGNUP_METHOD_PHONE = 'phone'; const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL; const DEFAULT_ACTIVE_FLOW_ID = 'openai'; +let latestState = null; let currentPlusModeEnabled = false; let currentPlusPaymentMethod = DEFAULT_PLUS_PAYMENT_METHOD; let currentSignupMethod = DEFAULT_SIGNUP_METHOD; @@ -1123,7 +1124,6 @@ function validateCurrentRegistrationEmail(email = inputEmail.value.trim(), optio return false; } -let latestState = null; let currentAutoRun = { autoRunning: false, phase: 'idle', diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js index 6adba6f..78134f7 100644 --- a/tests/sidepanel-plus-payment-method.test.js +++ b/tests/sidepanel-plus-payment-method.test.js @@ -116,6 +116,15 @@ test('sidepanel normalizeSignupMethod stays independent from signup constants du assert.doesNotMatch(source, /SIGNUP_METHOD_(PHONE|EMAIL)/); }); +test('sidepanel initializes latestState before bootstrapping shared step definitions', () => { + const latestStateIndex = sidepanelSource.indexOf('let latestState = null;'); + const bootstrapIndex = sidepanelSource.indexOf('let stepDefinitions = getStepDefinitionsForMode(false, {'); + + assert.notEqual(latestStateIndex, -1); + assert.notEqual(bootstrapIndex, -1); + assert.ok(latestStateIndex < bootstrapIndex); +}); + test('sidepanel signup method UI syncs shared step definitions with the selected signup method', () => { const source = extractFunction('updateSignupMethodUI'); assert.match(source, /syncStepDefinitionsForMode\(/); From 023e601dcc1a9de989ae4116ce01a5b008097ad0 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Thu, 14 May 2026 07:47:17 +0800 Subject: [PATCH 5/7] Add flow selector placeholder --- sidepanel/sidepanel.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 9b70f74..3db3046 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -110,6 +110,14 @@
+
+ Flow + +
来源